Skip to content

feat(launchpad): secret-material detection via gitleaks (#67) - #271

Merged
serina-mcfall merged 5 commits into
launchpadfrom
feat/secret-scanning-check-v2
Aug 24, 2026
Merged

feat(launchpad): secret-material detection via gitleaks (#67)#271
serina-mcfall merged 5 commits into
launchpadfrom
feat/secret-scanning-check-v2

Conversation

@benmitchell11

Copy link
Copy Markdown

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

Field Value
Harness / provider Claude Code
Model claude-sonnet-5
Session reference N/A - not exposed by this harness
Initiating human @benmitchell11

Objective

security_audit_secrets_check.py, registered into security_audit_registry.py, running gitleaks against the PR diff (fail on finding) and full git history (report, never fail) with a fork-specific .gitleaks.toml ruleset 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 current launchpad rather 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 0 on 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-path mechanism 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:

python3 -m unittest discover -s launchpad/scripts -p "test_security_audit*.py"

Raw output (tail):

Ran 42 tests in 0.015s

OK

Command run (proving all 7 required material categories fire, against a config with the fixtures' own path-exclusion temporarily removed):

gitleaks detect --no-git --source launchpad/scripts/security_audit_fixtures/secrets --config <test-copy-of-.gitleaks.toml-without-fixture-exclusion> --report-format json --report-path - --redact --no-banner --exit-code 0

Raw output (rule IDs and counts only, per the "never print the matched value" rule this task itself enforces):

buzz-private-key: 1
buzz-s3-minio-key: 2
generic-api-key: 3
github-pat: 1
glibc-crypt-hash: 2
nostr-nsec-private-key: 1
postgres-url-with-password: 1
private-key: 1
total: 12

Every one of the 7 categories #67 names is covered (SSH keys to private-key; crypt hashes to glibc-crypt-hash, both $6$ and $y$ shapes; Nostr nsec to nostr-nsec-private-key; 64-hex/BUZZ_PRIVATE_KEY to buzz-private-key + generic-api-key; S3/MinIO pair to buzz-s3-minio-key; registry tokens to github-pat; Postgres URL to postgres-url-with-password).

Command run (the real check, real config, real repo, matching CI's exact invocation):

gitleaks detect --config .gitleaks.toml --report-format json --report-path <file> --redact --no-banner --exit-code 0

Raw output:

5729 commits scanned.
scanned ~146886816 bytes (146.89 MB) in 16s
leaks found: 222

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 both Cargo.lock files (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:9 is the literal placeholder postgres://u:p@h:5432/d, and crates/buzz-db/src/replica_fence.rs's finding is a test helper building a connection string with the literal test password fence_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:

python3 launchpad/scripts/security_audit.py .

Raw output:

[         PASS] harness-self-test     - pass/fail/warn/indeterminate all render distinctly
[         WARN] gitleaks-secret-scan  - 222 finding(s) across full history in 16s: AGENTS.md:371 (generic-api-key), ...
1 pass, 0 fail, 1 warn, 0 indeterminate

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, the launchpad — security audit check observed failing for real, then closed/deleted.

  • Tests or checks were run and the raw output is pasted above
  • The diff is confined to the scope of the linked issue
  • No secrets, keys, tokens or hostnames were added to tracked files

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: read only — so it can run safely on a fork pull request. Findings never surface a matched value anywhere: --redact on 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.

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.
@benmitchell11 benmitchell11 added the by:agent Filed or authored by an AI agent, not a human label Aug 21, 2026
@benmitchell11

Copy link
Copy Markdown
Author

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 (BUZZ_PRIVATE_KEY=1111..., 68 repeated 1s, never a real key) in a throwaway file, opened #272 against launchpad to trigger the real pull_request path, observed the audit check fail for real, then closed #272 and deleted the branch.

Run: https://github.com/launchpad-26/buzz/actions/runs/32440647262/job/96650516602

Raw output from the actual CI run:

[         PASS] harness-self-test     - pass/fail/warn/indeterminate all render distinctly
[         FAIL] gitleaks-secret-scan  - 1 finding(s) in this PR: launchpad/scripts/THROWAWAY_PROOF_DELETE_ME.env:4 (buzz-private-key)
1 pass, 1 fail, 0 warn, 0 indeterminate

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.

@serina-mcfall

Copy link
Copy Markdown

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

launchpad/scripts/security_audit_secrets_check.py:143 — on the PR-diff path (the path this check's own docstring calls "the gate"), every infrastructure failure — a timed-out gitleaks call, a failed git fetch, an unset GITHUB_BASE_REF, a missing binary, a malformed report — reports Status.INDETERMINATE. security_audit_core.exit_code() treats INDETERMINATE identically to PASS, so the CI job that gates the merge goes green on a PR the scanner never actually finished scanning. This directly contradicts ADR-0008's own stated rule: "indeterminate must never render as pass." Suggested fix: in _scan_pr_diff specifically (not _scan_full_history, where WARN/INDETERMINATE is intentional), turn "couldn't execute" into Status.FAIL.

High

  • test_security_audit_secrets_check.py:37RunGitleaksTest never asserts the actual gitleaks command args, only the mocked return value. Dropping --redact (the flag this module's docstring leans on for its "never prints a secret value" promise) would still pass every test in the class.
  • test_security_audit_secrets_check.py:126ScanPrDiffTest/ScanFullHistoryTest never assert _run_gitleaks's log_opts/timeout call args, so the FETCH_HEAD..HEAD PR-scoping guarantee (the module's central claim — history predating the PR is never re-scanned) is unverified. Swapping the two timeout constants or deleting the log_opts scoping would leave every test green.
  • test_security_audit_secrets_check.py:144RunDispatchTest discards run()'s return value. A dropped return (silent None) would crash format_report uncaught, taking down the whole audit report's output — defeating security_audit_core.py's own stated guarantee that "a broken check is a finding, not an outage."

Medium / Low (non-blocking, worth a look)

  • Medium, security_audit_secrets_check.py:101 — an unexpected gitleaks exit code embeds raw, unredacted subprocess stderr into the public CheckResult.detail, which is a narrower channel than --redact covers and contradicts the module's unconditional "never prints a secret value anywhere" claim. No live reproduction of an actual leak — flagged as a real gap in principle, not a demonstrated one.
  • Medium, test_security_audit_secrets_check.py:18_summarize's limit=8 truncation and set-based de-dup are never unit-tested (the security-relevant "no secret value" boundary is separately covered elsewhere, so this is a narrower correctness gap).
  • Low, security_audit_secrets_check.py:184 — the __main__ exit-code branch checks Status.FAIL, which _scan_full_history never returns; dead code, always exits 0 locally.
  • Low, test_security_audit_secrets_check.py:152 — one test loops over three event literals with no subTest isolation; degrades failure diagnostics only, can't produce a false pass.

@serina-mcfall serina-mcfall left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — two Blockers from the automated review posted above, both need fixing before merge:

  1. 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.
  2. 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.
benmitchell11 added a commit that referenced this pull request Aug 21, 2026
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 serina-mcfall left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. --redact on every path, _summarize() builds detail strings only from File/StartLine/RuleID, and tests assert the fixture's fake secret value is absent from result.detail. I confirmed empirically that the real binary returns "Secret": "REDACTED".
  • History is scanned, not just the working tree — gitleaks detect throughout; 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.example path 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.

@serina-mcfall
serina-mcfall dismissed their stale review August 21, 2026 21:25

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.

@serina-mcfall

Copy link
Copy Markdown

Requested changes NOT yet done — worth a look soon

Checked at head faa3538ad. No commit has landed since the review (review 2026-08-21T20:55Z postdates the head), and the blocker is unchanged.

Still open — the fixtures are never exercised by any test CI runs.

launchpad/scripts/security_audit_fixtures/secrets/ holds fixtures for all seven secret categories, but nothing runs the real gitleaks binary against them. Re-checked the diff: the only test file is test_security_audit_secrets_check.py, whose own docstring still says it "mocks subprocess.run throughout — no real gitleaks invocation", and the workflow's only test step is a scoped unittest discover. Nothing in the diff invokes gitleaks against the fixture directory.

So python3 -m unittest discover -s launchpad/scripts -p "test_security_audit*.py" stays green if a .gitleaks.toml rule breaks — and your own comment in that file records a near-miss where a capturing group silently dropped a finding to zero.

The fix is small: one test or CI step running the checksum-pinned binary against security_audit_fixtures/secrets/ with the real config, asserting each of the seven rule IDs fires at least once. That command is already in your PR body's Verification section — it just needs to be repeatable rather than manual.

Everything else stands verified and is good work: the gate path fails closed on every infra failure, --redact is on every invocation and asserted by a test, history is scanned rather than the working tree, and the binary is version-pinned with a checksum. The non-blocking allowlist items are filed as #389 and need nothing here.

Flagging rather than nagging — this is the last thing between the PR and mergeable, and it is blocking #275 behind it.

serina-mcfall added a commit that referenced this pull request Aug 24, 2026
) (#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>
serina-mcfall added a commit that referenced this pull request Aug 24, 2026
…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>
@serina-mcfall

Copy link
Copy Markdown

Blocker fixed at fe42ddc0d@benmitchell11 is out sick, so applied on their behalf

@benmitchell11 — nothing here touches your check script or the ruleset. It adds the test that was missing, and the reasoning is in the commit message so you can re-derive it rather than take my word.

The blocker was: fixtures existed for all seven categories and the PR body pasted a manual run proving each fires, but no test ran gitleaks. test_security_audit_secrets_check.py mocks subprocess.run throughout and says so in its own docstring, so unittest discover -p "test_security_audit*.py" stayed green if a rule stopped matching — and that file's own comment records a near-miss where a capturing group silently dropped a finding to zero once useDefault was added.

Adds launchpad/scripts/test_security_audit_gitleaks_ruleset.py (named to match CI's existing test_security_audit*.py discovery pattern), which runs the pinned binary against the real fixtures 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.

Three of the seven categories are not ours — and that changes what this test is worth

I derived the rule IDs by running the binary rather than reading them off the PR body:

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.

@serina-mcfall
serina-mcfall merged commit c7d515d into launchpad Aug 24, 2026
22 checks passed
serina-mcfall added a commit that referenced this pull request Aug 24, 2026
#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>
serina-mcfall added a commit that referenced this pull request Aug 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

by:agent Filed or authored by an AI agent, not a human

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task: detect secret material in diffs and in git history

2 participants