diff --git a/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md b/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md new file mode 100644 index 0000000..3e365a3 --- /dev/null +++ b/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md @@ -0,0 +1,120 @@ +# SPIKE — Narrow-grant controller-swap mechanics (stdin `tee` write + sudoers wildcard matching) + +- **Date:** 2026-06-29 +- **Class:** Spike / empirical, read-only / non-destructive (scratch sudoers drop-in + `/tmp` scratch + file only; both removed in teardown). No production code, no change to the real + `/etc/sudoers.d/felhom-agent`, `/etc/felhom-controller-image`, `controllerswap.go`, or the bootstrap + unit. +- **Host:** felhom-pve (`demo-felhom`, 192.168.0.162), guest LXC 9201; agent runs non-root as + `felhom-agent`. Live controller image at spike time: `…/felhom-controller:0.89.0`. +- **Verdict: GO** — Option A (narrow-grant controller-swap) is mechanically sound as specced. All + three questions PASS; no vector needs reshaping. + +--- + +## Q1 — stdin round-trip through `sudo -n → pct exec → tee` — **PASS** + +Run as `felhom-agent`: +``` +printf '%s\n' 'gitea.dooplex.hu/admin/felhom-controller:0.89.0' \ + | sudo -u felhom-agent sudo -n pct exec 9201 -- tee /tmp/felhom-swap-spike +→ tee-exit=0 +``` +The bytes written into the guest, `od -c` tail: +``` +… c o n t r o l l e r : 0 . 8 9 . 0 \n +``` +Exact image ref **plus the trailing `\n`** — byte-identical to the golden's `printf '%s\n'` write. +The `$(cat)` parse the bootstrap uses: +``` +IMG=$(cat /tmp/felhom-swap-spike) → parsed=[gitea.dooplex.hu/admin/felhom-controller:0.89.0] +``` +stdin forwards cleanly through `sudo -n` and `pct exec` into the in-guest `tee`; the result is what +the reader expects. **The `bash -c "printf … >"` vector can be replaced by stdin-fed `tee`.** + +--- + +## Q2 — sudoers wildcard matching — **PASS (all 5 grants)** + +Scratch grants installed (validated `visudo -cf`): +``` +/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image +/usr/sbin/pct exec [0-9]* -- docker image inspect * +/usr/sbin/pct exec [0-9]* -- docker inspect -f * +/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service +/usr/sbin/pct exec [0-9]* -- tee /tmp/felhom-swap-spike (scratch path; real grant → /etc/felhom-controller-image) +``` +`sudo -n -l -- ` exit codes (0 = matched/permitted): + +| Real invocation | exit | +|---|---| +| `pct exec 9201 -- docker inspect -f '{{json .State}}' felhom-controller` | **0** | +| `pct exec 9201 -- docker inspect -f '{{.State.Running}}|{{if .State.Health}}…{{end}}|{{.Config.Image}}' felhom-controller` (the REAL `controllerHealthy` template) | **0** | +| `pct exec 9201 -- docker image inspect gitea.dooplex.hu/admin/felhom-controller:0.89.0` | **0** | +| `pct exec 9201 -- cat /etc/felhom-controller-image` | **0** | +| `pct exec 9201 -- systemctl restart felhom-controller-bootstrap.service` | **0** | +| `pct exec 9201 -- tee /tmp/felhom-swap-spike` | **0** | + +**The critical question — does `*` span spaces? YES.** `docker inspect -f *` matches the full +`-f '' ` line, the `*` spanning the format string AND the +container across the space. Same for `docker image inspect *`. (sudo matches the trailing argument +string with `fnmatch`, and an unanchored `*` covers whitespace.) **No grant needs reshaping** — the +`-f` template can stay the rich multi-field string; it does not need to be flattened to a fixed +template. + +### Negative controls — the grants stay SCOPED (not a blanket `pct exec`) — **PASS** + +| Non-granted invocation | exit (want 1) | +|---|---| +| `pct exec 9201 -- docker rm -f felhom-controller` | **1** | +| `pct exec 9201 -- rm -rf /etc` | **1** | +| `pct exec 9201 -- bash -c 'echo pwned'` | **1** | +| `pct exec 9201 -- tee /etc/felhom-controller-image` (real file; scratch only granted `/tmp`) | **1** | + +Arbitrary in-guest execution (`bash -c`, `rm`, `docker rm`) and writing the real image file are all +**denied**. The five narrow grants do NOT widen back into the general `pct exec * ` surface that this +work exists to avoid. (In the real grant, `tee /etc/felhom-controller-image` is a FIXED path — no +wildcard — so it is even tighter than the scratch `/tmp` form tested here.) + +--- + +## Q3 — read compatibility on the live guest — **PASS** + +Deployed unit `felhom-controller-bootstrap.service` → +`ExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh`. Its read line (verbatim): +``` +9: IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true) +10: [ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: …image missing"; exit 1; } +``` +`$(cat)` strips trailing newlines, so the read is newline-agnostic. The currently-deployed file is +48 bytes ending `…0.89.0\n` (od-confirmed) — i.e. the live golden already stores image+`\n`, exactly +what a `tee`-written `image\n` produces. **A `tee`-written file is consumed identically to today's +`printf` write** — no bootstrap change needed. + +--- + +## Go / No-go for Option A — **GO** + +All three unproven mechanisms hold on the live host: +1. stdin → `sudo -n` → `pct exec` → `tee` writes exact bytes incl. the trailing newline (Q1). +2. The 5 narrow sudoers grants match every real invocation, `*` spans spaces, and they remain scoped + — arbitrary exec stays denied (Q2 + negatives). +3. The deployed bootstrap's `$(cat)` read consumes a `tee`-written file identically (Q3). + +**No vector needs reshaping before implementation.** Implementation notes for the impl task (not this +spike): +- `GuestExec` (`guestbind.go:119`) has no stdin path today — the impl needs a stdin-carrying variant + (e.g. `GuestExecStdin(ctx, vmid, stdin io.Reader, args…)`) that sets `cmd.Stdin`; `writeImage` + switches from `bash -c "printf…>"` to that variant piping the image into `tee `. +- The 5 grants go into the real `/etc/sudoers.d/felhom-agent` (a new `FELHOM_CONTROLLERSWAP` alias) — + with `tee /etc/felhom-controller-image` as a FIXED path (no wildcard). Add them to the v0.44.0 + capability manifest so the build-test covers them. +- The image ref is already strict-validated (`controllerImageRe`) before write — the stdin/`tee` path + removes the shell entirely, so even that is now defence-in-depth rather than the only guard. + +## Teardown — confirmed clean + +`/etc/sudoers.d/felhom-agent-spike` removed; `/tmp/felhom-swap-spike` removed; `visudo -cf +/etc/sudoers` parses OK; remaining drop-ins `felhom-agent`, `README`, `zfs`; the real +`felhom-agent` grant is untouched (lxc-info grant still present). No secrets recorded (the image ref +is not a secret; no tokens were handled).