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