fix(ci): the secret scan never read history, and every doc said it did - #18
Open
hseshadr wants to merge 3 commits into
Open
fix(ci): the secret scan never read history, and every doc said it did#18hseshadr wants to merge 3 commits into
hseshadr wants to merge 3 commits into
Conversation
`secret-scan.yml` opened with "gitleaks over the FULL git history" and
"a credential committed five commits ago is exactly as leaked as one
committed at HEAD". Neither was true of what it ran.
gitleaks-action derives its scan range from the EVENT, not from
fetch-depth (gitleaks-action@e0c47f4, src/gitleaks.js:103-115,
src/index.js:176): on push and pull_request it appends
`--log-opts=--no-merges --first-parent BASE^..HEAD`; only on schedule
and workflow_dispatch does it omit --log-opts and read every commit.
`fetch-depth: 0` makes BASE^ resolvable. It does not widen the scan.
Measured on this repository, through this very workflow:
run 31051347230 push to main 0 commits scanned reported success
run 30978634362 pull_request 1 commit scanned reported success
run 30793713570 schedule 41 commits scanned reported success
The workflow is workflow_call-only, and every caller in examples/ but
one was push/PR. So no consumer's pre-existing history had ever been
scanned by CI, and a push whose base is already an ancestor of head
scanned zero commits and still reported success.
A reusable workflow cannot carry its own `schedule`, so the caller owns
it. Every consumer already has security-audit.yml on a weekly cron;
each now calls secret-scan.yml from it with `full-history: true`.
`full-history` does not widen the scan — nothing can, from inside a
reusable workflow. It makes the expectation machine-checked: on any
event that scans a partial range the job fails instead of reporting a
clean partial scan as clean. It defaults to false so this release
cannot redden an existing push/PR caller.
Also names every secret-scan caller job. An unnamed one reports as
"gitleaks / gitleaks" instead of the documented "Secret scan /
gitleaks", silently orphaning an adopter's required status check.
Guards, in tests/security-policy.sh:
- validate_secret_scan_history_sweep: a repo that calls
secret-scan.yml but never from a scheduled workflow is refused, as
is an unnamed caller job. Carries a vacuity floor.
- validate_secret_scan_coverage_cases: executes the workflow's real
coverage script under both event families rather than grepping for
its error string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx1Abdt3o9dLsKsd2xDcgS
…ne of them
Audited every factual claim in README.md against the tree, the tags, the
live consumer repos and the upstream action sources. 16 were false.
Fixed 13, deleted 3.
The two that could actively harm an adopter:
- The canonical copy-paste caller omitted `name: Secret scan`, so a
reader got the check context `gitleaks / gitleaks` instead of the
documented `Secret scan / gitleaks` — silently orphaning a required
status check. Five of the six callers in examples/ omitted it too.
- Four places claimed the secret scan covers "the full git history".
It covers the calling event's commit range. Corrected, with the
measured evidence, in a new section.
The rest were drift: the README was never updated past ci-v3.0.0 while
three releases and one consumer adoption landed.
- Current release and every 2a575cd pin -> 605e51c / ci-v3.2.1.
- Adoption: 7 call-sites across 5 repos, six on the publish path.
Verified by grepping every consumer's default branch, not asserted.
- Drift count said 29 in one place, 30 in two others. All three now
say 29, matching the allowlist.
- Third-party pin table listed floating majors (`# v7`, `# v6`, `# v3`)
as the release comment. That is the defect commit ae644d7 fixed; a
`# v6` on a SHA goes wrong the moment upstream moves the tag. Now
the exact versions, including the two actions pinned at different
versions in .github/ and examples/.
- Publish verification: 14 attempts / 600s, not 6 attempts / 60s.
- "These repos are private, so callers 404" — all eight are public.
- "`ci` has no branch protection and no repository secret scanning" —
three required contexts, force-push off, secret scanning and push
protection both on.
- secret-scan.yml is no longer in the "no consumer runs" list.
DELETED rather than fixed, because they cannot be made true:
- The `--allow-unlocked` "live gap" callout. Closed at ci-v3.2.1;
python-gate now nests the ci-v3.2.1 composite, which implements it.
- Two completed owner actions (branch protection, cut the release).
One owner action is genuinely open and is now stated with its fix: the
`ci-v3` pointer is 21 commits behind ci-v3.2.1.
Also adds a check-context table for adopters. python-gate.yml's context
is marked UNVERIFIED — no consumer calls it yet, so `gate / gate` is
derived from the rule, never observed. Documenting an unobserved context
as fact is how the gitleaks mismatch reached five repos.
tests/consumer-drift-allowlist.txt: aml-filter/ci.yml/secret-scan
deleted. aml-filter#93 merged 2026-08-02 and the consumer now calls the
brick (run 31051313153, SUCCESS on main). 30 -> 29.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx1Abdt3o9dLsKsd2xDcgS
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
PR #18's CI run ended in `startup_failure`: requesting 'pull-requests: read', but is only allowed 'pull-requests: none' `secret-scan.yml` declares `pull-requests: read` — gitleaks-action lists a PR's commits through the API. `ci.yml`'s `secret-scan-sweep` job granted only `contents: read`. A job-level `permissions:` block REPLACES the top-level one rather than adding to it, so restating `contents: read` silently dropped `pull-requests` to `none`. The sibling `secret-scan` job already had it right. The important half is what that failure reported: NOTHING. A `startup_failure` emits zero check runs. Run 31127046921 has `jobs: 0`, and the check-runs API for its head SHA lists only the checks from other workflows. `Security policy` and `Secret scan (own brick) / gitleaks` were not red on PR #18, they were missing — and branch protection reads a missing required check as "pending", never "failed". A gate that CANNOT run is indistinguishable from one that has not run yet. Same shape as the bug this PR exists to fix: a secret scan that scanned 0 commits and reported success. `if:` does not save you either. Permissions are validated before any condition is evaluated, so the run died on `pull_request` events where this job would never have started. The examples ship the bug too. Five `examples/*/security-audit.yml` call secret-scan.yml from a workflow whose only grant is a top-level `contents: read`, and at the pinned SHA (605e51c, ci-v3.2.1) that workflow already declared `pull-requests: read` — so every adopter who copied one got a scheduled sweep that could never start, and a weekly run that reported nothing rather than failing. New guard, static by necessity — there is no run to inspect, because the failure IS the absence of a run: tests/lib/scan-caller-permissions.rb tests/security-policy.sh::validate_caller_permission_sufficiency tests/security-policy.sh::validate_caller_permission_cases It parses each caller job's effective grant (job-level block, else workflow-level) and compares it scope-by-scope (none < read < write) against the callee's declared `permissions:`, over `.github/workflows/` and `examples/` alike. 15 both-polarity fixtures pin the property: the job-level-replacement trap, a granted `read` against a required `write`, `read-all`/`write-all` shorthands, a grant hidden behind a YAML alias, a third-party callee it must not blame, and a caller declaring no permissions anywhere (refused — the answer lives in a repo setting this tree cannot read). It also asserts it resolved at least 20 caller->callee pairs (25 today), so ref resolution that quietly broke cannot look like a clean tree. Evidence: red (guard, unfixed tree) exit 1, 6 findings: ci.yml + 5 examples green (guard, fixed tree) exit 0, 25 pairs resolved red (re-narrow ci.yml sweep) exit 1, names secret-scan-sweep red (re-narrow examples/edge-proc) exit 1, names its gitleaks job green (restored) exit 0 actionlint 0 · shellcheck 0 · lineage-guard-cases 0 · example-fidelity-cases 0 lint-examples 0 (zizmor: no findings; fidelity: 182 resolved, 0 missing) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fx1Abdt3o9dLsKsd2xDcgS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The claim this touches
All false as written. No consumer's pre-existing history had ever been scanned by CI.
Why
gitleaks-actionderives its scan range from the event, not fromfetch-depth(
gitleaks-action@e0c47f4,src/gitleaks.js:103-115,src/index.js:176):--log-optspush,pull_request--no-merges --first-parent BASE^..HEADschedule,workflow_dispatchfetch-depth: 0makesBASE^resolvable. It does not widen the scan.secret-scan.ymlisworkflow_call-only and every caller inexamples/but one ran on push/PR.Red run 1 — the property, measured on this repo through this very workflow
pushtomainpull_requestscheduleA push whose base is already an ancestor of head scans zero commits and still reports success.
Red run 2 — a real credential, planted where a PR cannot see it
Synthetic repo, 14 commits, a fake AWS key at commit 3, PR branch adds 2 clean commits. Local
gitleaks 8.30.1, reproducing the exact command the action builds:The fake key never left a temp dir and was never committed or pushed anywhere.
Red run 3 — the guards can fail (mutation, local, offline)
examples/edge-reco/security-audit.ymlFAIL: examples/edge-reco calls secret-scan.yml but never from a scheduled workflow — its history is never swept, only each push's own commitsname: Secret scanfromexamples/privacy-core/ci.ymlFAIL: examples/privacy-core/ci.yml job 'gitleaks' calls secret-scan.yml with no name: — it reports as 'gitleaks / gitleaks', not 'Secret scan / gitleaks'FAIL: secret-scan ACCEPTS full-history on a push event — a partial scan reported as a sweepFAIL: secret-scan ACCEPTS full-history on a pull_request event — a partial scan reported as a sweepThe fix: the caller owns the schedule
A
workflow_callworkflow cannot carry its ownschedule:, so this cannot be fixed inside the brick. Three alternatives were considered and rejected:ciruns a cron that sweeps the other repos — needs a cross-repo PAT and a fan-out; adds a long-lived secret to solve a docs problem.GITHUB_EVENT_NAMEso the action always takes its full-history branch — adapter code that corrects the library's semantics. That is the tell for a wrong abstraction, not a fix.gitleaksdirectly for the sweep — a second supply-chain pin and a parallel config path that will drift from the action's.Chosen: every consumer already has
security-audit.ymlon a weekly cron. Eachexamples/*/security-audit.ymlnow callssecret-scan.ymlfrom it withfull-history: true, so all five converging callers get a genuine weekly full sweep.examples/*/ci.ymlkeeps its caller for the per-PR incremental scan. The action is used exactly as designed; the enforcement lives on our side, where it is testable.full-historydoes not widen the scan — nothing can, from inside a reusable workflow. It makes the expectation machine-checked: on any event that scans a partial range, the job fails instead of reporting a clean partial scan as clean. Defaultfalse, so this release cannot redden an existing push/PR caller.Every run now prints what it covered. "No leaks detected" over 0 commits and over 41 commits are the same three words.
Second defect: unnamed caller jobs
Five of the six callers in
examples/— and the README's canonical copy-paste snippet — omittedname: Secret scan, so a copy-paste yields the check contextgitleaks / gitleaks, notSecret scan / gitleaks. That silently orphans an adopter's required status check. All named now, and the guard refuses an unnamed one.README: 16 false claims, 13 fixed, 3 deleted
Audited every claim against the tree, the tags, the live consumer repos, and upstream action sources.
ci-v3.0.0/2a575cdci-v3.2.1/605e51c, 3 releases later2a575cd # ci-v3.0.0examples/is605e51c # ci-v3.2.1name: Secret scan# ci-v2.0.3label"--allow-unlocked"ci-v3.2.1# v7,# v6,# v3ae644d7fixedcihas no branch protection, no secret scanning"ci-v3.2.1New section What the secret scan actually covers, and a check-context table for adopters.
python-gate.yml's context is marked UNVERIFIED — no consumer calls it yet, sogate / gateis derived from the rule and has never been emitted by a run. Documenting an unobserved context as fact is how thegitleaksmismatch reached five repos.Evidence
tests/security-policy.shtests/lineage-guard-cases.shtests/example-fidelity-cases.shtests/lint-examples.shshellcheck -xactionlintuvx zizmor@1.26.1 .(online audits, GH_TOKEN set)Still open — owner's call
ci-v3points at72521e7, a Dependabot merge 21 commits behindci-v3.2.1. Nothing pins it (every ref is a full SHA), so it misleads readers rather than breaking builds. Now documented in Owner actions with the fix:git tag -f ci-v3 ci-v3.2.1^{} && git push -f origin ci-v3Not merging — that stays yours. Note
enforce_admins=falsewith 1 required review, so this needs--admin.Downstream
The nine consumer repos still need the scheduled caller added to their real
security-audit.yml. This PR ships the templates and the guard that refuses a tree without them; it does not touch consumer repos.🤖 Generated with Claude Code
https://claude.ai/code/session_01Fx1Abdt3o9dLsKsd2xDcgS