From 364b267279ecf3ef90dc93fac4dfdd370779393c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:07:19 +0000 Subject: [PATCH 1/8] ci(lock): prove closure against SHA-bound receipts; prepare (not apply) scanner migration The lock gate proves that every ref named in actions.lock is locked and resolvable. Three shapes a regeneration produces are invisible to it, and all three read as green: * a dropped job-level reusable edge -- `gh actions-lock` v0.2.0 writes no job-level `uses:`, and clause 1 treats an absent job-level ref as harmless (upstream #129); * a shortened nested `uses:` list for a composite or remote reusable -- no ref dangles, so nothing fails; * a hand-added leaf record, which makes the gate green while representing nothing the callee uses. scripts/audit-lock-closure.sh compares each record's nested edges against the reviewed closure union in scripts/lib/lock-closure-receipts.tsv, at the recorded immutable commit. It separates FATAL (exit 1), baselinable REVIEW, NOT-RUN (exit 2, never a pass) and ACCEPTED, and it fails closed on an unparseable `uses:` instead of skipping it. `--online` re-resolves refs; `--emit-receipt` produces reviewed rows. scripts/tests/lock-closure-test.sh covers 41 assertions of dropped edges, malformed refs, baseline scope/staleness and the real repository. Also measured, and recorded in audits/lock-closure-2026-10-31/: every Secret Scanner run since 2026-10-27 has ZERO jobs (run 40933665219), so `scan / gitleaks`, `scan / rust-secrets` and `scan / shell-secrets` are absent rather than failed -- and Lock Sync Gate is green at the same commit. The same pin produced three jobs on 2026-10-25 and the caller YAML has not changed since 2026-10-20, so this is not a YAML regression here. docs/migrations/secret-scanner-cicd-suite/ prepares -- deliberately does not apply -- the move to hyperpolymath/cicd-suite@4404c3c0, which runs real detectors and publishes SHA-bound receipts. Contexts, triggers, permissions (reduced to contents: read), absence of secrets and `fast-pr: false` are enforced by preflight.sh, which currently exits 2 (NOT RUN) because the unified-dataset-vocab live canary does not exist. Deployment does not expand until it does. rollback.sh reverses the three patches and re-audits; verified apply -> audit 0 -> rollback 0 -> files byte-identical to main. Verification: audit 0 (fatal=0 review=0 accepted=3 not-run=0), audit --online 0, 41/41 contract tests, tests/e2e.sh 0, preflight exit 2 with findings=0. Not run here: a real gh actions-lock regeneration (extension asset unreachable), check-lock-sync.sh locally (no gawk/yq/Ruby; CI green), the live canary, and the root cause of the zero-job runs (the run API carries no reason). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/e2e.yml | 15 + audits/lock-closure-2026-10-31/REPORT.md | 174 ++++ audits/lock-closure-2026-10-31/evidence.json | 264 ++++++ .../secret-scanner-cicd-suite/PR.md | 178 ++++ .../secret-scanner-cicd-suite/README.adoc | 229 +++++ .../canary/receipt.example.json | 21 + .../canary/receipt.schema.json | 63 ++ .../fixtures/SHA256SUMS | 1 + ...85c02890aac113-secret-scanner-reusable.yml | 120 +++ .../patches/actions.lock.patch | 25 + .../patches/lock-closure-receipts.tsv.patch | 7 + .../patches/secret-scanner.yml.patch | 53 ++ .../secret-scanner-cicd-suite/preflight.sh | 322 +++++++ .../secret-scanner-cicd-suite/receipts.tsv | 13 + .../required-contexts.txt | 24 + .../secret-scanner-cicd-suite/rollback.sh | 77 ++ scripts/audit-lock-closure.sh | 850 ++++++++++++++++++ scripts/check-lock-sync.sh | 14 +- scripts/lib/lock-audit-baseline.tsv | 20 + scripts/lib/lock-closure-receipts.tsv | 53 ++ scripts/tests/lock-closure-test.sh | 478 ++++++++++ 21 files changed, 2997 insertions(+), 4 deletions(-) create mode 100644 audits/lock-closure-2026-10-31/REPORT.md create mode 100644 audits/lock-closure-2026-10-31/evidence.json create mode 100644 docs/migrations/secret-scanner-cicd-suite/PR.md create mode 100644 docs/migrations/secret-scanner-cicd-suite/README.adoc create mode 100644 docs/migrations/secret-scanner-cicd-suite/canary/receipt.example.json create mode 100644 docs/migrations/secret-scanner-cicd-suite/canary/receipt.schema.json create mode 100644 docs/migrations/secret-scanner-cicd-suite/fixtures/SHA256SUMS create mode 100644 docs/migrations/secret-scanner-cicd-suite/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml create mode 100644 docs/migrations/secret-scanner-cicd-suite/patches/actions.lock.patch create mode 100644 docs/migrations/secret-scanner-cicd-suite/patches/lock-closure-receipts.tsv.patch create mode 100644 docs/migrations/secret-scanner-cicd-suite/patches/secret-scanner.yml.patch create mode 100755 docs/migrations/secret-scanner-cicd-suite/preflight.sh create mode 100644 docs/migrations/secret-scanner-cicd-suite/receipts.tsv create mode 100644 docs/migrations/secret-scanner-cicd-suite/required-contexts.txt create mode 100755 docs/migrations/secret-scanner-cicd-suite/rollback.sh create mode 100755 scripts/audit-lock-closure.sh create mode 100644 scripts/lib/lock-audit-baseline.tsv create mode 100644 scripts/lib/lock-closure-receipts.tsv create mode 100755 scripts/tests/lock-closure-test.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1090100b..3945faee 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,3 +34,18 @@ jobs: run: bash tests/fleet-report-back-test.sh - name: Fleet enrolment fixer (report-back rollout) run: bash tests/fix-fleet-enrolment-test.sh + - name: Lock closure contracts (dropped edges, malformed refs) + run: bash scripts/tests/lock-closure-test.sh + - name: Audit this repository's lock closure + run: ./scripts/audit-lock-closure.sh + - name: Prepared secret-scanner migration stays gated + run: | + # 0 = every precondition holds, 2 = evidence still missing (expected + # until the unified-dataset-vocab canary lands). 1 and 3 are findings + # in the prepared migration and must fail the build. + rc=0 + ./docs/migrations/secret-scanner-cicd-suite/preflight.sh --quiet || rc=$? + case "$rc" in + 0|2) echo "preflight exit $rc (no findings)";; + *) echo "preflight exit $rc: findings in the prepared migration" >&2; exit 1;; + esac diff --git a/audits/lock-closure-2026-10-31/REPORT.md b/audits/lock-closure-2026-10-31/REPORT.md new file mode 100644 index 00000000..4cdbd350 --- /dev/null +++ b/audits/lock-closure-2026-10-31/REPORT.md @@ -0,0 +1,174 @@ +# Lock-sync closure audit and bounded secret-scanner migration + +**Date:** 2026-10-31 +**Corpus:** `hyperpolymath/gitbot-fleet` at `83e35bcfd04d43960d635fec5397da8dfa344268` (read-only for measurement; no workflow deployed, no ruleset edited) +**Scope:** the repository's CI lock-sync automation, its regeneration blind spots, and the *prepared* — not applied — consumer migration of `Secret Scanner` from `hyperpolymath/standards` to `hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113`. +**Status:** findings below include one live, user-visible failure. The migration is **blocked** on a live canary that has not run (`preflight.sh` exit 2). + +This report separates two things that a single green tick conflates: + +* **Findings** — measured defects in the artefact or in the live estate. They are actionable now. +* **Not run** — checks this audit intended and could not execute, with the exact command and the missing prerequisite. Not-run is never counted as a pass. + +--- + +## 1. What was measured, and how + +| # | Measurement | Evidence | +|---|---|---| +| M1 | The repository's Lock Sync Gate is green at `83e35bc` | push run [40933665365](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665365) — `success`; `check-lock-sync.sh` executes on the runner (so gawk/yq are present there) | +| M2 | All five job-level reusable callers fail with **zero jobs** | runs [40933665219](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665219) (secret-scanner), [40933665285](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665285) (governance), [40933665246](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665246) (hypatia-scan), [40933665259](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665259) (scorecard), [40933665305](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665305) (mirror): `conclusion=failure`, `jobs=[]`, `name == path` | +| M3 | The same caller pin produced jobs two days earlier | run [40175794668](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40175794668), 2026-10-25, `success`, `referenced_workflows=[standards…secret-scanner-reusable.yml@75bd5994…]` | +| M4 | The caller YAML has not changed since 2026-10-20 | commit history of `.github/workflows/secret-scanner.yml` ends at `6e38719569` (2026-10-20); `actions.lock` last changed 2026-10-27 (`e44af814`) but that change only added `fleet-report-back*` entries | +| M5 | The replacement callee runs and creates jobs | cicd-suite push run [41195030267](https://github.com/hyperpolymath/cicd-suite/actions/runs/41195030267) at `4404c3c0`, `success`, `jobs=1` | +| M6 | Required contexts, read from live rulesets | `Secret-Scan-Floor` (24309193): `scan / gitleaks`. `gitbot-fleet-proof-stack-branch` (22415578): `scan / gitleaks`, `scan / rust-secrets`, `scan / shell-secrets` (+ four non-scanner contexts) | +| M7 | Current lock's remote closure is **complete** for `standards@75bd5994…` | union of external `uses:` across the five called reusable files at that SHA = 11 refs; the lock record names exactly those 11 | +| M8 | Current lock's composite closure is **complete** for `actions/upload-pages-artifact@v5.0.0` → `fc324d35…` | its `action.yml` at that commit uses exactly `actions/upload-artifact@bbbca2dd…`; the record names it | +| M9 | Two `check-lock-sync.sh` implementations exist | this repo's script (transitive-closure clauses, job-level refs tolerated when absent) vs. the estate template in `hyperpolymath/standards` (exact uses-set equality) | +| M10 | Regeneration chain upstream is documented as incomplete for our two classes | `scripts/regen-dependabot-locks.sh` in standards: “`--verify-local` cannot see refs used through local composite actions, or job-level reusable-workflow refs”; `complete-job-refs.sh`: “`gh actions-lock` v0.2.0 … still is [blind to job-level `uses:`]” | +| M11 | `dtolnay/rust-toolchain@v1` resolves to two different commits inside one lock | records: `@v1 → 02cb101e…`, `@e2a55d2f… → ref v1`; live `refs/tags/v1` today = `683b588c…` | +| M12 | `@main` step-level pins have drifted from their snapshots | live `deed-ecosystem@main = edb3640c…` vs recorded `ed83d692…`; live `k9-ecosystem@main = 20f6be5b…` vs recorded `c1a34884…` | +| M13 | cicd-suite's own lock at `4404c3c0` is incomplete | 4 `workflows:` keys for 14 workflow files; no `actions/checkout@3d3c42e5…` record although the reusable pins that SHA | + +--- + +## 2. Findings + +### F1 — FATAL, live: the secret-scan floor is not executing + +The required contexts `scan / gitleaks`, `scan / rust-secrets`, `scan / shell-secrets` are **not being produced**. Every `Secret Scanner` run since 2026-10-27 has zero jobs (M2), including the push to `main` at `83e35bc`; the same pin produced jobs on 2026-10-25 (M3). This is not a YAML regression in this repository (M4). + +A zero-job run produces **no check run**. A required context that never reports does not read as failing — it reads as absent, which in `gh pr checks` is indistinguishable from “not yet started”. The ruleset is therefore satisfied by silence. + +> Consequence for this migration: it cannot “restore” scanning by itself. The replacement must be proven to create jobs *before* it is trusted, which is exactly what the live canary is for. + +### F2 — Tool-only regeneration drops job-level reusable edges + +`gh actions-lock` v0.2.0 has no code path that writes a job-level `uses:` (upstream #129; re-measured upstream 2026-10-14, M10). A bare regeneration therefore removes the five `standards@75bd5994…` entries under `workflows:`. Nothing dangles, so `check-lock-sync.sh` clauses 1–4 stay green: clause 1 deliberately tolerates an absent job-level ref, clause 2 only flags orphans, clause 3 only flags refs that *are* named, clause 4 only checks that the path key exists. + +The new audit reports the resulting state as **NOT RUN (`REUSABLE-UNLOCKED`)** — unproven closure — not green. `scripts/tests/lock-closure-test.sh` pins this: a tool-only regeneration fixture exits 2; a hand-added *leaf* record (the tempting way to go green) exits 1 with `CLOSURE-DROPPED`. + +### F3 — Composite and remote-reusable closure can be shortened with no dangling edge + +A record's nested `uses:` list is the closed edge set for an external composite action (and, for a job-level call, the union of external `uses:` of the called reusable workflows). Shortening that list to a leaf creates no unresolvable ref, so no existing clause sees it. This is the class that a receipt closes: `scripts/lib/lock-closure-receipts.tsv` records the reviewed union at the record's **resolved commit**, and the audit compares. + +Mutations covered by tests: dropped composite edge (`CLOSURE-DROPPED`), dropped reusable edge, leaf-ified record, unreviewed extra edge (`CLOSURE-EXTRA`). + +### F4 — The current lock's closure is complete *today* + +Measured against the pinned sources (M7, M8): 11/11 remote-reusable refs and 1/1 composite ref. This is the baseline the receipts encode, not an assumption. It is also why the audit exits 0 on the repository rather than adding noise. + +### F5 — REVIEW: one reviewed tag, two immutable commits (`dtolnay/rust-toolchain@v1`) + +Two records claim `ref: v1` with different commits (`02cb101e…`, `e2a55d2f…`), and the live tag is a third commit (`683b588c…`). The toolchain tag moves; the lock freezes snapshots. Keeping two *contradictory* snapshots is a representation defect even if both were individually valid when written. Baselined for review rather than rewritten here: re-resolving either record is a behaviour change for a different workflow. + +### F6 — REVIEW: mutable branch pins at step level (`deed-ecosystem@main`, `k9-ecosystem@main`) + +The YAML pins a branch; the lock freezes a commit that has since diverged (M12). The freeze is good — it is the only immutability these calls have — but “pin to `main`, trust the lock” is a policy that should be explicit. Fixing it means changing the dogfood-gate caller or publishing a tag, not editing the lock. + +### F7 — cicd-suite's published lock does not prove its own callability + +At `4404c3c0` the callee's `actions.lock` keys 4 of 14 workflow files and has no SHA-keyed record for the checkout it actually pins (M13). Its own same-repository runs succeed (M5), so this is **not** proof of a startup failure; it is also not proof that a *cross-repository* caller resolves. The canary is the discriminator. Coordination ask filed against cicd-suite #9 and in the migration README. + +### F8 — The current caller carries needless authority + +`security-events: write`, `pull-requests: write`, `actions: read` at job level while the callee's jobs declare `contents: read`. Granting more than the callee needs is not a startup failure, but it is unnecessary authority on a workflow whose only job is to read the tree. The migration reduces this to `contents: read`. + +### F9 — The gate's own toolchain is unpinned + +`check-lock-sync.sh` requires gawk with 3-argument `match()` *or* mikefarah yq / Ruby-Psych. The workflow installs none of them. CI is green (M1), so the current runner image happens to satisfy it; a runner-image change turns the gate red — or, if the probes were weaker, silently vacuous. Upstream's regeneration workflow explicitly installs `gawk`; ours should too (or the script should grow a portable parser). + +### F10 — Two authorities for one invariant + +Two `check-lock-sync.sh` variants disagree on whether an absent job-level ref is a failure (M9). This audit sides with this repository's measured position (absent is harmless, present-but-unresolvable is fatal) and adds the closure proof on top. Reconciling the variants is a standards-side change; recorded as a coordination ask, not patched here. + +### F11 — No regression test covered closure before this change + +`check-lock-sync.sh` had no fixture tests in this repository. The two classes in F2/F3 were therefore only discoverable in production. `scripts/tests/lock-closure-test.sh` adds 41 assertions, including the tool-only-regeneration and leaf-record shapes. + +--- + +## 3. Not run + +| # | Check | Why not run | How to run it | +|---|---|---|---| +| N1 | A real `gh actions-lock` regeneration diff | the extension's release asset download is blocked by this sandbox's egress allowlist (`release-assets.githubusercontent.com`), so the tool could not be installed | `gh extension install github/gh-actions-lock --pin v0.2.0 && gh actions-lock` on a runner; expect the five `standards@75bd5994…` `workflows:` entries and the composite nested list to be the first casualties, then compare with `scripts/audit-lock-closure.sh` | +| N2 | Executing `check-lock-sync.sh` in this sandbox | no gawk (mawk only), no mikefarah yq, no Ruby-Psych — the script fails closed with `FATAL` rather than lying | `sudo apt-get install -y gawk yq`; CI already runs it green (M1) | +| N3 | The live canary | no live run exists for `unified-dataset-vocab` against `4404c3c0…`; `preflight.sh` exits 2 | see `docs/migrations/secret-scanner-cicd-suite/README.adoc` §3 | +| N4 | Root cause of the zero-job failures | the run API carries no reason; the annotations endpoint 404s for zero-job runs; only the run page states one | open run 40933665219 in a browser as a repo admin, or ask standards for the callee-side change on 2026-10-26/27 | +| N5 | Remote resolution of every tag/branch | opt-in (`--online`) because it needs network; executed here (results: M11, M12) | `./scripts/audit-lock-closure.sh --online` | +| N6 | Fleet-wide consumer census / propagation | out of scope by instruction: deployment must not expand before the canary | not scheduled | +| N7 | The cicd-suite consumer-canary workflow (read-only corpus audit of this repo at `83e35bc`) | not executed in this session | `gh workflow run secret-scanner-consumer-canaries.yml -R hyperpolymath/cicd-suite` (owner action) | +| N8 | Whether GitHub validates a cross-repository reusable against the callee's lock for *this* callee | evidence is contradictory (cicd-suite's own runs succeed with an incomplete lock; standards callers failed while the standards lock looked complete) | the canary, plus a same-SHA control in a second repo | + +--- + +## 4. Deliverables in this change + +| Path | What it proves / does | +|---|---| +| `scripts/audit-lock-closure.sh` | Audits representation and closure against reviewed receipts; reports FINDINGS vs NOT RUN; exit 0/1/2/3. Portable (mawk), fails closed on unparseable `uses:`. | +| `scripts/lib/lock-closure-receipts.tsv` | SHA-bound reviewed closure union for `standards@75bd5994…` (11 rows) and `upload-pages-artifact@fc324d35…` (1 row), with provenance. | +| `scripts/lib/lock-audit-baseline.tsv` | Three acknowledged REVIEW findings (F5, F6) plus the online drift re-checks; FATAL findings cannot be baselined. | +| `scripts/tests/lock-closure-test.sh` | 41 assertions: dropped closure (reusable/composite/leaf), malformed refs (subpath, `refs/`, self-SHA action, missing/mismatched commit, case duplicate, ambiguous tag), unparseable values, local composites, the real repository. | +| `docs/migrations/secret-scanner-cicd-suite/` | Prepared three-file migration, receipts, vendored callee fixture, required-context snapshot, canary schema, `preflight.sh`, `rollback.sh`. | +| `.github/workflows/e2e.yml` | Runs the new audit and tests; asserts the migration remains gated (exit 0 or 2, never 1/3). | + +The migration itself is **not applied**: `.github/workflows/secret-scanner.yml` is byte-identical to `main`. + +--- + +## 5. Concrete PR and rollback + +**PR shape** (from `arena/67f4e25d-gitbot-fleet`): + +1. `ci(lock): audit closure against SHA-bound receipts` — audit script, receipts, baseline, tests, E2E wiring, gate guidance. +2. `docs(migration): prepare bounded secret-scanner move to cicd-suite@4404c3c0` — migration package and this report. +3. *(separate, later)* `ci(secrets): migrate the scan caller` — only after `canary/receipt.json` exists; the three patches are the diff. + +Required checks for PR 1/2 are unchanged: the new steps run inside `E2E Tests`; no `uses:` is added, so `actions.lock` does not change. + +**Rollback of PR 1/2:** revert the commit. The audit is additive; reverting removes tests and receipts and restores the previous gate guidance. No runtime behaviour changes. + +**Rollback of the migration (PR 3)** — verified on a full copy of this tree: +apply all three patches → `audit-lock-closure.sh` exit 0 → `rollback.sh --apply` +exit 0 → `secret-scanner.yml`, `actions.lock` and the receipts file are +byte-identical to `main`. The script refuses to run when any of the three +patches has drifted, and it re-audits after reverting. + +```bash +git revert +# or, before committing: +./docs/migrations/secret-scanner-cicd-suite/rollback.sh # dry run +./docs/migrations/secret-scanner-cicd-suite/rollback.sh --apply # reverse + re-audit +``` + +No ruleset edit is required in either direction: the caller job stays `scan`, and the three contexts keep their names. Stop conditions are listed in the migration README §5. + +--- + +## 6. Coordination + +**standards** (lock tooling, governance, regeneration): reconcile the two `check-lock-sync.sh` authorities; publish the repair order as the only regeneration path; make `--verify-local` state what it cannot see; decide the `dtolnay/rust-toolchain@v1` snapshot. Issue context: standards#968 (lock drift), standards#1108 §6b (no secret inheritance), standards#1122 (local composites). + +**cicd-suite** (replacement callee): close or regenerate the lock at the next published pin (4/14 workflow keys, no SHA-keyed checkout record); land cicd-suite#9; keep compatibility context names stable. Issue context: cicd-suite#9, #30 (floating `@main` composites), #63 (receipts / zero-job evidence). + +**Hypatia** (startup and subject verification): count zero-job runs as failed evidence; require SHA-bound detector receipts for compatibility contexts; record the canary receipt immutably and cite the run id. + +--- + +## 7. Reproduction + +```bash +# Findings on this repository (offline; expect exit 0 with 3 accepted REVIEWs) +./scripts/audit-lock-closure.sh + +# The same, resolving live tags/branches (expect the M11/M12 drift, all baselined) +./scripts/audit-lock-closure.sh --online + +# The regression contracts (41 assertions; no gawk/yq needed) +bash scripts/tests/lock-closure-test.sh + +# The migration gate (expect exit 2: canary not run) +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online +``` diff --git a/audits/lock-closure-2026-10-31/evidence.json b/audits/lock-closure-2026-10-31/evidence.json new file mode 100644 index 00000000..ac7e5585 --- /dev/null +++ b/audits/lock-closure-2026-10-31/evidence.json @@ -0,0 +1,264 @@ +{ + "schema": "gitbot-fleet/lock-closure-audit/1", + "generated_at": "2026-10-31T12:03:19Z", + "corpus": { + "repository": "hyperpolymath/gitbot-fleet", + "commit": "83e35bcfd04d43960d635fec5397da8dfa344268", + "mode": "read-only measurement; no workflow deployed, no ruleset edited" + }, + "callees": { + "current": { + "source": "hyperpolymath/standards", + "resolved_commit": "75bd5994470661a099df562d6d5ad2fe744dbd66", + "workflow": ".github/workflows/secret-scanner-reusable.yml", + "runs_on": "ubuntu-24.04 / ubuntu-24.04-arm", + "jobs": [ + "gitleaks", + "rust-secrets", + "shell-secrets" + ], + "declares_secrets": false + }, + "proposed": { + "source": "hyperpolymath/cicd-suite", + "resolved_commit": "4404c3c0474e17753fe7689f3485c02890aac113", + "workflow": ".github/workflows/secret-scanner-reusable.yml", + "jobs": [ + "gitleaks", + "supplemental-results" + ], + "matrix_languages": [ + "rust", + "shell" + ], + "declares_secrets": false, + "requires": "bare 40-hex pin of the callee at .jobs.scan.uses" + } + }, + "required_contexts": [ + "scan / gitleaks", + "scan / rust-secrets", + "scan / shell-secrets" + ], + "rulesets": [ + { + "id": 24309193, + "name": "Secret-Scan-Floor", + "contexts": [ + "scan / gitleaks" + ] + }, + { + "id": 22415578, + "name": "gitbot-fleet-proof-stack-branch", + "contexts": [ + "scan / gitleaks", + "scan / rust-secrets", + "scan / shell-secrets" + ] + } + ], + "live_runs": { + "gitbot-fleet@83e35bc_push": { + "lock_sync_gate": { + "run_id": 40933665365, + "conclusion": "success" + }, + "secret_scanner": { + "run_id": 40933665219, + "conclusion": "failure", + "jobs": 0, + "name_equals_path": true, + "trigger": "push", + "created_at": "2026-10-27T06:38:20Z" + }, + "governance_audit": { + "run_id": 40933665285, + "conclusion": "failure", + "jobs": 0 + }, + "hypatia_scan": { + "run_id": 40933665246, + "conclusion": "failure", + "jobs": 0 + }, + "scorecard": { + "run_id": 40933665259, + "conclusion": "failure", + "jobs": 0 + }, + "mirror": { + "run_id": 40933665305, + "conclusion": "failure", + "jobs": 0 + } + }, + "control_2026_10_25": { + "secret_scanner": { + "run_id": 40175794668, + "conclusion": "success", + "jobs": 3, + "created_at": "2026-10-25T08:13:48Z" + } + }, + "cicd_suite@4404c3c0_push": { + "run_id": 41195030267, + "conclusion": "success", + "jobs": 1 + } + }, + "file_history": { + ".github/workflows/secret-scanner.yml": { + "last_change": "6e38719569", + "date": "2026-10-20" + }, + ".github/workflows/actions.lock": { + "last_change": "e44af814", + "date": "2026-10-27", + "scope": "added fleet-report-back workflow keys only" + } + }, + "closure": { + "hyperpolymath/standards": { + "resolved_commit": "75bd5994470661a099df562d6d5ad2fe744dbd66", + "reviewed_refs": 11, + "receipt_rows": 11, + "complete": true, + "refs": [ + "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9", + "denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed", + "dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f", + "editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393", + "erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124", + "github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63", + "ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc", + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555" + ] + }, + "actions/upload-pages-artifact": { + "resolved_commit": "fc324d3547104276b827a68afc52ff2a11cc49c9", + "reviewed_refs": 1, + "receipt_rows": 1, + "complete": true, + "refs": [ + "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" + ] + } + }, + "review_findings": [ + { + "rule": "REF-AMBIGUOUS", + "detail": "dtolnay/rust-toolchain@v1 recorded twice with different commits: 02cb101ec7c40f2c49e1d9714d64511d8e1b74de (@v1) and e2a55d2ffb04f378e9626c28d38b36d230d1e12f (@e2a55d2f); live tag v1 resolves to 683b588c487a69d01f6c6ddf40f036ff682cc2c8" + }, + { + "rule": "REF-MUTABLE", + "detail": "hyperpolymath/deed-ecosystem@main recorded ed83d6927e8fb21431e403dbf6d7a4af96772746; live main edb3640c" + }, + { + "rule": "REF-MUTABLE", + "detail": "hyperpolymath/k9-ecosystem@main recorded c1a34884054fabf0e9de81dbf68f4ba7874e85f1; live main 20f6be5b" + }, + { + "rule": "REMOTE-STALE", + "detail": "dtolnay/rust-toolchain@v1 live 683b588c487a69d01f6c6ddf40f036ff682cc2c8" + }, + { + "rule": "REMOTE-STALE", + "detail": "hyperpolymath/deed-ecosystem@main live edb3640c" + }, + { + "rule": "REMOTE-STALE", + "detail": "hyperpolymath/k9-ecosystem@main live 20f6be5b" + } + ], + "checks": { + "audit_offline": { + "command": "./scripts/audit-lock-closure.sh", + "exit": 0, + "fatal": 0, + "review": 0, + "accepted": 3, + "not_run": 0 + }, + "audit_online": { + "command": "./scripts/audit-lock-closure.sh --online", + "exit": 0 + }, + "tests": { + "command": "bash scripts/tests/lock-closure-test.sh", + "passed": 41, + "failed": 0 + }, + "preflight": { + "command": "./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online", + "exit": 2, + "findings": 0, + "not_run": [ + "CANARY", + "ONLINE" + ], + "online_resolved": true + } + }, + "not_run": [ + { + "id": "N1", + "check": "real gh actions-lock regeneration", + "reason": "extension release asset egress blocked in this sandbox", + "how": "gh extension install github/gh-actions-lock --pin v0.2.0" + }, + { + "id": "N2", + "check": "check-lock-sync.sh in this sandbox", + "reason": "no gawk/yq/ruby; CI green at 83e35bc", + "how": "apt-get install gawk yq" + }, + { + "id": "N3", + "check": "live canary for unified-dataset-vocab", + "reason": "no live run recorded", + "how": "migration README \u00a73" + }, + { + "id": "N4", + "check": "root cause of zero-job runs", + "reason": "run API exposes no reason; annotations 404", + "how": "run page as repo admin" + }, + { + "id": "N5", + "check": "remote tag/branch resolution", + "reason": "opt-in network check", + "how": "audit --online", + "result": "executed; drift matches baseline" + }, + { + "id": "N6", + "check": "fleet consumer census and propagation", + "reason": "out of scope while the canary gate is closed", + "how": "not scheduled" + }, + { + "id": "N7", + "check": "cicd-suite consumer-canary workflow against 83e35bc", + "reason": "not executed in this session", + "how": "gh workflow run secret-scanner-consumer-canaries.yml -R hyperpolymath/cicd-suite" + }, + { + "id": "N8", + "check": "cross-repository callee lock validation", + "reason": "evidence contradictory; canary is the discriminator", + "how": "canary plus a second-repo control" + } + ], + "boundaries": [ + "deployment is not expanded before the first unified-dataset-vocab live canary receipt exists", + "no required status context is disabled or renamed", + "no secret is forwarded to the callee; secrets: inherit is rejected by preflight", + "fast-pr scanning stays disabled", + "reviewed tag refs and their immutable resolved commits are preserved; regeneration must not de-pin them" + ] +} diff --git a/docs/migrations/secret-scanner-cicd-suite/PR.md b/docs/migrations/secret-scanner-cicd-suite/PR.md new file mode 100644 index 00000000..9ddcc0e2 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/PR.md @@ -0,0 +1,178 @@ +# Pull requests for this change + +Three pull requests, in order. The first two are safe to merge now. The third is +*authored but blocked*: it must not merge until `canary/receipt.json` records a +live run. + +Branch: `arena/67f4e25d-gitbot-fleet` + +--- + +## PR 1 — `ci(lock): prove closure against SHA-bound receipts, and fail closed on dropped edges` + +**Base:** `main` · **Head:** `arena/67f4e25d-gitbot-fleet` + +### Why + +The existing gate (`scripts/check-lock-sync.sh`, clauses 1–4) proves that every +ref named in `actions.lock` is locked and resolvable. It cannot see three shapes +that a regeneration produces: + +1. **A dropped job-level reusable edge.** `gh actions-lock` v0.2.0 does not write + job-level `uses:` entries, so a bare regeneration removes the five + `hyperpolymath/standards@75bd5994…` entries under `workflows:` — and keeps + passing, because clause 1 treats an absent job-level ref as harmless. +2. **A shortened nested `uses:` list.** A composite or reusable record whose + nested list is reduced to a leaf leaves no dangling ref, so clauses 2 and 3 + are silent. +3. **A hand-added leaf record** that makes the gate green while representing + nothing that the callee actually uses. + +All three are closure losses that read as green. This PR makes them observable +and separates them from the question "has this been checked at all?". + +### What + +* `scripts/audit-lock-closure.sh` — new audit (mawk-portable, fails closed on + unparseable `uses:`). Emits `FATAL` (exit 1), `REVIEW` (baselinable), + `NOT-RUN` (exit 2 — never a pass) and `ACCEPTED`. `--online` re-resolves tags + and branches against the network; `--emit-receipt` produces reviewed closure + rows. +* `scripts/lib/lock-closure-receipts.tsv` — the reviewed closure union at the + pinned commits: 11 refs for `hyperpolymath/standards@75bd5994…`, 1 ref for + `actions/upload-pages-artifact@fc324d35…`, with provenance. +* `scripts/lib/lock-audit-baseline.tsv` — three acknowledged REVIEW findings + (one tag with two snapshots; two mutable `@main` step pins) plus their online + drift re-checks. A FATAL finding can never be baselined (asserted by test). +* `scripts/tests/lock-closure-test.sh` — 41 assertions: dropped reusable edge, + dropped composite edge, leaf-ified record, unreviewed extra edge, subpath ref, + `refs/`-prefixed ref, missing/mismatched commit, case-duplicate ref, + self-SHA action ref, ambiguous tag, blocked-scalar `uses:`, local composite + closure, hand-written record without a receipt, and this repository's own + lock. +* `.github/workflows/e2e.yml` — runs the contracts and the audit, and asserts + that the prepared migration never acquires a finding. +* `scripts/check-lock-sync.sh` — message only: it now points at the repair + order and at the audit, and its "job-level refs not locked (harmless)" note + no longer claims a closure it cannot prove. +* `audits/lock-closure-2026-10-31/{REPORT.md,evidence.json}` — measurement. + +### Findings in this PR (versus not run) + +* **FATAL, live:** every `Secret Scanner` run since 2026-10-27 has **zero jobs** + (run 40933665219, push to `main` at `83e35bc`), so `scan / gitleaks`, + `scan / rust-secrets` and `scan / shell-secrets` are *not produced*. The same + pin produced three jobs on 2026-10-25 (run 40175794668) and the caller YAML + has not changed since 2026-10-20. A required context that never reports is + absent, not failed. +* Same signature on the other four reusable callers (governance, hypatia-scan, + scorecard, mirror): `failure`, `jobs: []`. +* `Lock Sync Gate` is green at the same commit: the gate cannot see this class. +* Current closure itself is complete today (11/11 and 1/1) — the receipts encode + that, they do not assume it. +* **Not run:** a real `gh actions-lock` regeneration diff (the extension's + release asset is unreachable from this sandbox), `check-lock-sync.sh` locally + (no gawk/yq/Ruby; CI is green), the live canary, and the root cause of the + zero-job runs (the run API carries no reason). Each has a command in + `REPORT.md` §3. + +### Test plan + +```bash +bash scripts/tests/lock-closure-test.sh # 41 passed, 0 failed +./scripts/audit-lock-closure.sh # exit 0, fatal=0 review=0 accepted=3 not-run=0 +./scripts/audit-lock-closure.sh --online # exit 0 (drift all baselined) +PATH= bash tests/e2e.sh # 41 passed, 0 failed +``` + +Required checks are unchanged; no `uses:` is added, so `actions.lock` does not +change in this PR. + +### Rollback + +`git revert` — the change is additive. Reverting removes the audit, receipts, +baseline, tests and the report, and restores the previous gate message. No +runtime behaviour depends on it. + +--- + +## PR 2 — `docs(migration): prepare bounded secret-scanner move to cicd-suite@4404c3c0 (not applied)` + +**Base:** `main` (or PR 1) · **Head:** `arena/67f4e25d-gitbot-fleet` + +### Why + +The current callee is not executing (PR 1, FATAL finding). The published +replacement, `hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113`, +runs real Gitleaks/Rust/shell detectors, verifies its own suite SHA from the +direct caller, and publishes SHA-bound receipts that its compatibility contexts +consume (its push run at that SHA: 41195030267, `success`, 1 job). + +### What + +Everything under `docs/migrations/secret-scanner-cicd-suite/`, and nothing +outside it except this PR text: + +* three patches (`secret-scanner.yml`, `actions.lock`, closure receipt); +* `preflight.sh` — the gate. It fails on findings and refuses to treat missing + evidence as a pass; +* `rollback.sh` — dry-run reversal with re-audit; +* the vendored callee workflow at the pinned SHA, with `SHA256SUMS`; +* `required-contexts.txt` — the three scanner contexts read from the live + rulesets on 2026-10-31; +* `canary/receipt.schema.json` + a `result: not_run` example that preflight + rejects. + +### Deliberate boundaries + +* No context renamed, added or disabled. Caller job stays `scan`; contexts stay + `scan / gitleaks`, `scan / rust-secrets`, `scan / shell-secrets`. +* No `secrets:` line (`secrets: inherit` is rejected by preflight). +* `fast-pr: false`; triggers unchanged (`pull_request`, `push` to `main`). +* The reviewed `standards@75bd5994…` record is preserved for the four other + callers, with its immutable commit. +* Deployment does not expand: the migration is not applied, and the canary gate + is the only thing that opens it. + +### Current gate state + +`./preflight.sh --online` → **exit 2**, `findings=0`, NOT-RUN = `CANARY`. +`--check-apply` confirms all three patches apply to this checkout. + +### Test plan + +```bash +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online # exit 2, findings 0 +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online --check-apply +./docs/migrations/secret-scanner-cicd-suite/rollback.sh # dry run on an APPLIED tree +# verified: apply -> audit exit 0 -> rollback --apply exit 0 -> files byte-identical +``` + +### Rollback + +Close without merging; nothing was applied. If a later PR applies it, use +`git revert`, or `rollback.sh --apply` before committing. + +--- + +## PR 3 — `ci(secrets): migrate the scan caller to cicd-suite@4404c3c0` — BLOCKED + +Draft until the first live `unified-dataset-vocab` canary succeeds and +`canary/receipt.json` is committed with `result: pass`, `suite_sha: 4404c3c0…`, +`fast_pr: false`, `secrets_passed: []`, and all three contexts `success` with +`receipt_sha == subject_sha`. + +Then: + +1. `preflight.sh --online --check-apply` must exit 0. +2. Apply the three patches (README §4). +3. `./scripts/audit-lock-closure.sh` must exit 0 on the applied tree. +4. Open the PR; require that `Secret Scanner` produces three contexts on the PR + itself — zero jobs is a failure, not a pending check. +5. Merge only with `Lock Sync Gate` green and no ruleset change in the diff. + +Rollback: `git revert `, or +`docs/migrations/secret-scanner-cicd-suite/rollback.sh --apply` if uncommitted. +Stop conditions: README §5 (zero-job runs, unresolvable ref, success without a +detector receipt, a required secret, or a false negative the current scanner +caught). diff --git a/docs/migrations/secret-scanner-cicd-suite/README.adoc b/docs/migrations/secret-scanner-cicd-suite/README.adoc new file mode 100644 index 00000000..9e3ef89e --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/README.adoc @@ -0,0 +1,229 @@ += Secret-scanner consumer migration: standards → cicd-suite +:toc: preamble +:sectnums: + +*Status: PREPARED, NOT APPLIED.* The live workflow in this repository still +calls `hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994…`. +Nothing in this directory changes it. + +The migration is gated by `preflight.sh`, which currently exits *2 (NOT RUN)* +because no live canary receipt exists for `hyperpolymath/unified-dataset-vocab`. +That is deliberate: see <>. + +== 1. Why migrate at all + +Two independent facts, both measured on 2026-10-31 and both recorded in +`audits/lock-closure-2026-10-31/REPORT.md`: + +. The current consumer pin is *not producing jobs*. Every `Secret Scanner` run + since 2026-10-27 has `conclusion=failure`, `jobs=0`, `name == path` — the + signature of a run GitHub refused to start (link:https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665219[run 40933665219]). + The same pin produced jobs on 2026-10-25 (link:https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40175794668[run 40175794668]), + so this is not a YAML change in this repository: the workflow file has not + changed since 2026-10-20. The required contexts `scan / gitleaks`, + `scan / rust-secrets` and `scan / shell-secrets` are therefore *absent*, not + green — a required context that never reports reads as "waiting", and a + branch that merges anyway reads as clean. +. The replacement callee executes real detectors. At `4404c3c0…` the published + suite runs Gitleaks plus its own Rust and shell detectors, verifies the + exact suite SHA from the direct caller, checks out the scanner implementation + outside the subject, and publishes SHA-bound receipts that the compatibility + contexts consume (`supplemental-results`). Its own runs at that SHA succeed + with jobs (link:https://github.com/hyperpolymath/cicd-suite/actions/runs/41195030267[run 41195030267]). + +The migration is *bounded*: one caller, one callee, no fleet-wide deployment, +no `fix-*` script rewrites, no new triggers. + +== 2. What changes, and what does not + +[cols="1,2",options="header"] +|=== +|Path |Change + +|`.github/workflows/secret-scanner.yml` +|`uses:` moves to +`hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113`; +`with: fast-pr: false`; job-level permissions reduce to `contents: read`. + +|`.github/workflows/actions.lock` +|The caller's `workflows:` edge moves to `hyperpolymath/cicd-suite@4404c3c0…`; +a dependency record is added with the *immutable resolved commit* and the +callee's nested `actions/checkout@3d3c42e5…`. The `standards@75bd5994…` record +stays: four other workflows still call it, and its nested closure is unchanged +(the union across its called files is still the same 11 refs). + +|`scripts/lib/lock-closure-receipts.tsv` +|One reviewed closure receipt row for `hyperpolymath/cicd-suite@4404c3c0…`, +emitted from the vendored callee source at that SHA. +|=== + +Unchanged, on purpose: + +* *Contexts.* `scan / gitleaks` (callee job `gitleaks`), `scan / rust-secrets` + and `scan / shell-secrets` (callee matrix job `supplemental-results` with + `name: ${{ matrix.language }}-secrets`). `required-contexts.txt` records the + live rulesets (`Secret-Scan-Floor` 24309193, `gitbot-fleet-proof-stack-branch` + 22415578) as read on 2026-10-31. +* *Triggers.* `pull_request` and `push` to `main` only. No `schedule`, no + `workflow_dispatch`, no `pull_request_target`, no `workflow_run`. +* *Secrets.* None are passed. The previous caller already had no `secrets:` + line; the callee declares no `workflow_call.secrets` and references no + `secrets.*`. `secrets: inherit` is explicitly rejected by preflight. +* *Fast-PR scanning.* Off (`fast-pr: false`). Every non-PR run remains a + full-history scan. Introduced-commit PR coverage is a separate security-owner + decision and is not taken here. +* *Rulesets.* No edit. A migration that renames a context is a ruleset change + in disguise; this one does not. + +== 3. Evidence required before applying +[[gate]] + +`preflight.sh` is the only gate that matters, and it separates the two +questions that a single "green" would blur: + +*FINDINGS* (exit 1 or 3) — the proposed artefact is wrong. Examples: a required +context would disappear; `secrets:` or `secrets: inherit` appears; `fast-pr` +would be true; an extra trigger appears; the proposed lock loses a closure +edge; a patch no longer applies. + +*NOT RUN* (exit 2 or 3) — the evidence does not exist. Currently: no live +canary receipt. This is not a pass. + +=== The live canary + +The first live canary is on `hyperpolymath/unified-dataset-vocab`, which +already runs a secret scanner and already has the required contexts. The +canary must: + +. pin this same callee SHA (`4404c3c0…`), +. keep `fast-pr: false` and pass no secrets, +. complete a run with `jobs > 0` (a zero-job startup failure is a *failed* + canary, not an absent one), +. report all three contexts with real detector results bound to the subject + SHA — `scan / rust-secrets` and `scan / shell-secrets` must validate the + receipts of detectors actually executed, not echo a pass, +. record the run in `canary/receipt.json`. + +`canary/receipt.schema.json` is the format; `canary/receipt.example.json` is a +`result: not_run` placeholder that preflight rejects. Do not hand-write a +receipt from a dry run, a corpus audit, or a run against a different SHA: the +receipt is evidence of a live run or it is nothing. + +Only after that run is recorded does `preflight.sh` reach exit 0. Expanding +beyond the canary (other repos, fleet propagation) is a *separate* decision +with its own cohort and stop conditions. + +== 4. Apply procedure + +[source,bash] +---- +# 1. Read the current state; expect NOT RUN for the canary, and no findings. +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online + +# 2. Once canary/receipt.json exists, prove the patches apply to this checkout. +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online --check-apply + +# 3. Apply. The patches touch exactly three files. +patch -p1 < docs/migrations/secret-scanner-cicd-suite/patches/secret-scanner.yml.patch +patch -p1 < docs/migrations/secret-scanner-cicd-suite/patches/actions.lock.patch +patch -p1 < docs/migrations/secret-scanner-cicd-suite/patches/lock-closure-receipts.tsv.patch + +# 4. Re-run both gates on the applied tree. +./scripts/audit-lock-closure.sh +./docs/migrations/secret-scanner-cicd-suite/preflight.sh --online +---- + +Pull-request checklist: + +* [ ] preflight exits 0, and the canary receipt names the merged callee SHA. +* [ ] `Lock Sync Gate` green (the lock change is part of the same commit). +* [ ] `Secret Scanner` produces three contexts on the PR, each with a real + detector receipt; zero jobs is a failure, not a pending check. +* [ ] No ruleset change appears in the diff. +* [ ] `git diff --stat` shows only the three intended files. + +== 5. Rollback + +Rollback is a revert, not a repair. Contexts do not change in either +direction, so no ruleset is touched. `rollback.sh` reverse-applies the three +patches in the opposite order and re-audits; its dry run is only meaningful on +an *applied* tree (it refuses if a patch has drifted). Verified end to end on a +full copy: apply → audit exit 0 → `rollback.sh --apply` exit 0 → the three +files byte-identical to `main`. + +[source,bash] +---- +# Preferred: revert the migration commit. +git revert + +# If the change is uncommitted or must be reversed in place: +./docs/migrations/secret-scanner-cicd-suite/rollback.sh # dry run +./docs/migrations/secret-scanner-cicd-suite/rollback.sh --apply # reverse + re-audit +---- + +Stop conditions — roll back rather than loosen anything: + +* any of the three contexts is absent after the run (zero jobs); +* the callee rejects startup because a ref is unresolvable; +* a supplemental context reports success without a detector receipt; +* the run requires a secret the caller should not forward; +* the canary cohort shows a false negative that the standards scanner caught. + +Do *not* respond to a failed migration by deleting a required context, +adding `secrets: inherit`, enabling `fast-pr`, or lowering permissions below +`contents: read` (that last one rejects the run at startup). + +== 6. Coordination + +*With `hyperpolymath/standards`* (owner of the current reusable, the lock +tooling and the regeneration chain): + +. Reconcile the two `check-lock-sync.sh` variants: this repository's script + treats an absent job-level ref as harmless and adds transitive-closure and + coverage clauses; the estate template compares the exact uses set. Two + authorities means two answers. +. Publish the repair order (`gh actions-lock → relock-sha-keys → + complete-job-refs → close-lock → prune-stale`) as the *only* documented + regeneration path. A bare `gh actions-lock` drops every job-level entry + (upstream #129) and cannot close a remote reusable's record. +. Make `--verify-local` state what it cannot see (local composites, job-level + reusables). Today its silence reads as coverage. +. Decide the `dtolnay/rust-toolchain@v1` snapshot: one tag, two recorded commits. + +*With `hyperpolymath/cicd-suite`* (owner of the replacement): + +. The published `actions.lock` at `4404c3c0…` keys 4 of 14 workflow files and + has no SHA-keyed `actions/checkout@3d3c42e5…` record, while the reusable pins + that SHA. Its own same-repo runs succeed, so this is *not* proof of a startup + failure — but it is also not proof of callability from another repository. + The live canary is the discriminator; publish a closed lock at the next pin + either way. +. Land link:https://github.com/hyperpolymath/cicd-suite/issues/9[#9] (stale lock + versus own uses) before consumers depend on the scanner. +. Keep the compatibility contexts' names stable; they are required contexts in + consumer rulesets. + +*With Hypatia* (startup/subject verification): + +. Treat zero-job runs as failed evidence, not absent evidence: the run API + carries no reason, so the only reliable signal is `jobs == 0` together with + `name == path`. +. Require detector receipts to be bound to the subject SHA; a passing + compatibility context with no executed detector is a vacuous pass. +. Record the canary receipt once, immutably, and cite its run id in the + migration PR. + +== 7. Files + +[cols="1,2",options="header"] +|=== +|Path |Purpose + +|`patches/*.patch` |The three-file change. Never applied in this commit. +|`preflight.sh` |Findings-vs-not-run gate, static + closure + canary. +|`rollback.sh` |Dry-run-by-default reversal with re-audit. +|`receipts.tsv` |Migration-only closure receipt (merged by the patch). +|`fixtures/` |Vendored callee workflow at `4404c3c0…`, checksum-pinned. +|`required-contexts.txt` |Live ruleset snapshot; the three scanner contexts. +|`canary/` |Receipt schema, rejected example, and where the real receipt goes. +|=== diff --git a/docs/migrations/secret-scanner-cicd-suite/canary/receipt.example.json b/docs/migrations/secret-scanner-cicd-suite/canary/receipt.example.json new file mode 100644 index 00000000..6f297323 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/canary/receipt.example.json @@ -0,0 +1,21 @@ +{ + "_comment": "EXAMPLE ONLY — never accepted as evidence. Preflight requires canary/receipt.json, produced from a real run. This file exists so the shape is reviewable and so a copied placeholder fails the `result` check rather than looking like a pass.", + "schema_version": 1, + "result": "not_run", + "subject": "hyperpolymath/unified-dataset-vocab", + "subject_sha": "0000000000000000000000000000000000000000", + "suite_sha": "4404c3c0474e17753fe7689f3485c02890aac113", + "run_id": 1, + "run_url": "https://github.com/hyperpolymath/unified-dataset-vocab/actions/runs/1", + "event": "pull_request", + "scanned_mode": "full", + "fast_pr": false, + "secrets_passed": [], + "jobs_total": 2, + "contexts": [ + { "name": "scan / gitleaks", "conclusion": "skipped", "receipt_sha": "0000000000000000000000000000000000000000", "detector": "gitleaks" }, + { "name": "scan / rust-secrets", "conclusion": "skipped", "receipt_sha": "0000000000000000000000000000000000000000", "detector": "rust" }, + { "name": "scan / shell-secrets", "conclusion": "skipped", "receipt_sha": "0000000000000000000000000000000000000000", "detector": "shell" } + ], + "measured_at": "1970-01-01T00:00:00Z" +} diff --git a/docs/migrations/secret-scanner-cicd-suite/canary/receipt.schema.json b/docs/migrations/secret-scanner-cicd-suite/canary/receipt.schema.json new file mode 100644 index 00000000..517d1f38 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/canary/receipt.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/hyperpolymath/gitbot-fleet/docs/migrations/secret-scanner-cicd-suite/canary/receipt.schema.json", + "title": "Live canary receipt for the cicd-suite secret-scanner migration", + "description": "A receipt is written ONLY from a real workflow run, after the run finished. It is the evidence that the callee resolves at startup, creates jobs, and produces all three required contexts with real detector results. It is not a plan, not a dry run and not a corpus audit.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "result", + "subject", + "subject_sha", + "suite_sha", + "run_id", + "run_url", + "event", + "scanned_mode", + "fast_pr", + "secrets_passed", + "contexts", + "measured_at" + ], + "properties": { + "schema_version": { "const": 1 }, + "result": { "enum": ["pass", "fail", "not_run"] }, + "subject": { "const": "hyperpolymath/unified-dataset-vocab" }, + "subject_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "suite_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "run_id": { "type": "integer", "minimum": 1 }, + "run_url": { + "type": "string", + "pattern": "^https://github\\.com/hyperpolymath/unified-dataset-vocab/actions/runs/[0-9]+$" + }, + "event": { "enum": ["pull_request", "push"] }, + "scanned_mode": { "enum": ["full", "pr-range"] }, + "fast_pr": { "type": "boolean" }, + "secrets_passed": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of caller secrets forwarded to the reusable. MUST be empty: the callee declares none and needs none." + }, + "jobs_total": { "type": "integer", "minimum": 1 }, + "contexts": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "conclusion", "receipt_sha"], + "properties": { + "name": { + "enum": ["scan / gitleaks", "scan / rust-secrets", "scan / shell-secrets"] + }, + "conclusion": { "enum": ["success", "failure", "cancelled", "skipped"] }, + "receipt_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "detector": { "enum": ["gitleaks", "rust", "shell"] } + } + } + }, + "measured_at": { "type": "string", "format": "date-time" } + } +} diff --git a/docs/migrations/secret-scanner-cicd-suite/fixtures/SHA256SUMS b/docs/migrations/secret-scanner-cicd-suite/fixtures/SHA256SUMS new file mode 100644 index 00000000..7bb09746 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/fixtures/SHA256SUMS @@ -0,0 +1 @@ +4745e9318346b1a3eae2e0bf65c6732f73d2ead78ef29de9e20659a71f8057d8 cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml diff --git a/docs/migrations/secret-scanner-cicd-suite/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml b/docs/migrations/secret-scanner-cicd-suite/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml new file mode 100644 index 00000000..81b2ba1d --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: MPL-2.0 +# Single required context remains scan / gitleaks in direct callers. +name: Bounded secret scanner (reusable) +on: + workflow_call: + inputs: + fast-pr: + description: Security-owner-approved introduced-commit PR scan; non-PR runs always scan full history + type: boolean + default: false +permissions: + contents: read +jobs: + gitleaks: + outputs: + subject_sha: ${{ steps.scan.outputs.subject_sha }} + rust_status: ${{ steps.scan.outputs.rust_status }} + shell_status: ${{ steps.scan.outputs.shell_status }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + shell: bash + steps: + - name: Check out subject and complete ancestry + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: subject + fetch-depth: 0 + persist-credentials: false + - name: Resolve exact suite pin from the direct scan caller + id: suite + env: + CALLER_REF: ${{ github.workflow_ref }} + CALLER_REPO: ${{ github.repository }} + run: | + set -euo pipefail + file="${CALLER_REF#"$CALLER_REPO"/}"; file="${file%@*}" + case "$file" in .github/workflows/*.yml|.github/workflows/*.yaml) ;; *) exit 2 ;; esac + ref=$(yq -r '.jobs.scan.uses' "subject/$file") + prefix=hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@ + [[ $ref == "$prefix"* ]] || { echo '::error::requires a direct scan caller'; exit 2; } + sha=${ref#"$prefix"} + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo '::error::caller must pin a full SHA'; exit 2; } + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Check out pinned scanner implementation outside subject + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/cicd-suite + ref: ${{ steps.suite.outputs.sha }} + path: suite + persist-credentials: false + - name: Verify pinned checkout and install checksum-pinned Gitleaks + env: + EXPECTED: ${{ steps.suite.outputs.sha }} + run: | + set -euo pipefail + [[ $(git -C suite rev-parse HEAD) == "$EXPECTED" ]] || exit 2 + bash suite/actions/secret-scan-check/install.sh "$RUNNER_TEMP/suite-gitleaks" + - name: Run real detector controls before trusting the scan + env: + GITLEAKS_BIN: ${{ runner.temp }}/suite-gitleaks/gitleaks + run: bash suite/actions/secret-scan-check/control.sh + - name: Scan subject (bounded, redacted, both populations) + id: scan + env: + GITLEAKS_BIN: ${{ runner.temp }}/suite-gitleaks/gitleaks + SUBJECT: ${{ github.workspace }}/subject + ESTATE_BASELINE: ${{ github.workspace }}/suite/config/gitleaks/estate-baseline.toml + FAST_PR: ${{ inputs.fast-pr }} + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + export SCAN_MODE=full SCAN_TIMEOUT_SECONDS=180 + if [[ "$EVENT" == pull_request && "$FAST_PR" == true ]]; then + export SCAN_MODE=pr-range HEAD_SHA="$PR_HEAD" + # A PR may be behind the base tip: scan from their common ancestor, + # not from the final file diff and not from a guessed depth. + export BASE_SHA + BASE_SHA=$(git -C "$SUBJECT" merge-base "$PR_BASE" "$PR_HEAD") + fi + bash suite/actions/secret-scan-check/check.sh + + supplemental-results: + # Compatibility contexts consumed by fleet branch rules. These validate + # receipts from detectors already executed in gitleaks, not echo-only passes. + needs: gitleaks + if: ${{ always() }} + name: ${{ matrix.language }}-secrets + runs-on: ubuntu-24.04 + timeout-minutes: 2 + strategy: + fail-fast: false + matrix: + language: [rust, shell] + steps: + - name: Verify completed detector result and subject binding + shell: bash + env: + LANGUAGE: ${{ matrix.language }} + UPSTREAM_RESULT: ${{ needs.gitleaks.result }} + SUBJECT_SHA: ${{ needs.gitleaks.outputs.subject_sha }} + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + DETECTOR_STATUS: ${{ needs.gitleaks.outputs[format('{0}_status', matrix.language)] }} + run: | + set -euo pipefail + case "$LANGUAGE" in rust|shell) ;; *) exit 2 ;; esac + case "$UPSTREAM_RESULT" in success|failure) ;; *) echo 'No completed scan receipt'; exit 2 ;; esac + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ && "$SUBJECT_SHA" == "$EXPECTED_SHA" ]] \ + || { echo 'Missing or mismatched subject receipt'; exit 2; } + case "$DETECTOR_STATUS" in + 0) echo "$LANGUAGE detector actually completed successfully for $SUBJECT_SHA" ;; + 1) echo "$LANGUAGE detector found hardcoded credentials"; exit 1 ;; + *) echo "$LANGUAGE detector did not produce a trustworthy passing result"; exit 2 ;; + esac diff --git a/docs/migrations/secret-scanner-cicd-suite/patches/actions.lock.patch b/docs/migrations/secret-scanner-cicd-suite/patches/actions.lock.patch new file mode 100644 index 00000000..2784481a --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/patches/actions.lock.patch @@ -0,0 +1,25 @@ +--- a/.github/workflows/actions.lock 2026-10-31 12:01:29.730296740 +0000 ++++ b/.github/workflows/actions.lock 2026-10-31 12:01:29.750296840 +0000 +@@ -65,7 +65,7 @@ + '.github/workflows/scorecard.yml': + - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' + '.github/workflows/secret-scanner.yml': +- - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' ++ - 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113' + '.github/workflows/supervised-fleet-scan.yml': + - 'actions/checkout@v7.0.1' + '.github/workflows/vendored-bot-drift.yml': +@@ -173,6 +173,13 @@ + commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + owner_id: 75048950 + repo_id: 623796603 ++ 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113': ++ ref: '4404c3c0474e17753fe7689f3485c02890aac113' ++ commit: 'sha1-4404c3c0474e17753fe7689f3485c02890aac113' ++ owner_id: 6759885 ++ repo_id: 1326697643 ++ uses: ++ - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + 'hyperpolymath/deed-ecosystem@main': + ref: 'main' + commit: 'sha1-ed83d6927e8fb21431e403dbf6d7a4af96772746' diff --git a/docs/migrations/secret-scanner-cicd-suite/patches/lock-closure-receipts.tsv.patch b/docs/migrations/secret-scanner-cicd-suite/patches/lock-closure-receipts.tsv.patch new file mode 100644 index 00000000..3bbbedca --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/patches/lock-closure-receipts.tsv.patch @@ -0,0 +1,7 @@ +--- a/scripts/lib/lock-closure-receipts.tsv 2026-10-31 12:02:39.046646854 +0000 ++++ b/scripts/lib/lock-closure-receipts.tsv 2026-10-31 12:02:39.050646874 +0000 +@@ -51,3 +51,4 @@ + hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 + actions/upload-pages-artifact fc324d3547104276b827a68afc52ff2a11cc49c9 actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f ++hyperpolymath/cicd-suite 4404c3c0474e17753fe7689f3485c02890aac113 actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/docs/migrations/secret-scanner-cicd-suite/patches/secret-scanner.yml.patch b/docs/migrations/secret-scanner-cicd-suite/patches/secret-scanner.yml.patch new file mode 100644 index 00000000..5683f4df --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/patches/secret-scanner.yml.patch @@ -0,0 +1,53 @@ +--- a/.github/workflows/secret-scanner.yml 2026-10-31 12:01:29.726296720 +0000 ++++ b/.github/workflows/secret-scanner.yml 2026-10-31 12:01:29.734296760 +0000 +@@ -1,9 +1,22 @@ +-# This workflow is managed by gh actions-lock. + # SPDX-License-Identifier: MPL-2.0 + # This workflow is managed by gh actions-lock. +-# This workflow is managed by gh actions-lock. + name: Secret Scanner + ++# Consumer migration to the published cicd-suite scanner. The three contexts ++# this repository's rulesets require — scan / gitleaks, scan / rust-secrets, ++# scan / shell-secrets — are produced by the callee's gitleaks job and by its ++# supplemental-results matrix; none is renamed, added or dropped here. ++# ++# Boundaries, all enforced by preflight.sh: ++# * `fast-pr` stays false: non-PR runs scan full history, and introduced-commit ++# PR scanning is not enabled before the security owner accepts it. ++# * No `secrets:` line. The callee declares no workflow_call secret and the ++# scanner installs its own checksum-pinned binary, so `secrets: inherit` ++# would forward every repository and organisation secret for no benefit. ++# * `contents: read` is the whole grant; the previous caller's ++# security-events / pull-requests / actions grants are not carried over. ++# * Triggers are unchanged: pull_request and push to main, nothing else. ++ + on: + pull_request: + push: +@@ -14,15 +27,19 @@ + cancel-in-progress: true + + permissions: +- actions: read + contents: read + + jobs: + scan: ++ # This job-level block REPLACES the workflow-level one. `contents: read` is ++ # the complete grant the callee's jobs declare; granting more is needless ++ # authority, granting less rejects the run at startup with zero jobs. + permissions: +- security-events: write + contents: read +- pull-requests: write +- actions: read +- uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 +- # secret-scanner-reusable declares no caller secret, so none is passed. ++ uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113 ++ with: ++ # Keep false until the unified-dataset-vocab live canary has passed and ++ # the security owner accepts introduced-commit PR coverage. Every non-PR ++ # run remains a full-history scan. ++ fast-pr: false ++ # Deliberately no `secrets:` line — see the boundary note above. diff --git a/docs/migrations/secret-scanner-cicd-suite/preflight.sh b/docs/migrations/secret-scanner-cicd-suite/preflight.sh new file mode 100755 index 00000000..83de7368 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/preflight.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# preflight.sh — the gate between "prepared" and "applied" for the cicd-suite +# secret-scanner migration. +# +# It answers three questions and refuses to blur them: +# +# FINDINGS the proposed artefact is wrong: a context would change, a secret +# would be forwarded, fast-PR scanning would be on, the lock closure +# would lose an edge, or a patch would not apply. +# +# NOT RUN the evidence does not exist yet: no live canary receipt for +# hyperpolymath/unified-dataset-vocab, or jq/network unavailable. +# Not-run is not a pass, and it blocks the migration. +# +# PASS every static property holds AND the live canary receipt is bound +# to the same callee SHA and every required context reported a real +# detector result for the subject SHA. +# +# The migration is deliberately NOT applied in this change. The live canary is +# the first proof that the published callee resolves at startup, creates jobs +# and emits real receipts; until it exists, "deploy" would be a guess. +# +# Usage: preflight.sh [--receipt PATH] [--online] [--quiet] +# [--check-apply] +# +# --receipt PATH canary receipt (default canary/receipt.json) +# --online also re-read the live rulesets and the callee commit +# --check-apply refuse unless `patch --dry-run` applies every patch to +# this checkout (run before applying; it never edits) +# +# Exit: 0 pass, 1 findings, 2 not-run only, 3 both. A non-zero exit MUST block +# the migration; it is not advice. + +set -uo pipefail + +MIG="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$MIG/../../.." && pwd)" +RECEIPT="$MIG/canary/receipt.json" +ONLINE=0 +QUIET=0 +CHECK_APPLY=0 +SUITE_SHA="4404c3c0474e17753fe7689f3485c02890aac113" +CALLEE="hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@$SUITE_SHA" +FIXTURE="$MIG/fixtures/cicd-suite-$SUITE_SHA-secret-scanner-reusable.yml" + +while [ $# -gt 0 ]; do + case "$1" in + --receipt) RECEIPT="${2:?--receipt needs a path}"; shift ;; + --online) ONLINE=1 ;; + --quiet) QUIET=1 ;; + --check-apply) CHECK_APPLY=1 ;; + -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "preflight: unknown argument: $1" >&2; exit 64 ;; + esac + shift +done + +FINDINGS=(); NOTRUN=() +finding() { FINDINGS+=("$1|$2"); } +notrun() { NOTRUN+=("$1|$2"); } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/scanner-preflight.XXXXXX")" || exit 64 +trap 'rm -rf "$TMP"' EXIT + +# ------------------------------------------------------- inputs and patches -- +[ -f "$MIG/patches/secret-scanner.yml.patch" ] || finding PATCH-MISSING "no secret-scanner.yml.patch" +[ -f "$MIG/patches/actions.lock.patch" ] || finding PATCH-MISSING "no actions.lock.patch" +[ -f "$MIG/patches/lock-closure-receipts.tsv.patch" ] || finding PATCH-MISSING "no lock-closure-receipts.tsv.patch" + +# The proposed tree is built from the real checkout plus the patches, so every +# check below reads exactly what would land. +mkdir -p "$TMP/tree/scripts/lib" +cp -R "$ROOT/.github" "$TMP/tree/" 2>/dev/null || finding TREE "$ROOT/.github missing" +cp "$ROOT/scripts/audit-lock-closure.sh" "$TMP/tree/scripts/" 2>/dev/null || finding TREE "audit-lock-closure.sh missing" +cp "$ROOT/scripts/lib/lock-closure-receipts.tsv" "$TMP/tree/scripts/lib/" 2>/dev/null || true +cp "$ROOT/scripts/lib/lock-audit-baseline.tsv" "$TMP/tree/scripts/lib/" 2>/dev/null || true + +apply_ok=1 +for p in secret-scanner.yml.patch actions.lock.patch lock-closure-receipts.tsv.patch; do + if ! ( cd "$TMP/tree" && patch -p1 -s -f --no-backup-if-mismatch < "$MIG/patches/$p" ); then + finding PATCH-APPLY "patch does not apply to the current checkout: $p" + apply_ok=0 + fi +done + +if [ "$CHECK_APPLY" -eq 1 ]; then + for p in "$MIG"/patches/*.patch; do + if ! ( cd "$ROOT" && patch -p1 --dry-run -s -f < "$p" >/dev/null 2>&1 ); then + finding PATCH-APPLY "patch --dry-run failed against $ROOT: $(basename "$p")" + fi + done +fi + +PROPOSED="$TMP/tree/.github/workflows/secret-scanner.yml" +if [ ! -f "$PROPOSED" ]; then + finding TREE "proposed secret-scanner.yml not present after patching" +fi + +# ---------------------------------------------------------------- fixture ---- +if [ ! -f "$FIXTURE" ]; then + notrun FIXTURE-MISSING "vendored callee workflow is absent: $FIXTURE" +elif [ ! -f "$MIG/fixtures/SHA256SUMS" ]; then + notrun FIXTURE-CHECKSUM "no fixes/SHA256SUMS, so the vendored callee cannot be trusted" +elif ! ( cd "$MIG/fixtures" && sha256sum -c SHA256SUMS >/dev/null 2>&1 ); then + finding FIXTURE-CHECKSUM "vendored callee workflow does not match fixtures/SHA256SUMS" +fi + +# ------------------------------------------------- static properties of patch -- +code_only() { grep -vE '^[[:space:]]*#'; } + +if [ -f "$PROPOSED" ]; then + # Contexts are not renamed by this migration. + grep -qE '^ scan:$' "$PROPOSED" || finding WF-SHAPE "job id 'scan' is required: the callee resolves its own suite pin from .jobs.scan.uses" + code_only < "$PROPOSED" | grep -qF "uses: $CALLEE" || finding WF-SHAPE "the scan job must use exactly $CALLEE" + code_only < "$PROPOSED" | grep -qE '^[[:space:]]+fast-pr:[[:space:]]+false[[:space:]]*$' \ + || finding WF-FAST-PR "fast-pr must be explicitly false (got: $(code_only < "$PROPOSED" | grep -E 'fast-pr:' | tr -d ' ' || true))" + code_only < "$PROPOSED" | grep -qE '^[[:space:]]*fast-pr:[[:space:]]+true' && finding WF-FAST-PR "fast-pr: true would enable introduced-commit PR scanning" + + # No secret inheritance, in any spelling. + if code_only < "$PROPOSED" | grep -qE '^[[:space:]]*secrets[[:space:]]*:'; then + finding WF-SECRETS "the caller carries a secrets: block; the callee declares none and needs none" + fi + code_only < "$PROPOSED" | grep -qF 'secrets: inherit' && finding WF-SECRETS "secrets: inherit would forward every repository and organisation secret" + + # Triggers unchanged: no new event, no PR-target, no scheduled/full audit. + for bad in pull_request_target workflow_run schedule workflow_dispatch release; do + if code_only < "$PROPOSED" | grep -qE "^[[:space:]]*$bad[[:space:]]*:"; then + finding WF-TRIGGER "trigger '$bad' would be added by the migration" + fi + done + code_only < "$PROPOSED" | grep -qE '^[[:space:]]*pull_request:[[:space:]]*$' || finding WF-TRIGGER "pull_request trigger missing" + code_only < "$PROPOSED" | grep -qE '^[[:space:]]*push:[[:space:]]*$' || finding WF-TRIGGER "push trigger missing" + code_only < "$PROPOSED" | grep -qE '^[[:space:]]*branches:[[:space:]]*\[main\][[:space:]]*$' || finding WF-TRIGGER "push must stay limited to main" + + # Least privilege: contents: read is the whole grant. + for bad in security-events pull-requests id-token packages deployments attestations; do + if code_only < "$PROPOSED" | grep -qE "^[[:space:]]+$bad[[:space:]]*:"; then + finding WF-PERMS "needless grant '$bad' would be carried over" + fi + done + if code_only < "$PROPOSED" | grep -qE '^[[:space:]]+actions[[:space:]]*:[[:space:]]*read'; then + finding WF-PERMS "needless grant 'actions: read' would be carried over" + fi +fi + +# ------------------------------------------------ contexts from the callee ---- +# Derive the contexts the pinned callee would report for a direct caller. This +# is a property of the vendored source, not of the caller's wishes: the job id +# names the first half, the matrix name the second. +derive_contexts() { # + # A direct caller of a reusable workflow reports ` / `, or, when the callee job has a matrix name, the rendered name. + # Here the caller job is always `scan`. + awk ' + function flush( lang, n, L, i) { + if (job == "") return + if (name ~ /matrix\.language/) { + n = split(langs, L, ",") + for (i = 1; i <= n; i++) if (L[i] != "") print "scan / " L[i] "-secrets" + } else { + print "scan / " job + } + job = ""; name = ""; langs = "" + } + /^jobs:[[:space:]]*$/ { injobs = 1; next } + !injobs { next } + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + flush() + job = $0; sub(/^ /, "", job); sub(/:.*$/, "", job) + next + } + /^ name:[[:space:]]*/ { + name = $0; sub(/^ name:[[:space:]]*/, "", name); gsub(/["'\'']/, "", name); next + } + /^[[:space:]]+language:[[:space:]]*\[/ { + langs = $0; sub(/.*\[/, "", langs); sub(/\].*/, "", langs); gsub(/[[:space:]]/, "", langs); next + } + END { flush() } + ' "$1" | LC_ALL=C sort -u +} + +if [ -f "$FIXTURE" ]; then + derive_contexts "$FIXTURE" > "$TMP/derived.txt" + if [ ! -s "$TMP/derived.txt" ]; then + notrun CONTEXTS "could not derive contexts from the vendored callee workflow" + else + grep -vE '^#' "$MIG/required-contexts.txt" | sed '/^[[:space:]]*$/d' | LC_ALL=C sort -u > "$TMP/required.txt" + while IFS= read -r c; do + grep -qxF "$c" "$TMP/required.txt" || finding CONTEXT-DROPPED "the callee does not produce required context '$c'" + done < "$TMP/derived.txt" + while IFS= read -r c; do + grep -qxF "$c" "$TMP/derived.txt" || finding CONTEXT-MISSING "required context '$c' is not produced by the callee at $SUITE_SHA" + done < "$TMP/required.txt" + fi + # The callee must declare no workflow_call secret and reference no secrets. + if grep -qE '^[[:space:]]*secrets[[:space:]]*:' "$FIXTURE"; then + finding CALLEE-SECRETS "the vendored callee declares workflow_call.secrets; the caller's no-secrets rule would need revisiting" + fi + grep -qF 'secrets.' "$FIXTURE" && finding CALLEE-SECRETS "the vendored callee references a secrets context" +fi + +# ------------------------------------------------------- lock closure audit --- +if [ -x "$TMP/tree/scripts/audit-lock-closure.sh" ] || [ -f "$TMP/tree/scripts/audit-lock-closure.sh" ]; then + AUDIT_OUT="$(cd "$TMP/tree" && bash scripts/audit-lock-closure.sh \ + --workflows "$TMP/tree/.github/workflows" \ + --receipts "$TMP/tree/scripts/lib/lock-closure-receipts.tsv" \ + --baseline "$TMP/tree/scripts/lib/lock-audit-baseline.tsv" \ + --repo-root "$TMP/tree" 2>&1)" + audit_rc=$? + case "$audit_rc" in + 0) ;; + 1|3) finding LOCK-CLOSURE "the proposed lock is not closed or not representation-clean (audit exit $audit_rc); see below" ;; + 2) notrun LOCK-CLOSURE "the proposed lock's closure is unproven (audit exit 2); see below" ;; + *) finding LOCK-CLOSURE "audit failed to run (exit $audit_rc)" ;; + esac + if { [ "$audit_rc" -eq 1 ] || [ "$audit_rc" -eq 3 ]; } && [ "$QUIET" -eq 0 ]; then + printf '%s\n' "$AUDIT_OUT" | sed 's/^/ /' >&2 + fi +else + notrun LOCK-AUDIT "audit-lock-closure.sh is absent from the checkout" +fi + +# ------------------------------------------------------------------ canary ---- +canary_state="missing" +if [ ! -f "$RECEIPT" ]; then + notrun CANARY "no live canary receipt at $RECEIPT: the first unified-dataset-vocab live run has not been recorded, so deployment must not expand" +else + if ! command -v jq >/dev/null 2>&1; then + notrun CANARY "jq is unavailable, so the canary receipt cannot be validated" + else + canary_state="$(jq -r '.result // "malformed"' "$RECEIPT" 2>/dev/null || echo malformed)" + check="$(jq -r ' + def sha: test("^[0-9a-f]{40}$"); + if .schema_version != 1 then "schema_version" + elif .result != "pass" then "result" + elif .subject != "hyperpolymath/unified-dataset-vocab" then "subject" + elif (.subject_sha // "" | sha | not) then "subject_sha" + elif .subject_sha == "0000000000000000000000000000000000000000" then "subject_sha_placeholder" + elif .suite_sha != "'"$SUITE_SHA"'" then "suite_sha" + elif .fast_pr != false then "fast_pr" + elif (.secrets_passed | type) != "array" or (.secrets_passed | length) != 0 then "secrets_passed" + elif .scanned_mode != "full" then "scanned_mode" + elif (.contexts | type) != "array" or (.contexts | length) != 3 then "contexts" + elif ([.contexts[].name] | sort) != ["scan / gitleaks","scan / rust-secrets","scan / shell-secrets"] then "context_names" + elif any(.contexts[]; .conclusion != "success") then "context_conclusion" + elif any(.contexts[]; (.receipt_sha // "" | sha | not)) then "context_receipt_sha" + elif any(.contexts[]; .receipt_sha != $subject) then "context_subject_binding" + elif (.run_url // "" | test("^https://github\\.com/hyperpolymath/unified-dataset-vocab/actions/runs/[0-9]+$") | not) then "run_url" + else "ok" end + ' --arg subject "$(jq -r '.subject_sha // ""' "$RECEIPT" 2>/dev/null)" "$RECEIPT" 2>/dev/null || echo "unreadable")" + if [ "$check" != "ok" ]; then + case "$check" in + result) + if [ "$canary_state" = "not_run" ] || [ -z "$canary_state" ]; then + notrun CANARY "the receipt at $RECEIPT records result='$canary_state': the live canary has not passed" + else + finding CANARY "the receipt records result='$canary_state'; a failing canary must stop the migration" + fi + ;; + suite_sha) finding CANARY "the receipt is bound to a different callee SHA than $SUITE_SHA" ;; + subject_sha_placeholder) finding CANARY "the receipt carries an all-zero subject SHA (placeholder, not evidence)" ;; + context_*) finding CANARY "the receipt's contexts are not the three required ones with SHA-bound success results ($check)" ;; + *) finding CANARY "the receipt is malformed or incomplete ($check)" ;; + esac + fi + fi +fi + +# ------------------------------------------------------------------ --online -- +if [ "$ONLINE" -eq 1 ]; then + if ! command -v gh >/dev/null 2>&1; then + notrun ONLINE "gh is unavailable, so live rulesets and the callee commit were not re-read" + else + if ! gh api "repos/hyperpolymath/cicd-suite/commits/$SUITE_SHA" >/dev/null 2>&1; then + finding REMOTE "the callee commit $SUITE_SHA is not reachable at hyperpolymath/cicd-suite" + fi + if contexts="$(gh api repos/hyperpolymath/gitbot-fleet/rulesets/24309193 --jq '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context' 2>/dev/null)"; then + printf '%s\n' "$contexts" | grep -qxF "scan / gitleaks" \ + || finding RULESET "Secret-Scan-Floor no longer requires scan / gitleaks" + else + notrun RULESET "could not read ruleset 24309193; required contexts were not re-verified" + fi + if contexts="$(gh api repos/hyperpolymath/gitbot-fleet/rulesets/22415578 --jq '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context' 2>/dev/null)"; then + printf '%s\n' "$contexts" > "$TMP/live-contexts.txt" + for c in "scan / gitleaks" "scan / rust-secrets" "scan / shell-secrets"; do + grep -qxF "$c" "$TMP/live-contexts.txt" || notrun RULESET "live ruleset 22415578 does not list '$c' (required-contexts.txt may be stale)" + done + else + notrun RULESET "could not read ruleset 22415578; required-contexts.txt was not re-verified" + fi + fi +elif [ "$QUIET" -eq 0 ]; then + notrun ONLINE "live rulesets and the callee commit were not re-read (pass --online to do so)" +fi + +# ------------------------------------------------------------------- output --- +if [ "$QUIET" -eq 0 ]; then + echo "preflight: secret-scanner → cicd-suite@$SUITE_SHA" + echo "canary: $RECEIPT (result=$canary_state)" + if [ "${#FINDINGS[@]}" -gt 0 ]; then + echo + echo "== FINDINGS (${#FINDINGS[@]}) ==" + for f in "${FINDINGS[@]}"; do printf '%s\t%s\n' "${f%%|*}" "${f#*|}"; done + fi + if [ "${#NOTRUN[@]}" -gt 0 ]; then + echo + echo "== NOT RUN (${#NOTRUN[@]}) ==" + for n in "${NOTRUN[@]}"; do printf 'NOT-RUN\t%s\t%s\n' "${n%%|*}" "${n#*|}"; done + fi + if [ "${#FINDINGS[@]}" -eq 0 ] && [ "${#NOTRUN[@]}" -eq 0 ]; then + echo + echo "PASS: static properties hold, lock closure is complete, and the live canary receipt is SHA-bound to this callee." + echo "The migration may now be applied by the reviewer; this script never edits the checkout." + fi +fi + +if [ "${#FINDINGS[@]}" -gt 0 ] && [ "${#NOTRUN[@]}" -gt 0 ]; then exit 3; fi +if [ "${#FINDINGS[@]}" -gt 0 ]; then exit 1; fi +if [ "${#NOTRUN[@]}" -gt 0 ]; then exit 2; fi +exit 0 diff --git a/docs/migrations/secret-scanner-cicd-suite/receipts.tsv b/docs/migrations/secret-scanner-cicd-suite/receipts.tsv new file mode 100644 index 00000000..48d3261c --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/receipts.tsv @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Migration-only closure receipts, merged into scripts/lib/lock-closure-receipts.tsv +# by patches/lock-closure-receipts.tsv.patch when the migration is applied. +# +# Provenance: hyperpolymath/cicd-suite @ 4404c3c0474e17753fe7689f3485c02890aac113, +# .github/workflows/secret-scanner-reusable.yml (vendored verbatim under fixtures/, +# checksum-pinned by fixtures/SHA256SUMS). The suite's scanner also executes +# actions/secret-scan-check/{install,control,check}.sh from that same checkout; +# scripts are not actions, so they add no lock edges. +# +# fields: source commit nested-ref +hyperpolymath/cicd-suite 4404c3c0474e17753fe7689f3485c02890aac113 actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/docs/migrations/secret-scanner-cicd-suite/required-contexts.txt b/docs/migrations/secret-scanner-cicd-suite/required-contexts.txt new file mode 100644 index 00000000..ffdef1b1 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/required-contexts.txt @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Required status contexts that the migration MUST NOT change. Captured from the +# live rulesets on hyperpolymath/gitbot-fleet, default branch main, 2026-10-31: +# +# ruleset 24309193 "Secret-Scan-Floor" (active, ~DEFAULT_BRANCH) +# scan / gitleaks +# ruleset 22415578 "gitbot-fleet-proof-stack-branch" (active, ~DEFAULT_BRANCH) +# scan / gitleaks +# scan / rust-secrets +# scan / shell-secrets +# build · test · clippy (robot-repo-automaton) +# build · test · clippy (shared-context) +# build · test · clippy (dashboard) +# Dispatch path and outcome contracts +# GSBot build, tests and dependency security +# +# The three scanner contexts are reproduced here; the non-scanner contexts are +# listed only as context and are outside this migration's scope. Re-read the +# rulesets before applying; a context required today but absent from the callee +# makes the migration a ruleset change in disguise. +scan / gitleaks +scan / rust-secrets +scan / shell-secrets diff --git a/docs/migrations/secret-scanner-cicd-suite/rollback.sh b/docs/migrations/secret-scanner-cicd-suite/rollback.sh new file mode 100755 index 00000000..2925e134 --- /dev/null +++ b/docs/migrations/secret-scanner-cicd-suite/rollback.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# rollback.sh — reverse the prepared secret-scanner migration in a checkout. +# +# Three offline reversals, in the opposite order to application: +# 1. remove the migration's cicd-suite closure receipt; +# 2. move the caller's lock edge back to the previous callee; +# 3. move the caller workflow back to the previous callee. +# +# The required contexts do not change in either direction — the caller job is +# `scan` before and after — so a rollback never edits a ruleset. That is the +# whole point of not renaming a context in the forward change. +# +# The default is a DRY RUN. Pass --apply to edit the checkout. +# +# Usage: rollback.sh [--apply] [--root DIR] +# Exit: 0 dry run clean / rollback applied and re-audited; 1 refusal or audit +# failure; 2 could not verify (missing audit deps). + +set -uo pipefail +MIG="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$MIG/../../.." && pwd)" +APPLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --root) ROOT="${2:?--root needs a directory}"; shift ;; + -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "rollback: unknown argument: $1" >&2; exit 64 ;; + esac + shift +done + +fail=0 +note() { printf '%s\n' "$*"; } +refuse() { printf 'REFUSE: %s\n' "$*" >&2; fail=1; } + +# The migration must be the only thing reverted: if either side has drifted, a +# blind reverse-apply is how a half-migration reaches main. +for p in lock-closure-receipts.tsv.patch actions.lock.patch secret-scanner.yml.patch; do + if ! ( cd "$ROOT" && patch -p1 --dry-run -s -f -R < "$MIG/patches/$p" >/dev/null 2>&1 ); then + refuse "$p does not reverse-apply cleanly; inspect the drift before rolling back" + fi +done + +if [ "$fail" -ne 0 ]; then + note "No files were changed." + exit 1 +fi + +if [ "$APPLY" -eq 0 ]; then + note "Dry run: all three patches reverse-apply cleanly. Re-run with --apply to revert." + note "The required contexts are 'scan / gitleaks', 'scan / rust-secrets' and 'scan / shell-secrets' on both sides of this change." + exit 0 +fi + +for p in lock-closure-receipts.tsv.patch actions.lock.patch secret-scanner.yml.patch; do + ( cd "$ROOT" && patch -p1 -s -f -R < "$MIG/patches/$p" ) || { refuse "reversing $p failed"; exit 1; } +done + +# Re-prove the reverted lock before anyone believes it. +if [ ! -f "$ROOT/scripts/audit-lock-closure.sh" ]; then + note "NOT RUN: scripts/audit-lock-closure.sh is missing; the reverted lock was not re-audited." + exit 2 +fi +( cd "$ROOT" && bash scripts/audit-lock-closure.sh ) +rc=$? +case "$rc" in + 0) note "Rollback applied; the reverted lock is representation-clean and closed against its receipts." ;; + 1) refuse "the reverted lock has findings (audit exit 1); do not push it" ;; + 2) note "Rollback applied, but the reverted lock's closure is NOT RUN (audit exit 2)."; exit 2 ;; + *) refuse "the revert audit failed (exit $rc)" ;; +esac + +if [ "$fail" -ne 0 ]; then exit 1; fi +note "Next: git revert the migration commit (preferred) or commit the reversed files, and let Lock Sync Gate + Secret Scanner report before closing the incident." diff --git a/scripts/audit-lock-closure.sh b/scripts/audit-lock-closure.sh new file mode 100755 index 00000000..5e8e1abe --- /dev/null +++ b/scripts/audit-lock-closure.sh @@ -0,0 +1,850 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# audit-lock-closure.sh — audit what actions.lock *proves*, and report the rest +# as NOT RUN rather than as a pass. +# +# WHY THIS EXISTS (measured, 2026-10-31, hyperpolymath/gitbot-fleet@83e35bc) +# ------------------------------------------------------------------------- +# scripts/check-lock-sync.sh is green and its four clauses hold. It still cannot +# prove closure for two populations, because both are produced outside the +# workflow YAML it compares against: +# +# 1. REMOTE JOB-LEVEL REUSABLE CALLS. `gh actions-lock` v0.2.0 does not lock a +# job-level `uses:` (upstream #129, re-measured by hyperpolymath/standards +# 2026-10-14). A bare regeneration therefore DROPS the `workflows:` entry +# for every reusable caller. The dropped edge is not a dangling edge, so +# clause 3 cannot see it, and clause 1 deliberately treats an absent +# job-level ref as harmless. +# +# 2. COMPOSITE-ACTION CLOSURE. A record's nested `uses:` list is the closed +# edge set for an external composite action. Regeneration that shortens or +# omits that list produces no dangling ref either — there is simply nothing +# named to resolve — so clauses 1-4 all pass while the composite's real +# dependencies are no longer represented. +# +# Neither class is observable from `gh actions-lock --verify-local`; standards' +# own regeneration script says so in as many words. What makes them observable +# is a REVIEWED RECEIPT: the union of external `uses:` read from the pinned +# source (the called reusable workflows, or the composite's action.yml) at the +# exact commit the lock records. This script compares the lock against those +# receipts. A record with nested edges and no matching receipt is NOT RUN — not +# green, not red — because the evidence does not exist yet. +# +# FINDINGS vs NOT RUN +# ------------------- +# A FINDING is a defect in the artefact as written: a malformed ref, a record +# whose closure is demonstrably short of its receipt, a leaf record where a +# reusable's closure is owed. Findings exit non-zero; FATAL findings cannot be +# baselined, REVIEW findings can (scripts/lib/lock-audit-baseline.tsv) so a +# reviewed, pre-existing representation debt cannot hold the gate red forever +# while still staying visible in every run. +# +# A NOT-RUN is a check this script intended to perform and could not: no +# receipt for a source, no network for --online resolution, a workflow file it +# refuses to parse rather than guess at. Not-run is never counted as a pass. +# +# Exit: 0 clean, 1 findings, 2 not-run only, 3 both. +# +# Usage: +# scripts/audit-lock-closure.sh [--json] [--online] [--quiet] +# [--workflows DIR] [--lock FILE] +# [--receipts FILE] [--baseline FILE] +# [--repo-root DIR] +# scripts/audit-lock-closure.sh --emit-receipt OWNER/REPO@SHA --source FILE... +# +# Receipt rows (TSV, '#' comments allowed): +# owner/repo <40-hex commit> owner/repo@ref + +set -uo pipefail + +# Deliberately no `set -e`: every check records a finding or a not-run, and a +# single unreadable input must not truncate the report. + +WF_DIR=".github/workflows" +LOCK="" +RECEIPTS="scripts/lib/lock-closure-receipts.tsv" +BASELINE="scripts/lib/lock-audit-baseline.tsv" +REPO_ROOT="" +JSON=0 +ONLINE=0 +QUIET=0 +EMIT_KEY="" +EMIT_SOURCES=() + +while [ $# -gt 0 ]; do + case "$1" in + --json) JSON=1 ;; + --online) ONLINE=1 ;; + --quiet) QUIET=1 ;; + --workflows) WF_DIR="${2:?--workflows needs a directory}"; shift ;; + --lock) LOCK="${2:?--lock needs a file}"; shift ;; + --receipts) RECEIPTS="${2:?--receipts needs a file}"; shift ;; + --baseline) BASELINE="${2:?--baseline needs a file}"; shift ;; + --repo-root) REPO_ROOT="${2:?--repo-root needs a directory}"; shift ;; + --emit-receipt) EMIT_KEY="${2:?--emit-receipt needs owner/repo@sha}"; shift ;; + --source) EMIT_SOURCES+=("${2:?--source needs a file or directory}"); shift ;; + -h|--help) sed -n '2,55p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "audit-lock-closure: unknown argument: $1" >&2; exit 64 ;; + esac + shift +done + +[ -n "$LOCK" ] || LOCK="$WF_DIR/actions.lock" +[ -n "$REPO_ROOT" ] || REPO_ROOT="$(cd "$WF_DIR/../.." 2>/dev/null && pwd)" || REPO_ROOT="." + +if [ -z "${AWK:-}" ]; then + for cand in gawk mawk awk; do + if command -v "$cand" >/dev/null 2>&1; then AWK="$cand"; break; fi + done +fi +[ -n "${AWK:-}" ] || { echo "audit-lock-closure: FATAL: no awk" >&2; exit 64; } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/lock-closure.XXXXXX")" || exit 64 +trap 'rm -rf "$TMP"' EXIT +: > "$TMP/findings.jsonl" +: > "$TMP/notrun.jsonl" + +# ---------------------------------------------------------------- reporting -- +F_SEV=(); F_RULE=(); F_SUBJ=(); F_DET=(); F_FIX=() +N_RULE=(); N_SUBJ=(); N_DET=(); N_FIX=() +ACC_SEV=(); ACC_RULE=(); ACC_SUBJ=(); ACC_DET=() + +json_finding() { # sev rule subject detail fix + if [ "$JSON" -eq 1 ]; then + jq -nc --arg severity "$1" --arg rule "$2" --arg subject "$3" \ + --arg detail "$4" --arg fix "$5" \ + '{severity:$severity,rule:$rule,subject:$subject,detail:$detail,fix:$fix}' \ + >> "$TMP/findings.jsonl" + fi +} +json_notrun() { # rule subject detail fix + if [ "$JSON" -eq 1 ]; then + jq -nc --arg rule "$1" --arg subject "$2" --arg detail "$3" --arg fix "$4" \ + '{rule:$rule,subject:$subject,detail:$detail,fix:$fix}' \ + >> "$TMP/notrun.jsonl" + fi +} + +add_finding() { # severity rule subject detail fix + local sev="$1" rule="$2" subj="$3" det="$4" fix="$5" fp + fp="$rule|$subj" + if [ "$sev" = "REVIEW" ] && [ -n "${BASELINED[$fp]:-}" ]; then + ACC_SEV+=("$sev"); ACC_RULE+=("$rule"); ACC_SUBJ+=("$subj"); ACC_DET+=("$det") + return 0 + fi + F_SEV+=("$sev"); F_RULE+=("$rule"); F_SUBJ+=("$subj"); F_DET+=("$det"); F_FIX+=("$fix") + json_finding "$sev" "$rule" "$subj" "$det" "$fix" +} + +add_notrun() { # rule subject detail fix + N_RULE+=("$1"); N_SUBJ+=("$2"); N_DET+=("$3"); N_FIX+=("$4") + json_notrun "$1" "$2" "$3" "$4" +} + +# ------------------------------------------------------------- emit receipts -- +# Extract every external `uses:` from one file. Emits USEkindraw or +# UNPARSEDreason; kind is job or step, derived from the line's shape. A +# value this reader cannot interpret is UNPARSED, never guessed. +extract_uses() { # + "$AWK" ' + function strip_comment(s, out, i, n, c, q) { + out = ""; n = length(s); i = 1; q = "" + while (i <= n) { + c = substr(s, i, 1) + if (q == "") { + if (c == "\"" || c == "'\''") { q = c; out = out c; i++; continue } + if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) break + out = out c; i++; continue + } + if (c == q) q = "" + out = out c; i++ + } + return out + } + { + line = $0 + sub(/\r$/, "", line) + if (line ~ /^[[:space:]]*$/) next + if (line ~ /\t/) { print "UNPARSED\ttab in line"; next } + match(line, /^[[:space:]]*/); ind = RLENGTH + if (inblock) { + if (ind > blockind) next + inblock = 0 + } + trimmed = line; sub(/^[[:space:]]+/, "", trimmed) + if (substr(trimmed, 1, 1) == "#") next + # A `steps:` block marks its children as step-level. The dash alone is + # not enough: a step written as `- name: ...` puts `uses:` on the next + # line with no dash of its own. + if (in_steps && ind <= steps_ind) in_steps = 0 + if (line ~ /^[[:space:]]*(-[[:space:]]+)?["'\'']?steps["'\'']?[[:space:]]*:[[:space:]]*$/) { + in_steps = 1; steps_ind = ind; next + } + # `uses` must be the key on its line; a flow mapping or a key nested in + # another scalar is refused rather than parsed. + if (match(line, /^[[:space:]]*(-[[:space:]]+)?["'\'']?uses["'\'']?[[:space:]]*:/)) { + head = substr(line, 1, RLENGTH) + kind = ((head ~ /-/) || (in_steps && ind > steps_ind)) ? "step" : "job" + rest = substr(line, RLENGTH + 1) + sub(/^[[:space:]]+/, "", rest) + rest = strip_comment(rest) + sub(/[[:space:]]+$/, "", rest) + if (rest == "" || rest ~ /^[|>]/ || rest ~ /^[*&]/ || rest ~ /^[\[{]/) { + print "UNPARSED\tuses: value not a plain scalar on this line"; next + } + if (rest ~ /[[:space:]]/) { print "UNPARSED\tuses: value contains whitespace"; next } + if (rest ~ /^["'\'']/) { + q = substr(rest, 1, 1); rest = substr(rest, 2) + if (substr(rest, length(rest), 1) != q) { print "UNPARSED\tunterminated quoted uses: value"; next } + rest = substr(rest, 1, length(rest) - 1) + } + print "USE\t" kind "\t" rest + next + } + if (line ~ /(^|[[:space:],{])["'\'']?uses["'\'']?[[:space:]]*:/) { + print "UNPARSED\tuses: key not at start of line (flow or nested scalar)" + } + # A block scalar value belongs to some other key: skip its body rather + # than reading `uses:`-looking text inside it. A block scalar on `uses:` + # itself was already refused above, never skipped. + det = strip_comment(line) + if (det ~ /(^|[[:space:]-])["'\'']?[A-Za-z0-9_.-]+["'\'']?[[:space:]]*:[[:space:]]*[|>][-+]?[0-9]?[[:space:]]*$/) { + inblock = 1; blockind = ind; next + } + } + ' "$1" +} + +norm_ref() { # raw -> owner/repo@ref ; rc 2 = local/docker (not lock-keyed) + local raw="$1" path ref owner repo + case "$raw" in + ./*|\$/*|docker://*) return 2 ;; + esac + path="${raw%@*}" + ref="${raw##*@}" + [ -n "$path" ] && [ -n "$ref" ] || return 1 + [ "$path" != "$raw" ] || return 1 + case "$path" in *[[:space:]]*|*"#"*|*"'"*) return 1 ;; esac + IFS=/ read -r owner repo _ <<< "$path" + [ -n "$owner" ] && [ -n "$repo" ] || return 1 + printf '%s/%s@%s' "$owner" "$repo" "$ref" +} + +is_sha() { [[ "$1" =~ ^[0-9a-f]{40}$ ]]; } + +# Union of external uses reachable from one source file or directory tree. +collect_source_uses() { # ; prints normalized refs, one per line + local src="$1" f kind raw norm + if [ -d "$src" ]; then + while IFS= read -r f; do + collect_source_uses "$f" + done < <(find "$src" -maxdepth 4 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort) + return 0 + fi + [ -f "$src" ] || { echo "MISSING:$src" >&2; return 1; } + while IFS=$'\t' read -r tag kind raw; do + case "$tag" in + USE) + norm="$(norm_ref "$raw")" || continue + printf '%s\n' "$norm" + ;; + UNPARSED) echo "UNPARSED:$src:$raw" >&2; return 1 ;; + esac + done < <(extract_uses "$src") +} + +if [ -n "$EMIT_KEY" ]; then + case "$EMIT_KEY" in + *@[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + *) echo "audit-lock-closure: --emit-receipt needs owner/repo@<40-hex sha>" >&2; exit 64 ;; + esac + [ "${#EMIT_SOURCES[@]}" -gt 0 ] || { echo "audit-lock-closure: --emit-receipt needs --source" >&2; exit 64; } + repo="${EMIT_KEY%@*}"; sha="${EMIT_KEY##*@}" + rc=0 + { for s in "${EMIT_SOURCES[@]}"; do collect_source_uses "$s" || rc=1; done; } \ + | LC_ALL=C sort -u | while IFS= read -r ref; do + printf '%s\t%s\t%s\n' "$repo" "$sha" "$ref" + done + exit "$rc" +fi + +# ------------------------------------------------------------------- inputs -- +[ -f "$LOCK" ] || { echo "audit-lock-closure: FATAL: no lockfile at $LOCK" >&2; exit 64; } +[ -d "$WF_DIR" ] || { echo "audit-lock-closure: FATAL: no workflow directory at $WF_DIR" >&2; exit 64; } + +# Baseline: rule subject reason owner date +declare -A BASELINED=() +declare -A BASELINE_SEEN=() +if [ -f "$BASELINE" ]; then + while IFS=$'\t' read -r rule subj reason owner date; do + case "$rule" in ''|'#'*) continue ;; esac + [ -n "$subj" ] || continue + BASELINED["$rule|$subj"]="$reason" + done < "$BASELINE" +fi + +# Receipts: owner/repo sha nested ref +declare -A RECEIPT=() +declare -A RECEIPT_SRC=() +RECEIPT_LINES=0 +if [ -f "$RECEIPTS" ]; then + while IFS=$'\t' read -r src sha ref; do + case "$src" in ''|'#'*) continue ;; esac + RECEIPT_LINES=$((RECEIPT_LINES + 1)) + case "$src" in */*) ;; *) add_finding FATAL RECEIPT-PARSE "$src" "receipt source is not owner/repo" "Fix scripts/lib/lock-closure-receipts.tsv"; continue ;; esac + if ! is_sha "$sha"; then + add_finding FATAL RECEIPT-PARSE "$src@$sha" "receipt commit is not a full 40-hex commit" "Receipts must be SHA-bound; re-emit with --emit-receipt" + continue + fi + if [ -z "$ref" ]; then + add_finding FATAL RECEIPT-PARSE "$src@$sha" "receipt row has no nested ref (use a row-less source for zero nested uses)" "Remove the empty row" + continue + fi + norm="$(norm_ref "$ref")" || { add_finding FATAL RECEIPT-PARSE "$src@$sha" "receipt nested ref '$ref' is not owner/repo@ref" "Nested refs must be bare OWNER/REPO@REF"; continue; } + [ "$norm" = "$ref" ] || add_finding FATAL RECEIPT-SUBPATH "$src@$sha" "receipt nested ref '$ref' carries a subpath (schema rejects subpath pins)" "Collapse to $norm" + RECEIPT["$src@$sha|$norm"]=1 + RECEIPT_SRC["$src@$sha"]=1 + done < "$RECEIPTS" +else + add_notrun RECEIPTS "-" "no receipt file at $RECEIPTS, so no nested closure can be proven" \ + "Add reviewed receipts; see docs/migrations or run --emit-receipt" +fi + +# ------------------------------------------------------------- parse the lock -- +# Indentation is load-bearing: a workflow key sits at 4 spaces, its entries at +# 8; a dependency record at 4, its fields at 8, its nested uses: items at 12. +# Anchoring on the exact indent is what keeps `uses:` from being read as a key. +cat > "$TMP/lock.awk" <<'AWK' + BEGIN { section = ""; wf = ""; dep = "" } + { + line = $0 + sub(/\r$/, "", line) + if (line ~ /^[[:space:]]*$/) next + if (line ~ /^#/) next + if (line == "workflows:") { section = "workflows"; wf = ""; dep = ""; next } + if (line == "dependencies:") { section = "dependencies"; wf = ""; dep = ""; next } + if (line ~ /^[A-Za-z0-9_-]+:/) { section = "other"; next } + if (section == "workflows") { + if (line ~ /^ [^ ].*:([[:space:]]*\[\])?$/) { + k = line; sub(/^ /, "", k); sub(/:([[:space:]]*\[\])?$/, "", k) + if (k ~ /^'.*'$/) k = substr(k, 2, length(k) - 2) + if (k == "") { print "PARSE\tworkflow key is empty"; next } + wf = k; print "WFKEY\t" k + next + } + if (line ~ /^ - /) { + v = line; sub(/^ - /, "", v) + if (v == "[]") next + if (v ~ /^'.*'$/) v = substr(v, 2, length(v) - 2) + if (wf == "") { print "PARSE\tworkflow entry outside a workflow key: " v; next } + print "WFENTRY\t" wf "\t" v + next + } + if (line ~ /^ /) { print "PARSE\tworkflow section line is not an entry: " line; next } + print "PARSE\tworkflow key is not at the canonical 4-space indent: " line + next + } + if (section == "dependencies") { + if (line ~ /^ [^ ].*:([[:space:]]*\[\])?$/) { + k = line; sub(/^ /, "", k); sub(/:([[:space:]]*\[\])?$/, "", k) + if (k ~ /^'.*'$/) k = substr(k, 2, length(k) - 2) + if (k == "") { print "PARSE\tdependency key is empty"; next } + dep = k; print "DEPKEY\t" k + next + } + if (line ~ /^ - /) { + v = line; sub(/^ - /, "", v) + if (v ~ /^'.*'$/) v = substr(v, 2, length(v) - 2) + if (dep == "") { print "PARSE\tnested uses outside a dependency record: " v; next } + print "DEPUSE\t" dep "\t" v + next + } + if (line ~ /^ [A-Za-z0-9_-]+:/) { + if (dep == "") { print "PARSE\tdependency field outside a record: " line; next } + f = line; sub(/^ /, "", f) + name = f; sub(/:.*$/, "", name) + val = f; sub(/^[^:]*:[[:space:]]*/, "", val) + if (val ~ /^'.*'$/) val = substr(val, 2, length(val) - 2) + print "DEPFIELD\t" dep "\t" name "\t" val + next + } + print "PARSE\tdependency section line is not a field: " line + next + } + print "PARSE\tline outside a recognised section: " line + } +AWK +"$AWK" -f "$TMP/lock.awk" "$LOCK" > "$TMP/lock.tsv" + +# GitHub resolves owner and repository case-insensitively; the ref is case +# sensitive. Fold exactly the owner/repo half everywhere a set is keyed, so a +# `Swatinem/rust-cache` use matches its `swatinem/rust-cache` record without +# hiding a genuinely different ref. +fold_ref() { + local r="$1" path ref + path="${r%@*}"; ref="${r##*@}" + printf '%s@%s' "$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')" "$ref" +} +declare -A DISP=() +disp() { printf '%s' "${DISP[$1]:-$1}"; } + +declare -A LOCK_ENTRY=() # path|folded-ref +declare -A LOCK_ENTRIES=() # path -> " folded-ref ..." +declare -A WF_ORDER=() +declare -A DEP_REF=() +declare -A DEP_COMMIT=() +declare -A DEP_USES=() +declare -A DEP_HASFIELD=() +declare -A DEP_SEEN=() +declare -A DEP_CASE=() +declare -A DEP_IDX=() # folded key -> record key +DEP_KEYS=() + +while IFS=$'\t' read -r tag a b c; do + case "$tag" in + WFKEY) + if [ -n "${WF_ORDER["$a"]:-}" ]; then + add_finding FATAL LOCK-DUP-KEY "$a" "workflow key appears more than once in the lockfile" "Keep one key per workflow path" + fi + WF_ORDER["$a"]=1 + ;; + WFENTRY) + path="$a"; raw="$b" + norm="$(norm_ref "$raw")" || norm="" + if [ -z "$norm" ]; then + add_finding FATAL REF-WF-MALFORMED "$path" "workflow entry '$raw' is not owner/repo@ref" "Write the normalised lock key: owner/repo@ref" + continue + fi + if [ "$norm" != "$raw" ]; then + add_finding FATAL REF-SUBPATH "$path" "workflow entry '$raw' carries a subpath or owner/repo subkey" "Collapse to $norm" + fi + case "${norm##*@}" in + refs/*) add_finding FATAL REF-REFS-PREFIX "$path" "workflow entry '$norm' uses a refs/ namespaced ref" "Use the bare tag, branch or 40-hex commit" ;; + "") add_finding FATAL REF-EMPTY "$path" "workflow entry '$norm' has an empty ref" "Pin a tag, branch or commit" ;; + esac + f="$(fold_ref "$norm")"; DISP["$f"]="$norm" + LOCK_ENTRY["$path|$f"]=1 + LOCK_ENTRIES["$path"]="${LOCK_ENTRIES["$path"]:-} $f" + ;; + DEPKEY) + key="$a" + if [ -n "${DEP_SEEN["$key"]:-}" ]; then + add_finding FATAL REF-DUP-RECORD "$key" "dependency record appears more than once" "Keep one record per ref" + fi + DEP_SEEN["$key"]=1 + norm="$(norm_ref "$key")" || norm="" + if [ -z "$norm" ] || [ "$norm" != "$key" ]; then + add_finding FATAL REF-KEY-MALFORMED "$key" "dependency key is not a bare OWNER/REPO@REF" "Keys must be owner/repo@ref, with any action subpath collapsed" + fi + lc="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" + if [ -n "${DEP_CASE["$lc"]:-}" ] && [ "${DEP_CASE["$lc"]}" != "$key" ]; then + add_finding FATAL REF-CASE-DUP "$key" "record duplicates '${DEP_CASE["$lc"]}' ignoring case" "Keep one record; GitHub resolves owner/repo case-insensitively" + fi + DEP_CASE["$lc"]="$key" + DEP_KEYS+=("$key") + f="$(fold_ref "$key")"; DEP_IDX["$f"]="$key"; DISP["$f"]="$key" + ;; + DEPFIELD) + key="$a"; field="$b"; val="$c" + case "$field" in + ref) DEP_REF["$key"]="$val" ;; + commit) DEP_COMMIT["$key"]="$val" ;; + uses) DEP_USES["$key"]="${DEP_USES["$key"]:-} $val" ;; + owner_id|repo_id) + [[ "$val" =~ ^[0-9]+$ ]] || add_finding FATAL REF-FIELD "$key" "$field is '$val', not an integer" "Record the numeric GitHub id or omit the field" + ;; + *) add_finding REVIEW REF-FIELD "$key" "unknown dependency field '$field'" "Remove it or extend the audit" ;; + esac + ;; + DEPUSE) + key="$a"; raw="$b" + norm="$(norm_ref "$raw")" || norm="" + if [ -z "$norm" ]; then + add_finding FATAL REF-NESTED-MALFORMED "$key" "nested uses '$raw' is not a plain external ref" "Nested uses must be bare OWNER/REPO@REF (local ./ and docker:// are not lock-keyed)" + continue + fi + if [ "$norm" != "$raw" ]; then + add_finding FATAL REF-NESTED-SUBPATH "$key" "nested uses '$raw' carries a subpath; the lock schema rejects subpath pins" "Collapse to $norm" + fi + f="$(fold_ref "$norm")"; DISP["$f"]="$norm" + DEP_USES["$key"]="${DEP_USES["$key"]:-} $f" + ;; + PARSE) + add_finding FATAL LOCK-PARSE "$LOCK" "$a" "Make the lockfile match the gh-actions-lock layout; do not hand-edit around it" + ;; + esac +done < "$TMP/lock.tsv" + +for key in "${DEP_KEYS[@]}"; do + if [ -z "${DEP_REF["$key"]+x}" ]; then + add_finding FATAL REF-NO-REF "$key" "record has no ref: field" "Add ref: ''" + fi + commit="${DEP_COMMIT["$key"]:-}" + if [ -z "$commit" ]; then + add_finding FATAL REF-NO-COMMIT "$key" "record has no commit: field" "Add commit: 'sha1-<40-hex>'" + elif [[ ! "$commit" =~ ^(sha1-)?[0-9a-f]{40}$ ]]; then + add_finding FATAL REF-COMMIT "$key" "commit '$commit' is not a full sha1-<40-hex> digest" "Resolve the ref to the immutable commit and record it" + else + digest="${commit#sha1-}" + ref="${key##*@}" + if is_sha "$ref" && [ "$ref" != "$digest" ]; then + add_finding FATAL REF-COMMIT-MISMATCH "$key" "record key pins $ref but commit is $digest" "A SHA-keyed record's commit must be that same commit" + fi + if [ -n "${DEP_REF["$key"]:-}" ] && is_sha "${DEP_REF["$key"]}" && [ "${DEP_REF["$key"]}" != "$digest" ]; then + add_finding FATAL REF-REF-COMMIT-MISMATCH "$key" "ref '${DEP_REF["$key"]}' and commit '$digest' disagree" "The ref field must name the same immutable commit" + fi + DEP_COMMIT["$key"]="$digest" + fi +done + +# --------------------------------------------------------- parse the workflows -- +declare -A WF_FILES=() +declare -A STEP_SET=() # path|folded-ref +declare -A STEP_LIST=() # path -> " folded-ref ..." +declare -A JOB_LIST=() # path -> " folded-ref ..." +declare -A USED_ACTION=() +declare -A USED_REUSABLE=() +declare -A LOCAL_SEEN=() + +list_has() { # <" ref ref "> + case " $1 " in *" $2 "*) return 0 ;; esac + return 1 +} + +scan_workflow_file() { # [composite] + local file="$1" lockpath="$2" composite="${3:-0}" tag kind raw norm rc f localpath dir action + while IFS=$'\t' read -r tag kind raw; do + case "$tag" in + USE) + [ "$composite" -eq 1 ] && kind="step" + norm="$(norm_ref "$raw")"; rc=$? + if [ "$rc" -eq 2 ]; then + # Local action (its refs belong to the calling workflow) or local + # reusable workflow (that file is its own lock key, so it is not + # followed). + [ "$composite" -eq 1 ] && continue + localpath="${raw%@*}" + case "$localpath" in + ./*.yml|./*.yaml) continue ;; + ./*) + dir="$REPO_ROOT/${localpath#./}" + action="" + [ -f "$dir/action.yml" ] && action="$dir/action.yml" + [ -z "$action" ] && [ -f "$dir/action.yaml" ] && action="$dir/action.yaml" + if [ -z "$action" ]; then + add_notrun WF-LOCAL-MISSING "$lockpath" "local action '$localpath' has no action.yml under $REPO_ROOT" "Restore the composite or fix the path; its refs cannot be audited" + continue + fi + if [ -n "${LOCAL_SEEN["$action"]:-}" ]; then continue; fi + LOCAL_SEEN["$action"]=1 + scan_workflow_file "$action" "$lockpath" 1 + ;; + \$/*) + add_finding FATAL WF-DOLLAR-REF "$lockpath" "uses: $raw is the invalid \$/ local rewrite" "Restore ./ or the external ref" + ;; + esac + continue + fi + if [ "$rc" -ne 0 ] || [ -z "$norm" ]; then + add_finding FATAL REF-WF-UNPARSEABLE "$lockpath" "uses: '$raw' is not a valid external ref" "Fix the ref; an invalid uses: also fails workflow validation" + continue + fi + f="$(fold_ref "$norm")"; DISP["$f"]="$norm" + if [ "$kind" = "job" ]; then + if [[ ! "$raw" =~ /\.github/workflows/[^@]+\.ya?ml@ ]]; then + add_finding FATAL WF-JOB-NOT-REUSABLE "$lockpath" "job-level uses: '$raw' is not a reusable workflow path" "Job-level uses: must be owner/repo/.github/workflows/file.yml@ref (or a local path)" + continue + fi + JOB_LIST["$lockpath"]="${JOB_LIST["$lockpath"]:-} $f" + USED_REUSABLE["$f"]=1 + else + if [[ "$raw" =~ /\.github/workflows/[^@]+\.ya?ml@ ]]; then + add_finding FATAL WF-STEP-REUSABLE "$lockpath" "step-level uses: '$raw' names a workflow file" "A reusable workflow can only be called at job level" + continue + fi + STEP_SET["$lockpath|$f"]=1 + STEP_LIST["$lockpath"]="${STEP_LIST["$lockpath"]:-} $f" + USED_ACTION["$f"]=1 + fi + ;; + UNPARSED) + add_notrun WF-PARSE "$lockpath" "cannot safely read a uses: value ($raw)" "Rewrite as a plain single-line scalar; the audit refuses to guess" + ;; + esac + done < <(extract_uses "$file") +} + +shopt -s nullglob +for wf in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do + key=".github/workflows/$(basename "$wf")" + WF_FILES["$key"]="$wf" + scan_workflow_file "$wf" "$key" 0 +done +shopt -u nullglob + +if [ "${#WF_FILES[@]}" -eq 0 ]; then + add_finding FATAL WF-NONE "$WF_DIR" "no workflow files found" "Point --workflows at the directory holding the workflows" +fi + +# ------------------------------------------------ clause 4: coverage and sync -- +for key in "${!WF_FILES[@]}"; do + if [ -z "${WF_ORDER["$key"]:-}" ]; then + add_finding FATAL WF-UNLISTED "$key" "workflow file has no key in the lockfile; GitHub refuses such a run at startup (jobs=0)" "Add the path as a lockfile key; a zero-uses workflow takes []" + fi +done +for key in "${!WF_ORDER[@]}"; do + if [ -z "${WF_FILES["$key"]:-}" ]; then + add_finding FATAL WF-MISSING "$key" "lockfile entry names a workflow file that does not exist" "Remove the key or restore the file" + fi +done + +for key in "${!WF_FILES[@]}"; do + for combined in "${!STEP_SET[@]}"; do + path="${combined%%|*}"; ref="${combined#*|}" + [ "$path" = "$key" ] || continue + if [ -z "${LOCK_ENTRY["$key|$ref"]:-}" ]; then + add_finding FATAL WF-UNLOCKED "$key" "step-level use '$(disp "$ref")' is missing from this workflow's lock entry" "Add it and close its dependency record; do not hand-add the entry alone" + fi + done + for entry in ${LOCK_ENTRIES["$key"]:-}; do + if [ -z "${STEP_SET["$key|$entry"]:-}" ] && ! list_has "${JOB_LIST["$key"]:-}" "$entry"; then + add_finding FATAL WF-ORPHAN "$key" "lock entry '$(disp "$entry")' is not referenced by this workflow's uses:" "Remove it, or restore the use it belonged to" + fi + done + for ref in ${JOB_LIST["$key"]:-}; do + rec="${DEP_IDX["$ref"]:-}" + if [ -z "$rec" ]; then + if [ -z "${LOCK_ENTRY["$key|$ref"]:-}" ]; then + add_notrun REUSABLE-UNLOCKED "$(disp "$ref")" "job-level call in $key is not in the lockfile; gh actions-lock cannot write it, so its closure is unproven" "Add the entry and a closed record (complete-job-refs + close-lock), or accept the unproven edge" + else + add_finding FATAL REUSABLE-NO-RECORD "$(disp "$ref")" "workflow $key lists the job-level ref but there is no dependencies: record (dangling edge)" "Add a record with the callee's nested uses" + fi + fi + done +done + +# ------------------------------------------------------------- closure checks -- +# Compare every owned edge set against the reviewed receipt for the exact +# commit the record resolves to. A missing receipt is NOT RUN, never green. +for key in "${DEP_KEYS[@]}"; do + uses="${DEP_USES["$key"]:-}" + digest="${DEP_COMMIT["$key"]:-}" + [ -n "$digest" ] || continue + repo="$(printf '%s' "${key%@*}" | tr '[:upper:]' '[:lower:]')" + rid="$repo@$digest" + f="$(fold_ref "$key")" + + if [ -n "$uses" ]; then + if [ -z "${RECEIPT_SRC["$rid"]:-}" ]; then + add_notrun CLOSURE-NO-RECEIPT "$rid" "record '$key' names nested uses but no receipt exists for $rid; completeness cannot be proven" "Run: ./scripts/audit-lock-closure.sh --emit-receipt $rid --source " + continue + fi + for r in $uses; do + if [ -z "${RECEIPT["$rid|$r"]:-}" ]; then + add_finding FATAL CLOSURE-EXTRA "$rid" "record '$key' names '$(disp "$r")', which the reviewed source at $digest does not use" "Remove the unreviewed edge or re-review the pinned source" + fi + done + for combined in "${!RECEIPT[@]}"; do + src="${combined%%|*}"; r="${combined#*|}" + [ "$src" = "$rid" ] || continue + list_has "$uses" "$r" || add_finding FATAL CLOSURE-DROPPED "$rid" "'$(disp "$r")' is reached at $digest but record '$key' does not name it (dropped closure)" "Restore the edge; a shortened nested list produces no dangling ref, so no other gate sees it" + done + else + # A leaf record is correct only when the pinned source really has no + # external uses. A receipt with rows and a leaf record is a dropped list. + if [ -n "${RECEIPT_SRC["$rid"]:-}" ]; then + while IFS='|' read -r src r; do + [ "$src" = "$rid" ] || continue + add_finding FATAL CLOSURE-DROPPED "$rid" "'$(disp "$r")' is reached at $digest but record '$key' has no nested uses: at all (closure dropped to a leaf)" "Restore the nested list; a leaf produces no dangling ref, so no other gate sees it" + done < <(for combined in "${!RECEIPT[@]}"; do printf '%s\n' "$combined"; done) + elif [ -n "${USED_REUSABLE["$f"]:-}" ]; then + add_notrun REUSABLE-NO-RECEIPT "$rid" "record '$key' is a leaf but the repo is called as a reusable workflow; a leaf closure is not proven" "Add a receipt for $rid (rows = the called workflow's external uses) or fix the leaf record" + fi + fi +done + +# self-SHA classification: `ref:` repeating the resolved commit is legitimate +# for a reusable-workflow record and a defect for an action record. The class is +# read from how the workflows use that repository at that revision. +for key in "${DEP_KEYS[@]}"; do + digest="${DEP_COMMIT["$key"]:-}" + [ -n "$digest" ] || continue + [ "${DEP_REF["$key"]:-}" = "$digest" ] || continue + f="$(fold_ref "$key")" + is_action=0; is_reusable=0 + [ -n "${USED_ACTION["$f"]:-}" ] && is_action=1 + [ -n "${USED_REUSABLE["$f"]:-}" ] && is_reusable=1 + if [ "$is_action" -eq 1 ] && [ "$is_reusable" -eq 0 ]; then + add_finding FATAL REF-SELF-SHA-ACTION "$key" "record repeats the commit as ref: for a repo used as an ACTION" "Write the reviewed tag in ref: (commit stays the sha1 digest); a self-SHA action record makes callers die at Set up job" + elif [ "$is_action" -eq 1 ] && [ "$is_reusable" -eq 1 ]; then + add_finding REVIEW REF-SELF-SHA-BOTH "$key" "repo is used both as an action and as a reusable workflow at this revision, so the self-SHA ref is ambiguous" "Split the usages or confirm the reusable class is intended" + elif [ "$is_action" -eq 0 ] && [ "$is_reusable" -eq 0 ]; then + add_notrun REF-SELF-SHA-UNCLASSIFIED "$key" "no workflow uses this ref directly, so the self-SHA record cannot be classified" "Record the reviewed tag if the repo is used as an action anywhere" + fi +done + +# One reviewed ref must not resolve to two immutable commits: the snapshot is +# then ambiguous, and a regeneration may pick either. +declare -A TAG_DIGEST=() +for key in "${DEP_KEYS[@]}"; do + repo="$(printf '%s' "${key%@*}" | tr '[:upper:]' '[:lower:]')" + ref="${DEP_REF["$key"]:-${key##*@}}" + f="$repo@$ref" + is_sha "$ref" && continue + digest="${DEP_COMMIT["$key"]:-}" + [ -n "$digest" ] || continue + if [ -n "${TAG_DIGEST["$f"]:-}" ] && [ "${TAG_DIGEST["$f"]}" != "$digest" ]; then + add_finding REVIEW REF-AMBIGUOUS "$key" "the same reviewed ref resolves to two immutable commits (${TAG_DIGEST["$f"]} and $digest)" "Keep one reviewed snapshot per ref; delete or re-resolve the stale record" + fi + TAG_DIGEST["$f"]="$digest" +done + +# A mutable branch at step level is not an immutable pin: the lock freezes a +# snapshot, but resolution follows the branch. Flag only conventional branch +# names; anything else is reported by --online, never assumed. +for f in "${!USED_ACTION[@]}"; do + ref="${f##*@}" + is_sha "$ref" && continue + case "$ref" in + main|master|HEAD|head|develop|trunk) + add_finding REVIEW REF-MUTABLE "$(disp "$f")" "step-level action pins the mutable branch '$ref'; the lock records a snapshot commit that can drift from the live ref" "Request review: pin a reviewed tag with an immutable commit" + ;; + esac +done + +# receipt hygiene +for combined in "${!RECEIPT_SRC[@]}"; do + used=0 + for key in "${DEP_KEYS[@]}"; do + repo="$(printf '%s' "${key%@*}" | tr '[:upper:]' '[:lower:]')" + digest="${DEP_COMMIT["$key"]:-}" + [ "$repo@$digest" = "$combined" ] && used=1 && break + done + [ "$used" -eq 0 ] && add_finding REVIEW RECEIPT-UNUSED "$combined" "receipt exists for a source@commit no record resolves to" "Remove the stale receipt or update the record's commit" +done +# ------------------------------------------------------------------- --online -- +if [ "$ONLINE" -eq 1 ]; then + if ! command -v gh >/dev/null 2>&1; then + add_notrun REMOTE-UNREACHABLE "-" "--online requested but gh is not installed" "Install gh or run without --online" + else + declare -A CHECKED=() + for key in "${DEP_KEYS[@]}"; do + repo="${key%@*}"; digest="${DEP_COMMIT["$key"]:-}" + [ -n "$digest" ] || continue + [ -n "${CHECKED["$repo|$digest"]:-}" ] && continue + CHECKED["$repo|$digest"]=1 + out="$(gh api "repos/$repo/commits/$digest" 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then + if printf '%s' "$out" | grep -q 'HTTP 404'; then + add_finding FATAL REMOTE-COMMIT "$repo@$digest" "recorded commit does not exist in $repo (HTTP 404)" "Re-resolve the ref; a present-but-unresolvable record is fatal at startup" + else + add_notrun REMOTE-UNREACHABLE "$repo@$digest" "commit lookup failed: $(printf '%s' "$out" | head -1)" "Re-run with network and gh auth" + fi + fi + done + declare -A REMOTE_FILE=() + for key in "${DEP_KEYS[@]}"; do + ref="${key##*@}" + is_sha "$ref" && continue + repo="${key%@*}"; digest="${DEP_COMMIT["$key"]:-}" + [ -n "$digest" ] || continue + rf="${REMOTE_FILE["$repo"]:-}" + if [ -z "$rf" ]; then + rf="$TMP/remote.$(printf '%s' "$repo" | tr '/' '_')" + if ! git ls-remote --tags --heads "https://github.com/$repo" > "$rf" 2>/dev/null || [ ! -s "$rf" ]; then + add_notrun REMOTE-UNREACHABLE "$repo" "could not list refs at $repo" "Re-run with network; do not rewrite the recorded commit without review" + REMOTE_FILE["$repo"]="skip" + continue + fi + REMOTE_FILE["$repo"]="$rf" + fi + [ "$rf" = "skip" ] && continue + # An annotated tag has two lines: the tag object and its peeled ^{} + # commit. The lock records the COMMIT, so prefer the peeled line. + live="$(awk -v t="refs/tags/$ref^{}" '$2 == t { print $1; exit }' "$rf")" + [ -n "$live" ] || live="$(awk -v t="refs/tags/$ref" '$2 == t { print $1; exit }' "$rf")" + [ -n "$live" ] || live="$(awk -v t="refs/heads/$ref" '$2 == t { print $1; exit }' "$rf")" + if [ -z "$live" ]; then + add_notrun REMOTE-REF-UNRESOLVED "$key" "could not resolve '$ref' at $repo" "Resolve manually; do not rewrite the recorded commit without review" + continue + fi + if [ "$live" != "$digest" ]; then + add_finding REVIEW REMOTE-STALE "$key" "the live ref '$ref' resolves to $live, but the lock records $digest" "Decide deliberately: keep the reviewed snapshot or re-resolve; never let regeneration pick silently" + fi + done + fi +fi + +for fp in "${!BASELINED[@]}"; do + # This pass MUST run after --online: a REMOTE-* row can only match a finding the + # network phase produces. Running it earlier marks every online row stale. + if [ "$ONLINE" -eq 0 ]; then + case "$fp" in REMOTE-*) continue ;; esac + fi + seen=0 + for i in "${!F_RULE[@]}"; do [ "${F_RULE[$i]}|${F_SUBJ[$i]}" = "$fp" ] && seen=1 && break; done + for i in "${!ACC_RULE[@]}"; do [ "${ACC_RULE[$i]}|${ACC_SUBJ[$i]}" = "$fp" ] && seen=1 && break; done + [ "$seen" -eq 0 ] && add_notrun BASELINE-STALE "$fp" "baseline entry matches no current finding" "Remove it so a real regression cannot hide behind a stale acknowledgement" +done + +# --------------------------------------------------------------------- output -- +n_fatal=0; n_review=0 +for i in "${!F_SEV[@]}"; do + [ "${F_SEV[$i]}" = "FATAL" ] && n_fatal=$((n_fatal + 1)) || n_review=$((n_review + 1)) +done +n_notrun=${#N_RULE[@]} +n_accepted=${#ACC_RULE[@]} + +if [ "$JSON" -eq 1 ]; then + jq -s --argjson fatal "$n_fatal" --argjson review "$n_review" \ + --argjson not_run "$n_notrun" --argjson accepted "$n_accepted" \ + --arg lock "$LOCK" --arg wf "$WF_DIR" \ + '{lock:$lock,workflows:$wf, + summary:{fatal:$fatal,review:$review,accepted:$accepted,not_run:$not_run}, + findings:.[0], not_run:.[1]}' \ + "$TMP/findings.jsonl" "$TMP/notrun.jsonl" +else + echo "audit-lock-closure: $LOCK" + echo "workflows: $WF_DIR" + if [ "${#F_SEV[@]}" -gt 0 ]; then + echo + echo "== FINDINGS (${#F_SEV[@]}) ==" + for i in "${!F_SEV[@]}"; do + printf '%s\t%s\t%s\n' "${F_SEV[$i]}" "${F_RULE[$i]}" "${F_SUBJ[$i]}" + printf ' %s\n' "${F_DET[$i]}" + printf ' fix: %s\n' "${F_FIX[$i]}" + done + fi + if [ "$n_accepted" -gt 0 ]; then + echo + echo "== ACCEPTED (baselined REVIEW findings: $n_accepted) ==" + for i in "${!ACC_RULE[@]}"; do + printf 'ACCEPTED\t%s\t%s\n' "${ACC_RULE[$i]}" "${ACC_SUBJ[$i]}" + printf ' %s\n' "${ACC_DET[$i]}" + done + fi + if [ "$n_notrun" -gt 0 ]; then + echo + echo "== NOT RUN ($n_notrun) ==" + for i in "${!N_RULE[@]}"; do + printf 'NOT-RUN\t%s\t%s\n' "${N_RULE[$i]}" "${N_SUBJ[$i]}" + printf ' %s\n' "${N_DET[$i]}" + printf ' next: %s\n' "${N_FIX[$i]}" + done + fi + if [ "$n_fatal" -eq 0 ] && [ "$n_review" -eq 0 ] && [ "$n_notrun" -eq 0 ]; then + echo + echo "OK: lock is representation-clean and every owned edge set matches a reviewed receipt." + elif [ "$n_fatal" -eq 0 ] && [ "$n_review" -eq 0 ]; then + echo + echo "INCOMPLETE: no findings, but $n_notrun check(s) did not run; this is not a pass." + fi + echo + echo "summary: fatal=$n_fatal review=$n_review accepted=$n_accepted not-run=$n_notrun" +fi + +if [ $((n_fatal + n_review)) -gt 0 ] && [ "$n_notrun" -gt 0 ]; then exit 3; fi +if [ $((n_fatal + n_review)) -gt 0 ]; then exit 1; fi +if [ "$n_notrun" -gt 0 ]; then exit 2; fi +exit 0 diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 9edee642..85c25c3a 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -383,10 +383,16 @@ END { print "GitHub refuses such a run at startup: zero jobs are created and the run" print "reports \"This run likely failed because of a workflow file issue.\"" print "" - print "Fix, in this order:" + print "Fix, in this order (the same order upstream standards documents; do not" + print "short-circuit it with a bare `gh actions-lock`, which drops job-level" + print "reusable refs and can de-pin bare SHAs to floating tags):" print " 1. `gh actions-lock --no-migrate-local-actions`, then review the diff. It does" - print " NOT handle job-level reusable-workflow refs and it can de-pin bare SHAs to" - print " floating tags - both must be corrected by hand." + print " NOT handle job-level reusable-workflow refs, it can shorten a record's" + print " nested uses: list without leaving a dangling edge, and it can de-pin bare" + print " SHAs to floating tags - all three must be corrected by hand." + print " Then run scripts/audit-lock-closure.sh: it compares nested lists against" + print " the reviewed receipts in scripts/lib/lock-closure-receipts.tsv and reports" + print " an unproven closure as NOT RUN rather than as a pass." print " 2. For any DANGLING EDGES above, add a top-level `dependencies:` record for each" print " ref. A leaf record may legally omit the nested `uses:` key entirely, so adding" print " leaves introduces no new dangling edges and closure terminates in one pass." @@ -408,7 +414,7 @@ END { if (nunref > 0) printf " note: %d dependencies: record(s) are unreferenced - harmless, but prunable.\n", nunref if (jnote != "") - printf " note: job-level reusable refs not locked (harmless; see clause 1):%s\n", jnote + printf " note: job-level reusable refs absent from the lock (tolerated by clause 1, NOT proven closed; run scripts/audit-lock-closure.sh):%s\n", jnote } AWK diff --git a/scripts/lib/lock-audit-baseline.tsv b/scripts/lib/lock-audit-baseline.tsv new file mode 100644 index 00000000..f77b1f1e --- /dev/null +++ b/scripts/lib/lock-audit-baseline.tsv @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# lock-audit-baseline.tsv — reviewed, pre-existing representation debt. +# +# Only REVIEW findings can be acknowledged here. A FATAL finding cannot: it is +# either a lockfile that cannot be trusted, or a demonstrable closure loss, and +# silencing it is exactly how a green gate hides a dead workflow. +# +# Every row is a claim that a human looked at this defect and decided it is not +# to be fixed in this change. Remove a row as soon as the defect is fixed; a row +# that matches no current finding is reported as NOT RUN (BASELINE-STALE), so an +# acknowledgement cannot outlive its subject unnoticed. +# +# fields: rule subject reason reviewer date +REF-AMBIGUOUS dtolnay/rust-toolchain@v1 Two immutable snapshots are recorded for one moved ref (v1). The 02cb… record backs panicbot-sweep.yml's inline @v1; the e2a5… record backs the standards mirror reusable at 75bd. Re-resolving either without the other's owner is a silent behaviour change, so it needs a reviewed re-pin, not an audit-time rewrite. lock-audit 2026-10-31 +REF-MUTABLE hyperpolymath/deed-ecosystem@main dogfood-gate.yml pins the validate-action subpath at @main. The lock records an immutable snapshot, but the ref itself is not an immutable pin. Fixing it means changing the dogfood-gate caller (and possibly the action's release tags), which is outside a lock-closure change. lock-audit 2026-10-31 +REF-MUTABLE hyperpolymath/k9-ecosystem@main Same class as deed-ecosystem@main: dogfood-gate.yml pins the validate-action subpath at @main while the lock freezes a snapshot. Needs a reviewed tag on the action, not an audit-time rewrite. lock-audit 2026-10-31 +REMOTE-STALE dtolnay/rust-toolchain@v1 Re-checked 2026-10-31: the upstream tag now resolves to 683b588c… while the lock preserves the two reviewed snapshots above. The snapshot is intentionally immutable; only a reviewed re-pin may move it. lock-audit 2026-10-31 +REMOTE-STALE hyperpolymath/deed-ecosystem@main Re-checked 2026-10-31: main now resolves to edb3640c…, the lock preserves ed83d692…. That divergence is the reason to pin a tag; it is not a reason to chase main inside a lock-closure change. lock-audit 2026-10-31 +REMOTE-STALE hyperpolymath/k9-ecosystem@main Re-checked 2026-10-31: main now resolves to 20f6be5b…, the lock preserves c1a34884…. Same disposition as deed-ecosystem@main. lock-audit 2026-10-31 diff --git a/scripts/lib/lock-closure-receipts.tsv b/scripts/lib/lock-closure-receipts.tsv new file mode 100644 index 00000000..0ea96303 --- /dev/null +++ b/scripts/lib/lock-closure-receipts.tsv @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# lock-closure-receipts.tsv — reviewed closure receipts for actions.lock. +# +# READ THIS BEFORE EDITING +# ------------------------ +# A row says: "at this exact commit, the named source uses this external ref, +# written in lock-key form." The rows for one `owner/repo@sha` are the UNION of +# external `uses:` across the files this repository calls at that commit, and +# the union of external `uses:` of an external composite action's action.yml. +# The audit compares each record's nested `uses:` list against the union; a +# short list is a dropped edge (CLOSURE-DROPPED), an extra row is an unreviewed +# edge (CLOSURE-EXTRA). +# +# Why a checked-in receipt exists at all: `gh actions-lock` writes a record's +# nested edges from the pinned source, and a later regeneration can shorten +# that list without creating any dangling ref. No ref is then unresolvable, so +# clauses 1-4 of scripts/check-lock-sync.sh all pass while the composite's or +# reusable's real dependency set is no longer represented. The receipt is the +# reviewed snapshot that makes the loss observable. +# +# PROVENANCE (re-emit, do not hand-edit) +# standards@75bd5994470661a099df562d6d5ad2fe744dbd66 +# source: hyperpolymath/standards .github/workflows/{governance,hypatia-scan, +# mirror,scorecard,secret-scanner}-reusable.yml at that commit +# command: ./scripts/audit-lock-closure.sh \ +# --emit-receipt hyperpolymath/standards@75bd599... \ +# --source +# actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 +# source: action.yml at the commit the tag v5.0.0 resolved to when the lock +# record was written (fc324d3 is the reviewed immutable commit) +# command: ./scripts/audit-lock-closure.sh \ +# --emit-receipt actions/upload-pages-artifact@fc324d35... \ +# --source action.yml +# +# Receipts are SHA-bound on purpose. When a tag moves, the record's commit does +# not move with it (that is the point of preserving the resolved commit), so a +# receipt stays valid for the reviewed snapshot. To re-review a snapshot, emit a +# new receipt for the new commit and remove the old rows in the same change. +# +# fields: source commit nested-ref +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 +hyperpolymath/standards 75bd5994470661a099df562d6d5ad2fe744dbd66 webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 +actions/upload-pages-artifact fc324d3547104276b827a68afc52ff2a11cc49c9 actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f diff --git a/scripts/tests/lock-closure-test.sh b/scripts/tests/lock-closure-test.sh new file mode 100755 index 00000000..2138d2e8 --- /dev/null +++ b/scripts/tests/lock-closure-test.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# lock-closure-test.sh — regression tests for scripts/audit-lock-closure.sh. +# +# These fixtures exist because the two failure modes that matter here are both +# invisible to every other gate: +# +# * a regeneration that DROPS a job-level reusable `workflows:` entry +# (`gh actions-lock` has no code path that writes one) — nothing dangles, +# so clauses 1-4 of check-lock-sync.sh stay green; +# * a regeneration that SHORTENS or removes a record's nested `uses:` list — +# again nothing dangles, so the lock still "resolves"; +# * a ref that is well-formed enough to parse but malformed for GitHub +# (subpath pin inside nested uses, refs/tags/ prefix, self-SHA action ref, +# commit that disagrees with the key). +# +# Each case asserts on the audit's FINDINGS-vs-NOT-RUN split, not just its exit +# code, so a change that starts reporting a fatal defect as "not run" fails. +# +# Usage: bash scripts/tests/lock-closure-test.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +AUDIT="$ROOT/scripts/audit-lock-closure.sh" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/lock-closure-test.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { PASS=$((PASS + 1)); printf 'PASS: %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf 'FAIL: %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ /'; } + +RC=0; OUT="" +run_audit() { # [extra args...] + local dir="$1"; shift + OUT="$(cd "$ROOT" && "$AUDIT" --workflows "$dir/.github/workflows" \ + --receipts "$dir/receipts.tsv" --baseline "$dir/baseline.tsv" \ + --repo-root "$dir" "$@" 2>&1)" + RC=$? +} + +expect_rc() { # [forbidden-needle] + if [ "$RC" -eq "$2" ]; then pass "$1 (exit $RC)"; else fail "$1 (expected exit $2, got $RC)" "$OUT"; fi + if [ -n "${3:-}" ] && printf '%s' "$OUT" | grep -qF "$3"; then + fail "$1 (output must not contain '$3')" "$OUT" + fi +} +expect_out() { # + if printf '%s' "$OUT" | grep -qF "$2"; then pass "$1"; else fail "$1 (missing '$2')" "$OUT"; fi +} + +# Canonical fixture skeleton -------------------------------------------------- +new_repo() { # + local d="$WORK/$1" + mkdir -p "$d/.github/workflows" + : > "$d/receipts.tsv" + : > "$d/baseline.tsv" + printf '%s' "$d" +} + +SHA_75=75bd5994470661a099df562d6d5ad2fe744dbd66 +SHA_CK=3d3c42e5aac5ba805825da76410c181273ba90b1 +SHA_UA=cf430e030ddbb5b0abf93d22962f4752f3646cd9 +SHA_UPA=fc324d3547104276b827a68afc52ff2a11cc49c9 +SHA_UB=bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + +# A reusable caller whose closure is complete: entry + record + nested edge + +# matching receipt. Reference case for every "dropped" mutation below. +write_good_reusable() { # + cat > "$1/.github/workflows/scan.yml" < "$1/.github/workflows/actions.lock" < "$1/receipts.tsv" +} + +# ---------------------------------------------------------------- reference -- +d="$(new_repo good-reusable)"; write_good_reusable "$d"; run_audit "$d" +expect_rc "complete reusable closure is clean" 0 +expect_out "complete closure reports OK" "OK:" + +d="$(new_repo good-composite)" +cat > "$d/.github/workflows/pages.yml" < "$d/.github/workflows/actions.lock" < "$d/receipts.tsv" +run_audit "$d" +expect_rc "complete composite closure is clean" 0 + +# ------------------------------------------- a regeneration that drops closure -- +# `gh actions-lock` does not lock a job-level ref at all. Simulate the tool-only +# regeneration: the workflows: entry and its record disappear, nothing dangles. +d="$(new_repo regen-drops-job-ref)"; write_good_reusable "$d" +cat > "$d/.github/workflows/actions.lock" < "$d/receipts.tsv" # the source is no longer named by any record +run_audit "$d" +expect_rc "tool-only regeneration is not a pass (unlocked job-level ref)" 2 +expect_out "the dropped job-level ref is reported NOT RUN" "REUSABLE-UNLOCKED" +expect_out "closure is explicitly called unproven" "closure is unproven" + +# Same regeneration, but a hand-added entry with a LEAF record: the fatal shape, +# because a leaf record is present-but-unresolvable for a reusable workflow. +d="$(new_repo regen-leaf-record)"; write_good_reusable "$d" +cat > "$d/.github/workflows/actions.lock" < "$d/.github/workflows/actions.lock" < "$d/receipts.tsv" +run_audit "$d" +expect_rc "a shortened nested list is a finding" 1 +expect_out "the missing edge is named" "actions/cache@v6.1.0" +expect_out "the loss is classified as dropped closure" "dropped closure" + +# A composite record shortened to a leaf: the exact class clause 3 cannot see. +d="$(new_repo composite-leaf)" +cat > "$d/.github/workflows/pages.yml" < "$d/.github/workflows/actions.lock" < "$d/receipts.tsv" +run_audit "$d" +expect_rc "a leaf composite is a finding when its receipt has edges" 1 +expect_out "composite closure loss is reported" "CLOSURE-DROPPED" + +# An extra, unreviewed edge in a record is equally a finding: the lock would +# claim a dependency the reviewed source does not have. +d="$(new_repo composite-extra)" +cat > "$d/.github/workflows/pages.yml" < "$d/.github/workflows/actions.lock" < "$d/receipts.tsv" +run_audit "$d" +expect_rc "an unreviewed nested edge is a finding" 1 +expect_out "the extra edge is reported" "CLOSURE-EXTRA" + +# ------------------------------------------------- malformed ref representations -- +malformed_case() { # [receipts] + local name="$1" rule="$2" lock="$3" wf="$4" receipts="${5:-}" + local d; d="$(new_repo "$name")" + printf '%s\n' "$wf" > "$d/.github/workflows/w.yml" + printf '%s\n' "$lock" > "$d/.github/workflows/actions.lock" + [ -n "$receipts" ] && printf '%s\n' "$receipts" > "$d/receipts.tsv" + run_audit "$d" + if [ "$RC" -eq 1 ] || [ "$RC" -eq 3 ]; then pass "$name is a finding (exit $RC)"; else fail "$name is a finding (expected 1 or 3, got $RC)" "$OUT"; fi + expect_out "$name is classified $rule" "$rule" +} + +WF_STEP="name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1" + +malformed_case nested-subpath REF-NESTED-SUBPATH \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@v7.0.1' +dependencies: + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-$SHA_CK' + uses: + - 'github/codeql-action/upload-sarif@$SHA_CK'" "$WF_STEP" + +malformed_case refs-prefix REF-REFS-PREFIX \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@refs/tags/v7.0.1' +dependencies: + 'actions/checkout@refs/tags/v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-$SHA_CK'" "$WF_STEP" + +malformed_case missing-commit REF-NO-COMMIT \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@v7.0.1' +dependencies: + 'actions/checkout@v7.0.1': + ref: 'v7.0.1'" "$WF_STEP" + +malformed_case commit-mismatch REF-COMMIT-MISMATCH \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@$SHA_CK' +dependencies: + 'actions/checkout@$SHA_CK': + ref: 'v7.0.1' + commit: 'sha1-2222222222222222222222222222222222222222'" \ +"name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@$SHA_CK" + +malformed_case case-duplicate REF-CASE-DUP \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@v7.0.1' +dependencies: + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-$SHA_CK' + 'Actions/Checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-$SHA_CK'" "$WF_STEP" + +malformed_case self-sha-action REF-SELF-SHA-ACTION \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@$SHA_CK' +dependencies: + 'actions/checkout@$SHA_CK': + ref: '$SHA_CK' + commit: 'sha1-$SHA_CK'" \ +"name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@$SHA_CK" + +malformed_case ambiguous-tag REF-AMBIGUOUS \ +"version: 'v0.0.3' +workflows: + '.github/workflows/w.yml': + - 'actions/checkout@v7.0.1' + - 'actions/checkout@$SHA_CK' +dependencies: + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-$SHA_CK' + 'actions/checkout@$SHA_CK': + ref: 'v7.0.1' + commit: 'sha1-3333333333333333333333333333333333333333'" \ +"name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@$SHA_CK" + +# A self-SHA ref is legitimate for a reusable workflow: the reference fixture +# above already proves it (standards@75bd… records ref == commit and exits 0). + +# A baseline may acknowledge a REVIEW finding but never a FATAL one. +d="$(new_repo baseline-scope)"; write_good_reusable "$d" +cat > "$d/.github/workflows/actions.lock" < "$d/baseline.tsv" +run_audit "$d" +expect_rc "a baseline cannot suppress a fatal closure loss" 1 +expect_out "the fatal finding is still printed" "CLOSURE-DROPPED" + +# A baseline row that matches nothing is NOT RUN, not silence. Without this, a +# stale acknowledgement can hide the regression it was written for -- and a +# baseline pass that runs BEFORE the --online phase marks every REMOTE-* row +# stale, which is how this ordering bug shipped once already. +d="$(new_repo baseline-stale)"; write_good_reusable "$d" +printf 'REF-MUTABLE\tghost/action@main\tretired acknowledgement\tlock-audit\t2026-10-31\n' > "$d/baseline.tsv" +run_audit "$d" +expect_rc "an unmatched baseline row is NOT RUN" 2 +expect_out "the stale acknowledgement is named" "BASELINE-STALE" + +d="$(new_repo baseline-remote-offline)"; write_good_reusable "$d" +printf 'REMOTE-STALE\tghost/action@main\tonly meaningful with --online\tlock-audit\t2026-10-31\n' > "$d/baseline.tsv" +run_audit "$d" +expect_rc "an online-only baseline row is not called stale offline" 0 "BASELINE-STALE" + +# ------------------------------------------------------- unreadable / unknown -- +d="$(new_repo unparseable)" +cat > "$d/.github/workflows/w.yml" <<'YAML' +name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: >- + actions/checkout@v7.0.1 +YAML +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/w.yml': []\ndependencies: {}\n" > "$d/.github/workflows/actions.lock" +run_audit "$d" +expect_rc "an unreadable uses: value is NOT RUN, not green" 2 +expect_out "the parse refusal is explicit" "WF-PARSE" + +d="$(new_repo local-composite-missing)" +cat > "$d/.github/workflows/w.yml" <<'YAML' +name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/absent +YAML +printf "version: 'v0.0.3'\nworkflows:\n '.github/workflows/w.yml': []\ndependencies: {}\n" > "$d/.github/workflows/actions.lock" +run_audit "$d" +expect_rc "a missing local composite is NOT RUN" 2 +expect_out "the missing composite is named" "WF-LOCAL-MISSING" + +# A local composite's refs belong to the calling workflow (standards#1122). +d="$(new_repo local-composite-closure)" +mkdir -p "$d/.github/actions/x" +cat > "$d/.github/actions/x/action.yml" <<'YAML' +name: x +runs: + using: composite + steps: + - uses: actions/checkout@v7.0.1 +YAML +cat > "$d/.github/workflows/w.yml" <<'YAML' +name: W +on: push +jobs: + b: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/x +YAML +cat > "$d/.github/workflows/actions.lock" < Date: Sat, 31 Oct 2026 12:08:46 +0000 Subject: [PATCH 2/8] docs(audit): record the live zero-job reproduction on PR #631 Secret Scanner run 41199708273 on the PR head: failure, 0 jobs, and no scan / ... context appears in gh pr checks -- while Lock Sync Gate and E2E Tests passed normally on the same commit. That is the defect this audit describes, reproduced on the change that describes it. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- audits/lock-closure-2026-10-31/REPORT.md | 5 +++++ audits/lock-closure-2026-10-31/evidence.json | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/audits/lock-closure-2026-10-31/REPORT.md b/audits/lock-closure-2026-10-31/REPORT.md index 4cdbd350..1e26f67b 100644 --- a/audits/lock-closure-2026-10-31/REPORT.md +++ b/audits/lock-closure-2026-10-31/REPORT.md @@ -18,6 +18,7 @@ This report separates two things that a single green tick conflates: |---|---|---| | M1 | The repository's Lock Sync Gate is green at `83e35bc` | push run [40933665365](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665365) — `success`; `check-lock-sync.sh` executes on the runner (so gawk/yq are present there) | | M2 | All five job-level reusable callers fail with **zero jobs** | runs [40933665219](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665219) (secret-scanner), [40933665285](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665285) (governance), [40933665246](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665246) (hypatia-scan), [40933665259](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665259) (scorecard), [40933665305](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665305) (mirror): `conclusion=failure`, `jobs=[]`, `name == path` | +| M2b | **Reproduced live, after this audit was written** | PR #631 head `364b2672`: `Secret Scanner` run [41199708273](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/41199708273), `failure`, **0 jobs**; `gh pr checks 631` lists no `scan / …` context at all, while `Lock Sync Gate` and `E2E Tests` ran and passed | | M3 | The same caller pin produced jobs two days earlier | run [40175794668](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40175794668), 2026-10-25, `success`, `referenced_workflows=[standards…secret-scanner-reusable.yml@75bd5994…]` | | M4 | The caller YAML has not changed since 2026-10-20 | commit history of `.github/workflows/secret-scanner.yml` ends at `6e38719569` (2026-10-20); `actions.lock` last changed 2026-10-27 (`e44af814`) but that change only added `fleet-report-back*` entries | | M5 | The replacement callee runs and creates jobs | cicd-suite push run [41195030267](https://github.com/hyperpolymath/cicd-suite/actions/runs/41195030267) at `4404c3c0`, `success`, `jobs=1` | @@ -38,6 +39,10 @@ This report separates two things that a single green tick conflates: The required contexts `scan / gitleaks`, `scan / rust-secrets`, `scan / shell-secrets` are **not being produced**. Every `Secret Scanner` run since 2026-10-27 has zero jobs (M2), including the push to `main` at `83e35bc`; the same pin produced jobs on 2026-10-25 (M3). This is not a YAML regression in this repository (M4). +Reproduced on this very change: the PR that reports the defect (#631) shows no +`scan / gitleaks` entry in `gh pr checks`, because run 41199708273 created zero +jobs, while its `Lock Sync Gate` and `E2E Tests` checks passed normally. + A zero-job run produces **no check run**. A required context that never reports does not read as failing — it reads as absent, which in `gh pr checks` is indistinguishable from “not yet started”. The ruleset is therefore satisfied by silence. > Consequence for this migration: it cannot “restore” scanning by itself. The replacement must be proven to create jobs *before* it is trusted, which is exactly what the live canary is for. diff --git a/audits/lock-closure-2026-10-31/evidence.json b/audits/lock-closure-2026-10-31/evidence.json index ac7e5585..818b8de6 100644 --- a/audits/lock-closure-2026-10-31/evidence.json +++ b/audits/lock-closure-2026-10-31/evidence.json @@ -70,7 +70,7 @@ "jobs": 0, "name_equals_path": true, "trigger": "push", - "created_at": "2026-10-27T06:38:20Z" + "created_at": "2026-10-29T18:44:35Z" }, "governance_audit": { "run_id": 40933665285, @@ -105,6 +105,20 @@ "run_id": 41195030267, "conclusion": "success", "jobs": 1 + }, + "gitbot-fleet_pr631": { + "run_id": 41199708273, + "workflow": "secret-scanner.yml", + "event": "pull_request", + "head_sha": "364b267279ecf3ef90dc93fac4dfdd370779393c", + "conclusion": "failure", + "jobs": 0, + "pr_checks_list_scan_contexts": [], + "peer_checks": { + "Lock Sync Gate": "success", + "E2E Tests": "success" + }, + "note": "fresh reproduction of the zero-job signature on the change that reports it" } }, "file_history": { From b083d2d6028d684b0369215e1a48b87b716491ed Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:03:54 +0000 Subject: [PATCH 3/8] Update scripts/audit-lock-closure.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/audit-lock-closure.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/audit-lock-closure.sh b/scripts/audit-lock-closure.sh index 5e8e1abe..bd1f05b4 100755 --- a/scripts/audit-lock-closure.sh +++ b/scripts/audit-lock-closure.sh @@ -536,8 +536,8 @@ scan_workflow_file() { # [composite] add_notrun WF-LOCAL-MISSING "$lockpath" "local action '$localpath' has no action.yml under $REPO_ROOT" "Restore the composite or fix the path; its refs cannot be audited" continue fi - if [ -n "${LOCAL_SEEN["$action"]:-}" ]; then continue; fi - LOCAL_SEEN["$action"]=1 + if [ -n "${LOCAL_SEEN["$lockpath|$action"]:-}" ]; then continue; fi + LOCAL_SEEN["$lockpath|$action"]=1 scan_workflow_file "$action" "$lockpath" 1 ;; \$/*) From 9e8cb3438db616ee27137a4d4fd118a387ebcf7e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:04:49 +0000 Subject: [PATCH 4/8] Update scripts/audit-lock-closure.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/audit-lock-closure.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/audit-lock-closure.sh b/scripts/audit-lock-closure.sh index bd1f05b4..9579fd1d 100755 --- a/scripts/audit-lock-closure.sh +++ b/scripts/audit-lock-closure.sh @@ -262,10 +262,12 @@ if [ -n "$EMIT_KEY" ]; then [ "${#EMIT_SOURCES[@]}" -gt 0 ] || { echo "audit-lock-closure: --emit-receipt needs --source" >&2; exit 64; } repo="${EMIT_KEY%@*}"; sha="${EMIT_KEY##*@}" rc=0 - { for s in "${EMIT_SOURCES[@]}"; do collect_source_uses "$s" || rc=1; done; } \ - | LC_ALL=C sort -u | while IFS= read -r ref; do - printf '%s\t%s\t%s\n' "$repo" "$sha" "$ref" - done + for s in "${EMIT_SOURCES[@]}"; do + collect_source_uses "$s" >> "$TMP/emit.refs" || rc=1 + done + LC_ALL=C sort -u "$TMP/emit.refs" | while IFS= read -r ref; do + printf '%s\t%s\t%s\n' "$repo" "$sha" "$ref" + done exit "$rc" fi From d86edf73f936e96cff98e6ed34f77849234716ec Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:05:10 +0000 Subject: [PATCH 5/8] Update scripts/audit-lock-closure.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/audit-lock-closure.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/audit-lock-closure.sh b/scripts/audit-lock-closure.sh index 9579fd1d..701a2d64 100755 --- a/scripts/audit-lock-closure.sh +++ b/scripts/audit-lock-closure.sh @@ -799,13 +799,13 @@ n_notrun=${#N_RULE[@]} n_accepted=${#ACC_RULE[@]} if [ "$JSON" -eq 1 ]; then - jq -s --argjson fatal "$n_fatal" --argjson review "$n_review" \ + jq -n --argjson fatal "$n_fatal" --argjson review "$n_review" \ --argjson not_run "$n_notrun" --argjson accepted "$n_accepted" \ --arg lock "$LOCK" --arg wf "$WF_DIR" \ + --slurpfile findings "$TMP/findings.jsonl" --slurpfile notrun "$TMP/notrun.jsonl" \ '{lock:$lock,workflows:$wf, summary:{fatal:$fatal,review:$review,accepted:$accepted,not_run:$not_run}, - findings:.[0], not_run:.[1]}' \ - "$TMP/findings.jsonl" "$TMP/notrun.jsonl" + findings:$findings, not_run:$notrun}' else echo "audit-lock-closure: $LOCK" echo "workflows: $WF_DIR" From 3ab60a581255f19dce5dcdc42c20cab9d2b32b68 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:06:00 +0000 Subject: [PATCH 6/8] Update scripts/tests/lock-closure-test.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/tests/lock-closure-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/lock-closure-test.sh b/scripts/tests/lock-closure-test.sh index 2138d2e8..29c28c1e 100755 --- a/scripts/tests/lock-closure-test.sh +++ b/scripts/tests/lock-closure-test.sh @@ -470,7 +470,7 @@ run_audit "$d" expect_rc "a local composite's refs are locked under the caller" 0 # --------------------------------------------------------------- this repo ---- -( cd "$ROOT" && OUT="$(./scripts/audit-lock-closure.sh 2>&1)"; RC=$? ) +OUT="$(cd "$ROOT" && ./scripts/audit-lock-closure.sh 2>&1)"; RC=$? expect_rc "this repository's own lock is clean (offline)" 0 expect_out "the repo audit reports every owned edge set reviewed" "every owned edge set matches a reviewed receipt" From eb541360713c17e0dbda544f3fb28248e34e6b15 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:06:09 +0000 Subject: [PATCH 7/8] docs(scripts): clarify audit and migration helper function behavior --- .../secret-scanner-cicd-suite/preflight.sh | 11 ++++----- scripts/audit-lock-closure.sh | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/migrations/secret-scanner-cicd-suite/preflight.sh b/docs/migrations/secret-scanner-cicd-suite/preflight.sh index 83de7368..624d9c29 100755 --- a/docs/migrations/secret-scanner-cicd-suite/preflight.sh +++ b/docs/migrations/secret-scanner-cicd-suite/preflight.sh @@ -108,6 +108,8 @@ elif ! ( cd "$MIG/fixtures" && sha256sum -c SHA256SUMS >/dev/null 2>&1 ); then fi # ------------------------------------------------- static properties of patch -- +# Copy stdin without whole-line comments; inline comments remain. Return 1 +# when no lines remain, or a higher status on a grep error. code_only() { grep -vE '^[[:space:]]*#'; } if [ -f "$PROPOSED" ]; then @@ -146,13 +148,10 @@ if [ -f "$PROPOSED" ]; then fi # ------------------------------------------------ contexts from the callee ---- -# Derive the contexts the pinned callee would report for a direct caller. This -# is a property of the vendored source, not of the caller's wishes: the job id -# names the first half, the matrix name the second. +# Print sorted, unique scan contexts inferred from the callee workflow. Jobs +# named using matrix.language expand the listed languages as -secrets; +# other jobs use their job IDs. The reader expects the fixture's YAML layout. derive_contexts() { # - # A direct caller of a reusable workflow reports ` / `, or, when the callee job has a matrix name, the rendered name. - # Here the caller job is always `scan`. awk ' function flush( lang, n, L, i) { if (job == "") return diff --git a/scripts/audit-lock-closure.sh b/scripts/audit-lock-closure.sh index 701a2d64..21a9c8c4 100755 --- a/scripts/audit-lock-closure.sh +++ b/scripts/audit-lock-closure.sh @@ -126,6 +126,9 @@ json_notrun() { # rule subject detail fix fi } +# Record a finding, routing baselined REVIEW findings to the accepted list. +# Other findings are also appended to the JSON output when requested; a jq +# failure then propagates as the function's status. add_finding() { # severity rule subject detail fix local sev="$1" rule="$2" subj="$3" det="$4" fix="$5" fp fp="$rule|$subj" @@ -143,9 +146,9 @@ add_notrun() { # rule subject detail fix } # ------------------------------------------------------------- emit receipts -- -# Extract every external `uses:` from one file. Emits USEkindraw or -# UNPARSEDreason; kind is job or step, derived from the line's shape. A -# value this reader cannot interpret is UNPARSED, never guessed. +# Read uses: lines from one file, including local and Docker references. Emit +# USEkindraw or UNPARSEDreason; kind is job or step, inferred +# from the line's shape. Unsupported values are reported rather than guessed. extract_uses() { # "$AWK" ' function strip_comment(s, out, i, n, c, q) { @@ -216,7 +219,9 @@ extract_uses() { # ' "$1" } -norm_ref() { # raw -> owner/repo@ref ; rc 2 = local/docker (not lock-keyed) +# Collapse an external reference's subpath to owner/repo@ref. Return 1 for an +# unrecognized reference and 2 for ./, $/, or Docker references (not lock-keyed). +norm_ref() { # raw -> owner/repo@ref local raw="$1" path ref owner repo case "$raw" in ./*|\$/*|docker://*) return 2 ;; @@ -233,8 +238,11 @@ norm_ref() { # raw -> owner/repo@ref ; rc 2 = local/docker (not lock-keyed) is_sha() { [[ "$1" =~ ^[0-9a-f]{40}$ ]]; } -# Union of external uses reachable from one source file or directory tree. -collect_source_uses() { # ; prints normalized refs, one per line +# Print normalized external references, one per line, from a file or YAML files +# up to four levels under a directory. Duplicates are retained. Missing files +# and unparsed values return 1 for a file; directory traversal continues and +# returns 0 even if a contained file fails. +collect_source_uses() { # local src="$1" f kind raw norm if [ -d "$src" ]; then while IFS= read -r f; do @@ -509,11 +517,15 @@ declare -A USED_ACTION=() declare -A USED_REUSABLE=() declare -A LOCAL_SEEN=() +# Return success when the space-delimited list contains the entire reference. list_has() { # <" ref ref "> case " $1 " in *" $2 "*) return 0 ;; esac return 1 } +# Record external uses and parse findings under the caller's lock path. Local +# actions are scanned once per lock path; local workflows are not followed. +# A composite flag of 1 treats its external uses as steps and skips local uses. scan_workflow_file() { # [composite] local file="$1" lockpath="$2" composite="${3:-0}" tag kind raw norm rc f localpath dir action while IFS=$'\t' read -r tag kind raw; do From b97b51e6f2b8334d5b87d25836f4cc7ae2b43028 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:09:21 +0000 Subject: [PATCH 8/8] test(ci): cover scanner migration gates, rollback, and lock-closure receipts --- scripts/tests/lock-closure-test.sh | 34 ++++ .../tests/secret-scanner-migration-test.sh | 168 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 scripts/tests/secret-scanner-migration-test.sh diff --git a/scripts/tests/lock-closure-test.sh b/scripts/tests/lock-closure-test.sh index 29c28c1e..b6c7186e 100755 --- a/scripts/tests/lock-closure-test.sh +++ b/scripts/tests/lock-closure-test.sh @@ -469,6 +469,40 @@ LOCK run_audit "$d" expect_rc "a local composite's refs are locked under the caller" 0 +# Receipt emission must report the union of external references and refuse to +# certify an unreadable source. Local and Docker actions are not lock keys. +cat > "$WORK/source.yml" <<'YAML' +steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@v7.0.1 + - uses: github/codeql-action/upload-sarif@v3 + - uses: ./local + - uses: docker://alpine:3 +YAML +OUT="$("$AUDIT" --emit-receipt "example/source@$SHA_CK" --source "$WORK/source.yml" 2>&1)"; RC=$? +expect_rc "receipt emission succeeds for a readable source" 0 +expected="$(printf 'example/source\t%s\tactions/checkout@v7.0.1\nexample/source\t%s\tgithub/codeql-action@v3' "$SHA_CK" "$SHA_CK")" +if [ "$OUT" = "$expected" ]; then pass "receipt emission normalizes and deduplicates external refs"; else fail "receipt emission produces the reviewed union" "$OUT"; fi +OUT="$("$AUDIT" --emit-receipt "example/source@$SHA_CK" --source "$WORK/absent.yml" 2>&1)"; RC=$? +if [ "$RC" -ne 0 ]; then pass "receipt emission refuses a missing source"; else fail "receipt emission must fail for a missing source" "$OUT"; fi + +# JSON output must retain every finding and not-run entry, not just totals. +d="$(new_repo json-report)"; write_good_reusable "$d" +printf 'invalid-source\t%s\tactions/checkout@v7.0.1\nexample/source\tbad-sha\tactions/checkout@v7.0.1\n' "$SHA_CK" >> "$d/receipts.tsv" +printf 'REF-MUTABLE\tghost/action@main\tstale\ttest\t2026-10-31\n' > "$d/baseline.tsv" +run_audit "$d" --json +expect_rc "JSON audit reports findings and missing evidence" 3 +if printf '%s\n' "$OUT" | jq -e '[.findings | .. | objects | select(.rule? == "RECEIPT-PARSE")] | length == 2' >/dev/null; then + pass "JSON includes both receipt findings" +else + fail "JSON includes both receipt findings" "$OUT" +fi +if printf '%s\n' "$OUT" | jq -e '[.not_run | .. | objects | select(.rule? == "BASELINE-STALE")] | length == 1' >/dev/null; then + pass "JSON includes the not-run entry" +else + fail "JSON includes the not-run entry" "$OUT" +fi + # --------------------------------------------------------------- this repo ---- OUT="$(cd "$ROOT" && ./scripts/audit-lock-closure.sh 2>&1)"; RC=$? expect_rc "this repository's own lock is clean (offline)" 0 diff --git a/scripts/tests/secret-scanner-migration-test.sh b/scripts/tests/secret-scanner-migration-test.sh new file mode 100644 index 00000000..00739d53 --- /dev/null +++ b/scripts/tests/secret-scanner-migration-test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Isolated contract tests for the prepared scanner migration. +# Usage: bash scripts/tests/secret-scanner-migration-test.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/scanner-migration-test.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +TREE="$WORK/tree" +MIG="$TREE/docs/migrations/secret-scanner-cicd-suite" +mkdir -p "$TREE/docs/migrations" "$TREE/scripts" "$WORK/bin" +cp -R "$ROOT/.github" "$TREE/" +cp -R "$ROOT/scripts/lib" "$TREE/scripts/" +cp -R "$ROOT/docs/migrations/secret-scanner-cicd-suite" "$TREE/docs/migrations/" + +# The audit and GitHub API have their own contracts. Stub them here so these +# tests exercise how preflight and rollback interpret each result offline. +cat > "$TREE/scripts/audit-lock-closure.sh" <<'STUB' +#!/usr/bin/env bash +exit "${TEST_AUDIT_RC:-0}" +STUB +cat > "$WORK/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$2" in + repos/hyperpolymath/cicd-suite/commits/*) exit "${TEST_REMOTE_RC:-0}" ;; + repos/hyperpolymath/gitbot-fleet/rulesets/24309193) + [ "${TEST_FLOOR:-ok}" = ok ] && echo 'scan / gitleaks' + exit 0 ;; + repos/hyperpolymath/gitbot-fleet/rulesets/22415578) + [ "${TEST_RULESET:-ok}" = ok ] || exit 1 + printf '%s\n' 'scan / gitleaks' 'scan / rust-secrets' 'scan / shell-secrets' ;; + *) exit 1 ;; +esac +STUB +chmod +x "$WORK/bin/gh" +export PATH="$WORK/bin:$PATH" + +SHA=1234567890abcdef1234567890abcdef12345678 +jq --arg sha "$SHA" '.result = "pass" | .subject_sha = $sha | + .contexts |= map(.conclusion = "success" | .receipt_sha = $sha)' \ + "$MIG/canary/receipt.example.json" > "$WORK/good.json" +PASS=0; FAIL=0; RC=0; OUT='' +check() { # name, expected exit, expected output (empty means no output assertion) + if [ "$RC" -eq "$2" ] && { [ -z "$3" ] || printf '%s\n' "$OUT" | grep -qF -- "$3"; }; then + PASS=$((PASS + 1)); printf 'PASS: %s\n' "$1" + else + FAIL=$((FAIL + 1)); printf 'FAIL: %s (expected exit %s and %s; got exit %s)\n%s\n' "$1" "$2" "$3" "$RC" "$OUT" + fi +} +# Prevent shell startup hooks from putting a real gh ahead of the fixture stub. +preflight() { OUT="$(BASH_ENV= /bin/bash "$MIG/preflight.sh" "$@" 2>&1)"; RC=$?; } +rollback() { OUT="$(BASH_ENV= /bin/bash "$MIG/rollback.sh" --root "$TREE" "$@" 2>&1)"; RC=$?; } + +preflight --online --check-apply --receipt "$WORK/good.json" +check 'valid SHA-bound receipt and proposed patches pass' 0 'PASS: static properties hold' +preflight --online --receipt "$WORK/missing.json" +check 'missing receipt is not run' 2 'NOT-RUN' +preflight --online --receipt "$MIG/canary/receipt.example.json" +check 'example receipt cannot authorize migration' 2 "result='not_run'" +preflight --receipt "$WORK/good.json" +check 'offline verification is reported as not run' 2 'NOT-RUN' +preflight --quiet --receipt "$WORK/good.json" +check 'quiet offline mode accepts valid static evidence' 0 '' + +# Each mutation starts from a valid receipt, isolating the reason for refusal. +receipt_case() { # name, jq mutation, expected diagnostic + jq "$2" "$WORK/good.json" > "$WORK/case.json" + preflight --online --receipt "$WORK/case.json" + check "$1" 1 "$3" +} +receipt_case 'failed canary blocks migration' '.result = "fail"' "result='fail'" +receipt_case 'schema version is required' '.schema_version = 2' '(schema_version)' +receipt_case 'subject is bound to the canary repository' '.subject = "example/other"' '(subject)' +receipt_case 'subject SHA must be full lowercase hex' '.subject_sha = "abc"' '(subject_sha)' +receipt_case 'placeholder subject SHA is rejected' '.subject_sha = "0000000000000000000000000000000000000000"' 'all-zero subject SHA' +receipt_case 'callee SHA must match' '.suite_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' 'different callee SHA' +receipt_case 'fast PR scanning is rejected' '.fast_pr = true' '(fast_pr)' +receipt_case 'forwarded secrets are rejected' '.secrets_passed = ["TOKEN"]' '(secrets_passed)' +receipt_case 'full scan is required' '.scanned_mode = "pr-range"' '(scanned_mode)' +receipt_case 'three contexts are required' '.contexts = .contexts[:2]' '(contexts)' +receipt_case 'duplicate context names are rejected' '.contexts[1].name = .contexts[0].name' '(context_names)' +receipt_case 'skipped detector is rejected' '.contexts[0].conclusion = "skipped"' '(context_conclusion)' +receipt_case 'malformed detector receipt SHA is rejected' '.contexts[0].receipt_sha = "abc"' '(context_receipt_sha)' +receipt_case 'detector receipt must bind to subject SHA' '.contexts[0].receipt_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' '(context_subject_binding)' +receipt_case 'run URL must belong to canary repository' '.run_url = "https://github.com/example/other/actions/runs/1"' '(run_url)' +printf '{invalid json\n' > "$WORK/bad.json" +preflight --online --receipt "$WORK/bad.json" +check 'unreadable JSON is a finding' 1 'unreadable' + +# Findings and missing evidence must be visible together, with distinct exit 3. +TEST_REMOTE_RC=1 preflight --online --receipt "$WORK/missing.json" +check 'finding plus missing evidence exits 3' 3 'REMOTE' +TEST_FLOOR=missing preflight --online --receipt "$WORK/good.json" +check 'missing required live floor context is a finding' 1 'RULESET' +TEST_RULESET=unavailable preflight --online --receipt "$WORK/good.json" +check 'unreadable live ruleset is not run' 2 'NOT-RUN' +for audit_rc in 1 2 3; do + TEST_AUDIT_RC="$audit_rc" preflight --online --receipt "$WORK/good.json" + case "$audit_rc" in 1|3) expected=1 ;; 2) expected=2 ;; esac + check "audit exit $audit_rc is propagated as a gate result" "$expected" 'LOCK-CLOSURE' +done + +cp "$MIG/patches/secret-scanner.yml.patch" "$WORK/original.patch" +sed -i 's/^+ fast-pr: false$/+ fast-pr: true/' "$MIG/patches/secret-scanner.yml.patch" +preflight --online --receipt "$WORK/good.json" +check 'unsafe fast-pr in proposed caller is rejected' 1 'WF-FAST-PR' +cp "$WORK/original.patch" "$MIG/patches/secret-scanner.yml.patch" +sed -i 's/^+ fast-pr: false$/+ secrets: inherit/' "$MIG/patches/secret-scanner.yml.patch" +preflight --online --receipt "$WORK/good.json" +check 'secret inheritance in proposed caller is rejected' 1 'WF-SECRETS' +cp "$WORK/original.patch" "$MIG/patches/secret-scanner.yml.patch" +sed -i 's/^+ fast-pr: false$/+ security-events: write/' "$MIG/patches/secret-scanner.yml.patch" +preflight --online --receipt "$WORK/good.json" +check 'excess permissions in proposed caller are rejected' 1 'WF-PERMS' +cp "$WORK/original.patch" "$MIG/patches/secret-scanner.yml.patch" +printf '\n# changed\n' >> "$MIG/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml" +preflight --online --receipt "$WORK/good.json" +check 'tampered vendored callee is rejected' 1 'FIXTURE-CHECKSUM' +cp "$ROOT/docs/migrations/secret-scanner-cicd-suite/fixtures/cicd-suite-4404c3c0474e17753fe7689f3485c02890aac113-secret-scanner-reusable.yml" "$MIG/fixtures/" +mv "$MIG/fixtures/SHA256SUMS" "$WORK/SHA256SUMS" +preflight --online --receipt "$WORK/good.json" +check 'missing fixture checksum is not run' 2 'FIXTURE-CHECKSUM' +mv "$WORK/SHA256SUMS" "$MIG/fixtures/SHA256SUMS" +printf '%s\n' 'scan / missing-detector' >> "$MIG/required-contexts.txt" +preflight --online --receipt "$WORK/good.json" +check 'required context absent from callee is rejected' 1 'CONTEXT-MISSING' +cp "$ROOT/docs/migrations/secret-scanner-cicd-suite/required-contexts.txt" "$MIG/required-contexts.txt" + +# Apply only to the disposable tree. Dry run and refusal must leave it intact. +for p in secret-scanner.yml.patch actions.lock.patch lock-closure-receipts.tsv.patch; do + (cd "$TREE" && patch -p1 -s -f < "$MIG/patches/$p") || exit 1 +done +files=(.github/workflows/secret-scanner.yml .github/workflows/actions.lock scripts/lib/lock-closure-receipts.tsv) +(cd "$TREE" && sha256sum "${files[@]}") > "$WORK/applied.sha" +rollback +check 'rollback defaults to dry run' 0 'Dry run:' +if (cd "$TREE" && sha256sum -c "$WORK/applied.sha" >/dev/null); then RC=0; else RC=1; fi +OUT=''; check 'dry run does not modify migration files' 0 '' +printf '\ndrift\n' >> "$TREE/.github/workflows/secret-scanner.yml" +# Drift inside a patch hunk, rather than an unrelated append, must refuse all reversals. +sed -i 's/fast-pr: false/fast-pr: true/' "$TREE/.github/workflows/secret-scanner.yml" +(cd "$TREE" && sha256sum "${files[@]}") > "$WORK/drift.sha" +rollback --apply +check 'rollback refuses drift before applying any patch' 1 'REFUSE:' +if (cd "$TREE" && sha256sum -c "$WORK/drift.sha" >/dev/null); then RC=0; else RC=1; fi +OUT=''; check 'refused rollback leaves all three files intact' 0 '' +# Restore the applied version and check the complete reverse transaction. +cp "$ROOT/.github/workflows/secret-scanner.yml" "$TREE/.github/workflows/secret-scanner.yml" +(cd "$TREE" && patch -p1 -s -f < "$MIG/patches/secret-scanner.yml.patch") || exit 1 +rollback --apply +check 'rollback applies and re-audits' 0 'Rollback applied;' +for file in "${files[@]}"; do + if cmp -s "$ROOT/$file" "$TREE/$file"; then RC=0; else RC=1; fi + OUT=''; check "rollback restores $file byte for byte" 0 '' +done + +# Audit failure and incomplete evidence remain visible after a real reversal. +for audit_rc in 1 2; do + for p in secret-scanner.yml.patch actions.lock.patch lock-closure-receipts.tsv.patch; do + (cd "$TREE" && patch -p1 -s -f < "$MIG/patches/$p") || exit 1 + done + TEST_AUDIT_RC="$audit_rc" rollback --apply + check "rollback propagates audit exit $audit_rc" "$audit_rc" 'audit exit' +done + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]