diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b877f22..c1a726a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,16 @@ jobs: # portfolio and this repo — the one that publishes it — was the only repo not running # it. A local `./` ref is used deliberately: it always names THIS commit, so the # workflow is proven against the version being changed rather than a released SHA. + # + # TWO callers, because one job cannot answer both questions. gitleaks-action + # derives its scan range from the EVENT: on push/pull_request it scans only the + # commits that event introduced; on schedule/workflow_dispatch it scans every + # commit. Measured here — run 31051347230 (push to main) scanned 0 commits and + # still reported success; run 30793713570 (schedule) scanned 41. secret-scan: - name: Secret scan (own brick) + name: Secret scan (own brick) # required status check — do not rename + # Skipped on the sweep events so the full-history job below is not duplicated. + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' # The reusable workflow needs pull-requests: read to list a PR's commits via the API; # a caller job may raise a scope the read-only top level does not grant. permissions: @@ -33,6 +41,32 @@ jobs: pull-requests: read uses: ./.github/workflows/secret-scan.yml + # The half that actually reads history. `full-history: true` is not a switch that + # widens the scan — nothing can widen it from inside a reusable workflow — it is an + # assertion: if this job ever runs on an event that scans a partial range, it fails + # instead of reporting a partial scan as clean. + secret-scan-sweep: + name: Full-history secret sweep (own brick) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # Must match the callee, exactly as the job above does. A job-level block + # REPLACES the top-level one, it does not add to it, so granting only + # `contents: read` here left `pull-requests` at `none` while secret-scan.yml + # declares it needs `read`. GitHub refused the whole run: `startup_failure`, + # ZERO check runs — `Security policy` and `Secret scan (own brick) / gitleaks` + # were absent from the PR rather than red, and branch protection reads absent + # as "pending", never "failed". `if:` does not save you: permissions are + # checked before any condition is evaluated, so the run died on pull_request + # events where this job would never have started. + # tests/security-policy.sh::validate_caller_permission_sufficiency compares + # every caller against its callee statically, because a run that cannot start + # reports nothing to learn from. + permissions: + contents: read + pull-requests: read + uses: ./.github/workflows/secret-scan.yml + with: + full-history: true + security-policy: name: Security policy runs-on: ubuntu-latest diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index ab8db4c..2fa7e24 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -1,15 +1,43 @@ -# Reusable secret scan — gitleaks over the FULL git history (house standard §4). +# Reusable secret scan — gitleaks, over the range the CALLING EVENT defines. # The single most-duplicated job in the portfolio: five of the six repos run an # identical gitleaks job today (almamesh keeps a bespoke one — its gitleaks job # carries an extra key-custody tree-guard step; see the README limits section). # -# fetch-depth: 0 is mandatory — a credential committed five commits ago is exactly -# as leaked as one committed at HEAD. pull-requests:read is required because -# gitleaks-action lists a PR's commits via the API on pull_request events (else it -# 403s "Resource not accessible by integration" before scanning). GITLEAKS_LICENSE -# is NOT needed — these are personal-account repos, not an organization. A repo's -# own .gitleaks.toml allowlist (aml-filter, almamesh have one) is picked up -# automatically from the checkout; no input needed. +# READ THIS BEFORE ASSUMING YOUR HISTORY IS SWEPT. +# gitleaks-action decides what to scan from the EVENT, not from fetch-depth +# (gitleaks-action@e0c47f4, src/gitleaks.js:103-115 and src/index.js:176): +# +# push, pull_request adds --log-opts=--no-merges --first-parent BASE^..HEAD +# -> ONLY the commits that event introduced +# schedule, workflow_dispatch no --log-opts at all +# -> EVERY commit in the repository +# +# Measured on this repository, through this very workflow: +# +# event run commits scanned verdict +# schedule 30793713570 41 No leaks detected +# pull_request 30978634362 1 No leaks detected +# push (to main) 31051347230 0 No leaks detected +# +# A push whose base is already an ancestor of head scans ZERO commits and still +# reports success. That is the failure this workflow now refuses to hide: a green +# scan is not evidence unless you know what it looked at. +# +# fetch-depth: 0 is still mandatory, but it only makes BASE^ RESOLVABLE — it does +# not widen the range. Full history is swept when, and only when, a caller +# triggers this workflow on `schedule` or `workflow_dispatch`. A reusable workflow +# cannot carry its own `schedule`, so THE CALLER OWNS THE SCHEDULE: put a second +# caller job in the repo's scheduled workflow (every consumer already has +# `security-audit.yml` on a weekly cron) and pass `full-history: true`. +# tests/security-policy.sh refuses an examples/ tree where a repo calls this +# workflow but never from a scheduled one. +# +# pull-requests:read is required because gitleaks-action lists a PR's commits via +# the API on pull_request events (else it 403s "Resource not accessible by +# integration" before scanning). GITLEAKS_LICENSE is NOT needed — these are +# personal-account repos, not an organization. A repo's own .gitleaks.toml +# allowlist (aml-filter, almamesh have one) is picked up automatically from the +# checkout; no input needed. # # This is a single first-party action, so there is nothing to factor into a # composite — the whole job is the shared unit. @@ -20,10 +48,21 @@ # and keep the trailing version comment so Dependabot can bump it. Ready-to-copy # callers live in examples/. # -# Caller (one job): +# Caller — TWO jobs, in two files, because they answer two different questions: +# +# # ci.yml (on: push, pull_request) — did THIS change add a secret? # jobs: # gitleaks: -# uses: hseshadr/ci/.github/workflows/secret-scan.yml@<40-char-sha> # ci-v2.0.1 +# name: Secret scan # -> check context "Secret scan / gitleaks" +# uses: hseshadr/ci/.github/workflows/secret-scan.yml@<40-char-sha> # ci-v3.2.1 +# +# # security-audit.yml (on: schedule) — is anything in HISTORY a secret? +# jobs: +# gitleaks: +# name: Secret scan +# uses: hseshadr/ci/.github/workflows/secret-scan.yml@<40-char-sha> # ci-v3.2.1 +# with: +# full-history: true name: Secret scan (reusable) on: @@ -32,6 +71,15 @@ on: runs-on: type: string default: "ubuntu-latest" + # Declares that this caller expects the whole repository swept. It 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 loudly instead of reporting a clean partial scan as clean. + # Defaults to false so adopting this release cannot redden an existing + # push/PR caller; the scheduled caller opts in. + full-history: + type: boolean + default: false permissions: contents: read @@ -44,7 +92,31 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - fetch-depth: 0 # full history — gitleaks scans every commit, not just HEAD + # Makes BASE^ resolvable for the push/PR range, and is what lets the + # scheduled sweep reach every commit. It does NOT by itself widen the + # range — see the event table at the top of this file. + fetch-depth: 0 - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Say what was actually scanned. "No leaks detected" over 0 commits and over + # 41 commits are the same three words; only this line tells them apart. + # Runs unconditionally so the number is in every log, not just failing ones. + - name: Report what this scan actually covered + env: + SCAN_FULL_HISTORY: ${{ inputs.full-history }} + run: | + commits="$(git rev-list --count HEAD 2>/dev/null || echo '?')" + case "${GITHUB_EVENT_NAME:-unknown}" in + schedule | workflow_dispatch) + echo "gitleaks swept FULL HISTORY: all ${commits} commits (no --log-opts on a ${GITHUB_EVENT_NAME} event)." + ;; + *) + echo "gitleaks scanned ONLY the ${GITHUB_EVENT_NAME:-unknown} event's commit range, out of ${commits} commits in history." + echo "History is swept by a schedule/workflow_dispatch caller, not by this one." + if [ "${SCAN_FULL_HISTORY}" = "true" ]; then + echo "::error::full-history: true was requested, but a ${GITHUB_EVENT_NAME:-unknown} event scans only its own commit range. Call this workflow from a workflow triggered by 'schedule' or 'workflow_dispatch'." + exit 1 + fi + ;; + esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 20abae9..f8b542f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,94 @@ All notable changes to the shared CI/CD templates. Each release is cut as an imm listed below. `tests/security-policy.sh` rejects a moving `@ci-vN` ref, first-party included. +## Unreleased + +**A brick changed shape**: `secret-scan.yml` gains one optional input, `full-history` +(boolean, default `false`). Re-pinning without setting it is a drop-in — the default is +the existing behaviour, so no current caller changes verdict. + +**Action required if you copied `examples//security-audit.yml`**: its `gitleaks` job +now carries its own `permissions:` block. Without it that job cannot start — see below. + +- **A caller that under-granted did not go red, it went ABSENT.** `secret-scan.yml` + declares `pull-requests: read` (gitleaks-action lists a PR's commits through the API). + Five `examples/*/security-audit.yml` called it from a workflow whose only grant was a + top-level `contents: read`, and so did `ci.yml`'s own `secret-scan-sweep` job — whose + job-level `permissions: {contents: read}` *replaced* the top level rather than adding to + it, dropping `pull-requests` to `none`. GitHub refuses such a run before any job starts: + `requesting 'pull-requests: read', but is only allowed 'pull-requests: none'`. The + conclusion is `startup_failure` and it emits **zero check runs** — measured on run + [31127046921](https://github.com/hseshadr/ci/actions/runs/31127046921), which reported + `jobs: 0` while the check-runs API for its head SHA listed only the checks from other + workflows. `Security policy` and `Secret scan (own brick) / gitleaks` were not red, they + were missing, and **branch protection reads a missing required check as "pending", never + "failed"** — the same shape as the bug this release exists to fix, where a secret scan + that scanned 0 commits reported success. An `if:` guard does not help: permissions are + checked before any condition is evaluated, so `ci.yml` died on `pull_request` events + where the offending job would never have run at all. +- **New guard: `tests/lib/scan-caller-permissions.rb`**, driven by + `validate_caller_permission_sufficiency` in `tests/security-policy.sh`. It parses every + caller job's effective grant (job-level block, else workflow-level) and compares it + scope-by-scope against the callee's declared `permissions:`, across + `.github/workflows/` **and** `examples/`. It is static by necessity — there is no run to + inspect, because the failure *is* the absence of a run. 15 both-polarity fixtures pin the + property (`validate_caller_permission_cases`), including the job-level-replacement trap, + a granted `read` against a required `write`, `read-all`/`write-all` shorthands, a grant + supplied through a YAML alias, and a caller that declares no permissions anywhere. The + scanner also reports how many caller→callee pairs it resolved (25 today) against a floor + of 20, so ref resolution that quietly broke cannot masquerade as a clean tree. + +- **The secret scan never read history, and said it did.** `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 described 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`, and + only on `schedule`/`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](https://github.com/hseshadr/ci/actions/runs/31051347230) (push to `main`) + scanned **0 commits** and reported success; run + [30978634362](https://github.com/hseshadr/ci/actions/runs/30978634362) (pull_request) + scanned **1**; run + [30793713570](https://github.com/hseshadr/ci/actions/runs/30793713570) (schedule) + scanned **41**. The workflow is `workflow_call`-only and every caller in `examples/` but + one ran on push/PR, so no consumer's pre-existing history had ever been scanned by CI. +- **The caller owns the schedule.** A `workflow_call` workflow cannot carry its own + `schedule:`, so the fix is not inside the brick. Every consumer already has + `security-audit.yml` on a weekly cron; each `examples/*/security-audit.yml` now calls + `secret-scan.yml` from it with `full-history: true`. +- **`full-history` asserts, it does not widen.** Nothing can widen the range from inside a + reusable workflow. The input 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. + Every run also now prints what it actually covered, because "No leaks detected" over 0 + commits and over 41 commits are the same three words. +- **Every secret-scan caller job is named.** An unnamed one reports as `gitleaks / + gitleaks` instead of the documented `Secret scan / gitleaks`, silently orphaning an + adopter's required status check. Five of the six callers shipped in `examples/` omitted + the `name:`, as did the README's canonical copy-paste snippet. +- **Two guards, both shown failing.** `validate_secret_scan_history_sweep` refuses an + `examples/` tree where a repo calls `secret-scan.yml` but never from a scheduled + workflow, and refuses an unnamed caller job; it carries a vacuity floor. + `validate_secret_scan_coverage_cases` executes the workflow's real coverage script under + both event families instead of grepping for its error string. +- **README: 16 false claims fixed or deleted.** The file had never been updated past + `ci-v3.0.0` while three releases and one consumer adoption landed. Corrected: the current + release and every `2a575cd` pin (now `605e51c` / `ci-v3.2.1`), the adoption count (7 + call-sites across 5 repos, not 6 across 4), the drift count (29, not three different + numbers), the third-party pin table (exact versions — a `# v6` comment on a SHA is the + defect commit `ae644d7` fixed), the publish-verification bound (14 attempts / 600s, not + 6 / 60s), the "these repos are private" setup section (all eight are public), and the + repository-settings gap (branch protection and secret scanning are both on). Deleted: + the `--allow-unlocked` "live gap" callout, closed at `ci-v3.2.1`, and two completed + owner actions. The one genuinely open owner action is now stated: `ci-v3` still points + at `72521e7`, 21 commits behind `ci-v3.2.1`. +- **`aml-filter/ci.yml/secret-scan` deleted from the drift allowlist.** aml-filter#93 + merged on 2026-08-02 and the consumer now calls the brick + ([run 31051313153](https://github.com/hseshadr/aml-filter/actions/runs/31051313153), + `Secret scan / gitleaks` SUCCESS on `main`). First entry ever removed by an actual + convergence rather than by a bug fix. 30 -> 29. + ## ci-v3.2.1 — 2026-08-04 Commit `605e51cbc86f452b56edcf1c9660921da797cbfe`. diff --git a/README.md b/README.md index d35bb1e..9b86b65 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ both: ```mermaid flowchart TD CI["hseshadr/ci — one copy of each CI job
7 reusable workflows + 5 composite actions"] - CI -->|"called at a pinned commit: 2a575cd = ci-v3.0.0"| USED["In use today — 6 call-sites
ts-publish.yml ×3 · setup-python-uv ×3"] - USED --> WHO["assay · edge-proc · edgeproc-core · privacy-core
their release path runs this shared copy"] - CI -.->|"nobody calls these yet"| IDLE["The other 10 bricks — 0 call-sites
almamesh · aml-filter · edge-reco
still hand-roll their own CI"] + CI -->|"called at a pinned commit: 605e51c = ci-v3.2.1"| USED["In use today — 7 call-sites
ts-publish.yml ×3 · setup-python-uv ×3 · secret-scan.yml ×1"] + USED --> WHO["assay · edge-proc · edgeproc-core · privacy-core · aml-filter
their release path (and aml-filter's CI) runs this shared copy"] + CI -.->|"nobody calls these yet"| IDLE["The other 9 bricks — 0 call-sites
almamesh · edge-reco
still hand-roll their own CI"] ``` The dotted branch is the point of the [consumer-drift @@ -32,8 +32,8 @@ away. The counts below are that measurement. standardized." One place to bump `actions/checkout`, one place to fix the gitleaks pattern, one place that defines what "run the gate" means. No drift. -**Status.** Current release: **`ci-v3.0.0`** (commit -`2a575cd193e2e1fc093ccd26821020538e2547b7`, 2026-07-30). Templates written and statically +**Status.** Current release: **`ci-v3.2.1`** (commit +`605e51cbc86f452b56edcf1c9660921da797cbfe`, 2026-08-04). Templates written and statically validated — all 32 YAML files parse, and `actionlint` plus `zizmor` run in CI over the workflows *and* over `examples/` (the examples need staging into a `.github/workflows/` layout first, which `tests/lint-examples.sh` does; a plain repo-root scan reaches none of @@ -41,22 +41,23 @@ them). Both are clean. Every example is additionally resolved against the reposi written for — see [Guards that run in CI](#guards-that-run-in-ci). The cross-repo [access flip](#required-setup-read-this-first) is done, so callers resolve. -**Adopted in code by four repos — six call-sites, all of them on the publish path.** -Counted by grepping every consumer's `.github/workflows/` on 2026-07-31: +**Adopted in code by five repos — seven call-sites, six of them on the publish path.** +Counted by grepping every consumer's `.github/workflows/` on 2026-08-06: | Brick | Call-sites | Where | |---|---|---| | `setup-python-uv` (composite) | 3 | assay, edge-proc, edgeproc-core | | `ts-publish.yml` (reusable workflow) | 3 | assay (×2), privacy-core | -| the other 4 composites and 6 reusable workflows | **0** | nowhere | +| `secret-scan.yml` (reusable workflow) | 1 | aml-filter (`ci.yml` — the first non-publish adoption) | +| the other 4 composites and 5 reusable workflows | **0** | nowhere | `privacy-core` calls `ts-publish.yml` cross-repo; `assay` calls `ts-publish.yml` cross-repo **and** carries an inline PyPI job that composes this repo's `setup-python-uv` composite; `edge-proc` and `edgeproc-core` carry the same inline PyPI job (cross-repo PyPI is -structurally impossible — see the warning below). All six call-sites pin the `ci-v3.0.0` -commit SHA `2a575cd…`; five of the six still carry a stale `# ci-v2.0.3` label comment -beside it, which is a Dependabot-readability nit, not a wrong pin. `almamesh`, `aml-filter` -and `edge-reco` have zero call-sites of any kind. +structurally impossible — see the warning below). All seven call-sites pin the `ci-v3.2.1` +commit SHA `605e51c…`, each with a matching `# ci-v3.2.1` comment — verified against every +consumer's default branch on 2026-08-06. `almamesh` and `edge-reco` have zero call-sites of +any kind. **The publish path is LIVE-VALIDATED end-to-end — two consumer releases have run through it green (2026-07-22):** @@ -71,10 +72,15 @@ through it green (2026-07-22):** `setup-python-uv` composite at the pinned SHA) and `publish-npm` through cross-repo `ts-publish.yml`. -Still unproven: the gate, secret-scan, security-audit, frontend, and deploy templates +`secret-scan.yml` is live-validated too, and it is the first non-publish brick to get +there: aml-filter +[run 31051313153](https://github.com/hseshadr/aml-filter/actions/runs/31051313153) on +`main`, job `Secret scan / gitleaks` SUCCESS, at the `ci-v3.2.1` SHA. + +Still unproven: the gate, security-audit, frontend, and deploy templates have **no consumer runs** — those repos still run their own inline `ci.yml` and `security-audit.yml`. A daily sweep counts exactly how much of that is left: **29 -hand-rolled controls across 7 consumer repositories** as of 2026-07-31 (see +hand-rolled controls across 7 consumer repositories** as of 2026-08-06 (see [Consumer drift](#consumer-drift-what-is-still-hand-rolled)). And `edgeproc-core`'s six older green publish runs (when it was still named `shared-libs-python`) predate the migration *and* its PyPI trusted-publisher bootstrap, which is why the package never @@ -117,20 +123,81 @@ permissions: pull-requests: read jobs: gate: - uses: hseshadr/ci/.github/workflows/python-gate.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 + uses: hseshadr/ci/.github/workflows/python-gate.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 with: { sync-args: "--frozen --all-extras" } gitleaks: - uses: hseshadr/ci/.github/workflows/secret-scan.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 + name: Secret scan + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 ``` That is the *whole file*, and it is copy-pasteable as written: the SHA above **is** -`ci-v3.0.0`, the current release. `gate` runs the repo's `poe gate` (lint, format-check, -types, complexity, tests + coverage floor); `gitleaks` scans the full git history for -secrets. Ready-to-copy callers for all seven consumer repos live in -[`examples/`](./examples), carrying the same SHA. Every `hseshadr/ci/...` ref must be a +`ci-v3.2.1`, the current release. `gate` runs the repo's `poe gate` (lint, format-check, +types, complexity, tests + coverage floor); `gitleaks` scans **the commits this push or +pull request introduced** — not the repository's history. Sweeping history needs a second +caller on a `schedule`; see [What the secret scan actually +covers](#what-the-secret-scan-actually-covers). The `name: Secret scan` is not decoration: +without it the check reports as `gitleaks / gitleaks`, which is the wrong context for +branch protection — see the next section. Ready-to-copy callers for all seven consumer +repos live in [`examples/`](./examples), carrying the same SHA. Every `hseshadr/ci/...` ref must be a full commit SHA, never a moving `@ci-vN` tag; see [Version pinning](#version-pinning-full-commit-shas) for why. +### What the secret scan actually covers + +**A green secret scan is not evidence unless you know how many commits it read.** + +`gitleaks-action` derives its scan range from the **event**, not from `fetch-depth` +(`gitleaks-action@e0c47f4`, `src/gitleaks.js:103-115` and `src/index.js:176`): + +| Event | What the action passes to gitleaks | What gets scanned | +|---|---|---| +| `push`, `pull_request` | `--log-opts=--no-merges --first-parent BASE^..HEAD` | **only the commits that event introduced** | +| `schedule`, `workflow_dispatch` | nothing | **every commit in the repository** | + +`fetch-depth: 0` is still mandatory — it makes `BASE^` resolvable and is what lets the +scheduled sweep reach every commit — but on its own it does **not** widen the range. + +Measured on this repository, through `secret-scan.yml` itself: + +| Event | Run | Commits scanned | Verdict | +|---|---|---|---| +| `schedule` | [30793713570](https://github.com/hseshadr/ci/actions/runs/30793713570) | **41** | No leaks detected | +| `pull_request` | [30978634362](https://github.com/hseshadr/ci/actions/runs/30978634362) | **1** | No leaks detected | +| `push` to `main` | [31051347230](https://github.com/hseshadr/ci/actions/runs/31051347230) | **0** | No leaks detected | + +A push whose base is already an ancestor of head scans **zero commits** and still reports +success. Until 2026-08-06 this file claimed the opposite — "gitleaks over the FULL git +history", "a credential committed five commits ago is exactly as leaked as one committed +at HEAD" — and every consumer's only caller was on `push`/`pull_request`. So no repo's +pre-existing history had ever been scanned by CI. + +**The caller owns the schedule.** A `workflow_call` workflow cannot carry its own +`schedule:`, so the fix is not inside `secret-scan.yml`. Every consumer already has a +`security-audit.yml` on a weekly cron; add a second caller job there: + +```yaml +# security-audit.yml — on: schedule +jobs: + gitleaks: + name: Secret scan + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true +``` + +`full-history: true` 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 loudly instead of reporting a clean partial scan as clean. It defaults +to `false` so adopting this release cannot redden an existing push/PR caller. + +Every run now also prints what it covered ("gitleaks swept FULL HISTORY: all N commits" or +"gitleaks scanned ONLY the push event's commit range, out of N commits in history"), +because "No leaks detected" over 0 commits and over 41 commits are the same three words. + +`tests/security-policy.sh` refuses an `examples/` tree where a repo calls +`secret-scan.yml` but never from a scheduled workflow, and executes the coverage step +under both event families rather than grepping for its error string. + ### Adopting a reusable workflow renames its check run **Read this before converging a repo that has branch protection.** GitHub names a @@ -140,7 +207,7 @@ caller job alone. So replacing an inline job called `gitleaks` with ```yaml gitleaks: name: Secret scan - uses: hseshadr/ci/.github/workflows/secret-scan.yml@ # ci-v3.0.0 + uses: hseshadr/ci/.github/workflows/secret-scan.yml@ # ci-v3.2.1 ``` produces a check named **`Secret scan / gitleaks`**. The old `gitleaks` context stops @@ -160,10 +227,24 @@ This is a real cost of adoption and it is worth naming plainly, because it is pa person converging and invisible to the person who published the brick. It is one reason a hand-rolled copy keeps winning: inlining never renames anything. +**It applies to every caller job, not just the secret scan.** The quickstart's `gate:` job +is unnamed, so it reports as `gate / gate`. Name it after whatever context your branch +protection already requires. + +| Brick | Check context an adopter gets | Observed in a real run? | +|---|---|---| +| `secret-scan.yml` called by `gitleaks:` with `name: Secret scan` | `Secret scan / gitleaks` | ✅ aml-filter [run 31051313153](https://github.com/hseshadr/aml-filter/actions/runs/31051313153) | +| `secret-scan.yml` called by `gitleaks:` with **no** `name:` | `gitleaks / gitleaks` | ✅ this is the mismatch that reached five repos | +| `secret-scan.yml` called by `secret-scan:` with `name: Secret scan (own brick)` | `Secret scan (own brick) / gitleaks` | ✅ this repo's own CI, and a required context on `main` | +| `python-gate.yml` called by an unnamed `gate:` job | `gate / gate` | ⚠️ **unverified** — no consumer calls `python-gate.yml` yet, so this string is derived from the rule above and has never been emitted by a run | + +The last row is deliberately marked rather than stated. Documenting an unobserved context +name as fact is precisely how the `gitleaks` mismatch got copied into five repos. + ### Our releases are `ci-vX.Y.Z`, and that can trip a consumer's own pin guard Third-party actions tag `vN`; this repo tags `ci-vN.N.N`, so the trailing comment on a -first-party pin reads `# ci-v3.0.0`, not `# v3.0.0`. A consumer that lints its own pinned +first-party pin reads `# ci-v3.2.1`, not `# v3.2.1`. A consumer that lints its own pinned `uses:` comments with a `^v\d` regex will **reject a correct `hseshadr/ci` pin** — and the only way to satisfy that regex is to write a comment naming a tag that does not exist. @@ -227,7 +308,7 @@ it is broken. |---|---|---| | `python-gate.yml` (workflow) | your Python repo runs `uv run poe gate` | — | | `frontend-gate.yml` (workflow) | your JS repo runs `pnpm gate`, optionally with Playwright | — | -| `secret-scan.yml` (workflow) | any repo — gitleaks over the full git history | — | +| `secret-scan.yml` (workflow) | any repo — gitleaks over the calling event's commits; add a scheduled caller with `full-history: true` to sweep history | aml-filter | | `security-audit.yml` (workflow) | you want `pip-audit` and/or `pnpm audit` (at least one must be on) | — | | `cloudflare-pages-deploy.yml` (workflow) | you deploy a built site to Cloudflare Pages | — | | `ts-publish.yml` (workflow) | you release an npm package from a `v*` tag, token-free via OIDC | assay (×2), privacy-core | @@ -248,7 +329,7 @@ it is broken. ci.yml # validates this repo's CI security policy python-gate.yml # checkout → setup → uv run poe gate → (opt) codecov frontend-gate.yml # checkout → pnpm setup → (opt) Playwright → pnpm gate - secret-scan.yml # gitleaks over full history + secret-scan.yml # gitleaks over the calling event's commit range security-audit.yml # pip-audit and/or pnpm audit (each bool-gated) cloudflare-pages-deploy.yml # preflight → build → wrangler pages deploy python-publish.yml # gate → uv build → PyPI via OIDC → verify on PyPI (SAME-REPO only) @@ -277,6 +358,9 @@ tests/ lib/ scan-run-interpolation.rb # finds attacker-controllable ${{ }} inside run: blocks scan-publish-provenance.rb # proves every publish path is signed + scan-caller-permissions.rb # proves no caller grants a reusable workflow LESS + # than it needs (that run cannot start, and a run + # that cannot start emits ZERO check runs) workflow-run-pin.rb # parses fork-deploy gates into a boolean AST classify-workflow.rb # classifies a consumer workflow by behavior example-references.rb # resolves an example's references inside a consumer repo @@ -302,21 +386,12 @@ composite (details below). |---|---|---|---| | `python-gate.yml` | `working-directory` `.`, `python-version` `3.13`, `sync-args` `--locked` (must carry `--frozen`/`--locked`; opt out only via `--allow-unlocked`), `gate-task` `gate`, `upload-coverage` `false`, `coverage-file` `coverage.xml` | `CODECOV_TOKEN` (optional) | checkout → **setup-python-uv** → `uv run poe ` → optional Codecov upload | | `frontend-gate.yml` | `working-directory` `.`, `package-json-file`, `node-version` `24` / `node-version-file`, `cache-dependency-path` `pnpm-lock.yaml`, `install-args` `--frozen-lockfile`, `gate-command` `pnpm gate`, `install-playwright` `false`, `playwright-browsers` `chromium` | — | checkout → **setup-pnpm** → optional **setup-playwright** → `gate-command` | -| `secret-scan.yml` | `runs-on` | uses `GITHUB_TOKEN` | checkout `fetch-depth:0` → `gitleaks-action` over full history | +| `secret-scan.yml` | `runs-on`, `full-history` `false` | uses `GITHUB_TOKEN` | checkout `fetch-depth:0` → `gitleaks-action` over the calling event's commit range (whole history only on `schedule`/`workflow_dispatch`) → report what was covered | | `security-audit.yml` | `run-python-audit` `false`, `run-pnpm-audit` `false`, `python-working-directory` `.`, allowlisted `pip-audit-export-args`, `frontend-working-directory` `frontend`, `pnpm-audit-level` `low` | — | `pip-audit` job (validated export args → `pip-audit`) and/or `pnpm-audit` job (validated severity) | | `cloudflare-pages-deploy.yml` | `project-name`*, `dist-dir`*, `build-command`*, `install-working-directory` `.`, `pre-build-run` `""`, `node-version(-file)`, `cache-dependency-path`, `branch` `main`, `wrangler-version` `4.110.0` | `CLOUDFLARE_API_TOKEN`*, `CLOUDFLARE_ACCOUNT_ID`* | preflight (skip-clean if secrets absent) → guard → **setup-pnpm** → pre-build → build → **pages-deploy-dist** | | `python-publish.yml` (**same-repo only** — cross-repo consumers inline it) | `working-directory` `.`, `python-version` `3.13`, `sync-args` `--locked`, `gate-task` `gate`, `run-gate` `true`, `packages-dir` `dist`, `attestations` `true`, `environment` `""` | — (OIDC, token-free) | checkout → **setup-python-uv** → reuse gate → `uv build` → `gh-action-pypi-publish` (PyPI **OIDC Trusted Publishing**) | | `ts-publish.yml` | `working-directory` `.`, `node-version` `24`, `gate-command` `pnpm gate`, `build-command` `pnpm build`, `run-gate` `true`, `provenance` `true` (a **private** caller must pass `false` explicitly), `registry-url` `…npmjs.org`, `environment` `""` | `NPM_READ_TOKEN` (optional, private-dep installs only) | checkout → **setup-node** (registry for OIDC) → **setup-pnpm** → gate → build → `npm publish` (npm **OIDC Trusted Publishing**) | -> ⚠️ **One live gap at `ci-v3.0.0`: `--allow-unlocked` does not work through a reusable -> workflow yet.** The `sync-args` / `install-args` lock requirement is enforced by the -> *composites*, and by the arithmetic explained in [The release-commit -> bootstrap](#the-release-commit-bootstrap) the composites nested inside `ci-v3.0.0`'s -> reusable workflows are the `ci-v2.0.3` copies, which do not know that opt-out sentinel -> and will reject it as an unknown flag. Pass a lockfile flag (`--frozen` / `--locked` / -> `--frozen-lockfile`), which is what every example does, or call the composite directly. -> The gap closes at the next release. - \* required. Every other input has a documented default — no version or path is a magic literal buried in a step; the gate's coverage floor is deliberately **not** an input (it lives in each repo's `pytest --cov-fail-under`, so CI can never pass a looser bar than local). @@ -334,7 +409,7 @@ for each brand-new npm name before OIDC can take over. **Signing is the default; not signing is what you ask for.** `ts-publish`'s `provenance` and `python-publish`'s `attestations` both default **true** — `provenance` since -`ci-v3.0.0`, which is the current release — and +`ci-v3.0.0` — and `tests/security-policy.sh` **rejects** any workflow or example that publishes without them — a PyPI upload missing `attestations: true`, an inline `npm`/`pnpm`/`yarn publish` missing `--provenance` (in a workflow *or* a composite action), a `ts-publish` caller @@ -387,7 +462,7 @@ Consumers pin a **full 40-character commit SHA**, with the release name in a tra comment so Dependabot can bump it: ```yaml -uses: hseshadr/ci/.github/workflows/python-gate.yml@2a575cd193e2e1fc093ccd26821020538e2547b7 # ci-v3.0.0 +uses: hseshadr/ci/.github/workflows/python-gate.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 ``` Moving tags are **not** a supported pin, not even for first-party refs. @@ -410,18 +485,25 @@ deliberate, reviewable commit rather than a tag someone else can move under you. ### Immutable third-party action pins Every executable third-party `uses:` reference is pinned to the full 40-character -commit behind the selected release. The trailing release comment is intentional: -Dependabot updates both the SHA and its readable `# v…` label. +commit behind the selected release. The trailing comment must name the **exact** version +that SHA is, never a floating major: a `# v6` comment goes silently wrong the moment +upstream moves the `v6` tag, and the comment is what a human reads to decide whether the +pin is current. Commit `ae644d7` fixed exactly that. Dependabot updates both the SHA and +its label. + +Two actions are pinned at different versions on different surfaces — `.github/` runs the +newer one, `examples/` still shows the release consumers copied. Both are listed. | Action | Release comment | Pin policy | |---|---|---| -| `actions/checkout` | `# v7` | full commit SHA | -| `actions/setup-node` | `# v6` | full commit SHA | -| `actions/cache` | `# v6` | full commit SHA | -| `pnpm/action-setup` | `# v6` | full commit SHA | -| `astral-sh/setup-uv` | `# v8.3.2` | full commit SHA | -| `codecov/codecov-action` | `# v7` | full commit SHA | -| `gitleaks/gitleaks-action` | `# v3` | full commit SHA | +| `actions/checkout` | `# v7.0.1` (`.github/`), `# v7.0.0` (`examples/`) | full commit SHA | +| `actions/setup-node` | `# v6.4.0` (setup-pnpm composite), `# v7.0.0` (ts-publish) | full commit SHA | +| `actions/cache` | `# v6.1.0` | full commit SHA | +| `pnpm/action-setup` | `# v6.0.9` | full commit SHA | +| `astral-sh/setup-uv` | `# v9.0.0` | full commit SHA | +| `codecov/codecov-action` | `# v7.0.0` | full commit SHA | +| `gitleaks/gitleaks-action` | `# v3.0.0` | full commit SHA | +| `ruby/setup-ruby` | `# v1.321.0` | full commit SHA | First-party `hseshadr/ci/...` references get the **same** treatment — full commit SHA, no exceptions. First-party is not a synonym for trustworthy: a moving tag is a moving @@ -503,9 +585,11 @@ Both publish workflows ask the registry whether the release actually landed, ins trusting the upload step's exit code. After `pypa/gh-action-pypi-publish` (or `npm publish`), the job derives the exact `name` + `version` it just shipped — from the sdist filename for PyPI, from `npm pkg get` for npm — and polls -`https://pypi.org/pypi///json` or `npm view @`. Six -attempts, ten seconds apart, roughly a minute. Propagation delay gets retries; a timeout -is a **failure**, never a pass. +`https://pypi.org/pypi///json` or `npm view @`. Fourteen +attempts on a 5/10/15/30/60s backoff — 600 seconds of sleep, about 3× the slowest +propagation actually measured. Propagation delay gets retries; a timeout is a **failure**, +never a pass. (The bound was six attempts ten seconds apart until `ci-v3.2.1`, which is +the release that widened it after a real publish lost that race.) This exists because a green upload and a published package turned out to be different facts. `edgeproc-core` (then named `shared-libs-python`) collected six green @@ -522,7 +606,7 @@ never been shown saying NO is decoration. | Suite | Question it answers | Runs | |---|---|---| -| `tests/security-policy.sh` | is *this repo's* YAML safe — pins, pin provenance, permissions, shell injection, signed publishes? | push / PR / weekly | +| `tests/security-policy.sh` | is *this repo's* YAML safe — pins, pin provenance, permissions (including [callers that under-grant](#a-caller-that-under-grants-does-not-go-red-it-goes-absent)), shell injection, signed publishes? | push / PR / weekly | | `tests/lint-examples.sh` | do the files consumers copy pass `actionlint` + `zizmor`, and do they still resolve? | push / PR / weekly | | `tests/consumer-drift.sh` | is a consumer hand-rolling a control we already publish? | daily + PR | @@ -530,6 +614,48 @@ Everything above is Ruby or Bash, and `.ruby-version` (3.4.10) pins the Ruby the in CI too, via `ruby/setup-ruby`. Guards that decide whether a workflow is safe should not run on whatever Ruby a runner image happens to ship. +#### A caller that under-grants does not go red, it goes ABSENT + +If a caller job grants a reusable workflow less than that workflow declares it needs, +GitHub refuses the run before any job starts: + +``` +requesting 'pull-requests: read', but is only allowed 'pull-requests: none' +``` + +The conclusion is `startup_failure`, and it emits **zero check runs**. Not one red check. +Nothing. Measured here on +[run 31127046921](https://github.com/hseshadr/ci/actions/runs/31127046921): `jobs: 0`, and +the check-runs API for that head SHA listed only the checks from *other* workflows. +`Security policy` and `Secret scan (own brick) / gitleaks` were not failing — they were not +there. + +That is the dangerous part. Branch protection cannot distinguish a required check that is +**missing** from one that has not reported **yet**, so the PR sits pending instead of going +red, and a gate you made un-skippable is skipped in silence. + +The trap that produces it: **a job-level `permissions:` block replaces the top-level one, it +does not add to it.** Restating `contents: read` on a job looks harmless and silently drops +every other scope to `none`. + +```yaml +permissions: + contents: read # workflow level + +jobs: + sweep: + permissions: + contents: read # looks like a restatement — it is a REPLACEMENT. + pull-requests: read # without this line the run never starts. + uses: ./.github/workflows/secret-scan.yml +``` + +`validate_caller_permission_sufficiency` in `tests/security-policy.sh` compares every caller +in `.github/workflows/` **and** `examples/` against the callee it names, statically. It has +to be static: there is no run to read, because the failure *is* the absence of a run. It +also asserts it resolved at least 20 caller→callee pairs, so a scanner that quietly stopped +resolving refs cannot look like a clean tree. + #### Example fidelity: do the examples still fit their repos? `actionlint` and `zizmor` check an example's YAML shape and its workflow security. Neither @@ -571,7 +697,7 @@ carried their own Cloudflare Pages deploy while a reusable one sat here, and one five copies drifted into a fork-PR deploy hole. The bug was in the copy, not in the shared workflow — and nothing was comparing the two. -Today's count: **30 hand-rolled controls across 7 repositories** (almamesh 6, aml-filter 6, +Today's count: **29 hand-rolled controls across 7 repositories** (almamesh 6, aml-filter 5, edge-reco 5, assay 4, edge-proc 3, edgeproc-core 3, privacy-core 3). They are listed individually in `tests/consumer-drift-allowlist.txt`, which is a **convergence backlog, not an exemption list**: every entry requires a written reason, deleting one is free, and *new* @@ -610,8 +736,11 @@ none of them is charged to the person who inlines the action instead. A shared brick that only fits repos already shaped like it loses to hand-rolling forever, so "the consumer should have known" is not an acceptable stopping point. The consumer is -converging to `secret-scan.yml` rather than being granted an exemption; the allowlist entry -is a pointer to that open PR and is marked for deletion when it lands. +converged to `secret-scan.yml` rather than being granted an exemption: aml-filter#93 merged +on 2026-08-02, `aml-filter/ci.yml` now calls the brick +([run 31051313153](https://github.com/hseshadr/aml-filter/actions/runs/31051313153), +`Secret scan / gitleaks` SUCCESS on `main`), and the allowlist entry has been deleted — +the first entry ever removed by an actual convergence rather than by a bug fix. **On the Dagger question:** a 2026-07-31 decision not to adopt dagger.io set a disconfirming test — *attempt the convergence sweep, and if new hand-rolled controls reappear within 60 @@ -647,8 +776,11 @@ permissions; Dependabot waits seven days before adopting new action releases. ### Required setup (read this first) -**These repos are private, so callers 404 with "workflow was not found" until this repo -allows them.** One time, on `hseshadr/ci`: +**All eight repos are public, so cross-repo callers resolve with no access configuration +at all** — verified 2026-08-06 (`gh api repos/hseshadr/ --jq .private` returns +`false` for `ci`, assay, edge-proc, edgeproc-core, privacy-core, almamesh, aml-filter, +edge-reco). Nothing below is required today. It is kept only because it becomes required +again the moment `hseshadr/ci` is made private: > **Settings → Actions → General → Access →** select **"Accessible from repositories > owned by the user"** → **Save.** @@ -660,8 +792,8 @@ gh api -X PUT repos/hseshadr/ci/actions/permissions/access -f access_level=user ``` This governs both the reusable workflows *and* the composite actions in this repo (the -workflows pull the composites from here at a pinned SHA), so it must be set once for -everything to resolve. When the repo is public this is automatic. +workflows pull the composites from here at a pinned SHA), so it would have to be set once +for everything to resolve. While the repo is public it is automatic. --- @@ -684,7 +816,7 @@ Bespoke = the irreducible repo-specific build, which still composes the shared c | **assay** | python-gate, frontend-gate, secret-scan, security-audit, **ts-publish** (npm OIDC — adopted, ×2) | **setup-python-uv** (inside its inline PyPI publish job — adopted) | none | | **privacy-core** | frontend-gate (+Playwright), **ts-publish** (npm OIDC — adopted), secret-scan, security-audit | — | none | | **edge-reco** | secret-scan, python-gate (backend), cloudflare-pages-deploy, security-audit | setup-pnpm, restore-model-cache, setup-playwright (frontend + e2e jobs) | the frontend/e2e *gate commands* only | -| **aml-filter** | secret-scan, security-audit | setup-pnpm, restore-model-cache, setup-playwright (ci); setup-pnpm + **pages-deploy-dist** (deploy) | bundle sign/verify build; `publish-watchlist.yml` | +| **aml-filter** | **secret-scan** (adopted in `ci.yml`), security-audit | setup-pnpm, restore-model-cache, setup-playwright (ci); setup-pnpm + **pages-deploy-dist** (deploy) | bundle sign/verify build; `publish-watchlist.yml` | | **almamesh** | security-audit (python) | (optional) setup-python-uv | Bun + Pyodide `test.yml`, `deploy.yml`, `nightly-e2e.yml`; key-custody gitleaks | | **ci** (this repo) | **secret-scan** (via a local `./` ref, so it runs against the commit being changed) | — | its own policy suite + actionlint + zizmor + example-fidelity + the daily consumer-drift sweep, weekly on a `schedule` as well as on push/PR | @@ -695,20 +827,21 @@ step, so there is one deploy half across edge-reco, aml-filter, and almamesh. **This repo is on that list too, and for a while it wasn't.** `ci` published `secret-scan.yml` while running no gitleaks step of its own, and had no scheduled run at all — so its zizmor **online** audits, which check a *moving* advisory database, only ever -told you the tree was clean the last time someone pushed. Both are fixed above. One gap -remains and it is not fixable from a workflow file: **`ci` has no branch protection and no -repository secret scanning**, which are repository settings. See -[Owner actions](#owner-actions). +told you the tree was clean the last time someone pushed. Both are fixed above, and so are +the two repository settings that a workflow file cannot reach: `main` requires +`Security policy`, `Secret scan (own brick) / gitleaks` and `Consumer drift detector`, +force-push is off, and GitHub secret scanning **and** push protection are enabled +(verified 2026-08-06). ### Owner actions Settings this repository cannot configure for itself: -| Setting | Why it matters here | -|---|---| -| **Branch protection on `main`** (require the CI check, no force-push, no deletion) | Every consumer pins a commit SHA from this repo's history. An unprotected `main` means the branch those SHAs descend from can be rewritten. | -| **Repository secret scanning + push protection** | Complements the gitleaks job: gitleaks catches what is already committed, push protection stops the commit. | -| **Cut the release after `ci-v3.0.0`** | The re-pin commit on `main` after the `ci-v3.0.0` tag is what makes this release's *composites* reachable through its reusable workflows. Until a tag exists at or after that commit, `ci-v3.0.0` callers keep getting `ci-v2.0.3` composites — see [The release-commit bootstrap](#the-release-commit-bootstrap). | +| Setting | State | Why it matters here | +|---|---|---| +| **Branch protection on `main`** | ✅ done — required contexts `Security policy`, `Secret scan (own brick) / gitleaks`, `Consumer drift detector`; force-push off | Every consumer pins a commit SHA from this repo's history. An unprotected `main` means the branch those SHAs descend from can be rewritten. | +| **Repository secret scanning + push protection** | ✅ both enabled | Complements the gitleaks job: gitleaks catches what is already committed, push protection stops the commit. | +| **Move the `ci-v3` pointer** | ⛔ **open** — `ci-v3` still points at `72521e7`, a Dependabot merge **21 commits behind** `ci-v3.2.1`. Nothing pins it (every ref is a full SHA), so it misleads readers rather than breaking builds. Fix: `git tag -f ci-v3 ci-v3.2.1^{}` `&& git push -f origin ci-v3` | The moving major pointer is documented as "the newest release in that major". It is not. | ## Limits — where standardization genuinely can't reach @@ -741,8 +874,8 @@ An honest self-assessment against a publish-readiness checklist: - **No hardcoded config** — ✅ every version/path is a documented input default; the coverage floor is deliberately owned by each repo's gate, not a CI input. - **Status matches reality / tags match the story** — ✅ CHANGELOG top release = - `ci-v3.0.0` (`2a575cd…`, 2026-07-30), and every release lists the SHA consumers actually - pin. All **40** first-party refs in this tree pin `ci-v3.0.0`, and + `ci-v3.2.1` (`605e51c…`, 2026-08-04), and every release lists the SHA consumers actually + pin. All **45** first-party refs in this tree pin `ci-v3.2.1`, and `validate_first_party_release_lineage` fails the build if one drifts off it. `main` sits ahead of the tag, and at least the first commit of that gap is structural rather than drift: the re-pin cannot be *in* the commit it names, because a commit cannot contain @@ -757,13 +890,14 @@ An honest self-assessment against a publish-readiness checklist: consumer's committed default branch; UNVERIFIABLE is a failure, not a pass. It caught 8 broken references that actionlint and zizmor passed. See [Guards that run in CI](#guards-that-run-in-ci). -- **The gap to full adoption is measured, not guessed** — ⚠️ **6** call-sites across 4 - repos today, all on the publish path, against **30** hand-rolled controls still standing - across 7 repos. Every one of the 30 is itemized with a reason in +- **The gap to full adoption is measured, not guessed** — ⚠️ **7** call-sites across 5 + repos today, six of them on the publish path, against **29** hand-rolled controls still + standing across 7 repos. Every one of the 29 is itemized with a reason in `tests/consumer-drift-allowlist.txt`, and new drift fails the build — which it did, on 2026-08-02, catching one it had never seen before - ([details](#it-caught-one-and-the-cause-was-partly-this-repo)). The gap is also **growing - slightly faster than it is closing**: 29 on 07-31, 30 on 08-02, zero converged in between. + ([details](#it-caught-one-and-the-cause-was-partly-this-repo)). That one has since + converged: 29 on 07-31, 30 on 08-02, **29 on 08-06** — the first entry ever deleted + because a consumer adopted the brick. - **Live-validated end-to-end** — ✅ **for the publish path** (2026-07-22): privacy-core [run 29886074787](https://github.com/hseshadr/privacy-core/actions/runs/29886074787) (npm `v0.2.1` through cross-repo `ts-publish.yml`) and assay @@ -771,9 +905,11 @@ An honest self-assessment against a publish-readiness checklist: (`v0.1.1`: PyPI through the inline job composing `setup-python-uv`, plus npm through cross-repo `ts-publish.yml`) — both SUCCESS, both executing this repo's code inside real consumer releases at the SHA pinned that day, `ci-v2.0.3`. Those callers have since been - re-pinned to `ci-v3.0.0`; whether a consumer release has run through **that** SHA is - **unverified** here. ⛔ **Still open:** the gate, - secret-scan, security-audit, frontend, and deploy templates have zero consumer runs, + re-pinned to `ci-v3.2.1`; whether a consumer *release* has run through **that** SHA is + **unverified** here — though `secret-scan.yml` has: aml-filter + [run 31051313153](https://github.com/hseshadr/aml-filter/actions/runs/31051313153), + `Secret scan / gitleaks` SUCCESS on `main` at the `ci-v3.2.1` SHA. ⛔ **Still open:** the gate, + security-audit, frontend, and deploy templates have zero consumer runs, and cross-repo PyPI through `python-publish.yml` is structurally **impossible** (`job_workflow_ref` mismatch — documented above), not merely unverified; consumers inline that job instead. diff --git a/examples/aml-filter/ci.yml b/examples/aml-filter/ci.yml index 1d4beab..bab5cc8 100644 --- a/examples/aml-filter/ci.yml +++ b/examples/aml-filter/ci.yml @@ -25,6 +25,10 @@ jobs: # after the old inline job, update branch protection in the same move or merges # will block on a context that can no longer report. See the README section # "Adopting a reusable workflow renames its check run". + # + # This scan sees ONLY the commits this push/PR introduced — gitleaks-action + # derives its range from the event. The full-history sweep is the scheduled + # caller in security-audit.yml, not this one. gitleaks: name: Secret scan # The reusable workflow lists a PR's commits over the API, which needs diff --git a/examples/aml-filter/security-audit.yml b/examples/aml-filter/security-audit.yml index e998646..bb57c02 100644 --- a/examples/aml-filter/security-audit.yml +++ b/examples/aml-filter/security-audit.yml @@ -13,5 +13,19 @@ jobs: run-pnpm-audit: true frontend-working-directory: frontend pnpm-audit-level: low + # THIS is where history gets swept. The same workflow called from ci.yml scans + # only the commits a push/PR introduced; called here, on a schedule, gitleaks + # gets no --log-opts and reads every commit. `full-history: true` makes that + # expectation machine-checked — the job fails if the event cannot deliver it. gitleaks: + name: Secret scan + # Required, and not optional: secret-scan.yml declares `pull-requests: read`, + # the top level above grants only `contents: read`, and a caller that grants + # less than its callee makes GitHub refuse the ENTIRE run — `startup_failure`, + # zero check runs, so the gate is ABSENT from the PR rather than red. + permissions: + contents: read + pull-requests: read uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true diff --git a/examples/edge-proc/ci.yml b/examples/edge-proc/ci.yml index 7ed6cf7..c9acfec 100644 --- a/examples/edge-proc/ci.yml +++ b/examples/edge-proc/ci.yml @@ -15,5 +15,14 @@ jobs: uses: hseshadr/ci/.github/workflows/python-gate.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 with: sync-args: "--frozen --all-extras" + # ADOPTING THIS RENAMES THE CHECK RUN. A caller job that `uses:` a reusable + # workflow reports as " / " — with the name + # below that is "Secret scan / gitleaks", not "gitleaks". If a required status + # check is named after an old inline job, update branch protection in the same + # move or merges block on a context that will never report again. + # + # This scan sees ONLY the commits this push/PR introduced. The full-history + # sweep is the scheduled caller in security-audit.yml, not this one. gitleaks: + name: Secret scan uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 diff --git a/examples/edge-proc/security-audit.yml b/examples/edge-proc/security-audit.yml index ccddb37..f98c164 100644 --- a/examples/edge-proc/security-audit.yml +++ b/examples/edge-proc/security-audit.yml @@ -11,3 +11,19 @@ jobs: uses: hseshadr/ci/.github/workflows/security-audit.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 with: run-python-audit: true + # THIS is where history gets swept. The same workflow called from ci.yml scans + # only the commits a push/PR introduced; called here, on a schedule, gitleaks + # gets no --log-opts and reads every commit. `full-history: true` makes that + # expectation machine-checked — the job fails if the event cannot deliver it. + gitleaks: + name: Secret scan + # Required, and not optional: secret-scan.yml declares `pull-requests: read`, + # the top level above grants only `contents: read`, and a caller that grants + # less than its callee makes GitHub refuse the ENTIRE run — `startup_failure`, + # zero check runs, so the gate is ABSENT from the PR rather than red. + permissions: + contents: read + pull-requests: read + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true diff --git a/examples/edge-reco/ci.yml b/examples/edge-reco/ci.yml index 7f85220..8943b8e 100644 --- a/examples/edge-reco/ci.yml +++ b/examples/edge-reco/ci.yml @@ -16,7 +16,16 @@ permissions: contents: read pull-requests: read jobs: + # ADOPTING THIS RENAMES THE CHECK RUN. A caller job that `uses:` a reusable + # workflow reports as " / " — with the name + # below that is "Secret scan / gitleaks", not "gitleaks". If a required status + # check is named after an old inline job, update branch protection in the same + # move or merges block on a context that will never report again. + # + # This scan sees ONLY the commits this push/PR introduced. The full-history + # sweep is the scheduled caller in security-audit.yml, not this one. gitleaks: + name: Secret scan uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 quality: diff --git a/examples/edge-reco/security-audit.yml b/examples/edge-reco/security-audit.yml index ab4875d..05b0d43 100644 --- a/examples/edge-reco/security-audit.yml +++ b/examples/edge-reco/security-audit.yml @@ -14,3 +14,19 @@ jobs: run-pnpm-audit: true python-working-directory: backend frontend-working-directory: frontend + # THIS is where history gets swept. The same workflow called from ci.yml scans + # only the commits a push/PR introduced; called here, on a schedule, gitleaks + # gets no --log-opts and reads every commit. `full-history: true` makes that + # expectation machine-checked — the job fails if the event cannot deliver it. + gitleaks: + name: Secret scan + # Required, and not optional: secret-scan.yml declares `pull-requests: read`, + # the top level above grants only `contents: read`, and a caller that grants + # less than its callee makes GitHub refuse the ENTIRE run — `startup_failure`, + # zero check runs, so the gate is ABSENT from the PR rather than red. + permissions: + contents: read + pull-requests: read + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true diff --git a/examples/edgeproc-core/ci.yml b/examples/edgeproc-core/ci.yml index d513ab0..c6791b0 100644 --- a/examples/edgeproc-core/ci.yml +++ b/examples/edgeproc-core/ci.yml @@ -20,5 +20,14 @@ jobs: # secret — including PyPI/npm credentials — into a workflow that only wants Codecov. secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # ADOPTING THIS RENAMES THE CHECK RUN. A caller job that `uses:` a reusable + # workflow reports as " / " — with the name + # below that is "Secret scan / gitleaks", not "gitleaks". If a required status + # check is named after an old inline job, update branch protection in the same + # move or merges block on a context that will never report again. + # + # This scan sees ONLY the commits this push/PR introduced. The full-history + # sweep is the scheduled caller in security-audit.yml, not this one. gitleaks: + name: Secret scan uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 diff --git a/examples/edgeproc-core/security-audit.yml b/examples/edgeproc-core/security-audit.yml index 0fe329a..f2e6ae1 100644 --- a/examples/edgeproc-core/security-audit.yml +++ b/examples/edgeproc-core/security-audit.yml @@ -11,3 +11,19 @@ jobs: uses: hseshadr/ci/.github/workflows/security-audit.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 with: run-python-audit: true + # THIS is where history gets swept. The same workflow called from ci.yml scans + # only the commits a push/PR introduced; called here, on a schedule, gitleaks + # gets no --log-opts and reads every commit. `full-history: true` makes that + # expectation machine-checked — the job fails if the event cannot deliver it. + gitleaks: + name: Secret scan + # Required, and not optional: secret-scan.yml declares `pull-requests: read`, + # the top level above grants only `contents: read`, and a caller that grants + # less than its callee makes GitHub refuse the ENTIRE run — `startup_failure`, + # zero check runs, so the gate is ABSENT from the PR rather than red. + permissions: + contents: read + pull-requests: read + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true diff --git a/examples/privacy-core/ci.yml b/examples/privacy-core/ci.yml index 3abe5b4..9e4990c 100644 --- a/examples/privacy-core/ci.yml +++ b/examples/privacy-core/ci.yml @@ -17,5 +17,14 @@ jobs: with: install-args: "--frozen-lockfile --config.dangerously-allow-all-builds=true" install-playwright: true + # ADOPTING THIS RENAMES THE CHECK RUN. A caller job that `uses:` a reusable + # workflow reports as " / " — with the name + # below that is "Secret scan / gitleaks", not "gitleaks". If a required status + # check is named after an old inline job, update branch protection in the same + # move or merges block on a context that will never report again. + # + # This scan sees ONLY the commits this push/PR introduced. The full-history + # sweep is the scheduled caller in security-audit.yml, not this one. gitleaks: + name: Secret scan uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 diff --git a/examples/privacy-core/security-audit.yml b/examples/privacy-core/security-audit.yml index b789a7f..9b940f0 100644 --- a/examples/privacy-core/security-audit.yml +++ b/examples/privacy-core/security-audit.yml @@ -13,3 +13,19 @@ jobs: run-pnpm-audit: true frontend-working-directory: "." pnpm-audit-level: moderate + # THIS is where history gets swept. The same workflow called from ci.yml scans + # only the commits a push/PR introduced; called here, on a schedule, gitleaks + # gets no --log-opts and reads every commit. `full-history: true` makes that + # expectation machine-checked — the job fails if the event cannot deliver it. + gitleaks: + name: Secret scan + # Required, and not optional: secret-scan.yml declares `pull-requests: read`, + # the top level above grants only `contents: read`, and a caller that grants + # less than its callee makes GitHub refuse the ENTIRE run — `startup_failure`, + # zero check runs, so the gate is ABSENT from the PR rather than red. + permissions: + contents: read + pull-requests: read + uses: hseshadr/ci/.github/workflows/secret-scan.yml@605e51cbc86f452b56edcf1c9660921da797cbfe # ci-v3.2.1 + with: + full-history: true diff --git a/tests/consumer-drift-allowlist.txt b/tests/consumer-drift-allowlist.txt index 43bc9fc..08d170e 100644 --- a/tests/consumer-drift-allowlist.txt +++ b/tests/consumer-drift-allowlist.txt @@ -25,12 +25,17 @@ # is worse than no allowlist, because it is what people read before deciding not # to converge. Every reason below has been checked against the live repo. # -# 30 again on 2026-08-02, and this time the extra one IS new drift: +# 30 again on 2026-08-02, and this time the extra one WAS new drift: # aml-filter/ci.yml/secret-scan. Until then this header said "nothing here is new # drift", which was true for exactly seven days and is the kind of sentence that # quietly stops being true. It is not claimed any more — read each entry instead. +# +# Back to 29 on 2026-08-06: aml-filter#93 merged on 08-02, aml-filter/ci.yml now +# calls secret-scan.yml (run 31051313153, "Secret scan / gitleaks" SUCCESS on +# main), and its entry is deleted. That is the first entry ever deleted by an +# actual convergence rather than by a bug fix. # The count went 30 (07-26, one of them bogus) -> 29 (07-31, bogus one deleted) -# -> 30 (08-02, one genuinely new). Only the last step is real drift. +# -> 30 (08-02, one genuinely new) -> 29 (08-06, that one converged). # # A NEW ENTRY IS A CONFESSION, NOT A DECISION. It records that a control got # hand-rolled and says what is being done about it. If you are adding one, the @@ -47,19 +52,12 @@ edge-reco/deploy.yml/pages-deploy|Converged caller already drafted at examples/e almamesh/deploy.yml/pages-deploy|Bespoke: builds the Pyodide runtime + models and re-verifies deployed source identity after upload. No examples/almamesh/deploy.yml exists yet — converge after aml-filter and edge-reco land. aml-filter/publish-watchlist.yml/pages-deploy|Not a site deploy: signs a watchlist bundle with a decoded key, then pushes it to the same Pages project. Needs a signing-aware pre-deploy hook before it can call the reusable workflow. -# --- secret-scan (8) --------------------------------------------------------- +# --- secret-scan (7) --------------------------------------------------------- # gitleaks runs as a step inside an existing job rather than as a job calling # secret-scan.yml. Converging means splitting a job out — mechanical, but it # touches every consumer, so it is batched deliberately rather than forgotten. almamesh/test.yml/secret-scan|Uses gitleaks/gitleaks-action directly instead of calling secret-scan.yml. aml-filter/security-audit.yml/secret-scan|Uses gitleaks/gitleaks-action directly; examples/aml-filter/security-audit.yml already shows the converged caller. -# THE ONLY ENTRY HERE THAT IS NOT FROM THE ORIGINAL 2026-07-26 SWEEP, and the one -# most likely to be read as this file doing the thing it says it never does. -# Added 2026-08-02 as a POINTER TO AN OPEN PR, not as an exemption: the fix is -# already written and cannot be merged from the same change that records it. -# Delete this line when that PR lands — the detector reports a stale entry as a -# warning, never a failure, so deleting it early costs nothing. -aml-filter/ci.yml/secret-scan|NEW on 2026-08-02, and being converged, not exempted — see aml-filter#93. Origin: aml-filter#89 (2026-08-01) closed a real hole (gitleaks ran only in the weekly security-audit.yml sweep, never on a PR, so a secret could merge and sit in public history for up to seven days) and closed it by inlining gitleaks/gitleaks-action at the same pinned SHA secret-scan.yml already uses. Three causes on OUR side, all addressed: examples/aml-filter/ci.yml carried no secret-scan job, so the worked example for the exact file being edited had nothing to copy; nothing warned that adopting renames the check run to "Secret scan / gitleaks", which breaks a required status check named "gitleaks"; and our ci-vX.Y.Z release scheme fails a consumer pin-comment guard that expects ^v\d, which is what actually reddened aml-filter#93 on its first run. DELETE THIS ENTRY when aml-filter#93 merges. assay/ci.yml/secret-scan|Uses gitleaks/gitleaks-action directly; assay has no converged examples/assay/ci.yml yet. edge-proc/ci.yml/secret-scan|Uses gitleaks/gitleaks-action directly; examples/edge-proc/ci.yml already shows the converged caller. edge-reco/ci.yml/secret-scan|Uses gitleaks/gitleaks-action directly; examples/edge-reco/ci.yml already shows the converged caller. diff --git a/tests/lib/scan-caller-permissions.rb b/tests/lib/scan-caller-permissions.rb new file mode 100644 index 0000000..2808ff6 --- /dev/null +++ b/tests/lib/scan-caller-permissions.rb @@ -0,0 +1,209 @@ +# Report caller jobs that grant a reusable workflow LESS than it declares it needs. +# +# WHY THIS EXISTS: this is the one workflow defect that reports nothing at all. +# +# When a caller job's `permissions:` are narrower than the called workflow's own +# `permissions:`, GitHub refuses the run before any job starts: +# +# requesting 'pull-requests: read', but is only allowed 'pull-requests: none' +# +# The run's conclusion is `startup_failure`, and — this is the part that matters — +# it emits ZERO check runs. Not one red check. Nothing. Measured on this +# repository: run 31127046921 (PR #18, ci.yml) has `jobs: 0`, and the check-runs +# API for its head SHA lists only the two checks from OTHER workflows. `Security +# policy` and `Secret scan (own brick) / gitleaks` were simply absent. +# +# Branch protection cannot tell that apart from "hasn't reported yet". A required +# check that CANNOT RUN looks exactly like a required check that is still queued, +# so the PR sits pending instead of going red, and the gate that was supposed to +# be un-skippable is skipped in silence. Same shape as the bug PR #18 exists to +# fix: a secret scan that scanned 0 commits and reported success. +# +# So the check has to be STATIC. A guard that waits for a run to tell it something +# is a guard that learns nothing from the failure mode it is written for. +# +# WHAT IS COMPARED +# grant — the caller job's `permissions:` if it declares one; otherwise the +# caller workflow's top-level `permissions:`. A job-level block +# REPLACES the top level, it does not merge with it — the trap that +# produced this bug, since ci.yml's top level was already read-only and +# the job-level block silently dropped `pull-requests` to `none`. +# need — the called workflow's own top-level `permissions:`. +# +# Anything the callee names above `none` must be granted at that level or higher +# (none < read < write). Scopes the grant does not list are `none`. +# +# WHAT IS NOT COMPARED +# A third-party reusable workflow lives in a repository this scanner cannot +# read, so it is neither blamed nor counted. `--count` reports only the pairs +# that were actually resolved, so the caller of this scanner can refuse a run +# that resolved nothing — a scanner that stopped resolving would otherwise be +# indistinguishable from a clean tree. +# +# Output: one `\t` line per violation; empty output means clean. +# +# Usage: +# scan-caller-permissions.rb --ci-root DIR [--count] FILE... +require "yaml" + +LEVELS = { "none" => 0, "read" => 1, "write" => 2 }.freeze + +# An unrecognised level gets the loudest verdict, never the quietest: as a +# requirement it is treated as `write`, as a grant it is treated as `none`. +def required_level(value) + LEVELS.fetch(value.to_s.strip, LEVELS["write"]) +end + +def granted_level(value) + LEVELS.fetch(value.to_s.strip, LEVELS["none"]) +end + +# Normalise every spelling of a `permissions:` value to a scope => level hash. +# `nil` means the key was absent, which is NOT the same as `{}` (all none). +def normalise(value) + case value + when nil then nil + when Hash then value + when String + case value.strip + when "write-all" then :all_write + when "read-all" then :all_read + when "" then {} + else :unknown + end + else :unknown + end +end + +# Highest level this grant confers on `scope`. +def grant_for(grant, scope) + case grant + when :all_write then LEVELS["write"] + when :all_read then LEVELS["read"] + when :unknown then LEVELS["none"] + when Hash then granted_level(grant[scope]) + else LEVELS["none"] + end +end + +# Every scope the callee asks for above `none`, as scope => required level. +def demands(need) + case need + when :all_write then { "*" => LEVELS["write"] } + when :all_read then { "*" => LEVELS["read"] } + when :unknown then { "*" => LEVELS["write"] } + when Hash + need.each_with_object({}) do |(scope, level), acc| + wanted = required_level(level) + acc[scope] = wanted if wanted > LEVELS["none"] + end + else {} + end +end + +def level_name(level) + LEVELS.key(level) || "write" +end + +# Absolute path of the called workflow, or nil when it is not readable from here. +# +# `./x` names a file in the CALLER's own repository. That is this repository only +# when the caller itself lives here; an example is written for a consumer repo, so +# its `./` refs point at a tree this scanner has never seen. +def resolve_callee(uses, file, ci_root) + ref = uses.to_s.strip + local_prefix = File.join(ci_root, ".github", "workflows") + + if ref.start_with?("./") + return nil unless File.expand_path(file).start_with?(local_prefix + File::SEPARATOR) + + return File.join(ci_root, ref.sub(%r{\A\./}, "")) + end + + match = %r{\Ahseshadr/ci/(\.github/workflows/[^@]+)@}.match(ref) + match && File.join(ci_root, match[1]) +end + +def load_document(path) + document = YAML.safe_load(File.read(path), aliases: true) + document.is_a?(Hash) ? document : nil +rescue StandardError + nil +end + +# Reasons this one caller job cannot start, plus whether the pair resolved. +def job_findings(job_id, job, top_level, file, ci_root) + callee = resolve_callee(job["uses"], file, ci_root) + return [false, []] if callee.nil? + + callee_document = load_document(callee) + return [false, []] if callee_document.nil? + + need = normalise(callee_document["permissions"]) + return [true, []] if need.nil? + + # No declaration anywhere means the grant is the repository's default token + # setting, which is not in this tree and not knowable statically. Refusing is + # the only answer that cannot be wrong by accident. + unless job.key?("permissions") || !top_level.nil? + return [true, ["job `#{job_id}` calls #{File.basename(callee)} but declares no `permissions:` at " \ + "job or workflow level — the grant is the repository default, which cannot be checked here"]] + end + + grant = normalise(job.key?("permissions") ? job["permissions"] : top_level) + source = job.key?("permissions") ? "job-level" : "workflow-level" + + reasons = demands(need).map do |scope, wanted| + held = grant_for(grant, scope) + next if held >= wanted + + "job `#{job_id}` grants `#{scope}: #{level_name(held)}` (#{source}) but #{File.basename(callee)} " \ + "requires `#{scope}: #{level_name(wanted)}` — the run would end in startup_failure with NO check runs" + end.compact + + [true, reasons] +end + +def scan(file, ci_root) + document = load_document(file) + return [0, []] if document.nil? + + jobs = document["jobs"] + return [0, []] unless jobs.is_a?(Hash) + + resolved = 0 + reasons = jobs.flat_map do |job_id, job| + next [] unless job.is_a?(Hash) && job.key?("uses") + + pair_resolved, pair_reasons = job_findings(job_id, job, document["permissions"], file, ci_root) + resolved += 1 if pair_resolved + pair_reasons + end + + [resolved, reasons] +end + +count_only = false +ci_root = nil +files = [] +arguments = ARGV.dup +until arguments.empty? + argument = arguments.shift + case argument + when "--count" then count_only = true + when "--ci-root" then ci_root = arguments.shift + else files << argument + end +end + +abort "scan-caller-permissions.rb: --ci-root is required" if ci_root.nil? +ci_root = File.expand_path(ci_root) + +total_resolved = 0 +files.each do |file| + resolved, reasons = scan(file, ci_root) + total_resolved += resolved + reasons.each { |reason| puts "#{file}\t#{reason}" } unless count_only +end + +puts total_resolved if count_only diff --git a/tests/security-policy.sh b/tests/security-policy.sh index 2f1ef18..7db5629 100755 --- a/tests/security-policy.sh +++ b/tests/security-policy.sh @@ -62,6 +62,10 @@ yaml_sources() { YAML_SOURCE_FLOOR=20 PERMISSION_SOURCE_FLOOR=15 USES_LINE_FLOOR=30 +# Caller job -> reusable workflow pairs whose callee this repository can actually +# read. 24 resolve today; a scanner that quietly stopped resolving refs would +# print "no violations" and look identical to a clean tree. +CALLER_PERMISSION_PAIR_FLOOR=20 inputs_are_sufficient() { local count="$1" minimum="${2:-1}" @@ -1109,6 +1113,323 @@ validate_self_ci() { fi } +# --- a caller that under-grants does not go red, it goes ABSENT --------------- +# +# If a caller job's `permissions:` are narrower than the reusable workflow it +# calls, GitHub refuses the whole run before any job starts: +# +# requesting 'pull-requests: read', but is only allowed 'pull-requests: none' +# +# The conclusion is `startup_failure` and it emits ZERO check runs. Measured on +# this repository: run 31127046921 reported `jobs: 0`, and the check-runs API for +# its head SHA listed only the checks from other workflows — `Security policy` +# and `Secret scan (own brick) / gitleaks` were not red, they were NOT THERE. +# Branch protection reads a missing required check as "pending", never "failed", +# so an un-skippable gate is skipped in silence. +# +# That is why this guard is static. Nothing about the failure can be learned from +# a run, because the failure is the absence of a run. +# +# The trap that produced it: a job-level `permissions:` block REPLACES the +# top-level one. ci.yml's top level was already `contents: read`, and adding +# `permissions: {contents: read}` to the sweep job looked like a restatement +# while actually dropping `pull-requests` from whatever it was to `none`. +validate_caller_permission_sufficiency() { + local findings resolved + local yaml_files=() + + while IFS= read -r file; do + yaml_files+=("$file") + done < <(yaml_sources) + + require_inputs "caller-permission scan" "${#yaml_files[@]}" "$YAML_SOURCE_FLOOR" + + resolved="$(ruby "$repo_root/tests/lib/scan-caller-permissions.rb" \ + --ci-root "$repo_root" --count "${yaml_files[@]}")" || { + fail "caller-permission scan failed to execute" + return + } + require_inputs "caller-permission scan (resolved callees)" \ + "$resolved" "$CALLER_PERMISSION_PAIR_FLOOR" + + findings="$(ruby "$repo_root/tests/lib/scan-caller-permissions.rb" \ + --ci-root "$repo_root" "${yaml_files[@]}")" || { + fail "caller-permission scan failed to execute" + return + } + + while IFS=$'\t' read -r file reason; do + [[ -z "$file" ]] || fail "$file: $reason" + done <<< "$findings" +} + +# True (exit 0) when the scanner reports at least one finding for the file. +scanner_reports_caller_permission_finding() { + local findings + findings="$(ruby "$repo_root/tests/lib/scan-caller-permissions.rb" --ci-root "$2" "$1")" || return 2 + [[ -n "$findings" ]] +} + +# Break the property, not the form. Every fixture below is a workflow that parses, +# lints and reviews clean — the PERMISSION ARITHMETIC is the only thing that +# differs. The two load-bearing cases are `narrowed-by-job` (a job-level block +# that looks like a restatement of a sufficient top level and is not — the exact +# shape of the bug) and `undeclared` (no grant anywhere, so the answer lives in a +# repository setting this tree cannot see and "clean" would be a guess). +validate_caller_permission_cases() { + local dir + dir="$(mktemp -d)" + trap 'rm -rf "${dir:-}"' RETURN + mkdir -p "$dir/.github/workflows" + + # The synthetic brick every fixture calls. + cat > "$dir/.github/workflows/brick.yml" <<'YAML' +on: {workflow_call: {}} +permissions: + contents: read + pull-requests: read +jobs: + work: + runs-on: ubuntu-latest + steps: [{run: "true"}] +YAML + cat > "$dir/.github/workflows/writer.yml" <<'YAML' +on: {workflow_call: {}} +permissions: {contents: write} +jobs: + work: + runs-on: ubuntu-latest + steps: [{run: "true"}] +YAML + cat > "$dir/.github/workflows/silent.yml" <<'YAML' +on: {workflow_call: {}} +jobs: + work: + runs-on: ubuntu-latest + steps: [{run: "true"}] +YAML + + local brick="hseshadr/ci/.github/workflows/brick.yml@0000000000000000000000000000000000000000" + + # The bug: a job-level block narrower than the callee. + printf 'permissions:\n contents: read\njobs:\n sweep:\n permissions:\n contents: read\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/narrowed-by-job.yml" + # Same file, one line added — the fix. + printf 'permissions:\n contents: read\njobs:\n sweep:\n permissions:\n contents: read\n pull-requests: read\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/job-sufficient.yml" + # No job-level block: the sufficient top level is what the callee gets. + printf 'permissions:\n contents: read\n pull-requests: read\njobs:\n sweep:\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/top-sufficient.yml" + # No job-level block and an insufficient top level — the examples/ shape. + printf 'permissions:\n contents: read\njobs:\n sweep:\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/top-narrow.yml" + # read does not satisfy write. Presence is not sufficiency. + printf 'permissions:\n contents: read\njobs:\n sweep:\n uses: hseshadr/ci/.github/workflows/writer.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/read-vs-write.yml" + printf 'permissions:\n contents: write\njobs:\n sweep:\n uses: hseshadr/ci/.github/workflows/writer.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/write-vs-write.yml" + # Shorthand grants. + printf 'permissions: read-all\njobs:\n sweep:\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/read-all.yml" + printf 'permissions: read-all\njobs:\n sweep:\n uses: hseshadr/ci/.github/workflows/writer.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/read-all-vs-write.yml" + printf 'permissions: write-all\njobs:\n sweep:\n uses: hseshadr/ci/.github/workflows/writer.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/write-all.yml" + # No grant anywhere: the repository default is not in this tree. + printf 'jobs:\n sweep:\n uses: %s\n' "$brick" > "$dir/.github/workflows/undeclared.yml" + # A callee that asks for nothing cannot be under-granted. + printf 'jobs:\n sweep:\n uses: hseshadr/ci/.github/workflows/silent.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/silent-callee.yml" + # The `./` spelling ci.yml uses — same arithmetic, different ref syntax. + printf 'permissions:\n contents: read\njobs:\n sweep:\n permissions:\n contents: read\n uses: ./.github/workflows/brick.yml\n' \ + > "$dir/.github/workflows/local-narrowed.yml" + # A workflow this repository cannot read is neither blamed nor counted. + printf 'permissions:\n contents: read\njobs:\n sweep:\n uses: someone/else/.github/workflows/x.yml@0000000000000000000000000000000000000000\n' \ + > "$dir/.github/workflows/third-party.yml" + # The alias form that walked a top-level write past this suite once already. + printf 'x-perms: &perms\n contents: read\npermissions: *perms\njobs:\n sweep:\n uses: %s\n' \ + "$brick" > "$dir/.github/workflows/aliased-narrow.yml" + # A step-based job has no callee; it must not be counted or blamed. + printf 'permissions:\n contents: read\njobs:\n work:\n runs-on: ubuntu-latest\n steps: [{run: "true"}]\n' \ + > "$dir/.github/workflows/no-caller.yml" + + expect_success "caller-permission guard misses a job-level block narrower than its callee" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/narrowed-by-job.yml" "$dir" + expect_failure "caller-permission guard flags a job-level block that grants exactly what the callee needs" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/job-sufficient.yml" "$dir" + expect_failure "caller-permission guard flags a job inheriting a SUFFICIENT top level" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/top-sufficient.yml" "$dir" + expect_success "caller-permission guard misses a job inheriting an INSUFFICIENT top level" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/top-narrow.yml" "$dir" + expect_success "caller-permission guard treats a granted 'read' as satisfying a required 'write'" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/read-vs-write.yml" "$dir" + expect_failure "caller-permission guard flags a write granted against a write required" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/write-vs-write.yml" "$dir" + expect_failure "caller-permission guard flags 'read-all' against read-only requirements" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/read-all.yml" "$dir" + expect_success "caller-permission guard treats 'read-all' as satisfying a required write" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/read-all-vs-write.yml" "$dir" + expect_failure "caller-permission guard flags 'write-all' against a required write" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/write-all.yml" "$dir" + expect_success "caller-permission guard passes a caller with NO permissions declared anywhere" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/undeclared.yml" "$dir" + expect_failure "caller-permission guard flags a callee that declares no permissions at all" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/silent-callee.yml" "$dir" + expect_success "caller-permission guard misses the './' local-ref spelling ci.yml uses" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/local-narrowed.yml" "$dir" + expect_failure "caller-permission guard blames a third-party workflow it cannot read" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/third-party.yml" "$dir" + expect_success "caller-permission guard misses an insufficient grant supplied through a YAML ALIAS" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/aliased-narrow.yml" "$dir" + expect_failure "caller-permission guard flags a job that calls no reusable workflow" \ + scanner_reports_caller_permission_finding "$dir/.github/workflows/no-caller.yml" "$dir" + + # The non-vacuity half: the scanner must actually RESOLVE the callees it is + # judging. `third-party.yml` and `no-caller.yml` contribute nothing, so a + # scanner whose ref resolution broke would report 0 here while still printing + # "no violations" above. + local resolved + resolved="$(ruby "$repo_root/tests/lib/scan-caller-permissions.rb" --ci-root "$dir" --count \ + "$dir/.github/workflows/narrowed-by-job.yml" "$dir/.github/workflows/local-narrowed.yml")" + [[ "$resolved" == "2" ]] || + fail "caller-permission scanner resolved $resolved of 2 fixture callees — ref resolution has drifted" + resolved="$(ruby "$repo_root/tests/lib/scan-caller-permissions.rb" --ci-root "$dir" --count \ + "$dir/.github/workflows/third-party.yml" "$dir/.github/workflows/no-caller.yml")" + [[ "$resolved" == "0" ]] || + fail "caller-permission scanner counted $resolved unreadable callees as resolved" +} + +# --- the secret scan's range is a property of the EVENT, not of fetch-depth ---- +# +# gitleaks-action@e0c47f4 appends `--log-opts=--no-merges --first-parent BASE^..HEAD` +# on push and pull_request (src/gitleaks.js:103-115) and omits --log-opts only on +# schedule / workflow_dispatch (src/index.js:176). `fetch-depth: 0` makes BASE^ +# RESOLVABLE; it does not widen the range. Measured on this repository, through +# secret-scan.yml itself: +# +# 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 +# +# A repo whose only secret-scan caller runs on push/PR has therefore NEVER had its +# history scanned by CI, while every comment and README line said it had. Two +# properties are asserted, neither of which a comment alone can satisfy: +# +# 1. Every examples/ repo that calls secret-scan.yml at all also calls it from a +# workflow carrying an `on: schedule:` trigger, with `full-history: true`. +# And every caller job is NAMED — an unnamed one reports as +# "gitleaks / gitleaks", silently orphaning a required "Secret scan / +# gitleaks" status check for whoever copy-pastes it. +# 2. The workflow's coverage step actually REFUSES the mismatch. Its real `run:` +# script is executed under both event families rather than grepped for its +# error string, so deleting the check reddens these cases. + +# " " +# for every job in examples/ that calls secret-scan.yml, one per line. +secret_scan_callers() { + # The Ruby program owns its own patterns; single quotes keep the shell out of them. + # shellcheck disable=SC2016 + ruby -r yaml -e ' + Dir.glob("examples/*/*.yml").sort.each do |path| + doc = begin + YAML.safe_load(File.read(path), aliases: true) + rescue StandardError + next + end + next unless doc.is_a?(Hash) + jobs = doc["jobs"] + next unless jobs.is_a?(Hash) + # `on:` is a YAML 1.1 boolean, so Psych keys it as true, not "on". + triggers = doc["on"] || doc[true] + scheduled = triggers.is_a?(Hash) && triggers.key?("schedule") + jobs.each do |job_id, job| + next unless job.is_a?(Hash) + uses = job["uses"] + next unless uses.is_a?(String) && uses.include?("workflows/secret-scan.yml") + with = job["with"] + puts [ + File.basename(File.dirname(path)), + path, + job_id, + scheduled ? "scheduled" : "event-range", + (job["name"].is_a?(String) && !job["name"].strip.empty?) ? "named" : "unnamed", + (with.is_a?(Hash) && with["full-history"] == true) ? "full-history" : "range-only", + ].join(" ") + end + end + ' +} + +validate_secret_scan_history_sweep() { + local report repo path job scheduled named full repos + + report="$(secret_scan_callers)" || { + fail "could not enumerate secret-scan callers in examples/" + return 1 + } + # Vacuity floor: an empty report and a clean report are indistinguishable. + [[ -n "$report" ]] || { + fail "no examples/ workflow calls secret-scan.yml — this guard is scanning nothing" + return 1 + } + + while read -r repo path job scheduled named full; do + [[ "$named" == "named" ]] || + fail "$path job '$job' calls secret-scan.yml with no name: — it reports as '$job / gitleaks', not 'Secret scan / gitleaks'" + if [[ "$scheduled" == "scheduled" ]]; then + [[ "$full" == "full-history" ]] || + fail "$path job '$job' is the scheduled caller but omits full-history: true — its sweep is unasserted" + else + [[ "$full" == "range-only" ]] || + fail "$path job '$job' asks for full-history: true on a workflow with no schedule — that event scans a partial range and the job will refuse" + fi + done <<< "$report" + + repos="$(awk '{ print $1 }' <<< "$report" | sort -u)" + for repo in $repos; do + awk -v r="$repo" '$1 == r && $4 == "scheduled" && $6 == "full-history" { found = 1 } + END { exit found ? 0 : 1 }' <<< "$report" || + fail "examples/$repo calls secret-scan.yml but never from a scheduled workflow — its history is never swept, only each push's own commits" + done +} + +# Execute secret-scan.yml's real coverage script the way the runner would, under a +# chosen event. Nothing is stubbed: the script's only external call is a +# `git rev-list` that falls back to "?" outside a repository. +run_scan_coverage_step() { + local event="$1" full="$2" script workdir status=0 + script="$(extract_run_script_by_env .github/workflows/secret-scan.yml SCAN_FULL_HISTORY)" || return 2 + workdir="$(mktemp -d)" + ( + cd "$workdir" && + env GITHUB_EVENT_NAME="$event" SCAN_FULL_HISTORY="$full" \ + bash -e -u -o pipefail -c "$script" + ) >/dev/null 2>&1 || status=$? + rm -rf "$workdir" + return "$status" +} + +validate_secret_scan_coverage_cases() { + # Both polarities on purpose. If extraction ever breaks — step renamed, env var + # dropped — the expect_success cases go red rather than the expect_failure cases + # passing vacuously on a script that was never found. + expect_success "secret-scan refuses a scheduled full-history sweep" \ + run_scan_coverage_step schedule true + expect_success "secret-scan refuses a workflow_dispatch full-history sweep" \ + run_scan_coverage_step workflow_dispatch true + expect_success "secret-scan refuses an ordinary push caller that made no full-history claim" \ + run_scan_coverage_step push false + expect_success "secret-scan refuses an ordinary pull_request caller" \ + run_scan_coverage_step pull_request false + + # The whole point: a caller that believes it is sweeping history while the event + # hands it a handful of commits must go RED, not report success. + expect_failure "secret-scan ACCEPTS full-history on a push event — a partial scan reported as a sweep" \ + run_scan_coverage_step push true + expect_failure "secret-scan ACCEPTS full-history on a pull_request event — a partial scan reported as a sweep" \ + run_scan_coverage_step pull_request true +} + # A decoded private key must not be able to outlive the job that decoded it. # # examples/aml-filter/deploy.yml decoded an Ed25519 PRODUCTION signing seed and @@ -1339,6 +1660,10 @@ run_check validate_publish_provenance_cases run_check validate_trusted_command_contracts run_check validate_argument_guards run_check validate_self_ci +run_check validate_caller_permission_sufficiency +run_check validate_caller_permission_cases +run_check validate_secret_scan_history_sweep +run_check validate_secret_scan_coverage_cases run_check validate_no_vacuous_success run_check validate_no_vacuous_success_cases run_check validate_key_scrub_cannot_be_skipped