Skip to content

ci(secret-scanner): make gitleaks gate for real, not continue-on-error#500

Merged
hyperpolymath merged 1 commit into
mainfrom
fix/gitleaks-real-gate
Jul 17, 2026
Merged

ci(secret-scanner): make gitleaks gate for real, not continue-on-error#500
hyperpolymath merged 1 commit into
mainfrom
fix/gitleaks-real-gate

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Problem

The gitleaks job in secret-scanner-reusable.yml carried continue-on-error: true:

- name: Gitleaks Secret Scan
  continue-on-error: true  # gitleaks-action tool-cache PATH injection unreliable on self-hosted runners
  uses: gitleaks/gitleaks-action@e0c47f4f… # v3.0.0

So the estate's primary secret scanner cannot fail a single PR anywhere this
reusable is consumed. The flag was a workaround for a real infra problem — the
gitleaks-action injects gitleaks into the runner tool-cache and prepends it to
PATH, which is unreliable on self-hosted runners — but the workaround throws
out the signal with the flakiness: a genuinely leaked secret now passes green.

Fix

Replace the flaky action with a pinned, checksum-verified gitleaks 8.18.4
binary
run directly, and let it gate:

- name: Install gitleaks (pinned + checksum-verified)   # sha256 ba6dbb65…
- name: Gitleaks secret scan (gating)
  run: gitleaks detect --source . --no-git --redact --no-banner --exit-code 1

A pinned direct install is deterministic on every runner — it removes the exact
unreliability the continue-on-error was papering over — so the scan can gate
for real. Comments/summary were already disabled via env vars, so the action
provided only the scan; the swap loses no functionality and drops the
now-unneeded GITHUB_TOKEN env and fetch-depth: 0. (secrets: inherit is no
longer required by this job; harmless if callers keep it.)

Verification

Exercised the exact install + scan logic locally with the pinned binary:

  • checksum of gitleaks_8.18.4_linux_x64.tar.gz matches (sha256sum -c → OK);
  • a private-key fixture → exit 1 (gate fails on a secret);
  • a clean tree → exit 0 (passes when clean);
  • actionlint clean on the changed steps.

Note: reusable-workflow changes are not self-tested in this repo — the caller
secret-scanner.yml pins the reusable at a fixed SHA, so this repo's own PR CI
runs the old reusable. The change is exercised once a consumer re-pins to a SHA
containing it.

⚠️ Cascade / triage before wide re-pinning (why this is a draft)

Running the new gate against this repo's own tree surfaces 15 findings —
all false positives
, not real secrets:

Count Rule Where
8 generic-api-key avow-protocol/**/deno.lock (integrity hashes)
3 generic-api-key docs / .md / examples.ndjson
1 generic-api-key lol/analysis/Project.toml (Julia UUIDs)
1 generic-api-key docs/audits/…a2ml
1 generic-api-key avow-lib/ffi/zig/src/example.zig

Lockfiles and UUID-bearing manifests are everywhere in the estate, so flipping
gitleaks to gating without a shared allowlist would cry wolf across many
repos
— noise that trains people to ignore the gate, the opposite of the goal.
Companion work needed before re-pinning consumers: a canonical
.gitleaks.toml (allowlist *.lock, UUID manifests, example/fixture/docs
paths) passed to callers via --config, or per-repo allowlists. Kept out of this
PR to keep it surgical.

Related follow-ups (separate PRs)

  • governance-reusable.yml: "Code quality + docs" and "Guix primary / Nix
    fallback policy" jobs currently cannot fail (no exit 1); the redundant
    trufflehog there (also continue-on-error) is better removed per this
    reusable's own "trufflehog removed as redundant" note. Deferred pending a
    decision on the existing advisory-first design stance.

The gitleaks job carried continue-on-error: true (comment: gitleaks-action
tool-cache PATH injection unreliable on self-hosted runners), so the estate's
primary secret scanner could not fail a single PR anywhere it is used.

Replace gitleaks/gitleaks-action with a pinned, checksum-verified gitleaks
8.18.4 binary run directly (gitleaks detect --source . --no-git --exit-code 1).
A direct install is deterministic on every runner, removing the exact
unreliability the continue-on-error was papering over, so the scan now gates.

Comments/summary were already disabled via env vars, so the action provided
only the scan; the swap loses no functionality and drops the now-unneeded
GITHUB_TOKEN env and fetch-depth: 0.

