Skip to content

Commit 01f6dfc

Browse files
authored
Implement reclaim-agent host-identity check/validation (#49)
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent f53d619 commit 01f6dfc

6 files changed

Lines changed: 562 additions & 5 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,66 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-04-26 00:30] - Phase 4 of security audit: reclaim-agent host-identity verification
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reclaim_agent.rs`: new pure helpers `read_host_machine_id(path)`
18+
and `compare_machine_ids(node, node_name, expected_host_id)` plus a
19+
`HostIdentityError` enum with four variants (`ReadFailed`, `Empty`,
20+
`Mismatch`, `NodeMachineIdMissing`). Both helpers are I/O-light and
21+
unit-testable without a kube client.
22+
- `src/bin/reclaim_agent.rs`: read `/etc/machine-id` once at startup
23+
via `read_host_machine_id`; cache as `Option<String>`. Before each
24+
`annotate_node` PATCH, fetch the target Node and call
25+
`compare_machine_ids` against the cached host id. On mismatch the
26+
agent logs `error!` and bails (no PATCH). New `--machine-id-path`
27+
(env `MACHINE_ID_PATH`, default `/etc/machine-id`) and
28+
`--skip-host-id-check` (env `SKIP_HOST_ID_CHECK`, default false /
29+
strict) CLI flags.
30+
- `deploy/node-agent/daemonset.yaml`:
31+
- Single-file read-only mount of `/etc/machine-id`
32+
`/host/etc/machine-id`. `hostPath.type: File` so kubelet fails to
33+
schedule the pod if the host file is missing (fail-fast over
34+
silent skip-check).
35+
- `MACHINE_ID_PATH=/host/etc/machine-id` env var to point the agent
36+
at the mounted location.
37+
- Commented `SKIP_HOST_ID_CHECK` env var documenting the bypass for
38+
operators who deploy in environments without `/etc/machine-id`.
39+
- `src/reclaim_agent_tests.rs`: 9 new tests covering both helpers —
40+
`read_host_machine_id` happy path / missing file / empty file /
41+
whitespace-only file; `compare_machine_ids` match / mismatch (asserts
42+
both ids appear in the error message for forensics) / no status /
43+
empty machine-id / whitespace-only machine-id.
44+
- `docs/src/concepts/emergency-reclaim.md`: new "Host-identity
45+
verification" section documenting the contract, the two modes
46+
(strict default vs `--skip-host-id-check` opt-out), the mount shape
47+
in the DaemonSet, and what the check does NOT defend against
48+
(TPM-grade attestation is out of scope).
49+
50+
### Why
51+
Phase 4 of the 2026-04-25 security audit roadmap. Without the
52+
cross-check, an attacker with `update daemonsets` in `5spot-system`
53+
could override the agent's `NODE_NAME` env var (downward API value →
54+
hard-coded victim) and cause the agent to trigger emergency-reclaim on
55+
a Node it has no business touching. The exploit precondition is
56+
cluster-admin-equivalent in most clusters, so this is filed Low
57+
severity / defence-in-depth, but the binding cost is small and removes
58+
the impersonation primitive entirely.
59+
60+
### Impact
61+
- [ ] Breaking change
62+
- [x] Requires cluster rollout (DaemonSet manifest now mounts
63+
`/etc/machine-id`; existing deployments need `kubectl apply` of
64+
the new manifest. Hosts that lack `/etc/machine-id` will fail
65+
pod scheduling — operators must either populate the host file
66+
or set `SKIP_HOST_ID_CHECK=true` for those nodes.)
67+
- [ ] Config change only
68+
- [ ] Documentation only
69+
70+
---
71+
1272
## [2026-04-25 22:00] - Phase 3 of security audit: route Node→SM via canonical CAPI Machine
1373

1474
**Author:** Erick Bourgeois

deploy/node-agent/daemonset.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ spec:
111111
valueFrom:
112112
fieldRef:
113113
fieldPath: spec.nodeName
114+
# MACHINE_ID_PATH points at the host's /etc/machine-id, mounted
115+
# read-only via the host-machine-id volume below. The agent
116+
# cross-checks its value against the target Node's
117+
# status.nodeInfo.machineID before each PATCH; a mismatch
118+
# refuses the write. Closes the "modified-DaemonSet
119+
# → impersonate-victim-Node" attack documented in Phase 4 of
120+
# the 2026-04-25 security audit roadmap.
121+
- name: MACHINE_ID_PATH
122+
value: "/host/etc/machine-id"
123+
# SKIP_HOST_ID_CHECK opts out of the cross-check above. Default
124+
# is "false" (strict). Enable ONLY for environments where
125+
# /etc/machine-id is genuinely unavailable (containers without
126+
# the host file, dev sandboxes, kubelet variants that do not
127+
# populate status.nodeInfo.machineID). Production must stay
128+
# strict — leaving this commented out keeps the binary default.
129+
# - name: SKIP_HOST_ID_CHECK
130+
# value: "true"
114131
# Reading every `/proc/<pid>/comm` + `cmdline` requires root;
115132
# CAP_SYS_PTRACE is not strictly required for rung 1 but is kept
116133
# dropped. CAP_NET_ADMIN will be re-added for rung 2 (netlink
@@ -141,13 +158,32 @@ spec:
141158
- name: host-proc
142159
mountPath: /host/proc
143160
readOnly: true
161+
# Single-file mount of the host's /etc/machine-id into
162+
# /host/etc/machine-id (matches MACHINE_ID_PATH above).
163+
# Mounted read-only — the agent only ever reads it. Single
164+
# file rather than the whole /etc to minimise host filesystem
165+
# exposure: the only path the agent needs is machine-id, so
166+
# everything else stays out of the container's view.
167+
- name: host-machine-id
168+
mountPath: /host/etc/machine-id
169+
readOnly: true
144170
volumes:
145171
# Host /proc — the detection surface. Mounted read-only; the agent
146172
# never writes to /proc, only reads comm + cmdline.
147173
- name: host-proc
148174
hostPath:
149175
path: /proc
150176
type: Directory
177+
# Host machine-id (Phase 4 of the 2026-04-25 security audit
178+
# roadmap). hostPath type=File ensures kubelet refuses to
179+
# schedule the pod if the file is missing on the host — fail
180+
# fast rather than silently degrading to skip-check mode. The
181+
# file is written at host boot by systemd-machine-id-setup
182+
# (or kairos / k0s-installer) and is stable across reboots.
183+
- name: host-machine-id
184+
hostPath:
185+
path: /etc/machine-id
186+
type: File
151187
# No ConfigMap volume. The agent uses the kube API directly to watch
152188
# its per-node ConfigMap (reclaim-agent-<NODE_NAME>) and reloads
153189
# live on every change — see src/bin/reclaim_agent.rs. This sidesteps

docs/src/concepts/emergency-reclaim.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,80 @@ These are out of scope — recorded here because operators often ask:
374374

375375
---
376376

377+
## Host-identity verification
378+
379+
Before the agent PATCHes any Node with reclaim annotations, it
380+
cross-checks the host's `/etc/machine-id` against the target Node's
381+
`status.nodeInfo.machineID`. Both are populated from the same source
382+
(`systemd-machine-id-setup` on host boot; kubelet on Node registration),
383+
so they agree on a healthy node — and a mismatch is a strong signal
384+
that the agent is about to write to the wrong Node.
385+
386+
### Why
387+
388+
This closes the *modified-DaemonSet → impersonate-victim-Node* path:
389+
390+
1. Without the check, the agent trusts whatever value `NODE_NAME` carries
391+
(sourced from the downward API: `spec.nodeName`).
392+
2. An attacker with `update daemonsets` in `5spot-system` can override
393+
`NODE_NAME` to a hard-coded value (e.g. `victim-node`).
394+
3. The agent runs on host A, sees a process match, and PATCHes
395+
`victim-node` instead of host A — triggering `Phase::EmergencyRemove`
396+
on a node it has no business reclaiming.
397+
398+
The cross-check makes the impersonation visible: the spoofed Node's
399+
`status.nodeInfo.machineID` belongs to a different host, so the
400+
comparison fails and the agent refuses to write.
401+
402+
The exploit precondition (`update daemonsets`) is cluster-admin in most
403+
clusters, so this is **defence-in-depth** — but the check is cheap and
404+
removes a category of impersonation. Filed as Phase 4 of the
405+
2026-04-25 security audit roadmap.
406+
407+
### Modes
408+
409+
| Mode | Flag / env | Behaviour |
410+
|---|---|---|
411+
| **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. |
412+
| 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`. |
413+
414+
### How `/etc/machine-id` is exposed
415+
416+
The DaemonSet mounts the host file as a single read-only file (not the
417+
whole `/etc`):
418+
419+
```yaml
420+
volumeMounts:
421+
- name: host-machine-id
422+
mountPath: /host/etc/machine-id
423+
readOnly: true
424+
volumes:
425+
- name: host-machine-id
426+
hostPath:
427+
path: /etc/machine-id
428+
type: File
429+
```
430+
431+
`type: File` makes kubelet refuse to schedule the pod if the host file
432+
is missing — fail-fast is preferable to silently degrading to skip-check
433+
mode. The agent reads the path from `MACHINE_ID_PATH` (default
434+
`/host/etc/machine-id` when deployed via the manifest).
435+
436+
### What it does NOT defend against
437+
438+
- A compromise that lets an attacker modify *both* the DaemonSet **and**
439+
the host's `/etc/machine-id` (would require root on the node).
440+
- Kubelet bugs or out-of-band manipulation of
441+
`Node.status.nodeInfo.machineID`. The kubelet writes this field; if
442+
the cluster trusts a misbehaving kubelet, identity verification has
443+
no anchor.
444+
445+
Both gaps are out of scope for a node-side agent; remediating them
446+
requires a stronger node-attestation primitive (TPM-backed identity,
447+
SPIFFE SVIDs, etc.) — not in this controller's scope.
448+
449+
---
450+
377451
## Related
378452

