# SESSION-0026.5 — flotilla operator-CLI security audit (READ-ONLY subsession) - **Parent:** SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md - **Role:** subsession 0026.5 of OHM security-audit driver session 0026 - **Surface:** `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla` (the flotilla operator CLI) - **Scope:** READ-ONLY. No edits, no deploys, no secret rotation, no git mutation. Only file written = this transcript. - **Focus:** §3 invariant 1 ("no secret bytes in the build pipeline / state / log / stdout"), overlay-vs-secret separation, SSH command safety, `secret set` stdin-only. ## Pre-state Branch `main`, clean except untracked `.claude/`. Audited the canonical tree under `ohm_rfc_app_flotilla/` (NOT the `.claude/worktrees/` copies, which a naive `find` surfaced first — pivoted to the real package dir). Read SPEC.md §1–§13 + CLAUDE.md. ## Turn-by-turn arc 1. Read SPEC.md (§3 invariants, §6 overlay, §7 secrets, §8 deploy gesture, §10 log) and the binding CLAUDE.md "never ask for secret bytes" / two-layer rules. 2. Listed canonical source; read secrets.py, ssh.py, deploy.py in one batch. 3. Read deploy_log.py, db.py, registry.py, plan.py, pin.py. 4. Grepped cli.py for stdin/tty/secret/overlay output paths; read the overlay + secret CLI verbs in full; read `secret set` (TTY guard + stdin). 5. Checked migrations for any `secret_value` column (none — confirmed) and skimmed test_secrets.py + test_deploy.py for hardcoded/echoed real secrets (none; all stub bytes like `b"NEVER-LEAK-..."`). 6. Adversarial pass on candidate findings: - KILLED "snapshot hash leaks overlay values" — hash is sha256, one-way; values not recoverable. Not a leak. - KILLED "SSH command injection via target_version" — pin.py validates strict SemVer regex `^\d+\.\d+\.\d+$` before it becomes `v`; all VM coords and vite values go through `shlex.quote`. No injection. - KILLED "secret could be set as overlay" as a *code bug* — downgraded to informational: overlay/secret are distinct tables + distinct CLI paths; the non-secret-overlay boundary is operator discipline per CLAUDE.md two-layer rule, and there's no automated key-name guard, but that's a documented design boundary, not a defect. Noted F3. - CONFIRMED the phase-detail redaction gap (F1) by tracing append_phase → persisted `phases[].detail` vs. the later error_summary-only redaction. - Held F2 (gcloud `--quiet` host-key TOFU) as needs-verification. ## Cut state — findings - **F1 (MEDIUM, high confidence):** Per-phase `detail` is persisted to the `deploys.phases` JSON column UNREDACTED. deploy.py `_PhaseRunner.run` writes `detail=e.detail` (e.g. phase-3 `f"git fetch/checkout failed: {r.stderr...[:240]}"`) via `deploy_log.append_phase` at the moment of failure. The `redact_live` scrub runs LATER and ONLY on the separate `error_summary` string (`_finalize_failure`). A secret that surfaces in a command's stderr lands unredacted in flotilla state. Violates §3 invariant 1 / §10.2. The existing test (test_deploy.py:248) checks only `outcome.error_summary`, never the persisted `phases[].detail` — so the gap is untested. Remediation: redact phase detail at `append_phase` time (pass the live `_ResolvedSecrets` into `_PhaseRunner`), or redact in `_phaseN` before raising `_PhaseFailure`. - **F2 (LOW, needs-verification):** ssh.py invokes `gcloud compute ssh ... --quiet`. `--quiet` auto-accepts prompts including first-connect host-key confirmation (TOFU silently accepted). gcloud still manages `google_compute_known_hosts` and pulls host keys from instance metadata when present, so steady-state MITM exposure is limited; residual risk is first-connect. Verify exact gcloud host-key behavior before treating as actionable. Remediation if confirmed: rely on GCP guest-attr host keys / IAP (the §19.2 "harden SSH via IAP" candidate already tracks this). - **F3 (INFORMATIONAL):** No code guard prevents an operator from placing a sensitive value into the non-secret overlay (e.g. as a `VITE_*` key, which deploy.py phase-5 interpolates into the build command AND bundle-embeds). This is the documented two-layer boundary (operator discipline), not a defect — flagged so the driver knows the enforcement is human, not mechanical. - **F4 (LOW, edge):** `redact()` / `redact_live()` match needles via `bytes.decode("utf-8", errors="replace")`. A secret containing non-UTF-8 bytes would decode to a different string than what landed in (replace-decoded) stderr, so redaction could miss it. OHM secrets are random ASCII/UTF-8 tokens, so low real risk; noted for completeness. ## What's solid (controls correctly enforcing §3 invariant 1) - No `secret_value` column anywhere; migration 002 `secret_refs` holds only project/id/version refs. DB stores references, not bytes. - `secret set` is stdin-only with an explicit `sys.stdin.isatty()` TTY refusal (cli.py) — matches CLAUDE.md exactly; bytes never enter shell history/args. - `read_secret` returns bytes that the deploy/plan paths use transiently; `_ResolvedSecrets` holds them in bytearrays scrubbed-to-zero on scope exit (incl. exception unwind), env_body bytearray zeroed in a `finally`. - `.env` written atomically over SSH stdin (never local disk, never a command arg): `umask 077` + `cat > tmp` + `mv` as `sudo -u `, mode 0600, owner = service user. Verified env body travels via `input=` (stdin), not the command string (test_deploy.py:166 asserts bytes are in stdin, not in any recorded command). - Snapshot hash and plan render use ONLY ref strings, never bytes. - error_summary redaction path IS applied (the F1 gap is the *phase detail*, a separate persisted field). - All SSH-interpolated values `shlex.quote`d; pin is strict-SemVer-validated. - `secret list` / `overlay show` render `[secret: /@]`, never resolved bytes. - Tests use only stub bytes (`b"NEVER-LEAK-..."`, `b"abc123"`); no real secret hardcoded or echoed. ## Driver needs to know - F1 is the one worth fixing: it's a real (if narrow — requires a secret to appear in remote stderr) §3-invariant-1 leak into flotilla state, and it's untested. - F2 ties into the already-tracked §19.2 "harden SSH via IAP" item. ## §19.2 candidates surfaced - Mechanical overlay-key denylist / secret-key collision guard (relates F3) — make the two-layer rule enforced, not just disciplined. - Redaction hardening: redact at every state-write boundary (phase detail included), not only at finalize (relates F1, F4). No secret bytes appear anywhere in this transcript.