Verified locally with the pinned binary: checksum matches; a private-key
fixture exits 1, a clean tree exits 0; actionlint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@hyperpolymath
hyperpolymath marked this pull request as ready for review July 17, 2026 22:02
@hyperpolymath
hyperpolymath enabled auto-merge (rebase) July 17, 2026 22:02
@hyperpolymath
hyperpolymath disabled auto-merge July 17, 2026 22:32
@hyperpolymath
hyperpolymath merged commit 08e2d24 into main Jul 17, 2026
20 checks passed
@hyperpolymath
hyperpolymath deleted the fix/gitleaks-real-gate branch July 17, 2026 22:32
hyperpolymath added a commit that referenced this pull request Jul 21, 2026
## The main fix: standards' own secret scanning has not run since
2026-07-06

`secret-scanner.yml` pinned the reusable at `@891b1ed` (2026-07-06),
which is **21 commits behind #500** (`08e2d246`). At that pin the
reusable's `gitleaks` job requests `pull-requests: write` + `actions:
read`, but this caller grants only `contents: read`. A called workflow
cannot exceed its caller's grant, so **every run terminated at
`startup_failure`**:

```
startup_failure  main                                        2026-07-20T17:36
startup_failure  test/signed-push-smoke                       2026-07-20T17:33
startup_failure  main                                        2026-07-20T17:24
startup_failure  feat/signed-push-app-action                  2026-07-20T16:55
startup_failure  main                                        2026-07-20T13:43
startup_failure  dependabot/github_actions/actions-5fa456a2a3 2026-07-20T13:28
```

This is worse than a fake-green gate: the scan never executed at all, on
the repo that *defines* the estate's secret-scanning standard.

**Fix:** use the local relative path
`./.github/workflows/secret-scanner-reusable.yml`, mirroring the
`governance.yml` exemplar. This repo owns the reusable, so it should
always scan itself with the current version — and a local path **cannot
re-stale**, so this class of breakage cannot recur here. The current
reusable needs only `contents: read`, so the existing grant is already
correct (the in-file comment was accurate; only the pin was stale).

### Estate-wide corollary (not fixed here)

The same `@d135b05` / pre-#500 pins are still in place on callers across
the estate. Verified for the record:

```
$ gh api repos/hyperpolymath/standards/compare/08e2d246...d135b05
{"status":"behind","ahead":0,"behind":64}
```

At `d135b05` the gitleaks step is literally:

```yaml
- name: Gitleaks Secret Scan
  continue-on-error: true   # <-- cannot fail
  uses: gitleaks/gitleaks-action@e0c47f4f...
```

versus current `main`:

```yaml
- name: Install gitleaks (pinned + checksum-verified)   # sha256-verified binary
- name: Gitleaks secret scan (gating)
  run: ... gitleaks detect ... --exit-code 1            # gates for real
