Skip to content

ADR-0005: Secret findings — no plaintext persistence anywhere in Phase 2

Status: Accepted Date: 2026-05-14 Tags: security · secrets · redaction · chokepoint · threat-model · trust-tier · structural-defense Related: 02-ADR-0010, production ADR-0012, production ADR-0005, Phase 0 ADR — sanitizer chokepoint

Context

Phase 2 introduces three sources of secret findings: gitleaks (walks .git/ history for credential patterns), semgrep p/secrets (rule-pack matches over source), and an entropy fallback (Shannon entropy ≥ 4.5 bits/char over len ≥ 32 unknowns). Each can surface real cleartext — AKIA[0-9A-Z]{16}, ghp_[A-Za-z0-9]{36}, JWTs, RSA private-key blocks, npm_…, sk-ant-… — verbatim in a ProbeOutput.schema_slice. Without a defense, that cleartext flows through the Phase 0 sanitizer (which scrubs known field-name patterns but not unknown high-entropy values inside arbitrary string fields) and lands in repo-context.yaml, every raw/*.json, the cache blob, and potentially the audit anchor. Phase 0 ADR-0011 puts .codegenie/ at 0700/0600 inside the user's $HOME — but the analyzed repo is often shared (a PR, a git host, a CI runner artifact). Once cleartext is in .codegenie/context/raw/gitleaks-findings.json, it can leak via any normal artifact-copy path.

The security lens proposed encryption-at-rest: store gitleaks findings encrypted under a per-repo key (~/.codegenie/keys/<repo>.key) into .codegenie/findings/secrets/<fp>.enc. The lens's own §Risks §5 admits "keys live in $HOME, same trust tier as ~/.ssh/id_rsa." The critic (critique.md §"Attacks on the security-first design" #5) called this verbatim — "key + ciphertext in the same trust tier ($HOME) is obfuscation, not security": the host compromise the encryption is theoretically defending against can read both files. Encryption-as-theatre with operational fragility cost (lost key = lost findings; per-repo key management; rotation story).

The synthesis (final-design.md §"Components" #4, §"Conflict-resolution table" row 4, §"Departures from all three inputs" #4) picked the structural fix: don't persist plaintext at all in Phase 2. Findings are detected by SecretRedactor, in-memory SecretFinding records are collected for the CLI summary line (count + file:line list, fingerprints only), and the persisted slice contains <REDACTED:fingerprint=BLAKE3_8> where the cleartext was. No ~/.codegenie/keys/. No .codegenie/findings/secrets/<fp>.enc. Phase 5's microVM (production ADR-0012) is named explicitly as the escalation door if a downstream stage (Phase 4 LLM fallback, Phase 6 SHERPA gate, etc.) genuinely requires cleartext access for a remediation judgment — that cleartext is re-derived inside the sandbox from the analyzed repo at decision time, not from a Phase-2 persisted artifact.

Options considered

  • Option A — Inline plaintext in gitleaks-findings.json, redact only on render to repo-context.yaml. Pattern: none. Best-practices lens's silent default. Wrong: raw/*.json artifacts are persisted, copied, shared; PR-evidence-bundle workflows would carry cleartext.
  • Option B — Encrypt plaintext at rest under a per-repo key in $HOME. Pattern: Capability + key management. Security lens's pick. Critic-attacked as theatre — key and ciphertext in the same trust tier; operational fragility cost; no security gain against the actual threat (host compromise reads both).
  • Option C — Don't persist plaintext anywhere; redact at the writer chokepoint; in-memory fingerprints + file:line only; defer cleartext access to a sandboxed Phase-5+ consumer that re-derives from the analyzed repo. Pattern: Structural defense (make the failure mode impossible by construction) + chokepoint composition. Synthesis pick. Eliminates trust-tier theatre; the chokepoint discipline survives; the reviewer's existing workflow (run gitleaks manually at PR time for cleartext) is preserved.
  • Option D — Persist plaintext but mark .codegenie/findings/ 0600 with a refusal-to-copy hook. Pattern: Defense-in-depth. Wrong: filesystem permissions are advisory across cp, tar, rsync; "refusal-to-copy hook" is not a real mechanism.

Decision

Adopt Option C — no plaintext persistence anywhere in Phase 2. SecretRedactor (a new pass composed into Phase 0's OutputSanitizer) intercepts every string in every ProbeOutput.schema_slice before it reaches repo-context.yaml, raw/*.json, the cache blob, or the audit anchor. Each pattern match is replaced with <REDACTED:fingerprint=BLAKE3_8> inline. The in-memory list[SecretFinding] (probe name, fingerprint, pattern class, optional file:line) is consumed by the CLI summary line only — not persisted to any file. Plaintext present in zero persisted files is the testable invariant (tests/adv/phase02/test_secret_in_source.py). Phase 5's microVM (production ADR-0012) is the named escalation door for any future cleartext-required judgment; cleartext is re-derived from the analyzed repo inside the sandbox at decision time. Pattern: Structural defense (make the failure impossible by construction) + Chain of responsibility / pipeline composition.

Tradeoffs

Gain Cost
Eliminates the trust-tier theatre — no key + ciphertext in the same $HOME directory; the host compromise that defeats encryption-at-rest is no longer in the defense story A reviewer who wants to inspect the actual secret string must run gitleaks themselves against the repo at PR-review time; the persisted evidence carries only fingerprint + file:line. The team's existing secret-hunting workflow is unchanged but Phase 2 doesn't compress it
No per-repo key management — no rotation story, no "lost key = lost findings" operational fragility, no first-run side effect on ~/.codegenie/keys/ Phase 2 cannot do "secret rotation suggestions" inline; that is deferred to Phase 4+ task classes (LLM-assisted remediation), which will go through Phase 5's microVM for cleartext
Single chokepoint — the Phase 0 OutputSanitizer.scrub pipeline gains one composed pass (redact_secrets); there is no parallel write pathway. The chokepoint discipline survives by composition, not by edit Phase 0's OutputSanitizer API surface grows by one new function (redact_secrets); the discipline now requires Phase 4+ LLM consumers to also funnel through the same chokepoint (Gap 5 improvement: test_no_inmemory_secret_leak.py asserts this structurally)
Tested by structural invariant — tests/adv/phase02/test_secret_in_source.py plants a seeded AWS key and asserts plaintext is present in zero persisted files (yaml, every raw artifact, cache, audit). A regression is a build failure A future contributor could thread the in-memory list[SecretFinding] into a debug log or audit anchor and silently leak plaintext — that risk is closed by the RedactedSlice smart constructor (02-ADR-0010) making "redactor was called" type-checkable
Mutation-tested pattern set — tests/unit/test_secret_redactor.py::test_aws_key_mutation fails the build if a deliberately weakened pattern (e.g., AKIA[0-9A-Z]{15}) still passes. Pattern coverage is verified, not asserted Pattern coverage is finite — a novel credential class not in the pattern set (a vendor-specific token shape) escapes redaction. The Shannon-entropy fallback (≥ 4.5 bits/char on len ≥ 32 unknowns) is the safety net; entropy-based detection has false positives that downgrade to <REDACTED:fingerprint=…> losing the original UTF-8 (acceptable per Rule 12 — fail loud, conservative)
Phase 5 microVM (production ADR-0012) is the named escalation door — when cleartext is genuinely required for a remediation judgment, it lives only inside the sandbox lifetime, never on disk in $HOME Phase 4+ designers must engage with the escalation door at design time; "we'll just read the plaintext" is not an option — they must take the microVM path. This is the load-bearing structural promise

Pattern fit

Pattern: Structural defense — make the failure impossible by construction (design-patterns-toolkit.md §"Make illegal states unrepresentable" applied at the persistence boundary) + Chain of responsibility / pipeline composition (design-patterns-toolkit.md §"Chain of responsibility / Pipeline"). The toolkit's illegal-states discipline is normally a typing rule ("choose types so the impossible state can't be constructed"); here the same discipline is applied at the I/O boundary — "choose the persistence pipeline so cleartext can't reach disk." The pipeline shape is the existing Phase 0 sanitizer: one chokepoint, one pass added by composition, no parallel pathway. The pattern's failure mode the toolkit warns against ("a 'pipeline' where each stage has 14 ways to mutate shared state") is avoided: the redactor takes a slice + probe name, returns a redacted slice + in-memory findings list, side-effects nothing. The capability-pattern temptation the security lens fell into ("LLM accesses plaintext only via SecretFindingCapability token") is refused — the toolkit's capability-pattern failure mode ("authorization with a fancier name") applies verbatim across an LLM boundary.

Consequences

  • src/codegenie/output/sanitizer.py (Phase 0) gains the redact_secrets(slice_, probe_name) -> tuple[dict[str, JSONValue], list[SecretFinding]] function. The pipeline composition is: field-name regex (Phase 0) → JSONValue tree walk (Phase 0) → redact_secrets (Phase 2). One chokepoint.
  • The pattern set ships with mutation tests: AWS (AKIA[0-9A-Z]{16}), GitHub (ghp_[A-Za-z0-9]{36}), JWT, RSA private-key block (-----BEGIN…PRIVATE KEY-----), NPM (npm_[A-Za-z0-9]{36}), Anthropic (sk-ant-…), Shannon-entropy ≥ 4.5 bits/char fallback on len ≥ 32 unknowns. Each pattern has a mutation test under tests/unit/test_secret_redactor.py.
  • tests/adv/phase02/test_secret_in_source.py is the load-bearing structural test: plant a seeded AKIA... in the repo; gather; assert plaintext present in zero persisted files (repo-context.yaml, every raw/*.json, the cache blob, the audit anchor). Build FAILS if cleartext is found.
  • tests/adv/phase02/test_no_inmemory_secret_leak.py (Gap 5 improvement) asserts at the boundary: every artifact reachable from the Phase 0 writer chokepoint passes through redact_secrets; no probe output is exposed to any consumer outside the sanitizer pipeline. Textual / structural test pattern (the same shape Phase 0 already uses for forbidden-patterns).
  • The CLI gathers prints a one-line summary on completion: secrets_redacted_count: <N> plus the file:line list (no plaintext). A zero-count run is grep-able for auditors.
  • Composes with 02-ADR-0010 (RedactedSlice smart constructor): the writer accepts only RedactedSlice, making "redactor was called" type-checkable rather than convention-enforced. The in-memory list[SecretFinding] is returned separately, never threaded back into a RedactedSlice.
  • Phase 4 (LLM fallback) inherits the chokepoint guarantee. The Phase-2 redactor runs before any artifact an LLM could see; Phase 4's RAG store builds on top of redacted artifacts. If Phase 4 designs a new "RAG document" shape that pulls fields directly from in-memory probe outputs, test_no_inmemory_secret_leak.py fails — the test is the contract.
  • Phase 5 microVM (production ADR-0012) is the named escalation door — when a Phase 4+ task class needs cleartext for an automated remediation judgment, the microVM re-derives the secret from the analyzed repo inside the sandbox lifetime. The exact handoff (does the microVM receive (file:line, pattern_class, fingerprint) and re-scan? does it get a one-time decryption capability tied to the workflow ID?) is a Phase 5 design concern; Phase 2's commitment is only that we do NOT persist plaintext anywhere Phase 4 can reach it.

Reversibility

Medium. Switching to encrypt-at-rest later is a redact_secrets rewrite + a new encrypt_at_rest pass added to the sanitizer pipeline + key-management infrastructure under ~/.codegenie/keys/. The persisted-artifact layout would gain .codegenie/findings/secrets/<fp>.enc; consumers (Phase 3+ adapters, LLM stages) would need to know to decrypt. The harder reversal is switching back to plaintext — once the structural-defense commitment is gone, Phase 2's load-bearing test (test_secret_in_source.py) deletes, and the "plaintext present in zero persisted files" invariant is lost forever. The structural-defense direction is one-way by design; that's what makes it load-bearing.

Evidence / sources

  • ../final-design.md §"Components" #4 SecretRedactor — the structural-fix rationale
  • ../final-design.md §"Conflict-resolution table" row 4 — Secret findings handling resolution
  • ../final-design.md §"Departures from all three inputs" #4 — explicit departure from all three input lenses
  • ../final-design.md §"Failure modes & recovery" row 7gitleaks finds AWS key in .git/ history; structural containment
  • ../phase-arch-design.md §"Component design" #4 SecretRedactor — pattern set, mutation test discipline, in-memory findings policy
  • ../phase-arch-design.md §"Goals" G5 — the testable invariant (plaintext in zero persisted files)
  • ../phase-arch-design.md §"Gap analysis & improvements" Gap 4, Gap 5RedactedSlice (02-ADR-0010) + test_no_inmemory_secret_leak.py closures
  • ../critique.md §"Attacks on the security-first design" #5 — encryption-as-theatre framing
  • Production ADR-0012 — microVM substitution; the named escalation door
  • Production ADR-0005 — composes with: Phase 2's gather has no LLM access; the chokepoint guarantee inherits to Phase 4 RAG ingestion