379453
- [Machine Lifecycle](./machine-lifecycle.md) — full phase state machine including `EmergencyRemove`

src/bin/reclaim_agent.rs

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ use std::path::{Path, PathBuf};
3939
use std::sync::Arc;
4040
use std::time::Duration;
4141

42-
use anyhow::{anyhow, Context as _, Result};
42+
use anyhow::{anyhow, bail, Context as _, Result};
4343
use clap::Parser;
4444
use five_spot::reclaim_agent::{
45-
already_requested, build_patch_body, configmap_to_config, scan_proc, Config, Match,
45+
already_requested, build_patch_body, compare_machine_ids, configmap_to_config,
46+
read_host_machine_id, scan_proc, Config, Match,
4647
};
4748
use futures::StreamExt;
4849
use k8s_openapi::api::core::v1::{ConfigMap, Node};
@@ -57,6 +58,11 @@ use tracing::{debug, error, info, warn};
5758
/// Default path the agent reads as `/proc`. Overridable for testing.
5859
const DEFAULT_PROC_ROOT: &str = "/proc";
5960

61+
/// Default path the agent reads for the host machine-id. Mounted into
62+
/// the DaemonSet via a single-file hostPath; the file is set at host
63+
/// boot by `systemd-machine-id-setup` (or kairos / k0s-installer).
64+
const DEFAULT_MACHINE_ID_PATH: &str = "/etc/machine-id";
65+
6066
/// Field manager name used on PATCH. Distinct from the main controller
6167
/// so audit logs can tell apart a controller-side write from an
6268
/// agent-side write.
@@ -89,6 +95,29 @@ struct Cli {
8995
/// for one-shot invocations and for smoke tests.
9096
#[clap(long)]
9197
oneshot: bool,
98+
99+
/// Path to the host machine-id file. Default `/etc/machine-id`. Mounted
100+
/// into the DaemonSet via a single-file hostPath; override only for
101+
/// tests / sandboxes where the file lives elsewhere.
102+
#[clap(long, env = "MACHINE_ID_PATH", default_value = DEFAULT_MACHINE_ID_PATH)]
103+
machine_id_path: PathBuf,
104+
105+
/// Skip the host-identity cross-check before patching the Node.
106+
///
107+
/// Default: false (strict). Before each PATCH the agent fetches the
108+
/// target Node, reads its `status.nodeInfo.machineID`, and refuses to
109+
/// proceed if it does not match `/etc/machine-id` from the agent's
110+
/// host. This closes the "modified DaemonSet hard-codes NODE_NAME"
111+
/// impersonation vector documented in Phase 4 of the 2026-04-25
112+
/// security audit roadmap.
113+
///
114+
/// Set to true ONLY for environments where `/etc/machine-id` is
115+
/// genuinely unavailable (containers without the host file mounted,
116+
/// dev sandboxes, kubelet variants that do not populate
117+
/// `status.nodeInfo.machineID`). The strict default is the safe
118+
/// posture for production.
119+
#[clap(long, env = "SKIP_HOST_ID_CHECK", default_value_t = false)]
120+
skip_host_id_check: bool,
92121
}
93122

