diff --git a/.claude/CHANGELOG.md b/.claude/CHANGELOG.md index c739e24..a01b52e 100644 --- a/.claude/CHANGELOG.md +++ b/.claude/CHANGELOG.md @@ -9,6 +9,66 @@ The format is based on the regulated environment requirements: --- +## [2026-04-26 00:30] - Phase 4 of security audit: reclaim-agent host-identity verification + +**Author:** Erick Bourgeois + +### Changed +- `src/reclaim_agent.rs`: new pure helpers `read_host_machine_id(path)` + and `compare_machine_ids(node, node_name, expected_host_id)` plus a + `HostIdentityError` enum with four variants (`ReadFailed`, `Empty`, + `Mismatch`, `NodeMachineIdMissing`). Both helpers are I/O-light and + unit-testable without a kube client. +- `src/bin/reclaim_agent.rs`: read `/etc/machine-id` once at startup + via `read_host_machine_id`; cache as `Option`. Before each + `annotate_node` PATCH, fetch the target Node and call + `compare_machine_ids` against the cached host id. On mismatch the + agent logs `error!` and bails (no PATCH). New `--machine-id-path` + (env `MACHINE_ID_PATH`, default `/etc/machine-id`) and + `--skip-host-id-check` (env `SKIP_HOST_ID_CHECK`, default false / + strict) CLI flags. +- `deploy/node-agent/daemonset.yaml`: + - Single-file read-only mount of `/etc/machine-id` → + `/host/etc/machine-id`. `hostPath.type: File` so kubelet fails to + schedule the pod if the host file is missing (fail-fast over + silent skip-check). + - `MACHINE_ID_PATH=/host/etc/machine-id` env var to point the agent + at the mounted location. + - Commented `SKIP_HOST_ID_CHECK` env var documenting the bypass for + operators who deploy in environments without `/etc/machine-id`. +- `src/reclaim_agent_tests.rs`: 9 new tests covering both helpers — + `read_host_machine_id` happy path / missing file / empty file / + whitespace-only file; `compare_machine_ids` match / mismatch (asserts + both ids appear in the error message for forensics) / no status / + empty machine-id / whitespace-only machine-id. +- `docs/src/concepts/emergency-reclaim.md`: new "Host-identity + verification" section documenting the contract, the two modes + (strict default vs `--skip-host-id-check` opt-out), the mount shape + in the DaemonSet, and what the check does NOT defend against + (TPM-grade attestation is out of scope). + +### Why +Phase 4 of the 2026-04-25 security audit roadmap. Without the +cross-check, an attacker with `update daemonsets` in `5spot-system` +could override the agent's `NODE_NAME` env var (downward API value → +hard-coded victim) and cause the agent to trigger emergency-reclaim on +a Node it has no business touching. The exploit precondition is +cluster-admin-equivalent in most clusters, so this is filed Low +severity / defence-in-depth, but the binding cost is small and removes +the impersonation primitive entirely. + +### Impact +- [ ] Breaking change +- [x] Requires cluster rollout (DaemonSet manifest now mounts + `/etc/machine-id`; existing deployments need `kubectl apply` of + the new manifest. Hosts that lack `/etc/machine-id` will fail + pod scheduling — operators must either populate the host file + or set `SKIP_HOST_ID_CHECK=true` for those nodes.) +- [ ] Config change only +- [ ] Documentation only + +--- + ## [2026-04-25 22:00] - Phase 3 of security audit: route Node→SM via canonical CAPI Machine **Author:** Erick Bourgeois diff --git a/deploy/node-agent/daemonset.yaml b/deploy/node-agent/daemonset.yaml index a0c79ad..38d2709 100644 --- a/deploy/node-agent/daemonset.yaml +++ b/deploy/node-agent/daemonset.yaml @@ -111,6 +111,23 @@ spec: valueFrom: fieldRef: fieldPath: spec.nodeName + # MACHINE_ID_PATH points at the host's /etc/machine-id, mounted + # read-only via the host-machine-id volume below. The agent + # cross-checks its value against the target Node's + # status.nodeInfo.machineID before each PATCH; a mismatch + # refuses the write. Closes the "modified-DaemonSet + # → impersonate-victim-Node" attack documented in Phase 4 of + # the 2026-04-25 security audit roadmap. + - name: MACHINE_ID_PATH + value: "/host/etc/machine-id" + # SKIP_HOST_ID_CHECK opts out of the cross-check above. Default + # is "false" (strict). Enable ONLY for environments where + # /etc/machine-id is genuinely unavailable (containers without + # the host file, dev sandboxes, kubelet variants that do not + # populate status.nodeInfo.machineID). Production must stay + # strict — leaving this commented out keeps the binary default. + # - name: SKIP_HOST_ID_CHECK + # value: "true" # Reading every `/proc//comm` + `cmdline` requires root; # CAP_SYS_PTRACE is not strictly required for rung 1 but is kept # dropped. CAP_NET_ADMIN will be re-added for rung 2 (netlink @@ -141,6 +158,15 @@ spec: - name: host-proc mountPath: /host/proc readOnly: true + # Single-file mount of the host's /etc/machine-id into + # /host/etc/machine-id (matches MACHINE_ID_PATH above). + # Mounted read-only — the agent only ever reads it. Single + # file rather than the whole /etc to minimise host filesystem + # exposure: the only path the agent needs is machine-id, so + # everything else stays out of the container's view. + - name: host-machine-id + mountPath: /host/etc/machine-id + readOnly: true volumes: # Host /proc — the detection surface. Mounted read-only; the agent # never writes to /proc, only reads comm + cmdline. @@ -148,6 +174,16 @@ spec: hostPath: path: /proc type: Directory + # Host machine-id (Phase 4 of the 2026-04-25 security audit + # roadmap). hostPath type=File ensures kubelet refuses to + # schedule the pod if the file is missing on the host — fail + # fast rather than silently degrading to skip-check mode. The + # file is written at host boot by systemd-machine-id-setup + # (or kairos / k0s-installer) and is stable across reboots. + - name: host-machine-id + hostPath: + path: /etc/machine-id + type: File # No ConfigMap volume. The agent uses the kube API directly to watch # its per-node ConfigMap (reclaim-agent-) and reloads # live on every change — see src/bin/reclaim_agent.rs. This sidesteps diff --git a/docs/src/concepts/emergency-reclaim.md b/docs/src/concepts/emergency-reclaim.md index 9adc5a1..eeb6a90 100644 --- a/docs/src/concepts/emergency-reclaim.md +++ b/docs/src/concepts/emergency-reclaim.md @@ -374,6 +374,80 @@ These are out of scope — recorded here because operators often ask: --- +## Host-identity verification + +Before the agent PATCHes any Node with reclaim annotations, it +cross-checks the host's `/etc/machine-id` against the target Node's +`status.nodeInfo.machineID`. Both are populated from the same source +(`systemd-machine-id-setup` on host boot; kubelet on Node registration), +so they agree on a healthy node — and a mismatch is a strong signal +that the agent is about to write to the wrong Node. + +### Why + +This closes the *modified-DaemonSet → impersonate-victim-Node* path: + +1. Without the check, the agent trusts whatever value `NODE_NAME` carries + (sourced from the downward API: `spec.nodeName`). +2. An attacker with `update daemonsets` in `5spot-system` can override + `NODE_NAME` to a hard-coded value (e.g. `victim-node`). +3. The agent runs on host A, sees a process match, and PATCHes + `victim-node` instead of host A — triggering `Phase::EmergencyRemove` + on a node it has no business reclaiming. + +The cross-check makes the impersonation visible: the spoofed Node's +`status.nodeInfo.machineID` belongs to a different host, so the +comparison fails and the agent refuses to write. + +The exploit precondition (`update daemonsets`) is cluster-admin in most +clusters, so this is **defence-in-depth** — but the check is cheap and +removes a category of impersonation. Filed as Phase 4 of the +2026-04-25 security audit roadmap. + +### Modes + +| Mode | Flag / env | Behaviour | +|---|---|---| +| **Strict (default)** | `--skip-host-id-check=false` / `SKIP_HOST_ID_CHECK=false` | Read `/etc/machine-id` at startup; fail to start if missing or empty. Before each PATCH, fetch the target Node and refuse if `status.nodeInfo.machineID` does not match. | +| Bypass | `--skip-host-id-check=true` / `SKIP_HOST_ID_CHECK=true` | Trust `NODE_NAME` blindly (pre-Phase-4 behaviour). Use **only** when `/etc/machine-id` is genuinely unavailable: containers without the host file mounted, dev sandboxes, kubelet variants that do not populate `status.nodeInfo.machineID`. | + +### How `/etc/machine-id` is exposed + +The DaemonSet mounts the host file as a single read-only file (not the +whole `/etc`): + +```yaml +volumeMounts: + - name: host-machine-id + mountPath: /host/etc/machine-id + readOnly: true +volumes: + - name: host-machine-id + hostPath: + path: /etc/machine-id + type: File +``` + +`type: File` makes kubelet refuse to schedule the pod if the host file +is missing — fail-fast is preferable to silently degrading to skip-check +mode. The agent reads the path from `MACHINE_ID_PATH` (default +`/host/etc/machine-id` when deployed via the manifest). + +### What it does NOT defend against + +- A compromise that lets an attacker modify *both* the DaemonSet **and** + the host's `/etc/machine-id` (would require root on the node). +- Kubelet bugs or out-of-band manipulation of + `Node.status.nodeInfo.machineID`. The kubelet writes this field; if + the cluster trusts a misbehaving kubelet, identity verification has + no anchor. + +Both gaps are out of scope for a node-side agent; remediating them +requires a stronger node-attestation primitive (TPM-backed identity, +SPIFFE SVIDs, etc.) — not in this controller's scope. + +--- + ## Related - [Machine Lifecycle](./machine-lifecycle.md) — full phase state machine including `EmergencyRemove` diff --git a/src/bin/reclaim_agent.rs b/src/bin/reclaim_agent.rs index ca317cd..ee2416f 100644 --- a/src/bin/reclaim_agent.rs +++ b/src/bin/reclaim_agent.rs @@ -39,10 +39,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, bail, Context as _, Result}; use clap::Parser; use five_spot::reclaim_agent::{ - already_requested, build_patch_body, configmap_to_config, scan_proc, Config, Match, + already_requested, build_patch_body, compare_machine_ids, configmap_to_config, + read_host_machine_id, scan_proc, Config, Match, }; use futures::StreamExt; use k8s_openapi::api::core::v1::{ConfigMap, Node}; @@ -57,6 +58,11 @@ use tracing::{debug, error, info, warn}; /// Default path the agent reads as `/proc`. Overridable for testing. const DEFAULT_PROC_ROOT: &str = "/proc"; +/// Default path the agent reads for the host machine-id. Mounted into +/// the DaemonSet via a single-file hostPath; the file is set at host +/// boot by `systemd-machine-id-setup` (or kairos / k0s-installer). +const DEFAULT_MACHINE_ID_PATH: &str = "/etc/machine-id"; + /// Field manager name used on PATCH. Distinct from the main controller /// so audit logs can tell apart a controller-side write from an /// agent-side write. @@ -89,6 +95,29 @@ struct Cli { /// for one-shot invocations and for smoke tests. #[clap(long)] oneshot: bool, + + /// Path to the host machine-id file. Default `/etc/machine-id`. Mounted + /// into the DaemonSet via a single-file hostPath; override only for + /// tests / sandboxes where the file lives elsewhere. + #[clap(long, env = "MACHINE_ID_PATH", default_value = DEFAULT_MACHINE_ID_PATH)] + machine_id_path: PathBuf, + + /// Skip the host-identity cross-check before patching the Node. + /// + /// Default: false (strict). Before each PATCH the agent fetches the + /// target Node, reads its `status.nodeInfo.machineID`, and refuses to + /// proceed if it does not match `/etc/machine-id` from the agent's + /// host. This closes the "modified DaemonSet hard-codes NODE_NAME" + /// impersonation vector documented in Phase 4 of the 2026-04-25 + /// security audit roadmap. + /// + /// Set to true ONLY for environments where `/etc/machine-id` is + /// genuinely unavailable (containers without the host file mounted, + /// dev sandboxes, kubelet variants that do not populate + /// `status.nodeInfo.machineID`). The strict default is the safe + /// posture for production. + #[clap(long, env = "SKIP_HOST_ID_CHECK", default_value_t = false)] + skip_host_id_check: bool, } #[tokio::main] @@ -105,6 +134,30 @@ async fn main() -> Result<()> { .context("build in-cluster kube client")?; let nodes: Api = Api::all(client.clone()); + // Read host machine-id once at startup. Failing here (file missing, + // empty, etc.) is fatal in strict mode so an operator notices and + // either fixes the mount or sets --skip-host-id-check explicitly. + let host_machine_id: Option = if cli.skip_host_id_check { + warn!( + machine_id_path = %cli.machine_id_path.display(), + "--skip-host-id-check set: agent will trust NODE_NAME without verifying \ + /etc/machine-id. Use only when the file is genuinely unavailable." + ); + None + } else { + let id = read_host_machine_id(&cli.machine_id_path).with_context(|| { + format!( + "read host machine-id from {} (set --skip-host-id-check=true to bypass)", + cli.machine_id_path.display() + ) + })?; + info!( + machine_id_path = %cli.machine_id_path.display(), + "host machine-id loaded; will cross-check against Node.status.nodeInfo.machineID before each patch" + ); + Some(id) + }; + if is_already_requested(&nodes, &cli.node_name).await? { info!(node = %cli.node_name, "reclaim annotation already present — exiting idempotently"); return Ok(()); @@ -132,7 +185,15 @@ async fn main() -> Result<()> { // resubscribe inside `kube::runtime::watcher`. let watcher_handle = tokio::spawn(run_config_watcher(client, cm_name, tx)); - let scanner_result = run_scanner(&nodes, &cli.node_name, &cli.proc_root, rx, cli.oneshot).await; + let scanner_result = run_scanner( + &nodes, + &cli.node_name, + &cli.proc_root, + rx, + cli.oneshot, + host_machine_id.as_deref(), + ) + .await; watcher_handle.abort(); scanner_result } @@ -239,6 +300,7 @@ async fn run_scanner( proc_root: &Path, mut rx: watch::Receiver>, oneshot: bool, + host_machine_id: Option<&str>, ) -> Result<()> { loop { let cfg = rx.borrow().clone(); @@ -264,7 +326,7 @@ async fn run_scanner( Some(cfg) => match scan_proc(proc_root, &cfg) { Ok(Some(m)) => { info!(pid = m.pid, pattern = %m.matched_pattern, "match → annotating node"); - annotate_node(nodes, node_name, &m).await?; + annotate_node(nodes, node_name, &m, host_machine_id).await?; return Ok(()); } Ok(None) => { @@ -283,7 +345,36 @@ async fn run_scanner( } } -async fn annotate_node(nodes: &Api, node_name: &str, m: &Match) -> Result<()> { +/// PATCH the Node with reclaim annotations. +/// +/// Before the PATCH (when `host_machine_id` is `Some`) the agent fetches +/// the target Node and cross-checks `status.nodeInfo.machineID` against +/// the host machine-id loaded at startup — refusing to patch a Node +/// that does not match. This blocks the +/// "modified-DaemonSet → impersonate-victim-Node" attack documented in +/// Phase 4 of the 2026-04-25 security audit roadmap. +/// +/// `host_machine_id == None` means the operator passed +/// `--skip-host-id-check`; the function falls back to the pre-Phase-4 +/// behaviour of trusting `NODE_NAME` blindly. +async fn annotate_node( + nodes: &Api, + node_name: &str, + m: &Match, + host_machine_id: Option<&str>, +) -> Result<()> { + if let Some(expected) = host_machine_id { + let node = nodes + .get(node_name) + .await + .with_context(|| format!("fetch Node/{node_name} for host-identity check"))?; + if let Err(e) = compare_machine_ids(&node, node_name, expected) { + error!(error = %e, "host-identity check failed — refusing to patch Node"); + bail!(e); + } + debug!(node = %node_name, "host-identity check passed"); + } + let ts = chrono::Utc::now().to_rfc3339(); let patch = build_patch_body(m, &ts); let params = PatchParams::apply(FIELD_MANAGER).force(); diff --git a/src/reclaim_agent.rs b/src/reclaim_agent.rs index 8357977..0b9bdf5 100644 --- a/src/reclaim_agent.rs +++ b/src/reclaim_agent.rs @@ -252,6 +252,144 @@ pub use crate::constants::RECLAIM_CONFIG_DATA_KEY; // ConfigMap → Config bridge (reactive watch path) // ============================================================================ +// ============================================================================ +// Host-identity verification — Phase 4 of the 2026-04-25 security audit +// +// Closes the "modified DaemonSet hard-codes NODE_NAME" attack: an +// attacker with `update daemonsets` could change the agent's NODE_NAME +// env var to a victim node, causing the agent to PATCH the wrong Node +// with reclaim annotations. The fix cross-checks /etc/machine-id (the +// host's stable identifier set by systemd-machine-id-setup / kairos / +// k0s-installer) against the target Node's status.nodeInfo.machineID +// (which kubelet populates from the same source). Mismatch ⇒ refuse +// to patch. +// +// The exploit precondition (`update daemonsets`) is cluster-admin in +// most clusters, so this is defence-in-depth — but the binding makes +// the cross-check cheap and removes a category of impersonation. +// ============================================================================ + +/// Errors returned by host-identity verification. +#[derive(Debug, thiserror::Error)] +pub enum HostIdentityError { + /// The machine-id file could not be read (e.g. missing mount, + /// permission denied). The agent should refuse to patch. + #[error("cannot read host machine-id from {path}: {source}")] + ReadFailed { + /// Path the agent tried to read (default `/etc/machine-id`). + path: String, + /// Underlying I/O error. + #[source] + source: io::Error, + }, + /// The machine-id file exists but is empty or whitespace-only — + /// indistinguishable from "no host identity" and must fail closed. + #[error("host machine-id at {path} is empty or whitespace-only")] + Empty { + /// Path to the offending file. + path: String, + }, + /// The fetched Node's `status.nodeInfo.machineID` does not match + /// the agent's host machine-id. This is the attack-blocking case — + /// either the DaemonSet was tampered with to point at a wrong + /// Node, or kubelet+/etc/machine-id are inconsistent on the host. + #[error( + "host identity mismatch — refusing to patch wrong Node: agent /etc/machine-id={host_id:?}, \ + Node/{node_name}.status.nodeInfo.machineID={node_id:?}" + )] + Mismatch { + /// Machine-id read from the agent's filesystem. + host_id: String, + /// Machine-id reported by the target Node's kubelet. + node_id: String, + /// Name of the Node the agent was about to patch. + node_name: String, + }, + /// The Node has no `status.nodeInfo.machineID` (kubelet hasn't + /// populated it yet, or status is absent). Fail closed: we cannot + /// verify identity. + #[error("Node/{node_name} has no status.nodeInfo.machineID; cannot verify host identity")] + NodeMachineIdMissing { + /// Name of the Node missing the field. + node_name: String, + }, +} + +/// Read the host machine-id from disk and return it trimmed. +/// +/// In production the path is `/etc/machine-id`; tests pass a tempfile. +/// The file is single-line `man machine-id` format (32 hex digits + +/// trailing newline) — we trim whitespace but otherwise accept any +/// non-empty content for compatibility with kairos / k0s-installer +/// variants that may format slightly differently. +/// +/// # Errors +/// - [`HostIdentityError::ReadFailed`] if the file cannot be read. +/// - [`HostIdentityError::Empty`] if the file is empty or contains only +/// whitespace. +pub fn read_host_machine_id(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|e| HostIdentityError::ReadFailed { + path: path.display().to_string(), + source: e, + })?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(HostIdentityError::Empty { + path: path.display().to_string(), + }); + } + Ok(trimmed.to_string()) +} + +/// Compare a Node's `status.nodeInfo.machineID` against an expected +/// host machine-id. +/// +/// Pure — the caller fetches the Node via the kube API, then this +/// function does the comparison and produces a precise error message +/// useful for forensics (both ids appear in the message so an +/// operator chasing a security alert sees the spoofed vs expected +/// values directly). +/// +/// # Errors +/// - [`HostIdentityError::NodeMachineIdMissing`] when the Node has no +/// `status`, no `nodeInfo`, or an empty/whitespace-only `machineID`. +/// - [`HostIdentityError::Mismatch`] when both values are present and +/// differ. +pub fn compare_machine_ids( + node: &k8s_openapi::api::core::v1::Node, + node_name: &str, + expected_host_id: &str, +) -> Result<(), HostIdentityError> { + let node_id_raw = node + .status + .as_ref() + .and_then(|s| s.node_info.as_ref()) + .map(|info| info.machine_id.as_str()); + + let node_id = match node_id_raw { + Some(s) => s.trim(), + None => { + return Err(HostIdentityError::NodeMachineIdMissing { + node_name: node_name.to_string(), + }) + } + }; + + if node_id.is_empty() { + return Err(HostIdentityError::NodeMachineIdMissing { + node_name: node_name.to_string(), + }); + } + if node_id != expected_host_id { + return Err(HostIdentityError::Mismatch { + host_id: expected_host_id.to_string(), + node_id: node_id.to_string(), + node_name: node_name.to_string(), + }); + } + Ok(()) +} + /// Translate a `ConfigMap` (as observed by a kube watcher) into an /// `Option`: /// diff --git a/src/reclaim_agent_tests.rs b/src/reclaim_agent_tests.rs index 87afe37..36fdfc7 100644 --- a/src/reclaim_agent_tests.rs +++ b/src/reclaim_agent_tests.rs @@ -476,4 +476,162 @@ mod tests { "error must name the parse failure, got: {err}" ); } + + // ======================================================================== + // Phase 4: host-identity verification + // + // Closes the "modified DaemonSet hard-codes NODE_NAME" attack: before the + // agent PATCHes a Node with reclaim annotations, it cross-checks + // /etc/machine-id (the host's stable identifier, set by + // systemd-machine-id-setup / kairos / k0s-installer) against the target + // Node's status.nodeInfo.machineID (which kubelet populates from the + // same source). Mismatch ⇒ refuse to patch. + // + // Both helpers are pure — no kube I/O — so the binary can wire them + // around its own fetch. + // ======================================================================== + + #[test] + fn test_read_host_machine_id_returns_trimmed_content() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("machine-id"); + // Real /etc/machine-id format: 32 hex digits + trailing newline. + fs::write(&path, "abc123def4567890aabbccddeeff0011\n").unwrap(); + let id = read_host_machine_id(&path).expect("read"); + assert_eq!( + id, "abc123def4567890aabbccddeeff0011", + "trailing newline must be trimmed" + ); + } + + #[test] + fn test_read_host_machine_id_missing_file_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("does-not-exist"); + let err = read_host_machine_id(&path).expect_err("missing file must error"); + let msg = err.to_string(); + assert!( + msg.contains("does-not-exist") || msg.to_lowercase().contains("cannot read"), + "error must name the missing path, got: {msg}" + ); + } + + #[test] + fn test_read_host_machine_id_empty_file_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("machine-id"); + fs::write(&path, "").unwrap(); + let err = read_host_machine_id(&path).expect_err("empty file must error"); + assert!(err.to_string().to_lowercase().contains("empty")); + } + + #[test] + fn test_read_host_machine_id_whitespace_only_errors() { + // Defensive: a corrupted /etc/machine-id with only whitespace + // (newlines, spaces) is indistinguishable from "no identity" + // and must fail closed rather than producing an empty token. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("machine-id"); + fs::write(&path, " \n\t\n").unwrap(); + let err = read_host_machine_id(&path).expect_err("whitespace-only must error"); + assert!(err.to_string().to_lowercase().contains("empty")); + } + + fn node_with_machine_id(machine_id: &str) -> k8s_openapi::api::core::v1::Node { + use k8s_openapi::api::core::v1::{Node, NodeStatus, NodeSystemInfo}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + Node { + metadata: ObjectMeta { + name: Some("worker-1".to_string()), + ..Default::default() + }, + status: Some(NodeStatus { + node_info: Some(NodeSystemInfo { + machine_id: machine_id.to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn test_compare_machine_ids_match_returns_ok() { + let node = node_with_machine_id("abc123def4567890aabbccddeeff0011"); + compare_machine_ids(&node, "worker-1", "abc123def4567890aabbccddeeff0011") + .expect("match must succeed"); + } + + #[test] + fn test_compare_machine_ids_mismatch_errors_with_both_ids() { + // Spoofed-NODE_NAME exploit: agent runs on host-A but DaemonSet was + // edited to NODE_NAME=victim-host. The fetched victim-host Node has + // a DIFFERENT machineID than this agent's /etc/machine-id. Refuse. + let node = node_with_machine_id("victim-host-id-aaaaaaaaaaaaaaaa"); + let err = compare_machine_ids(&node, "victim-host", "agent-host-id-bbbbbbbbbbbbbbbb") + .expect_err("mismatch must error"); + let msg = err.to_string(); + assert!( + msg.contains("victim-host"), + "error must name the target node, got: {msg}" + ); + assert!( + msg.contains("agent-host-id-bbbbbbbbbbbbbbbb"), + "error must include the agent's host id for forensics, got: {msg}" + ); + assert!( + msg.contains("victim-host-id-aaaaaaaaaaaaaaaa"), + "error must include the Node's id, got: {msg}" + ); + assert!( + msg.to_lowercase().contains("mismatch") || msg.to_lowercase().contains("refus"), + "error must signal refusal, got: {msg}" + ); + } + + #[test] + fn test_compare_machine_ids_node_without_status_errors() { + use k8s_openapi::api::core::v1::Node; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + let node = Node { + metadata: ObjectMeta { + name: Some("worker-1".to_string()), + ..Default::default() + }, + status: None, + ..Default::default() + }; + let err = compare_machine_ids(&node, "worker-1", "anything").expect_err("must error"); + assert!( + err.to_string().to_lowercase().contains("machineid") + || err.to_string().to_lowercase().contains("missing"), + "error must signal missing machine-id, got: {err}" + ); + } + + #[test] + fn test_compare_machine_ids_empty_node_machine_id_errors() { + // Defensive: a Node with status.nodeInfo.machineID="" (kubelet hasn't + // populated it yet) must fail-closed — we cannot verify identity. + let node = node_with_machine_id(""); + let err = compare_machine_ids(&node, "worker-1", "anything").expect_err("must error"); + assert!( + err.to_string().to_lowercase().contains("machineid") + || err.to_string().to_lowercase().contains("missing"), + "error must signal missing machine-id, got: {err}" + ); + } + + #[test] + fn test_compare_machine_ids_whitespace_node_machine_id_is_ignored() { + // A whitespace-only machineID is equivalent to absent — fail closed. + let node = node_with_machine_id(" \n"); + let err = compare_machine_ids(&node, "worker-1", "anything").expect_err("must error"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("machineid") || msg.contains("no status") || msg.contains("missing"), + "error must signal absent machine-id, got: {err}" + ); + } }