feat(launchpad): secret-material detection via gitleaks (#67) - #271
Conversation
Implements the engine and allowlist location ADR-0006 decided: gitleaks, driven by a single .gitleaks.toml at the repo root extending the default ruleset. PR-diff path fails the run on any finding; scheduled full-history path reports (WARN, never FAIL) so pre-existing history findings don't permanently redden the audit -- no baseline snapshot is used, since a regenerated baseline has no field for a reason and can silently swallow a real finding, per ADR-0006's own rejection of that mechanism. Custom rules cover what gitleaks' default ruleset provably misses (verified empirically in ADR-0006 before this task started): Nostr nsec/hex private keys including BUZZ_PRIVATE_KEY, glibc crypt hashes ($1/$5/$6/$y$, including the optional rounds=N$ segment), BUZZ_S3_* access/secret keys, and a Postgres URL with an embedded password. SSH private keys and registry tokens are covered by gitleaks' own default rules, confirmed against fixtures rather than assumed. Verified locally before commit: - All 7 required material categories fire against synthetic fixtures (12 findings across 7 rule IDs, checked directly against the JSON report). - The two known false-positive classes -- the dev-deployment-SOP.md documentation placeholders and Cargo.lock checksums -- produce zero findings against the real repo, confirmed by filtering the actual scan output, not assumed from the config. - Full-history scan: 5729 commits, 16s -- comfortably inside the 3-minute PR budget even on the path that's explicitly allowed to exceed it. - Real first-run baseline measured: 222 findings across history, spot- checked several directly (test fixtures, Helm chart test placeholders, a Rust test helper's literal test password) -- consistent with the file list being dominated by *_test.* paths. Decided behavior: WARN, visible every run, no baseline file. Not silently triaged to zero here; remediating or allowlisting the 222 is follow-on work this task surfaces rather than resolves. - 42 harness tests pass together; the full security_audit.py entrypoint runs end-to-end locally reproducing what CI will do. Fixtures are synthetic (a real-but-unused SSH key generated solely for this purpose; every other value fabricated) and excluded from the live scan by an explicit, commented allowlist entry -- not by accident of path.
|
Throwaway-branch proof, per #67's own definition of done ("every rule has been observed failing a real run on a throwaway branch, with the run linked from the PR"): Pushed a disposable branch off this one with a single planted, synthetic secret ( Run: https://github.com/launchpad-26/buzz/actions/runs/32440647262/job/96650516602 Raw output from the actual CI run: File, line, rule ID — never the matched value, on the real workflow, not a local approximation. This demonstrates the PR-diff wiring itself (trigger, exit-code handling, the check going red on github.com) actually works end to end. The other 6 rule categories are proven firing against planted fixtures locally (see PR body Verification) rather than each getting their own throwaway PR — one real CI failure proves the mechanism; the fixture scan proves the ruleset. |
|
Automated review (requested by @serina, via her review-code/review-tests/review-adjudicate pipeline — independent reviewers + adjudicator, each re-verified claims against the actual repo and each other's severities rather than trusting the diff) Blocker
High
Medium / Low (non-blocking, worth a look)
|
serina-mcfall
left a comment
There was a problem hiding this comment.
Requesting changes — two Blockers from the automated review posted above, both need fixing before merge:
- security_audit_tracked_files_check.py:56 — the .example exemption reopens the exact hole #68 was filed to close. I ran the actual patterns against the files the code comment cites as justification (.env.example, deploy/compose/.env.example, mobile/.env.json.example) — none of them ever needed the exemption; every pattern is anchored to a filename ending that .example already changes. The exemption's only real effect is suppressing a match inside a tracked seed/ or seed.sample/ directory — the shape #68 documents a real past incident for (a committed SSH public key). A file like launchpad/deploy/archived/seed/authorized_keys.example reports PASS today. Please drop the exemption, or scope it to the .env family the way .gitleaks.toml already does.
- test_security_audit_ignore_coverage_check.py:20 — test_full_coverage_passes's fixture is built by iterating REQUIRED_COVERAGE itself, so it can't fail on a dropped pattern. 8 of the 11 required patterns — including identity.key, id_rsa, id_ed25519 — have no independent test; they could be silently removed from the dict and the suite would stay green. Please add a hardcoded-literal test per unprotected pattern, same shape as the existing *.pem and seed.sample/ tests.
The 5 Medium findings from the same review are non-blocking — happy to file those as follow-up issues too if you want, same as #271's.
#271) _scan_pr_diff reported INDETERMINATE on every infrastructure failure (unset GITHUB_BASE_REF, git fetch failure, gitleaks engine error), and security_audit_core.exit_code() treats INDETERMINATE the same as PASS - so a PR whose scan never actually ran went green on the gate path. Contradicts ADR-0008's "indeterminate must never render as pass". Fixed to FAIL; _scan_full_history's own INDETERMINATE is unchanged since that path already WARNs rather than FAILs on findings by design. Also closes three test gaps review-code flagged as High: no test asserted --redact was actually passed to gitleaks, no test asserted _run_gitleaks's log_opts/timeout call args (the FETCH_HEAD..HEAD PR-scoping guarantee was unverified), and RunDispatchTest discarded run()'s return value entirely, so a dropped `return` would go unnoticed here despite crashing format_report elsewhere.
…g hit on this PR's own CI run Reproduced live: after the previous commit's fix made infra failures FAIL instead of INDETERMINATE, this PR's own CI run failed with "98 finding(s) in this PR" spanning AGENTS.md, Justfile, and NIP spec docs -- years-old content nowhere near this PR's actual diff. Root cause: `git fetch --depth=1 origin base_ref` grafts a new shallow boundary onto that one ref, regardless of the checkout already having full history (this workflow's actions/checkout uses fetch-depth: 0). Once base_ref has advanced past this branch's own merge-base - true for almost any real PR, since branches don't rebase on every base push - git can no longer see the shared ancestor as reachable from the shallow, parent-less FETCH_HEAD, and `git log FETCH_HEAD..HEAD` silently expands from "this PR's own commits" to the entire history reachable from HEAD. Confirmed directly against this repo: with --depth=1, FETCH_HEAD..HEAD went from 2 commits to 2,484. Removing --depth=1 (a plain `git fetch origin base_ref`, matching what the checkout already guarantees) resolves it back to 2 commits, and the real gitleaks binary run end-to-end against this branch now reports PASS. Added a regression test asserting the fetch command never requests a shallow depth.
Blocker 1: security_audit_tracked_files_check.py's *.example suffix exemption reopened exactly the hole #68 was filed to close. None of _SENSITIVE_PATTERNS ever matched a .example-suffixed filename in the first place (all end-anchored to .env/.key/.pem/id_rsa/id_ed25519), so the exemption bought nothing there -- but seed/ and seed.sample/ match on directory component, not filename, so a tracked seed/authorized_keys.example previously slipped past it despite being the exact shape #68 documents a real past incident for. Dropped the exemption entirely per review-code's first suggested fix. Also fixed the same --depth=1 shallow-fetch bug from #271 in this file's own newly-hidden-tracked-file diff, found while in there. Blocker 2: test_full_coverage_passes built its fixture by iterating REQUIRED_COVERAGE itself, so it could never fail on a dropped pattern -- 8 of 11 required patterns, including identity.key/id_rsa/ id_ed25519, had no independent test protecting them. Added one hardcoded-literal regression test per previously-unprotected pattern, same shape as the existing *.pem/seed.sample/ tests. Verification: 78 tests pass (up from 61), and a real end-to-end `security_audit.py .` run reports 4 pass / 0 fail / 1 warn (the full-history WARN is expected and unchanged).
serina-mcfall
left a comment
There was a problem hiding this comment.
Changes requested — one blocker. Also: the change-request currently on this PR belongs to #275.
Reviewed in a fresh context, with the real pinned gitleaks binary. I am an agent; I do not approve or reject — this flags what needs fixing before @serina-mcfall approves.
First, a status problem worth untangling before anything else
The formal CHANGES_REQUESTED blocking this PR is not about this PR's code. It was submitted 2026-08-21T03:44:36Z and cites security_audit_tracked_files_check.py:56 and test_security_audit_ignore_coverage_check.py:20. Neither file exists in #271's diff. Both live in #275, which carries a textually identical review submitted two minutes earlier at 03:42:38Z — including the line "same as #271's", a self-reference that only makes sense sitting on #275.
So it is not stale and not live; it never applied here, and no change to #271 can address it. Worth dismissing rather than trying to satisfy.
#271's own review did land correctly as an issue comment at 03:34:07Z, raising a real blocker at security_audit_secrets_check.py:143 — INDETERMINATE rendering as PASS on the gate path, against ADR-0008. Commit 8a73711d fixed it, and I verified the current head: every infra-failure branch on the PR-diff path now returns FAIL, and the tests assert --redact is passed and that run()'s return value propagates. That one is genuinely addressed.
Fail-open: correct, and I want to state it plainly since it is the thing that matters most
The gate path fails closed on every infra failure — missing binary, timeout, unexpected exit code, malformed JSON, unset GITHUB_BASE_REF, failed base fetch. _run_gitleaks returns (None, reason) and never a false-clean []. Checked against the harness contract: exit_code() fails the run only on FAIL, which is exactly why the asymmetry is right — the gate path deliberately never returns INDETERMINATE, while the non-gating full-history scan legitimately does. The workflow runs it with no || true. Good design, and the reasoning is visible in the code.
Blocker — the fixtures that prove the rules work are never run by any test
launchpad/scripts/test_security_audit_secrets_check.py and launchpad/scripts/security_audit_fixtures/secrets/
Fixtures exist for all 7 secret categories, and the PR body shows a manual run proving each fires. But that run is not a test. The test file says so itself: "Mocks subprocess.run throughout — no real gitleaks invocation… this suite is about the check script's own branching and error handling." Every test patches subprocess.run or _run_gitleaks. Nothing ever runs the real binary against the fixtures.
So the command CI actually executes — python3 -m unittest discover -s launchpad/scripts -p "test_security_audit*.py" — stays green if a rule breaks.
The failure scenario is concrete and your own comment predicts it: the file notes a near-miss where a capturing group silently dropped a finding to zero once useDefault = true. Edit .gitleaks.toml, break nostr-nsec-private-key, and CI reports OK while a real Nostr private key committed to the repo goes uncaught.
Fix: one test or CI step that runs the checksum-pinned gitleaks against security_audit_fixtures/secrets/ with the real .gitleaks.toml and asserts each of the 7 rule IDs fires at least once — the command already in your Verification section, made repeatable instead of manual.
Blocking because this is the difference between a scanner and a scanner-shaped thing: every other guarantee here is only as good as the ruleset, and nothing watches the ruleset.
Verified clean
- No secret reaches a log.
--redacton every path,_summarize()builds detail strings only fromFile/StartLine/RuleID, and tests assert the fixture's fake secret value is absent fromresult.detail. I confirmed empirically that the real binary returns"Secret": "REDACTED". - History is scanned, not just the working tree —
gitleaks detectthroughout; my own full-history run covered 7,116 commits. Findings there WARN rather than FAIL by explicit design per ADR-0006, so pre-existing secrets never block a merge — a stated trade-off, not a hidden one. - Version pinned and checksum-verified — gitleaks 8.30.1, SHA256 checked against the published checksums.
- Two allowlist entries are sound: the
.env.examplepath scope, and the loopback-only Postgres URL regex.
Non-blocker, filed as #389
Two global allowlist entries are broader than their stated reason. .gitleaks.toml:108-109's <[A-Za-z0-9 ]+> regex is justified by a claim that does not hold — I ran the ruleset with the global block stripped and <64 hex characters> produces zero findings either way; the only line that fires is the adjacent Public key: one, already covered by a separate literal entry. And :106 exempts any Cargo.lock anywhere rather than the two files the comment verifies. I could not construct a working bypass through either, so this is forward-looking risk, not a hole today.
Not verified
gitleaks was not installed here — I fetched the pinned 8.30.1 into a scratch directory (never the repo tree) and checksum-verified it, so the empirical claims above are real runs, not inference. I did not visually confirm the literal final return/sys.exit line of security_audit_core.main(); I inferred it calls exit_code(results) from the module docstring and that being the function's only use. That is the one link in the fail-closed chain I would want a second pair of eyes on.
Dismissing as misfiled -- this review is not about #271. It cites security_audit_tracked_files_check.py:56 and test_security_audit_ignore_coverage_check.py:20; neither file exists in #271's diff. Both are in #275, which carries a textually identical review submitted two minutes earlier (03:42:38Z), including the phrase 'same as #271's' -- a self-reference that only makes sense on #275. No change to #271 could have addressed it. #271's own review landed correctly as an issue comment at 03:34:07Z and its blocker was fixed in 8a73711; a current review has been posted separately.
Requested changes NOT yet done — worth a look soonChecked at head Still open — the fixtures are never exercised by any test CI runs.
So The fix is small: one test or CI step running the checksum-pinned binary against Everything else stands verified and is good work: the gate path fails closed on every infra failure, Flagging rather than nagging — this is the last thing between the PR and mergeable, and it is blocking #275 behind it. |
) (#430) Reviewing a batch of PRs has two halves. One is judgement -- is this claim true, does the conclusion depend on this defect. The other is bookkeeping applied identically to every PR. This extracts the second half, which is the line ADR-0019 draws and the same extraction pr_body_check.py and adr_boundary_check.py already did for their own rules. Six classifiers, each one a rule applied by hand across three review batches on 2026-08-21/22, and each one applied WRONGLY at least once: - STALE/MISFILED reviews. Four PRs carried change-requests already satisfied. #262's blockers were fixed at 03:21 and the review restating them arrived at 03:57. #271's change-request was #275's review MISFILED -- textually identical including a "same as #271's" self-reference. No change to #271 could have addressed it. - CI triage. #268's red CI was setup-mold timing out on a one-markdown-file PR. #288's log printed four inherited warnings above the real blocker. - Independence. #265 carried a commit written in the reviewing session. - Leak scan. #281 quoted a private hook's header in a public file. - Placement, per AGENTS.md section 3. - Drift calibration. #374's "796 files" was reported REFUTED by a reviewer who measured at the live tip and got 912. The script emits the pinned SHA so a count that does not reproduce reads as drift, not error. It emits no severity. Five proposed blockers were demoted and one upheld across those batches, each turning on whether a conclusion depended on the defect; a script guessing that is the model-gating ADR-0019 forbids wearing automation's clothes. test_the_briefing_states_no_severity_anywhere asserts the absence. It also posts nothing, so it can run read-only. Three defects found by running it against live PRs rather than by reading it, each now a regression test: 1. FALSE MISFILED on #374. The only path token in a genuine review was `launchpad/ARCHITECTURE.md`, cited as corroborating evidence rather than as a defect site. Reviews cite files outside the diff constantly -- that is what checking a claim looks like. MISFILED now needs two or more cited paths, none in the diff, and no mention of any changed file. 2. Selecting log lines BY POSITION. The first draft took the last 80 lines; GitHub appends checkout teardown, so on #288 the size-guard line had scrolled past and a REAL failure classified as UNKNOWN. Selection is by content now. 3. The path regex required `:\d+` with no space, so it saw compiler output (`lib.rs:276:15`) but not the file-size guard (`lib.rs: 1000 -> 1001`). Verified against live PRs after the fixes: #288 classifies Desktop Core REAL on desktop/src-tauri/src/lib.rs and Desktop Smoke E2E PRE_EXISTING, matching the hand analysis; #374's calibration returns 796 files at the 67-commit point with tip 9891e64, matching the figure reconstructed by hand. 41 tests in test_pr_review_batch, 256 across launchpad/scripts. Registered in test_no_model.py's ALLOWLIST rather than NOT_OURS, deliberately: a script that prepares review material must be provably unable to call a model. DEVIATION from the issue's own done-when: it asked for registration in INTERFACE.md. Not done -- INTERFACE.md is #116's pre-flight record contract, not a script index, and an unrelated entry there would degrade a contract document. The script's module docstring is its interface. Refs #426 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…fixtures @benmitchell11 is out sick; this is the blocker from the change-request on #271, applied on their behalf so the PR is not held for their return. The blocker: fixtures existed for all seven secret categories and the PR body pasted a manual run proving each fires, but no test ran gitleaks. The only test file mocks subprocess.run throughout and says so in its own docstring, so `unittest discover -p "test_security_audit*.py"` stayed green if a rule in .gitleaks.toml stopped matching. That file's own comment records a near-miss where a capturing group silently dropped a finding to zero once useDefault was added. Adds test_security_audit_gitleaks_ruleset.py, which runs the pinned binary against launchpad/scripts/security_audit_fixtures/secrets/ with the real ruleset and asserts six things: findings exist at all; every rule this repo defines fires; every gitleaks default rule the config leans on fires; every fixture file is matched by something; no fixture has appeared without an assertion; and no finding carries an unredacted Secret field. THREE OF THE SEVEN CATEGORIES ARE NOT OURS. .gitleaks.toml sets [extend] useDefault = true, and SSH keys, registry tokens and the 64-hex/env-assignment shapes are matched by gitleaks' built-in private-key, github-pat and generic-api-key rules, not by anything in this repo. Derived by running the binary, not read off the PR body. They are asserted as a separate set from ours so a break points at the version pin rather than at a regex -- which is exactly the exposure #271's own "Not verified" named and could not catch. The fixtures are allowlisted out of the live scan by .gitleaks.toml's global [allowlist] paths, so a test using the config unmodified would scan an excluded directory, find nothing and pass. The suite builds a copy with that one entry removed and asserts the entry was present first, so a restructured config fails loudly instead of degrading to a scan of nothing. The suite skips when gitleaks is not on PATH so a local discover still runs. That skip is a hole in CI, where the binary IS installed, so the workflow now sets REQUIRE_GITLEAKS_RULESET=1, which turns the skip into a failure. Same reasoning as launchpad-agents-tests.yml's empty-discovery guard: a check that can be satisfied by absence is not a check. Verification -- the suite was mutation-tested rather than merely run: $ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts Ran 286 tests ... OK (with gitleaks 8.30.1 on PATH, REQUIRE=1) corrupt the nostr-nsec regex to ZZZ_WILL_NEVER_MATCH_ZZZ: FAILED (failures=1) -- "Rules defined in .gitleaks.toml that matched nothing: ['nostr-nsec-private-key']" restructure the fixture allowlist entry so it no longer matches verbatim: FAILED (errors=1) -- "The fixture path-allowlist entry was not found verbatim ... would silently scan nothing" gitleaks absent, no env var: OK (skipped=1) gitleaks absent, REQUIRE_GITLEAKS_RULESET=1: FAILED (errors=1) Binary used locally was gitleaks 8.30.1 fetched to a scratch directory outside the repo and checksum-verified against the same SHA256 the workflow pins (551f6fc8...70eb). No fixture value is printed by the suite or by this commit: gitleaks runs with --redact and every assertion message is built from rule IDs and filenames only. launchpad was merged in at this commit's parent because the pre-push branch-skew hook blocked the push -- launchpad had moved on launchpad/scripts/test_no_model.py, which this branch also touches. The merge was clean and needed no resolution. Not verified: whether gitleaks' default ruleset changes across versions other than the pinned 8.30.1 -- that is the risk this suite makes visible, not one it removes. The seven categories are asserted as the eight rule IDs that currently match them; a future gitleaks release could match the same fixture under a renamed rule, which would fail this suite correctly but for a reason that needs a human to read. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…t PASS @benmitchell11 is out sick; this is the blocker from the change-request on #275, applied on their behalf. The blocker: with no surface files, _run_gitleaks_no_git short-circuited to ([], None) and run() reported PASS - no credential-shaped strings found across 0 agent-surface file(s) identical in shape to a genuine clean scan of the real surface. Nothing distinguished "scanned and found nothing" from "found nothing to scan", and because exit_code() only fails on FAIL, the audit passed. A sparse checkout, a refactor moving the agent-config directories, or any environment where AGENT_SURFACE_DIRS and AGENT_CONFIG_FILE_GLOBS resolve to nothing produced a green control that had scanned zero bytes. run() now returns INDETERMINATE with a detail that names what it looked for and where, so the reason is visible in the summary rather than requiring someone to notice the "0". The guard sits in run() rather than in _run_gitleaks_no_git: ([], None) is a reasonable contract for a helper asked to scan an empty list, and the decision about what that MEANS belongs with the other status decisions. This is the same fail-closed shape the sibling ignore-coverage check already uses for its unreadable-file case, which is why the reviewer was right that the principle was already ours and only this site did not follow it. THE TEST SUITE HAD CODIFIED THE BUG. test_no_surface_files_at_all_passes asserted PASS and asserted "0 agent-surface file" appeared in the detail, so it was not a gap in coverage -- the fail-open behaviour was pinned by a passing test. Renamed to test_no_surface_files_at_all_is_indeterminate_not_pass, inverted, and given a docstring recording what it used to assert and why that was wrong, so re-introducing PASS here fails loudly instead of looking like a fix to a broken test. Verification: $ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts Ran 314 tests ... OK (post-merge, gitleaks 8.30.1 on PATH, REQUIRE=1) mutation -- delete the `if not surface_paths` guard from run(): FAILED (failures=1) AssertionError: <Status.PASS: 'pass'> != <Status.INDETERMINATE: 'indeterminate'> Merged origin/feat/secret-scanning-check-v2 (this PR's base, #271) at the parent commit rather than launchpad directly, which keeps the stack intact and brings launchpad in transitively -- the pre-push branch-skew hook blocks otherwise, since launchpad has moved on launchpad/scripts/test_no_model.py and this branch touches it. Clean merge, no resolution needed. Not verified: whether AGENT_SURFACE_DIRS and AGENT_CONFIG_FILE_GLOBS can resolve to nothing in the real repository today -- they cannot, .claude/ is tracked, which is exactly why this failed open unnoticed. The change is about the environments where they can. Also unverified is whether Path.glob("**/x") traverses hidden directories on the CI Python version, which the original review flagged as a possible edge for AGENT_CONFIG_FILE_GLOBS and which this commit does not address. Still open on #275 and unaddressed here: nothing. The non-blocking items from the review remain filed as #390, #391 and #392. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Blocker fixed at
|
| Category | Rule ID | Whose |
|---|---|---|
| Nostr nsec | nostr-nsec-private-key |
ours |
64-hex / BUZZ_PRIVATE_KEY |
buzz-private-key + generic-api-key |
ours + gitleaks default |
| crypt hashes | glibc-crypt-hash |
ours |
| S3/MinIO | buzz-s3-minio-key |
ours |
| Postgres URL | postgres-url-with-password |
ours |
| SSH keys | private-key |
gitleaks default |
| registry tokens | github-pat |
gitleaks default |
.gitleaks.toml:13 sets [extend] useDefault = true, and three categories are matched entirely by gitleaks' built-ins with no equivalent in this repo. Your own Not verified named this exactly — "whether gitleaks' default ruleset itself ever changes behavior on a version bump" — and nothing could catch it. So DEFAULT_RULES is asserted as a separate set from OUR_RULES, and its failure message names the running version and points at the pin in launchpad-security-audit.yml, because a break there is a version problem rather than a regex problem.
Two traps the test had to handle
The fixtures are allowlisted out of the live scan. .gitleaks.toml's global [allowlist] paths excludes them — correct for the live scan, fatal for a test using the config unmodified: it would scan an excluded directory, find nothing, and pass. The suite builds a copy with that one entry removed and asserts the entry was present first, so a restructured config fails loudly instead of degrading into a scan of nothing.
A skip is a hole in CI. The suite skips when gitleaks is not on PATH, so a local discover still runs. But CI installs gitleaks, so a skip there would pass vacuously if the install step ever broke or moved. The workflow now sets REQUIRE_GITLEAKS_RULESET=1, which turns the skip into a failure. Same reasoning as launchpad-agents-tests.yml's empty-discovery guard, and I have quoted that guard's own comment in the workflow so the link is visible.
Verification — mutation-tested, not just run
$ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts
Ran 286 tests ... OK # gitleaks 8.30.1 on PATH, REQUIRE=1
# corrupt the nostr-nsec regex so it cannot match:
FAILED (failures=1)
Rules defined in .gitleaks.toml that matched nothing:
['nostr-nsec-private-key']
# restructure the fixture allowlist entry so it no longer matches verbatim:
FAILED (errors=1)
The fixture path-allowlist entry was not found verbatim ... would
silently scan nothing
# gitleaks absent, no env var: OK (skipped=1)
# gitleaks absent, REQUIRE_GITLEAKS_RULESET=1: FAILED (errors=1)
The binary I used locally was gitleaks 8.30.1 fetched into a scratch directory outside the repo and checksum-verified against the same SHA256 the workflow pins (551f6fc8…70eb). No fixture value is printed by the suite, by the commit, or here: --redact on the invocation, and every assertion message is built from rule IDs and filenames only.
launchpad was merged in at the parent commit because the pre-push branch-skew hook blocked otherwise — launchpad had moved on launchpad/scripts/test_no_model.py, which this branch touches. Clean merge, no resolution.
State
All checks green, MERGEABLE, base is launchpad. #275 is stacked on this branch and cannot merge until this does — its own blocker is fixed too, at 6b75f7e96.
I approve and merge nothing. The change-request is @serina-mcfall's to release.
Not verified: whether gitleaks' default ruleset changes across versions other than the pinned 8.30.1 — that is the risk this suite makes visible, not one it removes. A future release could match the same fixture under a renamed rule, which would fail this suite correctly but for a reason needing a human to read.
🤖 Claude Code (claude-opus-5) for @serina-mcfall.
#271 merged as squash c7d515d. That commit is not an ancestor of this branch, which was stacked on #271's branch and carries #271's original commits, so GitHub retargeted this PR to launchpad and reported CONFLICTING with a diff of 22 files / 1917 insertions -- it was re-proposing all of #271's content. Three conflicts, all resolved to OURS, and in each case ours is a strict superset rather than a competing version. Verified per file before choosing, not assumed from the direction of the merge: .gitleaks.toml -- launchpad has #271's narrower `\s*=\s*` forms of buzz-private-key and buzz-s3-minio-key. This branch widened both to `['"]?\s*[:=]\s*` because #68's own agent-surface fixtures are JSON (`"BUZZ_PRIVATE_KEY": "..."`), not .env-shaped. `['"]?` is optional and `[:=]` includes `=`, so the widened form matches everything the narrow one did. Taking launchpad's would have silently un-fixed the JSON gap. security_audit_registry.py -- launchpad registers two checks (self-test, secret material). This branch registers five, adding ignore_coverage, tracked_sensitive_files and agent_surface_secret_scan. Taking launchpad's would have dropped three of this PR's four controls from the audit while leaving their files on disk -- checks present but never run, which is the exact failure mode this PR's own blocker was about. test_no_model.py -- pure superset: four NOT_OURS entries for #68's modules, and zero lines present only on launchpad's side. Verification, run after resolving: $ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts Ran 314 tests ... OK (gitleaks 8.30.1 on PATH, REQUIRE_GITLEAKS_RULESET=1) $ python3 launchpad/scripts/security_audit.py . [PASS] harness-self-test [WARN] gitleaks-secret-scan - 171 finding(s) across full history (WARN by design per ADR-0006, not a gate) [PASS] ignore-coverage - all 11 required patterns present [PASS] tracked-sensitive-files - 4438 tracked files checked [PASS] agent-surface-secret-scan - across 22 agent-surface file(s) 4 pass, 0 fail, 1 warn, 0 indeterminate Two things that run proves beyond the merge being clean. The ruleset suite from #271 passes against the WIDENED regexes, so taking ours on .gitleaks.toml did not break any of the seven fixture categories. And agent-surface-secret-scan reports PASS across 22 files rather than INDETERMINATE, so this PR's fail-closed guard does not fire when there is a real surface to scan -- the guard was mutation-tested for the empty case at 6b75f7e and this is the other half of that. @benmitchell11 is out sick; resolved on their behalf under @serina-mcfall's instruction. No file's content was authored here -- every hunk kept is one side of an existing conflict. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…surface (#68) (#275) * feat(launchpad): secret-material detection via gitleaks (#67) Implements the engine and allowlist location ADR-0006 decided: gitleaks, driven by a single .gitleaks.toml at the repo root extending the default ruleset. PR-diff path fails the run on any finding; scheduled full-history path reports (WARN, never FAIL) so pre-existing history findings don't permanently redden the audit -- no baseline snapshot is used, since a regenerated baseline has no field for a reason and can silently swallow a real finding, per ADR-0006's own rejection of that mechanism. Custom rules cover what gitleaks' default ruleset provably misses (verified empirically in ADR-0006 before this task started): Nostr nsec/hex private keys including BUZZ_PRIVATE_KEY, glibc crypt hashes ($1/$5/$6/$y$, including the optional rounds=N$ segment), BUZZ_S3_* access/secret keys, and a Postgres URL with an embedded password. SSH private keys and registry tokens are covered by gitleaks' own default rules, confirmed against fixtures rather than assumed. Verified locally before commit: - All 7 required material categories fire against synthetic fixtures (12 findings across 7 rule IDs, checked directly against the JSON report). - The two known false-positive classes -- the dev-deployment-SOP.md documentation placeholders and Cargo.lock checksums -- produce zero findings against the real repo, confirmed by filtering the actual scan output, not assumed from the config. - Full-history scan: 5729 commits, 16s -- comfortably inside the 3-minute PR budget even on the path that's explicitly allowed to exceed it. - Real first-run baseline measured: 222 findings across history, spot- checked several directly (test fixtures, Helm chart test placeholders, a Rust test helper's literal test password) -- consistent with the file list being dominated by *_test.* paths. Decided behavior: WARN, visible every run, no baseline file. Not silently triaged to zero here; remediating or allowlisting the 222 is follow-on work this task surfaces rather than resolves. - 42 harness tests pass together; the full security_audit.py entrypoint runs end-to-end locally reproducing what CI will do. Fixtures are synthetic (a real-but-unused SSH key generated solely for this purpose; every other value fabricated) and excluded from the live scan by an explicit, commented allowlist entry -- not by accident of path. * feat(launchpad): assert ignore coverage, tracked files, agent config surface (#68) Three checks registered into the #62 harness: - ignore-coverage: asserts .gitignore and launchpad/deploy/archived/.gitignore (moved from launchpad/deploy/.gitignore since #68 was filed) still cover the required patterns, by literal line presence, not gitignore-matcher equivalence. Adds *.pem, *.key, id_rsa, id_ed25519 to .gitignore -- absent today, per this task's own instruction to add rather than weaken. - tracked-sensitive-files: independent of the above -- scans every tracked file (git ls-tree) against the same sensitive shapes, since an ignore pattern never untracks a file committed before it existed. .env.example- shaped files exempted (verified template-only by content, not just name). Also warns, in PR mode, when a PR's diff adds an ignore pattern that newly covers an already-tracked path -- the shape an accidental cover-up takes. - agent-surface-secret-scan: reuses #67's gitleaks ruleset (no second pattern set) against .claude/, .codex/, .goose/, .agents/ and any .mcp.json / *.persona.md / plugin.json file anywhere in the repo, via the new security_audit_agent_surface.py list (one place, documented for a future agent tool to extend). Fixed a real gap in .gitleaks.toml found while writing the agent-surface check's own fixtures: buzz-private-key and buzz-s3-minio-key only matched .env-shaped `KEY=value` assignment, never JSON's `"KEY": "value"` -- exactly the shape .claude/settings.local.json and .mcp.json actually use. Both rules now accept `:` or `=` as the separator; #67's own fixtures re-verified still firing identically (12/12) after the change. This check's own test fixtures (a synthetic ghp_-shaped token, a synthetic 64-hex value) are assembled from separated string fragments at runtime rather than written as one contiguous literal in tracked source -- found the hard way, via a throwaway-branch CI run, that a contiguous literal here gets caught by #67's own PR-diff gitleaks scan. Re-verified directly against the real gitleaks binary before finalizing: no leaks found. Verified locally: 61 harness tests pass (42 existing + 19 new). The real audit runs clean against this repo today -- ignore-coverage, tracked-files, and agent-surface all PASS; the pre-existing gitleaks WARN baseline grew from 223 to 233 findings (the widened regex catching more real JSON-shaped matches in history, plus two harmless entries from an earlier throwaway proof commit that still lingers in git's object store), no new FAIL. Throwaway-branch proof that all three new checks fail a real run (this task's own definition-of-done requirement): a disposable branch planted one violation per check, a throwaway PR against launchpad observed all three fail together in one real run (https://github.com/launchpad-26/buzz/actions/runs/32441713726/job/96653580524), then was closed and deleted. * fix(launchpad): address review-code Blocker + High findings on #271 (PR #271) _scan_pr_diff reported INDETERMINATE on every infrastructure failure (unset GITHUB_BASE_REF, git fetch failure, gitleaks engine error), and security_audit_core.exit_code() treats INDETERMINATE the same as PASS - so a PR whose scan never actually ran went green on the gate path. Contradicts ADR-0008's "indeterminate must never render as pass". Fixed to FAIL; _scan_full_history's own INDETERMINATE is unchanged since that path already WARNs rather than FAILs on findings by design. Also closes three test gaps review-code flagged as High: no test asserted --redact was actually passed to gitleaks, no test asserted _run_gitleaks's log_opts/timeout call args (the FETCH_HEAD..HEAD PR-scoping guarantee was unverified), and RunDispatchTest discarded run()'s return value entirely, so a dropped `return` would go unnoticed here despite crashing format_report elsewhere. * fix(launchpad): drop --depth=1 on the PR-diff base-ref fetch, real bug hit on this PR's own CI run Reproduced live: after the previous commit's fix made infra failures FAIL instead of INDETERMINATE, this PR's own CI run failed with "98 finding(s) in this PR" spanning AGENTS.md, Justfile, and NIP spec docs -- years-old content nowhere near this PR's actual diff. Root cause: `git fetch --depth=1 origin base_ref` grafts a new shallow boundary onto that one ref, regardless of the checkout already having full history (this workflow's actions/checkout uses fetch-depth: 0). Once base_ref has advanced past this branch's own merge-base - true for almost any real PR, since branches don't rebase on every base push - git can no longer see the shared ancestor as reachable from the shallow, parent-less FETCH_HEAD, and `git log FETCH_HEAD..HEAD` silently expands from "this PR's own commits" to the entire history reachable from HEAD. Confirmed directly against this repo: with --depth=1, FETCH_HEAD..HEAD went from 2 commits to 2,484. Removing --depth=1 (a plain `git fetch origin base_ref`, matching what the checkout already guarantees) resolves it back to 2 commits, and the real gitleaks binary run end-to-end against this branch now reports PASS. Added a regression test asserting the fetch command never requests a shallow depth. * fix(launchpad): address review-code's 2 Blockers on #275 (PR #275) Blocker 1: security_audit_tracked_files_check.py's *.example suffix exemption reopened exactly the hole #68 was filed to close. None of _SENSITIVE_PATTERNS ever matched a .example-suffixed filename in the first place (all end-anchored to .env/.key/.pem/id_rsa/id_ed25519), so the exemption bought nothing there -- but seed/ and seed.sample/ match on directory component, not filename, so a tracked seed/authorized_keys.example previously slipped past it despite being the exact shape #68 documents a real past incident for. Dropped the exemption entirely per review-code's first suggested fix. Also fixed the same --depth=1 shallow-fetch bug from #271 in this file's own newly-hidden-tracked-file diff, found while in there. Blocker 2: test_full_coverage_passes built its fixture by iterating REQUIRED_COVERAGE itself, so it could never fail on a dropped pattern -- 8 of 11 required patterns, including identity.key/id_rsa/ id_ed25519, had no independent test protecting them. Added one hardcoded-literal regression test per previously-unprotected pattern, same shape as the existing *.pem/seed.sample/ tests. Verification: 78 tests pass (up from 61), and a real end-to-end `security_audit.py .` run reports 4 pass / 0 fail / 1 warn (the full-history WARN is expected and unchanged). * fix(launchpad): skip agent-surface controls cleanly when gitleaks binary is absent Real CI failures on #275's own scripts and adr-boundary jobs: both run `unittest discover` over launchpad/scripts without installing gitleaks (only the audit workflow does that). The skip guard here only checked for .gitleaks.toml's existence, not the binary -- so those two jobs found the config, ran the real subprocess call, got Status.INDETERMINATE back, and failed 3 tests expecting PASS/FAIL. Nothing wrong with the check's own logic; the test suite just didn't skip when its actual dependency wasn't present, the same way it already correctly skips when the config file is missing. Verified the guard toggles both ways: real PATH (gitleaks present) does not skip; shutil.which patched to return None does. * test(launchpad): prove .gitleaks.toml's rules fire, against the real fixtures @benmitchell11 is out sick; this is the blocker from the change-request on #271, applied on their behalf so the PR is not held for their return. The blocker: fixtures existed for all seven secret categories and the PR body pasted a manual run proving each fires, but no test ran gitleaks. The only test file mocks subprocess.run throughout and says so in its own docstring, so `unittest discover -p "test_security_audit*.py"` stayed green if a rule in .gitleaks.toml stopped matching. That file's own comment records a near-miss where a capturing group silently dropped a finding to zero once useDefault was added. Adds test_security_audit_gitleaks_ruleset.py, which runs the pinned binary against launchpad/scripts/security_audit_fixtures/secrets/ with the real ruleset and asserts six things: findings exist at all; every rule this repo defines fires; every gitleaks default rule the config leans on fires; every fixture file is matched by something; no fixture has appeared without an assertion; and no finding carries an unredacted Secret field. THREE OF THE SEVEN CATEGORIES ARE NOT OURS. .gitleaks.toml sets [extend] useDefault = true, and SSH keys, registry tokens and the 64-hex/env-assignment shapes are matched by gitleaks' built-in private-key, github-pat and generic-api-key rules, not by anything in this repo. Derived by running the binary, not read off the PR body. They are asserted as a separate set from ours so a break points at the version pin rather than at a regex -- which is exactly the exposure #271's own "Not verified" named and could not catch. The fixtures are allowlisted out of the live scan by .gitleaks.toml's global [allowlist] paths, so a test using the config unmodified would scan an excluded directory, find nothing and pass. The suite builds a copy with that one entry removed and asserts the entry was present first, so a restructured config fails loudly instead of degrading to a scan of nothing. The suite skips when gitleaks is not on PATH so a local discover still runs. That skip is a hole in CI, where the binary IS installed, so the workflow now sets REQUIRE_GITLEAKS_RULESET=1, which turns the skip into a failure. Same reasoning as launchpad-agents-tests.yml's empty-discovery guard: a check that can be satisfied by absence is not a check. Verification -- the suite was mutation-tested rather than merely run: $ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts Ran 286 tests ... OK (with gitleaks 8.30.1 on PATH, REQUIRE=1) corrupt the nostr-nsec regex to ZZZ_WILL_NEVER_MATCH_ZZZ: FAILED (failures=1) -- "Rules defined in .gitleaks.toml that matched nothing: ['nostr-nsec-private-key']" restructure the fixture allowlist entry so it no longer matches verbatim: FAILED (errors=1) -- "The fixture path-allowlist entry was not found verbatim ... would silently scan nothing" gitleaks absent, no env var: OK (skipped=1) gitleaks absent, REQUIRE_GITLEAKS_RULESET=1: FAILED (errors=1) Binary used locally was gitleaks 8.30.1 fetched to a scratch directory outside the repo and checksum-verified against the same SHA256 the workflow pins (551f6fc8...70eb). No fixture value is printed by the suite or by this commit: gitleaks runs with --redact and every assertion message is built from rule IDs and filenames only. launchpad was merged in at this commit's parent because the pre-push branch-skew hook blocked the push -- launchpad had moved on launchpad/scripts/test_no_model.py, which this branch also touches. The merge was clean and needed no resolution. Not verified: whether gitleaks' default ruleset changes across versions other than the pinned 8.30.1 -- that is the risk this suite makes visible, not one it removes. The seven categories are asserted as the eight rule IDs that currently match them; a future gitleaks release could match the same fixture under a renamed rule, which would fail this suite correctly but for a reason that needs a human to read. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com> * fix(launchpad): agent-surface scan of zero files is INDETERMINATE, not PASS @benmitchell11 is out sick; this is the blocker from the change-request on #275, applied on their behalf. The blocker: with no surface files, _run_gitleaks_no_git short-circuited to ([], None) and run() reported PASS - no credential-shaped strings found across 0 agent-surface file(s) identical in shape to a genuine clean scan of the real surface. Nothing distinguished "scanned and found nothing" from "found nothing to scan", and because exit_code() only fails on FAIL, the audit passed. A sparse checkout, a refactor moving the agent-config directories, or any environment where AGENT_SURFACE_DIRS and AGENT_CONFIG_FILE_GLOBS resolve to nothing produced a green control that had scanned zero bytes. run() now returns INDETERMINATE with a detail that names what it looked for and where, so the reason is visible in the summary rather than requiring someone to notice the "0". The guard sits in run() rather than in _run_gitleaks_no_git: ([], None) is a reasonable contract for a helper asked to scan an empty list, and the decision about what that MEANS belongs with the other status decisions. This is the same fail-closed shape the sibling ignore-coverage check already uses for its unreadable-file case, which is why the reviewer was right that the principle was already ours and only this site did not follow it. THE TEST SUITE HAD CODIFIED THE BUG. test_no_surface_files_at_all_passes asserted PASS and asserted "0 agent-surface file" appeared in the detail, so it was not a gap in coverage -- the fail-open behaviour was pinned by a passing test. Renamed to test_no_surface_files_at_all_is_indeterminate_not_pass, inverted, and given a docstring recording what it used to assert and why that was wrong, so re-introducing PASS here fails loudly instead of looking like a fix to a broken test. Verification: $ python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts Ran 314 tests ... OK (post-merge, gitleaks 8.30.1 on PATH, REQUIRE=1) mutation -- delete the `if not surface_paths` guard from run(): FAILED (failures=1) AssertionError: <Status.PASS: 'pass'> != <Status.INDETERMINATE: 'indeterminate'> Merged origin/feat/secret-scanning-check-v2 (this PR's base, #271) at the parent commit rather than launchpad directly, which keeps the stack intact and brings launchpad in transitively -- the pre-push branch-skew hook blocks otherwise, since launchpad has moved on launchpad/scripts/test_no_model.py and this branch touches it. Clean merge, no resolution needed. Not verified: whether AGENT_SURFACE_DIRS and AGENT_CONFIG_FILE_GLOBS can resolve to nothing in the real repository today -- they cannot, .claude/ is tracked, which is exactly why this failed open unnoticed. The change is about the environments where they can. Also unverified is whether Path.glob("**/x") traverses hidden directories on the CI Python version, which the original review flagged as a possible edge for AGENT_CONFIG_FILE_GLOBS and which this commit does not address. Still open on #275 and unaddressed here: nothing. The non-blocking items from the review remain filed as #390, #391 and #392. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com> --------- Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com> Co-authored-by: Serina Mcfall <serina.mcfall@gmail.com>
Summary
Adds secret-material detection to the #62 audit, per ADR-0006's engine/allowlist decision: gitleaks driven by
.gitleaks.toml, registered into the harness (not a second workflow), PR-diff path fails on any finding, scheduled full-history path reports without failing.Related issue
Closes #67
Issue type
Task
Agent provenance
Objective
security_audit_secrets_check.py, registered intosecurity_audit_registry.py, running gitleaks against the PR diff (fail on finding) and full git history (report, never fail) with a fork-specific.gitleaks.tomlruleset covering the seven material categories #67 names, proven by planted fixtures.Impacted components
.gitleaks.toml
launchpad/scripts/security_audit_secrets_check.py
launchpad/scripts/test_security_audit_secrets_check.py
launchpad/scripts/security_audit_fixtures/secrets/
launchpad/scripts/security_audit_registry.py
launchpad/scripts/test_no_model.py
.github/workflows/launchpad-security-audit.yml
Approach and rejected alternatives
This branch was originally started off
feat/security-audit-harness(#200's branch) before that merged, per a note left on the branch at the time. Rebuilt on a fresh branch off currentlaunchpadrather than rebasing the old one, since #200 and its own follow-up (#253) had already landed independently and the old branch's base no longer existed.Rejected
--exit-code 0on the gitleaks invocation: gitleaks' real exit code (0 clean, 1 leaks found, anything else a genuine engine failure) is how this check tells "it ran and found nothing" apart from "it crashed" — forcing exit 0 would erase that distinction and let a broken gitleaks invocation silently report clean.Rejected gitleaks'
--baseline-pathmechanism for the first-run noise (222 findings measured across full history, see Verification) in favor of no baseline at all, downgrading the full-history path to WARN instead. ADR-0006 already rejected the baseline mechanism specifically because a regenerated baseline has no field for a reason and can silently accept a real finding; the same logic applies here. The 222 findings stay visible on every scheduled run rather than being triaged away — remediating or allowlisting them individually is follow-on work this task surfaces, not resolves.Verification
Command run:
Raw output (tail):
Command run (proving all 7 required material categories fire, against a config with the fixtures' own path-exclusion temporarily removed):
Raw output (rule IDs and counts only, per the "never print the matched value" rule this task itself enforces):
Every one of the 7 categories #67 names is covered (SSH keys to
private-key; crypt hashes toglibc-crypt-hash, both$6$and$y$shapes; Nostr nsec tonostr-nsec-private-key; 64-hex/BUZZ_PRIVATE_KEYtobuzz-private-key+generic-api-key; S3/MinIO pair tobuzz-s3-minio-key; registry tokens togithub-pat; Postgres URL topostgres-url-with-password).Command run (the real check, real config, real repo, matching CI's exact invocation):
Raw output:
16s for a full-history scan — comfortably inside the 3-minute PR budget even though this is the path explicitly allowed to exceed it. Filtered the JSON report directly (not assumed) to confirm the two known false-positive classes produce zero findings against the real repo:
dev-deployment-SOP.md(0 findings) and bothCargo.lockfiles (0 findings). Fixtures directory: 0 findings (correctly excluded from the live scan).Spot-checked several of the 222 real findings directly against current file content to confirm they're benign test/fixture material, not real secrets — e.g.
deploy/charts/buzz/tests/hpa_test.yaml:9is the literal placeholderpostgres://u:p@h:5432/d, andcrates/buzz-db/src/replica_fence.rs's finding is a test helper building a connection string with the literal test passwordfence_probe_test. (Full-history findings report the file/line as it appeared in the historical commit gitleaks matched, not current HEAD, so a few spot-checks landed on lines that no longer say what the finding implies at today's HEAD — expected behavior for a history scan, not a bug.)Command run:
Raw output:
Confirms the full entrypoint runs end-to-end locally, matching what CI will do, and that a WARN does not fail the overall audit.
Throwaway-branch proof that the PR path actually fails a real run: see the linked comment below — a planted, synthetic secret was pushed to a disposable branch, a PR opened against
launchpad, thelaunchpad — security auditcheck observed failing for real, then closed/deleted.Not verified
Whether every one of the 222 pre-existing full-history findings is genuinely benign — only a handful were spot-checked directly; the rest are visible every scheduled run (by design, WARN not silently triaged) rather than individually confirmed here. Whether gitleaks' default ruleset (
useDefault = true) itself ever changes behavior on a version bump — this PR pins 8.30.1 in the workflow (checksum-verified against gitleaks' own published checksums file) but does not attempt to freeze the ruleset's own content against future gitleaks releases.Security implications
This is a detective control, not preventive — it reports secret material already committed; it does not stop a commit from happening (that's git/GitHub push protection territory, #72) and it does not rotate or remediate anything it finds (explicitly out of scope, per #67 itself). The workflow itself holds no repository secret and needs none —
permissions: contents: readonly — so it can run safely on a fork pull request. Findings never surface a matched value anywhere:--redacton every gitleaks invocation, and the check's own summary is built only from file/line/rule-id fields, never gitleaks' Secret/Match fields — verified directly in the test suite (asserting the fixture's fake secret value never appears in the result).Escalations
None new. The 222-finding first-run baseline is a real, visible signal this task surfaces rather than resolves — remediating or allowlisting individual findings is follow-on work, not blocking this task's own definition of done, which asks for a decided and documented behavior, not zero findings.