security: threat-model review, audit coverage, and key-rotation runbooks (#64) - #104
Merged
Conversation
…oks (#64) **The review.** `docs/security-review.md` maps every mitigation in docs/security.md to the test that makes it true, boundary by boundary. Where there is no test it says so and says what stands in its place — and it ends with five open items stated rather than left to be discovered, including that extraction is not out-of-process sandboxed and that rate limiting fails open. **Audit coverage** is a test rather than a paragraph that goes stale. `test_audit_coverage.py` fails when a declared `AUDIT_*` action has no call site, when the reviewed vocabulary drifts from the declared one, when an audited route stops writing its trail, or when a credential value reaches an audit row. What is deliberately *not* audited — reads, finding triage (it has the richer `finding_event` trail), lease/heartbeat traffic, failed logins — is recorded with the reasoning. **Pepper rotation needed a mechanism, not just a document.** Finding identity is `HMAC(pepper, locator ‖ rule ‖ HMAC(pepper, secret))` and the plaintext is never stored (ADR 0004), so a new pepper cannot be applied to existing rows by recomputation — the input is gone. Only an engine holding the secret mid-scan can compute its identity under a different key. So: a dual-pepper window. With `ICEBERG_PREVIOUS_FINGERPRINT_PEPPER_REF` set, leases carry both peppers, engines report each finding under both, and ingest treats a hit on the old identity as the same finding under a new key and re-keys it **in place** — same row, so state, resolution, assignee, notes, suppression and the whole event trail come with it. Scans count `rekeyed`, so watching it fall to zero is how an operator knows the rotation is complete. The previous pepper deliberately does not fail closed the way the current one does: without it a scan still produces correct findings, they just ingest as new, which the next scan of the window can fix. Refusing to scan would be the worse trade. `docs/runbooks/key-rotation.md` covers both keys and is explicit that they are different operations — the master key is a re-seal (mechanical, minutes, finding identities untouched), the pepper is a re-scan (a window of days). `test_pepper_rotation.py` is the dry-run the runbook's promises rest on: it builds a finding under an old pepper with a real analyst decision, runs a scan's worth of ingest, and asserts the decision is still attached to the same row — and separately demonstrates the duplicate-and-auto-resolve failure that happens without the window. Closes #64 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
richardmhope
force-pushed
the
m4-security-review
branch
from
July 31, 2026 08:26
a58d3f2 to
4344573
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the hardening epic's last issue. Three parts, one of which turned out to need code.
1. The review —
docs/security-review.mdEvery mitigation in
docs/security.mdmapped to the test that makes it true, boundary by boundary.The point is falsifiability: "we validate input" is not a control, a named test that fails when the
validation is removed is one.
Where a mitigation has no test it says so. It ends with five open items stated rather than left
to be discovered — extraction is not out-of-process sandboxed, the Vault backend is a seam, no
mTLS, reads are not audited, and rate limiting fails open by design.
2. Audit coverage — a test, not a paragraph
test_audit_coverage.pyfails when:AUDIT_*action has no call site (coverage on paper only);unchanged, only the trail is gone);
What is deliberately not audited — reads, finding triage (richer
finding_eventtrail),lease/heartbeat traffic, failed logins — is recorded with the reasoning rather than left as an
apparent gap.
3. Pepper rotation needed a mechanism
The problem. Identity is
HMAC(pepper, locator ‖ rule ‖ HMAC(pepper, secret))and the plaintextsecret is never stored (ADR 0004). A new pepper therefore cannot be applied to existing rows by
recomputation — the input is gone. No migration can do this; only an engine holding the secret
mid-scan can compute its identity under a different key.
Rotating without accounting for that is quietly destructive: every finding ingests as new and
reconciliation auto-resolves the originals, detaching every accepted risk and analyst note from
anything anyone will look at again.
The fix — a dual-pepper window. With
ICEBERG_PREVIOUS_FINGERPRINT_PEPPER_REFset, leases carryboth peppers, engines report each finding under both, and ingest treats a hit on the old identity as
the same finding under a new key — re-keying in place, so the row keeps its id and therefore its
state, resolution, assignee, notes, suppression and entire event trail. Scans count
rekeyed, sowatching it fall to zero is how an operator knows the rotation is done.
The previous pepper deliberately does not fail closed the way the current one does: without it a
scan still produces correct findings, they simply ingest as new, which the next scan of the window
can fix. Refusing to scan would be the worse trade.
Runbook + dry-run
docs/runbooks/key-rotation.mdcovers both keys and is explicit that they are different operations:master key = a re-seal (mechanical, minutes, finding identities untouched); pepper = a re-scan (a
window of days). It includes the re-seal loop, the "which sources still hold old identities?" query,
and what to do if a source cannot be scanned during the window.
test_pepper_rotation.pyis the dry-run those promises rest on — it builds a finding under an oldpepper with a real analyst decision, runs a scan's worth of ingest with both identities reported,
and asserts the decision is still on the same row afterwards. It also asserts the negative: that
without the window the same secret produces a duplicate.
make lint,make type,make helm-verifyand the full suite pass.Closes #64
🤖 Generated with Claude Code