94123
#[tokio::main]
@@ -105,6 +134,30 @@ async fn main() -> Result<()> {
105134
.context("build in-cluster kube client")?;
106135
let nodes: Api<Node> = Api::all(client.clone());
107136

137+
// Read host machine-id once at startup. Failing here (file missing,
138+
// empty, etc.) is fatal in strict mode so an operator notices and
139+
// either fixes the mount or sets --skip-host-id-check explicitly.
140+
let host_machine_id: Option<String> = if cli.skip_host_id_check {
141+
warn!(
142+
machine_id_path = %cli.machine_id_path.display(),
143+
"--skip-host-id-check set: agent will trust NODE_NAME without verifying \
144+
/etc/machine-id. Use only when the file is genuinely unavailable."
145+
);
146+
None
147+
} else {
148+
let id = read_host_machine_id(&cli.machine_id_path).with_context(|| {
149+
format!(
150+
"read host machine-id from {} (set --skip-host-id-check=true to bypass)",
151+
cli.machine_id_path.display()
152+
)
153+
})?;
154+
info!(
155+
machine_id_path = %cli.machine_id_path.display(),
156+
"host machine-id loaded; will cross-check against Node.status.nodeInfo.machineID before each patch"
157+
);
158+
Some(id)
159+
};
160+
108161
if is_already_requested(&nodes, &cli.node_name).await? {
109162
info!(node = %cli.node_name, "reclaim annotation already present — exiting idempotently");
110163
return Ok(());
@@ -132,7 +185,15 @@ async fn main() -> Result<()> {
132185
// resubscribe inside `kube::runtime::watcher`.
133186
let watcher_handle = tokio::spawn(run_config_watcher(client, cm_name, tx));
134187

135-
let scanner_result = run_scanner(&nodes, &cli.node_name, &cli.proc_root, rx, cli.oneshot).await;
188+
let scanner_result = run_scanner(
189+
&nodes,
190+
&cli.node_name,
191+
&cli.proc_root,
192+
rx,
193+
cli.oneshot,
194+
host_machine_id.as_deref(),
195+
)
196+
.await;
136197
watcher_handle.abort();
137198
scanner_result
138199
}
@@ -239,6 +300,7 @@ async fn run_scanner(
239300
proc_root: &Path,
240301
mut rx: watch::Receiver<Option<Config>>,
241302
oneshot: bool,
303+
host_machine_id: Option<&str>,
242304
) -> Result<()> {
243305
loop {
244306
let cfg = rx.borrow().clone();
@@ -264,7 +326,7 @@ async fn run_scanner(
264326
Some(cfg) => match scan_proc(proc_root, &cfg) {
265327
Ok(Some(m)) => {
266328
info!(pid = m.pid, pattern = %m.matched_pattern, "match → annotating node");
267-
annotate_node(nodes, node_name, &m).await?;
329+
annotate_node(nodes, node_name, &m, host_machine_id).await?;
268330
return Ok(());
269331
}
270332
Ok(None) => {
@@ -283,7 +345,36 @@ async fn run_scanner(
283345
}
284346
}
285347

286-
async fn annotate_node(nodes: &Api<Node>, node_name: &str, m: &Match) -> Result<()> {
348+
/// PATCH the Node with reclaim annotations.
349+
///
350+
/// Before the PATCH (when `host_machine_id` is `Some`) the agent fetches
351+
/// the target Node and cross-checks `status.nodeInfo.machineID` against
352+
/// the host machine-id loaded at startup — refusing to patch a Node
353+
/// that does not match. This blocks the
354+
/// "modified-DaemonSet → impersonate-victim-Node" attack documented in
355+
/// Phase 4 of the 2026-04-25 security audit roadmap.
356+
///
357+
/// `host_machine_id == None` means the operator passed
358+
/// `--skip-host-id-check`; the function falls back to the pre-Phase-4
359+
/// behaviour of trusting `NODE_NAME` blindly.
360+
async fn annotate_node(
361+
nodes: &Api<Node>,
362+
node_name: &str,
363+
m: &Match,
364+
host_machine_id: Option<&str>,
365+
) -> Result<()> {
366+
if let Some(expected) = host_machine_id {
367+
let node = nodes
368+
.get(node_name)
369+
.await
370+
.with_context(|| format!("fetch Node/{node_name} for host-identity check"))?;
371+
if let Err(e) = compare_machine_ids(&node, node_name, expected) {
372+
error!(error = %e, "host-identity check failed — refusing to patch Node");
373+
bail!(e);
374+
}
375+
debug!(node = %node_name, "host-identity check passed");
376+
}
377+
287378
let ts = chrono::Utc::now().to_rfc3339();
288379
let patch = build_patch_body(m, &ts);
289380
let params = PatchParams::apply(FIELD_MANAGER).force();

0 commit comments

Comments
 (0)