diff --git a/.github/workflows/github-backup-mirror-reusable.yml b/.github/workflows/github-backup-mirror-reusable.yml index 1885423f2..0e3138b42 100644 --- a/.github/workflows/github-backup-mirror-reusable.yml +++ b/.github/workflows/github-backup-mirror-reusable.yml @@ -41,7 +41,10 @@ # jobs: # backup: # uses: hyperpolymath/standards/.github/workflows/github-backup-mirror-reusable.yml@ -# secrets: inherit +# secrets: +# # The callee's one declared (optional) secret. Not `secrets: +# # inherit` — that forwards every caller secret (standards#1108 §6b). +# BACKUP_MIRROR_TOKEN: ${{ secrets.BACKUP_MIRROR_TOKEN }} # # Required repo configuration: # vars.BACKUP_MIRROR_ENABLED = 'true' (opt in; off by default) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index c2945c45e..d2411ebda 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,4 +17,8 @@ permissions: jobs: governance: uses: ./.github/workflows/governance-reusable.yml - secrets: inherit + # Only the one secret the callee declares (optional). Was `secrets: + # inherit`, which forwarded every repo/org secret (standards#1108 + # §6b); narrowed to the callee's declared contract. + secrets: + HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 1b3e669d1..06cf2db37 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -130,8 +130,8 @@ jobs: # code_scanning,secret_scanning}_alerts.ex). Without it those three # checks are skipped with "GITHUB_TOKEN not set" warnings. # - # Prefer a fine-grained, read-only PAT (HYPATIA_SCAN_PAT, inherited as - # an org secret via the caller's `secrets: inherit`): the built-in + # Prefer a fine-grained, read-only PAT (HYPATIA_SCAN_PAT, passed by + # the caller as this reusable's one declared secret): the built-in # Actions token CANNOT read Dependabot alerts (no `dependabot` # permission exists for it). Fall back to the built-in token so # code-scanning still works in repos where the PAT isn't present yet. diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 8686b8c29..aebc4d2ff 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -27,4 +27,9 @@ permissions: jobs: scan: uses: ./.github/workflows/hypatia-scan-reusable.yml - secrets: inherit + # Only the callee's one declared secret. Was `secrets: inherit`, + # which forwarded every repo/org secret (standards#1108 §6b); the + # reusable now declares HYPATIA_SCAN_PAT explicitly so it can be + # passed narrowly. + secrets: + HYPATIA_SCAN_PAT: ${{ secrets.HYPATIA_SCAN_PAT }} diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index feab28024..7d171db00 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -14,11 +14,13 @@ # `vars._MIRROR_ENABLED == 'true'`, so forge selection is # configured per-repo via Actions vars — no per-call inputs required. # -# Callers pass explicit secrets (preferred) or `secrets: inherit` so the reusable -# can read the per-forge SSH keys (GITLAB_SSH_KEY, BITBUCKET_SSH_KEY, -# CODEBERG_SSH_KEY, SOURCEHUT_SSH_KEY, DISROOT_SSH_KEY, GITEA_SSH_KEY) -# and RADICLE_KEY from the caller repo. Without a passed secret, -# `${{ secrets.X }}` inside the reusable evaluates to empty. +# Callers pass the per-forge SSH keys EXPLICITLY, by the names declared in +# `workflow_call.secrets` below (GITLAB_SSH_KEY, BITBUCKET_SSH_KEY, +# CODEBERG_SSH_KEY, SOURCEHUT_SSH_KEY, DISROOT_SSH_KEY, GITEA_SSH_KEY, +# RADICLE_KEY). Do not use `secrets: inherit`: it forwards every repo/org +# secret the caller can see, not just these seven (standards#1108 §6b). +# Without a passed secret, `${{ secrets.X }}` inside the reusable +# evaluates to empty and that forge's job no-ops. # # Caller example (wrapper): # # SPDX-License-Identifier: MPL-2.0 diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index aeef0a14e..03ca9e537 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,4 +15,15 @@ permissions: jobs: mirror: uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@b935e63937b1f1b5cbaeea7e375947d7b924038f - secrets: inherit + # Exactly the seven optional secrets the callee declares at the + # pinned SHA (verified declared ⊇ referenced). Was `secrets: + # inherit`, which forwarded every repo/org secret (standards#1108 + # §6b; the old convention is tracked in #943). + secrets: + GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} + BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} + CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} + SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} + DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} + GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} + RADICLE_KEY: ${{ secrets.RADICLE_KEY }} diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml index 0ce41aa0b..dcb8a5bca 100644 --- a/.github/workflows/secret-scanner-reusable.yml +++ b/.github/workflows/secret-scanner-reusable.yml @@ -29,10 +29,17 @@ # internally (rust-secrets exits early on no Cargo.toml; shell-secrets # no-ops on no .sh/.bash files), so there is nothing for the caller # to toggle. -# - Caller MUST use `secrets: inherit` so the gitleaks-action's -# `GITHUB_TOKEN` reference resolves. Without `secrets: inherit`, -# the inner `secrets.GITHUB_TOKEN` is empty and gitleaks falls back -# to anonymous mode (rate-limited; misses some PRs). +# - Caller passes NO `secrets:` line. This reusable declares no +# `workflow_call.secrets` and references no `secrets.*` context: +# gitleaks runs as a pinned local binary (see the PERMISSIONS note), +# and the `GITHUB_TOKEN` the jobs act under is governed by +# `permissions:`, not by caller secret passing. `secrets: inherit` +# would forward every repository and organisation secret the caller +# can see (CWE-250) and buy nothing. An earlier version of this +# header claimed inherit was REQUIRED so `gitleaks/gitleaks-action` +# could see `secrets.GITHUB_TOKEN`; that claim died with #500 but +# kept propagating into generated callers estate-wide. Do not +# re-add it. (standards#1108 §6b) # - Caller keeps its own `on:` triggers and `concurrency:` group, so # the read-only cancel-superseded behaviour stays in the wrapper. # - Trufflehog removed: gitleaks provides sufficient coverage at lower cost. diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 628be9684..e46f4e890 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -46,4 +46,7 @@ jobs: # `gh run list --json conclusion`. Between these two faults, this repo's own # secret scanning did not execute — while a live credential sat in the tree. uses: ./.github/workflows/secret-scanner-reusable.yml - secrets: inherit + # No `secrets:` line, by design: the reusable declares no + # `workflow_call.secrets` and references no `secrets.*`. This file + # previously ended in `secrets: inherit`, forwarding every repo/org + # secret to a scanner that needs none (standards#1108 §6b). diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index 52c9d779d..06aa24e75 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -263,42 +263,6 @@ "expires_at": "2026-12-22", "tracking_issue": "hyperpolymath/standards#941" }, - { - "severity": "warn", - "rule_module": "workflow_hardening", - "type": "WH008", - "file": ".github/workflows/mirror.yml", - "note": "ACCEPTED ESTATE CONVENTION (hypatia triage 2026-09-22): reusable-workflow callers use `secrets: inherit` by design.", - "expires_at": "2026-12-22", - "tracking_issue": "hyperpolymath/standards#943" - }, - { - "severity": "warn", - "rule_module": "workflow_hardening", - "type": "WH008", - "file": ".github/workflows/hypatia-scan.yml", - "note": "ACCEPTED ESTATE CONVENTION (hypatia triage 2026-09-22): reusable-workflow callers use `secrets: inherit` by design.", - "expires_at": "2026-12-22", - "tracking_issue": "hyperpolymath/standards#943" - }, - { - "severity": "warn", - "rule_module": "workflow_hardening", - "type": "WH008", - "file": ".github/workflows/governance.yml", - "note": "ACCEPTED ESTATE CONVENTION (hypatia triage 2026-09-22): reusable-workflow callers use `secrets: inherit` by design.", - "expires_at": "2026-12-22", - "tracking_issue": "hyperpolymath/standards#943" - }, - { - "severity": "warn", - "rule_module": "workflow_hardening", - "type": "WH008", - "file": ".github/workflows/secret-scanner.yml", - "note": "ACCEPTED ESTATE CONVENTION (hypatia triage 2026-09-22): reusable-workflow callers use `secrets: inherit` by design.", - "expires_at": "2026-12-22", - "tracking_issue": "hyperpolymath/standards#943" - }, { "severity": "high", "rule_module": "workflow_hardening", diff --git a/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml index 03e34c77a..52887c357 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml @@ -17,5 +17,5 @@ jobs: permissions: contents: read security-events: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f + # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/1-formats/a2ml/meta/.github/workflows/scorecard.yml b/1-formats/a2ml/meta/.github/workflows/scorecard.yml index 0b978aa38..d614a336f 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard.yml @@ -15,5 +15,5 @@ jobs: permissions: contents: read security-events: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f + # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/1-formats/a2ml/meta/.github/workflows/secret-scanner.yml b/1-formats/a2ml/meta/.github/workflows/secret-scanner.yml index 2966fe5f6..ce60aa715 100644 --- a/1-formats/a2ml/meta/.github/workflows/secret-scanner.yml +++ b/1-formats/a2ml/meta/.github/workflows/secret-scanner.yml @@ -17,5 +17,5 @@ jobs: scan: permissions: contents: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f + # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml index 434aa88b6..ac1483460 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml @@ -23,5 +23,5 @@ jobs: permissions: contents: read security-events: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f + # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/2-protocols/0-ai-gatekeeper/.github/workflows/secret-scanner.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/secret-scanner.yml index 2966fe5f6..ce60aa715 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/secret-scanner.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/secret-scanner.yml @@ -17,5 +17,5 @@ jobs: scan: permissions: contents: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f + # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/CICD-WORKFLOW-CATALOG.md b/CICD-WORKFLOW-CATALOG.md index 1597287db..f886b1150 100644 --- a/CICD-WORKFLOW-CATALOG.md +++ b/CICD-WORKFLOW-CATALOG.md @@ -333,8 +333,14 @@ These should **call** the reusable workflow instead of copying: ```yaml jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@main - secrets: inherit + permissions: + contents: read + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@ + # Add a `secrets:` block ONLY when the callee declares named secrets + # under `workflow_call.secrets`, passing each by name. Never use + # `secrets: inherit` — it forwards every repo/org secret the caller + # can see (standards#1108 §6b). Most reusables (e.g. the secret + # scanner) declare none and need no `secrets:` line at all. ``` ### For Non-Reusable Workflows diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh new file mode 100755 index 000000000..f10aa7e39 --- /dev/null +++ b/tests/test_secret_scanner_no_inherit.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# test_secret_scanner_no_inherit.sh — the secret scanner's caller contract +# must not regress to `secrets: inherit`. (standards#1108 §6b) +# +# WHY THIS EXISTS +# +# `secret-scanner-reusable.yml` long carried a header claiming callers +# MUST pass `secrets: inherit` so `gitleaks/gitleaks-action` could see +# `secrets.GITHUB_TOKEN`. The claim died with #500 (gitleaks became a +# pinned local binary), but the header survived and was copied into +# generated callers estate-wide — including this repo's own wrapper — +# forwarding every repository and organisation secret to a scanner that +# declares no `workflow_call.secrets` and references no `secrets.*`. +# Nothing failed visibly: over-broad secret delegation is silent. +# Production evidence: metadatastician/vordr#39 ran all three scanner +# jobs green with no secrets passed at all. +# +# WHAT IT ASSERTS +# +# 1. The reusable's premise still holds: no `${{ secrets.* }}` +# expression, no `secrets:` declaration or mapping anywhere in it. +# (If a future job genuinely needs a secret, it must be declared +# narrowly in `workflow_call.secrets` and this test updated in the +# same commit — that deliberateness is the point.) +# 2. This repo's own caller (`secret-scanner.yml`) has no `secrets:` +# line of any kind. +# 3. No live workflow in this repo (`.github/workflows/` plus the two +# packaged template `.github/workflows/` dirs) carries a non-comment +# `secrets: inherit` directive, except the explicit SUPPORTED list +# below — template callers pinned to callees whose declared contract +# at that pin requires inheritance. A listed file that stops +# inheriting is reported as a STALE exemption and fails, so the +# list can only shrink. +# 4. No WH008 (`secrets: inherit`) suppression for the scanner caller +# remains in `.hypatia-baseline.json`: the finding was fixed, not +# silenced, so the suppression must not outlive the fix. +# 5. The poisoned pin `@a3308ea8…` — whose callee lockfile failed every +# scanner job at runner setup estate-wide — appears nowhere live. +# 6. Every workflow has a section in `actions.lock` (the missing-key +# gap `validate-actions-lock.sh` states it cannot see), with an +# EXPECTED_ABSENT list pending `gh actions-lock` regeneration; +# stale entries fail. The lock is machine-generated: never "fix" +# this by hand-editing it. +# 7. Positive control for 6: the `actions/checkout` pin the scanner +# reusable actually uses is keyed in the lock — the scanner's own +# transitive closure is present for callers to validate against. +# +# METHOD NOTE (YAML-POLICY Y-1 deviation, deliberate): these are ABSENCE +# assertions over raw text. Half the defect lives in comments and caller +# examples, which a YAML parser discards before any check could see +# them; parsing first would blind this test to exactly what it guards. +# Structural checks therefore run over non-comment lines, and every +# check states the population it examined so an empty read fails loudly +# instead of passing vacuously. +# +# Usage: tests/test_secret_scanner_no_inherit.sh [repo-root] +# Exit: 0 = contract holds, 1 = contract broken (or unreadable inputs). + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${1:-${SCRIPT_DIR}/..}" +REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" + +WF_DIR="$REPO_ROOT/.github/workflows" +REUSABLE="$WF_DIR/secret-scanner-reusable.yml" +CALLER="$WF_DIR/secret-scanner.yml" +LOCKFILE="$WF_DIR/actions.lock" +BASELINE="$REPO_ROOT/.hypatia-baseline.json" +DEAD_PIN="a3308ea8d0cc513727c8ad149b1132317a886c98" + +# Template callers pinned to callees whose contract at their pin predates +# explicit secret declaration; narrowing them before re-pinning would +# startup-fail (passing an undeclared secret is a validation error). +# Each line: pathreason. Shrinks as the templates are re-pinned. +SUPPORTED_INHERIT=( + "1-formats/a2ml/meta/.github/workflows/mirror.yml callee declares 7 SSH secrets; explicit form lands with the template re-pin (standards#943)" + "1-formats/a2ml/meta/.github/workflows/hypatia-scan.yml callee predates the declared HYPATIA_SCAN_PAT contract at its pin (standards#1108 §6b)" + "2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml callee declares 7 SSH secrets; explicit form lands with the template re-pin (standards#943)" + "2-protocols/0-ai-gatekeeper/.github/workflows/hypatia-scan.yml callee predates the declared HYPATIA_SCAN_PAT contract at its pin (standards#1108 §6b)" +) + +# Workflows with no `workflows:` section in actions.lock yet. The cure is +# `gh actions-lock` regeneration (machine-generated file), tracked in +# standards#1108. An entry that gains a section must be deleted here. +EXPECTED_ABSENT_FROM_LOCK=( + ".github/workflows/lock-sync-gate.yml" + ".github/workflows/scorecard.yml" + ".github/workflows/secret-scanner.yml" + ".github/workflows/self-ci.yml" +) + +fails=0 +fail() { echo "::error::no-inherit: $*"; fails=$((fails + 1)); } +note() { echo "no-inherit: $*"; } + +# --- Inputs must exist and be the real files (positive controls) -------- +for f in "$REUSABLE" "$CALLER" "$LOCKFILE" "$BASELINE"; do + [ -r "$f" ] || { fail "required input unreadable: $f"; } +done +[ "$fails" -eq 0 ] || { echo "::error::no-inherit: aborting on unreadable inputs"; exit 1; } + +grep -q '^name: Secret Scanner (reusable)$' "$REUSABLE" \ + || fail "$REUSABLE does not look like the scanner reusable (name line missing) — refusing to certify the wrong file" +grep -q 'workflow_call:' "$REUSABLE" \ + || fail "$REUSABLE has no workflow_call trigger — refusing to certify the wrong file" + +# --- 1. The reusable needs and declares no secrets ---------------------- +if grep -nE '\$\{\{[^}]*secrets\.' "$REUSABLE" >/dev/null; then + fail "reusable references the secrets context — the no-inherit premise is gone; declare the secret narrowly and update this test deliberately:" + grep -nE '\$\{\{[^}]*secrets\.' "$REUSABLE" | sed 's/^/ /' +fi +# Non-comment `secrets:` line anywhere in the reusable (declaration or +# mapping). Comment prose explaining the contract is not a declaration. +if grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -nE '^[[:space:]]+secrets:' >/dev/null; then + fail "reusable declares or maps \`secrets:\` — update the caller contract and this test deliberately:" + grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -nE '^[[:space:]]+secrets:' | sed 's/^/ /' +fi +# The rationale must stay documented, so the next editor is not tempted +# to "fix" a token problem by re-adding inherit. +grep -q 'declares no' "$REUSABLE" \ + || fail "reusable no longer documents that it declares no secrets — keep the rationale with the contract" + +# --- 2. This repo's own caller passes nothing --------------------------- +if grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' >/dev/null; then + fail "canonical caller secret-scanner.yml carries a \`secrets:\` line; the callee needs none:" + grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' | sed 's/^/ /' +fi + +# --- 3. No live `secrets: inherit` directive outside the supported list - +supported_path() { + local p="$1" entry + for entry in "${SUPPORTED_INHERIT[@]}"; do + [ "${entry%% *}" = "$p" ] && return 0 + done + return 1 +} + +scan_dirs=( + "$WF_DIR" + "$REPO_ROOT/1-formats/a2ml/meta/.github/workflows" + "$REPO_ROOT/2-protocols/0-ai-gatekeeper/.github/workflows" +) +scanned=0 +declare -a seen_supported=() +for dir in "${scan_dirs[@]}"; do + [ -d "$dir" ] || { fail "expected workflow dir missing: $dir"; continue; } + for f in "$dir"/*.yml "$dir"/*.yaml; do + [ -f "$f" ] || continue + scanned=$((scanned + 1)) + rel="${f#"$REPO_ROOT"/}" + # A directive is a non-comment line `secrets: inherit` (a trailing + # suppression comment such as `# NOSONAR` does not change that). + if grep -vE '^[[:space:]]*#' "$f" | grep -qE '^[[:space:]]*secrets:[[:space:]]*inherit([[:space:]]|$)'; then + if supported_path "$rel"; then + seen_supported+=("$rel") + else + fail "$rel uses \`secrets: inherit\` — narrow it to the callee's declared secrets (or drop it if the callee declares none)" + fi + fi + done +done +[ "$scanned" -gt 0 ] || fail "scanned 0 workflow files — extraction is broken, not clean" +note "scanned $scanned workflow file(s) for inherit directives" +for entry in "${SUPPORTED_INHERIT[@]}"; do + p="${entry%% *}" + found=0 + for s in "${seen_supported[@]:-}"; do [ "$s" = "$p" ] && found=1; done + [ "$found" -eq 1 ] || fail "SUPPORTED_INHERIT entry is stale (no longer inheriting): $p — delete it" +done + +# --- 4. No live WH008 suppression covering the scanner caller ----------- +if command -v jq >/dev/null 2>&1; then + if jq -e --arg f ".github/workflows/secret-scanner.yml" \ + '[.. | objects | select(.type? == "WH008" and .file? == $f)] | length > 0' \ + "$BASELINE" >/dev/null 2>&1; then + fail ".hypatia-baseline.json still suppresses WH008 for secret-scanner.yml — the fix removed the directive, so remove the suppression too" + fi +else + fail "jq not found — cannot verify the hypatia baseline; failing closed" +fi + +# --- 5. The poisoned pin appears nowhere live --------------------------- +dead_hits="$(grep -rln "$DEAD_PIN" \ + --exclude-dir=.git --exclude-dir=9-archive "$REPO_ROOT" 2>/dev/null \ + | grep -v "test_secret_scanner_no_inherit.sh" || true)" +if [ -n "$dead_hits" ]; then + fail "poisoned pin @$DEAD_PIN still referenced:" + printf '%s\n' "$dead_hits" | sed 's/^/ /' +fi + +# --- 6+7. Lockfile sections + the scanner's own closure ------------------ +wf_count=0 +for f in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do + [ -f "$f" ] || continue + wf_count=$((wf_count + 1)) + rel=".github/workflows/$(basename "$f")" + if grep -qF "'$rel':" "$LOCKFILE"; then + for a in "${EXPECTED_ABSENT_FROM_LOCK[@]}"; do + [ "$a" = "$rel" ] && fail "$rel now has a lock section — delete its EXPECTED_ABSENT_FROM_LOCK entry" + done + else + ok=0 + for a in "${EXPECTED_ABSENT_FROM_LOCK[@]}"; do [ "$a" = "$rel" ] && ok=1; done + [ "$ok" -eq 1 ] || fail "$rel has no section in actions.lock — regenerate with \`gh actions-lock\` (never hand-edit)" + fi +done +[ "$wf_count" -gt 0 ] || fail "found 0 workflows — extraction is broken, not clean" +note "checked $wf_count workflow file(s) against actions.lock sections" + +checkout_pin="$(grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -oE 'actions/checkout@[0-9a-f]{40}' | sort -u)" +[ -n "$checkout_pin" ] || fail "could not find the reusable's actions/checkout pin — extraction is broken" +while IFS= read -r pin; do + [ -n "$pin" ] || continue + grep -qF "'$pin'" "$LOCKFILE" \ + || fail "scanner reusable uses $pin but actions.lock does not key it — callers pinned to this commit will fail lock validation at startup" +done <<< "$checkout_pin" + +# --- Verdict --------------------------------------------------------------- +if [ "$fails" -gt 0 ]; then + echo "::error::no-inherit: $fails check(s) failed" + exit 1 +fi +echo "no-inherit: OK — scanner caller contract holds (no inherited secrets;" +echo " $scanned workflow(s) scanned, $wf_count lock section(s) checked)" +exit 0