diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 06cf2db3..0f1c5ba5 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -21,6 +21,15 @@ on: type: string required: false default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 + secrets: + HYPATIA_SCAN_PAT: + description: >- + Fine-grained read-only PAT used to read Dependabot, code-scanning + and secret-scanning alerts; the scan falls back to the built-in + GITHUB_TOKEN when it is not supplied. Declared explicitly so + callers pass ONLY this secret instead of `secrets: inherit`, + which forwarded every caller secret (standards#1108 §6b). + required: false permissions: actions: read diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 7d171db0..3314e28e 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -34,7 +34,14 @@ # jobs: # mirror: # uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@ -# secrets: inherit +# 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 }} name: Mirror to Git Forges (reusable) @@ -399,7 +406,7 @@ jobs: with: fetch-depth: 0 # All Radicle steps gate on secrets.RADICLE_KEY being set on the - # caller repo (resolved via `secrets: inherit`). Without this gate + # caller repo (passed explicitly under that declared name). Without this gate # the workflow burned ~3 minutes of Rust+Radicle install on every # push to every RADICLE_MIRROR_ENABLED repo only to fail at # `~/.radicle/keys/radicle: No such file or directory` because the diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml index dcb8a5bc..2eee815b 100644 --- a/.github/workflows/secret-scanner-reusable.yml +++ b/.github/workflows/secret-scanner-reusable.yml @@ -91,7 +91,8 @@ # permissions: # contents: read # uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@ -# secrets: inherit +# # Deliberately NO `secrets:` line — the callee declares none and +# # needs none (see the Design note above). name: Secret Scanner (reusable) diff --git a/1-formats/a2ml/meta/.github/workflows/mirror.yml b/1-formats/a2ml/meta/.github/workflows/mirror.yml index d5201b96..b502c1cb 100644 --- a/1-formats/a2ml/meta/.github/workflows/mirror.yml +++ b/1-formats/a2ml/meta/.github/workflows/mirror.yml @@ -15,4 +15,14 @@ jobs: actions: read contents: read uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + # Exactly the seven optional secrets the callee declares at this pin + # (verified declared ⊇ referenced) — not `secrets: inherit` + # (standards#1108 §6b). + 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/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml index 52887c35..cfab025b 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml @@ -14,8 +14,13 @@ permissions: jobs: scorecard: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write 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 d614a336..22d2c805 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard.yml @@ -12,8 +12,13 @@ permissions: read-all jobs: analysis: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write 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/mirror.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml index d5201b96..b502c1cb 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml @@ -15,4 +15,14 @@ jobs: actions: read contents: read uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + # Exactly the seven optional secrets the callee declares at this pin + # (verified declared ⊇ referenced) — not `secrets: inherit` + # (standards#1108 §6b). + 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/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml index ac148346..7bab5966 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml @@ -20,8 +20,13 @@ permissions: jobs: scorecard: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-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 f886b115..c5d88db0 100644 --- a/CICD-WORKFLOW-CATALOG.md +++ b/CICD-WORKFLOW-CATALOG.md @@ -333,7 +333,12 @@ These should **call** the reusable workflow instead of copying: ```yaml jobs: governance: + # Grant exactly what the callee declares (governance-reusable asks for + # actions: read + contents: read). A called workflow can never exceed + # its caller's grant, and a job-level block REPLACES the workflow-level + # one — under-granting here rejects the run at startup. permissions: + actions: read contents: read uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@ # Add a `secrets:` block ONLY when the callee declares named secrets diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh index f10aa7e3..c87e58b4 100755 --- a/tests/test_secret_scanner_no_inherit.sh +++ b/tests/test_secret_scanner_no_inherit.sh @@ -77,9 +77,7 @@ DEAD_PIN="a3308ea8d0cc513727c8ad149b1132317a886c98" # 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)" ) @@ -130,6 +128,63 @@ if grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' >/dev/ grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' | sed 's/^/ /' fi +# --- 2b. Documented caller EXAMPLES must not teach inherit ------------- +# The directive scan below reads non-comment lines (a directive is live +# YAML), but the original §6b defect propagated precisely through the +# commented examples generators copy. So the example blocks in the two +# reusables that document a caller are asserted separately: extract from +# the "Caller example" marker to the first non-comment line and forbid +# the inherit string there (in any role — an example has no business +# containing it at all). +for ex_file in "$REUSABLE" "$WF_DIR/mirror-reusable.yml"; do + [ -r "$ex_file" ] || { fail "example-bearing reusable unreadable: $ex_file"; continue; } + example="$(awk '/Caller example/{f=1} f && /^[^#]/{exit} f{print}' "$ex_file")" + [ -n "$example" ] || { fail "no caller example found in $ex_file — extraction is broken"; continue; } + if printf '%s +' "$example" | grep -q 'secrets:[[:space:]]*inherit'; then + fail "caller example in $(basename "$ex_file") still teaches \`secrets: inherit\` — examples are what generators copy" + fi +done + +# --- 2c. Narrowed callers may pass only secrets the callee declares ---- +# Passing a secret the callee does not declare is a startup validation +# error (this is not theoretical: it startup-failed hypatia-scan on main +# when the caller was narrowed one commit before the declaration +# landed). For each standards wrapper that passes named secrets to a +# LOCAL reusable, every passed name must be declared in that reusable's +# workflow_call.secrets mapping. These workflows use two-space indentation; +# inspect only direct secret keys, not inputs or nested metadata. +check_declared() { # caller reusable + local caller="$1" reusable="$2" name declared + [ -r "$caller" ] && [ -r "$reusable" ] || return 0 + declared="$(awk ' + /^[[:space:]]*(#|$)/ { next } + /^ workflow_call:/ { call=1; next } + call && /^ {0,2}[^ ]/ { exit } + call && /^ secrets:[[:space:]]*(#.*)?$/ { secrets=1; next } + secrets && /^ [^ ]/ { exit } + secrets && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + sub(/^ /, ""); sub(/:.*/, ""); print + } + ' "$reusable")" + while IFS= read -r name; do + if ! grep -qxF -- "$name" <<< "$declared"; then + fail "$(basename "$caller") passes secret $name but $(basename "$reusable") does not declare it in workflow_call.secrets — every run startup-fails" + fi + done < <(awk ' + /^[[:space:]]*(#|$)/ { next } + /^jobs:/ { jobs=1; next } + jobs && /^[^ ]/ { exit } + jobs && /^ secrets:[[:space:]]*(#.*)?$/ { secrets=1; next } + secrets && /^ {0,4}[^ ]/ { secrets=0 } + secrets && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + sub(/^ /, ""); sub(/:.*/, ""); print + } + ' "$caller") +} +check_declared "$WF_DIR/hypatia-scan.yml" "$WF_DIR/hypatia-scan-reusable.yml" +check_declared "$WF_DIR/governance.yml" "$WF_DIR/governance-reusable.yml" + # --- 3. No live `secrets: inherit` directive outside the supported list - supported_path() { local p="$1" entry @@ -213,10 +268,16 @@ 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" +# Look the pin up inside the scanner reusable's OWN lock section, not the +# whole file: checkout is keyed under nearly every workflow, so a global +# search would pass even if the scanner's section lost its record. +lock_section="$(awk "/^ '\\.github\\/workflows\\/secret-scanner-reusable\\.yml':/{f=1;next} /^ '\\.github\\/workflows\\//{f=0} f{print}" "$LOCKFILE")" +[ -n "$lock_section" ] || fail "actions.lock has no section for secret-scanner-reusable.yml — regenerate with \`gh actions-lock\`" 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" + printf '%s +' "$lock_section" | grep -qF "'$pin'" \ + || fail "scanner reusable uses $pin but its actions.lock section does not key it — callers pinned to this commit will fail lock validation at startup" done <<< "$checkout_pin" # --- Verdict ---------------------------------------------------------------