```

So bumping those caller pins is **not cosmetic staleness** — it converts
the estate's primary secret scanner from cannot-fail to actually-gating,
*and* drops the permission demand to `contents: read` (removing the
startup_failure hazard). That caller sweep is tracked separately.

## Secondary fixes

- **`pages.yml`** (added in #503): had no SPDX header — tripping the
governance "missing SPDX header" gate — and four unpinned tag refs,
which the estate Actions policy rejects at run creation. Added `MPL-2.0`
(**this repo's licence — deliberately not AGPL**) and pinned all four to
release SHAs, keeping the current majors: `checkout` v7.0.0,
`upload-pages-artifact` v5.0.0, `deploy-pages` v5.0.0. (Note:
paint-type's pages.yml pins v3/v4 — copying those here would be a
downgrade.)
- **`spark-theatre-gate.yml`**: corrected a mislabelled pin comment —
SHA `9c091bb2` is v7.0.0, not v4. Cosmetic, no behavioural change.

## Verification

- All three files parse (`yaml.safe_load`).
- The `startup_failure` claim is from `gh run list`, not inference; this
PR's own Secret Scanner run is the direct test — it should execute
rather than startup_failure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
hyperpolymath added a commit that referenced this pull request Jul 21, 2026
…this repo only) + scorecard-verify false-red (new) (#514)

Completes the run to green on `main`, after #511 (secret-scanner caller
+ SPDX) and #512 (gitleaks allowlist). Two remaining causes, independent
of each other and of those PRs.

## 1. Hypatia has never run — the caller under-grants (#451)

`hypatia-scan-reusable.yml` declares `security-events: write` (it
uploads SARIF). `hypatia-scan.yml` granted `security-events: **read**`.

A called workflow may never request more than its caller grants. When it
does, GitHub rejects the run at **startup** — before any job exists:

```
completed  startup_failure  Hypatia Security Scan  main  push  1s
$ gh run view 29764318317 --log-failed
failed to get run log: log not found          # <- there is no log; no job ran
```

Granting `read` did not degrade the scan to a no-upload mode. **It
stopped the scan from existing.**

The reusable carried this comment:

```yaml
continue-on-error: true    # callers granting only security-events:read skip gracefully
```

That is a false mental model, and it is load-bearing — it is the reason
the mismatch looked intentional. Nothing skips gracefully in a job that
was never created. Corrected in place.

This is exactly the #451 pattern that startup-fails reusable-workflow
callers estate-wide. **Fixed here on the canon's own caller only** —
deliberately demonstrated on one repo before any propagation is
proposed.

## 2. Seven "fake passes" that were every one of them real (#381)

`registry-verify.yml` runs `build-scorecards.sh --verify`, which
**executes** every pass-row's check. The job installed no tools, and
`ubuntu-latest` ships neither ripgrep nor xmllint:

| rows | mechanism | observed |
|---|---|---|
| `k9-svc/M6` | calls `xmllint` directly | exit **127** |
| 6 × `release-pre-flight/*` | invoke `v1-audit.sh`, which hard-exits 2
without ripgrep, so the greps match nothing | exit **1** |

The verifier reported all seven as **"claimed PASS but the pass is not
real"**. Reproduced locally by stripping `rg` from `PATH` — identical
output.

Measured with the tools present:

```
── verify: 87 grounded pass · 0 broken pass · 0 unrunnable · 0 self-asserted · 0 stale-fail
EXIT=0
```

**All seven claims were true.** The checks had never run. A false-red is
not harmless: it trains maintainers to ignore a gate — the same end
state as the fake-green gates #500 removed, reached from the opposite
direction.

Two changes, since installing the tools alone leaves the trap armed for
the next missing dependency:

- `registry-verify.yml` installs `ripgrep` + `libxml2-utils`.
- `run_verify()` **refuses to judge pass-rows at all** in an incomplete
environment, and reports genuinely unrunnable checks as `unrunnable` —
still failing, since an unverifiable pass must never go green — rather
than as false claims.

### The required-tool list is declared, not inferred

An earlier draft parsed check strings to discover their commands. It
misparsed quoted regexes (`"a\|b\|c"`) and `[ $(…) -ge 25 ]`, inventing
**35** failures — precisely the sin the guard exists to prevent.
Replaced with a measured, documented list: `xmllint` is named by 4
checks, `jq` by 1, and `rg` by none but is required transitively via
`v1-audit.sh`.

## Verification

- complete env → `87 grounded · 0 broken · 0 unrunnable`, exit 0
- incomplete env → fails fast, judges nothing, names the missing tool
- `k9-svc/mime/k9.xml` + `k9.uti.plist` independently confirmed
well-formed and the two companion files present — so installing xmllint
turns that row **genuinely** green rather than exposing a real fault
- all three workflow files parse

## Not included, on purpose

`.gitleaks.toml` — **#512 covers it and is the better implementation.**
I built one independently and then discarded it: my regexes matched
against the whole line unanchored, and canary-testing showed that
suppressed real tokens.

| planted secret | gitleaks defaults | my draft |
|---|---|---|
| `ghp_…` PAT | caught | caught |
| same PAT, line also contains `EXAMPLE_KEY` | caught | **suppressed** |
| same PAT, line ends `# ...` | caught | **suppressed** |

#512's anchored `^…$` design has no such hole. Verified independently
against gitleaks 8.18.4 (the exact CI build, checksum-matched): all four
adversarial fixtures still caught, and `0` findings on the tree. Adopt
#512.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
hyperpolymath added a commit that referenced this pull request Jul 21, 2026
…e to real (#505) (#513)

Promotes the two `governance-reusable.yml` jobs that **could not fail**
into real gates — tasks 1, 2 and 3 of #505.

## Blast radius was measured first, not guessed

Over the **412 real repo-root callers** of `governance-reusable` in the
estate checkout (1,106 raw grep hits reduce to 412 once nested monorepo
subdirs — whose `.github/workflows` GitHub never reads — are excluded):

| check | missing | decision |
|---|---|---|
| README | **0 / 412** | **blocking now** — arming reds nobody |
| LICENSE | **0 / 412** | **blocking now** — arming reds nobody |
| CONTRIBUTING | 54 / 412 (13%) | warn → **blocking from 2026-08-21** |
| no Guix/Nix packaging | 70 / 412 (17%) | warn → **blocking from
2026-08-21** |

**Verified against all 412 callers with the final scripts: 0 red today
on both gates.** The grace window buys time without manufacturing red
noise now — and it is a *real date that flips itself*, not a flag
defaulting to off (which would be the theatre this replaces).

**94 unique repos** must land a `CONTRIBUTING` and/or packaging file
before the cutoff. Full list attached as a comment below.

## What changed

**`scripts/check-docs-presence.sh`** (new) — replaces the
`::warning::`-only step. Split gate: README+LICENSE fail immediately,
CONTRIBUTING warns until the cutoff. Critically, it **never prints a
pass claim while a required file is absent** — grace mode prints `NOT
YET ENFORCED`, not a green tick.

**`scripts/check-package-policy.sh`** (new) — replaces the job that
ended in an unconditional `✅ Package policy check passed`. Two honesty
fixes beyond adding `exit 1`:
- Predicate tightened from *"any `*.scm` anywhere"* (a stray Guile
source file satisfied it) to a genuine packaging artefact
(`guix.scm`/`manifest.scm`/`channels.scm`/`.guix-channel`). Tightening
moved only **13** repos, so honesty was nearly free.
- Vendored trees (`node_modules/`, `deps/`, `.lake/`, `vendor/`,
`.git/`) are pruned, so a dependency can't satisfy the policy on the
caller's behalf.
- The `git diff HEAD~1` lockfile branch is **dropped, not stubbed**: it
never resolved under `ref: github.sha` without `fetch-depth`, so it was
inert. hypatia `cicd_rules/nodejs_detected` is the real rule. Broadening
it beyond `package-lock.json` is follow-up, noted below.

**Redundant trufflehog removed** (#505 task 3). It was
`continue-on-error: true` so it never gated, and it duplicated the real
gitleaks gate from #500. `secret-scanner-reusable.yml`'s header
*already* claimed "Trufflehog removed as redundant" — this reconciles
the estate with itself. Removing a never-failing step cannot newly-red
any caller.

**Both gates refuse to be disarmed.** A malformed cutoff would make the
date comparison pick the grace branch forever — silently restoring the
fake gate. Both scripts validate their own cutoff format and hard-fail
on garbage.

## A real bug, found only by running against real repos

`find … | head -1` under `set -o pipefail` is a **SIGPIPE race**: `head`
exits after line 1, `find` keeps writing, takes SIGPIPE, and `pipefail`
propagates it — aborting under `set -e`. It only bites on trees large
enough that `find` is still running, so **all 20 unit fixtures passed
while two real estate repos red intermittently for no policy reason**.
Fixed, with a regression test that was itself red-teamed: it *fails*
against the buggy form and passes against the fix. (The first version of
that test was a false-green — 3,000 non-matching files don't trigger it,
because `find` emits one line; the fixture now creates 4,000 *matching*
paths.)

## Verification

`just governance-gates-test` → **21 cases, all green**. Drives both
gates on **both sides of the cutoff** (`DOCS_TODAY` / `PKG_TODAY`
seams), plus alternate extensions, inclusive-cutoff boundary,
vendored-tree pruning, stray-`.scm` rejection, malformed-cutoff refusal,
and the SIGPIPE regression.

This matters because **#505 notes standards CI does not exercise the
reusable for SHA-pinned callers** — watching this repo go green proves
nothing about them. Note however that **396 callers pin `@main`**, so
they pick this up *immediately on merge*; the grace window is what makes
that safe.

## ⚠️ Reviewer note — the plumbing proof and the expected red

standards calls this reusable **on its own PRs** (`governance.yml` →
`./.github/workflows/governance-reusable.yml`), and the script checkout
is pinned `ref: main`. The new scripts are not on `main` until this
merges, so the final commit's governance run **will red for that reason
alone, not because the change is broken**.

To prove the plumbing (sparse-checkout → `cp` to `$RUNNER_TEMP` → `rm
-rf .standards-checkout` → run) actually works end-to-end, this branch
temporarily points `ref:` at itself; that run is the evidence, and the
`ref:` is reverted to `main` before merge. See the commit history.

## Rollback

Revert this PR, or re-pin callers to the previous `governance-reusable`
SHA. The two new scripts are additive — nothing else calls them.

## Follow-up (not in scope here)

- Broaden hypatia `cicd_rules/nodejs_detected` beyond
`package-lock.json` (yarn/Gemfile/Pipfile/poetry lockfiles), recovering
the coverage the inert branch nominally claimed.
- The 94-repo remediation list before 2026-08-21.

Refs #505, #500

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant