diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 734dec811..d93ec8ef7 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -108,6 +108,40 @@ lock_has() { return 1 } +# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex +# SHA pin fail GitHub runner lockfile verification during `Set up job` +# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). +mapfile -t SELF_SHA_KEYS < <( + awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + pin = (n > 0) ? substr(cur, n + 1) : "" + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print tolower(cur) + } + cur = "" + } + ' "$LOCKFILE" | sort -u +) + +# Check a normalized owner/repo@sha key against the parsed self-SHA entries. +# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. +lock_has_self_sha() { + local needle="$1" k + for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do + [ "$k" = "$needle" ] && return 0 + done + return 1 +} + ERRORS=0 CHECKED=0 declare -a SEEN_ABSENT=() @@ -189,6 +223,10 @@ for ref in "${RAW[@]}"; do echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 echo " normalised to: $norm" >&2 ERRORS=$((ERRORS + 1)) + elif lock_has_self_sha "$norm"; then + echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 + echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 + ERRORS=$((ERRORS + 1)) fi done diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 24a50d8ee..de16c0924 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -35,7 +35,7 @@ workflows: '.github/workflows/ci-pipeline.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -109,7 +109,7 @@ workflows: - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@b935e63937b1f1b5cbaeea7e375947d7b924038f' + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' '.github/workflows/no-js-scan.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/pages-archive.yml': @@ -229,11 +229,6 @@ dependencies: commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' owner_id: 42048915 repo_id: 356423100 - 'dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067': - ref: 'v1' - commit: 'sha1-7e38f4b43b4db5c8dd498af069a4f6196df1d067' - owner_id: 1940490 - repo_id: 260749683 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f': ref: 'v1' commit: 'sha1-e2a55d2ffb04f378e9626c28d38b36d230d1e12f' @@ -250,12 +245,12 @@ dependencies: owner_id: 47606891 repo_id: 331103973 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': - ref: 'b96794f015dfd88f77b49b1c93e0fa7110f94c63' + ref: 'v4.38.0' commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': - ref: 'abea47f85e598557f500fa1fd2ab7464fcb39406' + ref: 'v2.2.1' commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' owner_id: 1006268 repo_id: 212984112 @@ -264,19 +259,16 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd': - ref: '571cc734cd69fb846032ec77a662aa8ee4fc32cd' - commit: 'sha1-571cc734cd69fb846032ec77a662aa8ee4fc32cd' - owner_id: 6759885 - repo_id: 1116521501 - 'hyperpolymath/standards@b935e63937b1f1b5cbaeea7e375947d7b924038f': - ref: 'b935e63937b1f1b5cbaeea7e375947d7b924038f' - commit: 'sha1-b935e63937b1f1b5cbaeea7e375947d7b924038f' + 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597': + ref: '427eb35c40552d912d8c757518e8326f0d20f597' + commit: 'sha1-427eb35c40552d912d8c757518e8326f0d20f597' owner_id: 6759885 repo_id: 1116521501 uses: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067' + - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7': ref: 'v3.9.0' @@ -304,7 +296,7 @@ dependencies: owner_id: 88700172 repo_id: 422287306 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: '6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + ref: 'v2.9.2' commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 @@ -314,7 +306,7 @@ dependencies: owner_id: 48932923 repo_id: 360548653 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'e83874834305fe9a4a2997156cb26c5de65a8555' + ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' owner_id: 135788 repo_id: 208510314 diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 9d2ec1478..6b69f765e 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -319,7 +319,7 @@ jobs: name: Secret scanning # Deliberately NOT gated on `detect`: a repo with no recognised ecosystem # can still be leaking credentials. - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 with: runs-on: ${{ inputs.runs-on }} @@ -330,7 +330,7 @@ jobs: name: Rust needs: detect if: ${{ needs.detect.outputs.has_rust == 'true' }} - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 with: runs-on: ${{ inputs.runs-on }} enable_coverage: ${{ inputs.enable_coverage }} diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 06cf2db37..0f1c5ba55 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 7d171db00..3314e28ee 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/mirror.yml b/.github/workflows/mirror.yml index 03ca9e537..9298823f9 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -14,7 +14,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@b935e63937b1f1b5cbaeea7e375947d7b924038f + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 # 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 diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml index dcb8a5bca..2eee815b0 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 d5201b962..b502c1cb6 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 52887c357..cfab025b8 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 d614a336f..22d2c8059 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 d5201b962..b502c1cb6 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 ac1483460..7bab5966f 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 f886b1150..c5d88db0e 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/scripts/close-lock.sh b/scripts/close-lock.sh index cc2f173d6..d74ff9c28 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -178,8 +178,24 @@ for pass in 1 2 3 4 5 6; do if ! printf '%s' "$sha" | grep -qxE '[0-9a-f]{40}'; then echo " SKIP $k (cannot resolve commit)"; skipped=$((skipped + 1)); continue fi + rec_ref="$ref" + if printf '%s' "$ref" | grep -qxE '[0-9a-f]{40}' && ! grep -qxF "$k$TAB" "$SC/owed"; then + # Consume all pages before choosing a tag. An early pipeline exit can + # SIGPIPE gh under pipefail and discard an otherwise valid result. + tag_ref="" + if "$GH_BIN" api "repos/$owner/$name/tags?per_page=100" --paginate \ + --jq '.[] | select(.commit.sha == "'"$sha"'") | .name' > "$SC/tags" 2>/dev/null; then + tag_ref=$(awk '/^[A-Za-z0-9._\/-]+$/ && !(length($0) == 40 && $0 ~ /^[0-9a-f]+$/) { if (!first) first = $0; if (!preferred && $0 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) preferred = $0 } END { if (preferred) print preferred; else if (first) print first }' "$SC/tags") + fi + if [ -z "$tag_ref" ]; then + echo " SKIP $k (cannot resolve a symbolic tag for leaf SHA)" + skipped=$((skipped + 1)) + continue + fi + rec_ref="$tag_ref" + fi { printf " '%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: %s\n repo_id: %s\n" \ - "$k" "$ref" "$sha" "${ids%% *}" "${ids##* }" + "$k" "$rec_ref" "$sha" "${ids%% *}" "${ids##* }" uses_block "$k"; } >> "$SC/new" echo " + $k" done < "$SC/miss" diff --git a/scripts/lock-selfcheck.sh b/scripts/lock-selfcheck.sh index aa8d3562b..b6f9b1a8d 100755 --- a/scripts/lock-selfcheck.sh +++ b/scripts/lock-selfcheck.sh @@ -110,6 +110,7 @@ for SHA in "$@"; do # Parse the lock into "\t" pairs. awk ' /^workflows:/ { in_wf = 1; next } + /^[a-z_]+:/ { in_wf = 0; next } !in_wf { next } # A workflow key line: \x27.github/workflows/foo.yml\x27: /^[[:space:]]+\x27[^\x27]+\x27:/ { @@ -128,6 +129,27 @@ for SHA in "$@"; do } ' "$TMP/lock" | sort -u > "$TMP/locked" + # Parse dependencies: entries whose `ref:` equals their own 40-hex SHA pin + # (self-SHA ref; rejected by the GitHub runner during Set up job, standards#1271). + awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + pin = (n > 0) ? substr(cur, n + 1) : "" + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print tolower(cur) + } + cur = "" + } + ' "$TMP/lock" | sort -u > "$TMP/self_sha_deps" + locked_pairs=$(wc -l < "$TMP/locked") locked_wfs=$(cut -f1 "$TMP/locked" | sort -u | wc -l) @@ -144,6 +166,7 @@ for SHA in "$@"; do tr 'A-Z' 'a-z' < "$TMP/locked" > "$TMP/locked.lc" : > "$TMP/missing" + : > "$TMP/self_sha_used" : > "$TMP/unkeyed_wf" : > "$TMP/scanned" : > "$TMP/unparsed" @@ -189,6 +212,8 @@ for SHA in "$@"; do printf '%s\t%s\n' "$wf" "$key" >> "$TMP/unkeyed_wf" elif ! /usr/bin/grep -qF "$(printf '%s\t%s' "$wf_lc" "$key_lc")" "$TMP/locked.lc"; then printf '%s\t%s\n' "$wf" "$key" >> "$TMP/missing" + elif /usr/bin/grep -qxF "$key_lc" "$TMP/self_sha_deps"; then + printf '%s\t%s\n' "$wf" "$key" >> "$TMP/self_sha_used" fi done done < "$TMP/wfs" @@ -196,6 +221,7 @@ for SHA in "$@"; do n_actions=$(/usr/bin/grep -c 'ACTION$' "$TMP/scanned" 2>/dev/null || echo 0) n_reusable=$(/usr/bin/grep -c 'REUSABLE-CALL$' "$TMP/scanned" 2>/dev/null || echo 0) n_missing=$(sort -u "$TMP/missing" 2>/dev/null | wc -l) + n_self_sha=$(sort -u "$TMP/self_sha_used" 2>/dev/null | wc -l) n_unkeyed=$(sort -u "$TMP/unkeyed_wf" 2>/dev/null | wc -l) n_unkeyed_wf=$(cut -f1 "$TMP/unkeyed_wf" 2>/dev/null | sort -u | wc -l) @@ -211,18 +237,28 @@ for SHA in "$@"; do echo " VERDICT: UNEXAMINED — yq could not parse $(sort -u "$TMP/unparsed" | wc -l) workflow(s); their refs were not checked:" sort -u "$TMP/unparsed" | sed 's/^/ /' overall_rc=1 - elif [ "$n_missing" -eq 0 ]; then + elif [ "$n_missing" -eq 0 ] && [ "$n_self_sha" -eq 0 ]; then echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock." echo " (Reachability at the remote is NOT proven by this check.)" else - echo " VERDICT: POISON — ${n_missing} action ref(s) used but NOT keyed in this SHA's own lock." - echo " Any caller pinned here dies at startup with 0 jobs and no stated reason." - sort -u "$TMP/missing" | while IFS=$'\t' read -r wf key; do - echo " ${wf}" - echo " uses ${key}" - locked_for=$(/usr/bin/grep -F "$(printf '%s\t' "$wf")" "$TMP/locked" | cut -f2 | sort -u | tr '\n' ' ') - echo " lock keys for this workflow: ${locked_for:-}" - done + if [ "$n_missing" -gt 0 ]; then + echo " VERDICT: POISON — ${n_missing} action ref(s) used but NOT keyed in this SHA's own lock." + echo " Any caller pinned here dies at startup with 0 jobs and no stated reason." + sort -u "$TMP/missing" | while IFS=$'\t' read -r wf key; do + echo " ${wf}" + echo " uses ${key}" + locked_for=$(/usr/bin/grep -F "$(printf '%s\t' "$wf")" "$TMP/locked" | cut -f2 | sort -u | tr '\n' ' ') + echo " lock keys for this workflow: ${locked_for:-}" + done + fi + if [ "$n_self_sha" -gt 0 ]; then + echo " VERDICT: POISON — ${n_self_sha} action ref(s) carry a self-SHA ref: in dependencies: (standards#1271)." + echo " GitHub runner verification fails at Set up job ('invalid lockfile pin')." + sort -u "$TMP/self_sha_used" | while IFS=$'\t' read -r wf key; do + echo " ${wf}" + echo " self-SHA ref ${key}" + done + fi overall_rc=1 fi done diff --git a/scripts/relock-sha-keys.sh b/scripts/relock-sha-keys.sh index 28ca4ecd9..35fafb0da 100755 --- a/scripts/relock-sha-keys.sh +++ b/scripts/relock-sha-keys.sh @@ -39,11 +39,12 @@ MERGED="" trap 'rm -f "$INLINE" "$TAGGED" "$MAP" "$TMPLOCK" "$MERGED"' EXIT find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 \ | xargs -0 -r awk ' - match($0, /uses:[ \t]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[0-9a-f]{40}/) { + match($0, /uses:[ \t]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[0-9a-f]+/) { s = substr($0, RSTART, RLENGTH) sub(/^uses:[ \t]*/, "", s) n = split(s, parts, "@") path = parts[1]; sha = parts[2] + if (length(sha) != 40) next split(path, seg, "/") print tolower(seg[1] "/" seg[2]) "\t" sha }' | sort -u > "$INLINE" @@ -63,7 +64,7 @@ find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 sub(/^uses:[ \t]*/, "", s) n = index(s, "@") path = substr(s, 1, n - 1); ref = substr(s, n + 1) - if (ref ~ /^[0-9a-f]{40}$/) next + if (length(ref) == 40 && ref ~ /^[0-9a-f]+$/) next split(path, seg, "/") print tolower(seg[1] "/" seg[2]) "\t" ref }' | sort -u > "$TAGGED" @@ -82,16 +83,16 @@ awk -v inline="$INLINE" -v tagged="$TAGGED" ' n = index(key, "@") action = substr(key, 1, n - 1); ref = substr(key, n + 1) # already SHA-keyed: nothing to do - if (ref ~ /^[0-9a-f]{40}$/) { cur = ""; next } + if (length(ref) == 40 && ref ~ /^[0-9a-f]+$/) { cur = ""; next } # still written as this tag somewhere: keep the tag key (see 1b) if ((tolower(action) SUBSEP ref) in intag) { cur = ""; next } cur = key; curaction = action; next } # its body carries commit: '\''sha1-'\'' - cur != "" && match($0, /commit:[ \t]*'\''sha1-[0-9a-f]{40}'\''/) { + cur != "" && match($0, /commit:[ \t]*'\''sha1-[0-9a-f]+'\''/) { c = substr($0, RSTART, RLENGTH) sub(/.*sha1-/, "", c); sub(/'\''.*/, "", c) - if ((tolower(curaction) SUBSEP c) in seen) print cur "\t" curaction "@" c + if (length(c) == 40 && ((tolower(curaction) SUBSEP c) in seen)) print cur "\t" curaction "@" c cur = "" } ' "$LOCK" > "$MAP" @@ -127,18 +128,39 @@ rm -f "$MAP.count" # already holds a SHA-keyed record for a commit and the tool added a # tag-keyed record for the same commit, step 3 leaves TWO records under one # key, and the lockfile reader rejects the file ("already defined"). Both -# describe the same commit, so the first is kept and the later one dropped -# with its body. A list (a workflow entry, a record's uses:) that now names -# the same ref twice keeps it once. +# describe the same commit, so prefer the record whose `ref:` is symbolic +# (tag/branch) over a self-SHA `ref:` (which fails GitHub runner lockfile +# verification; standards#1271) and drop the duplicate with its body. A list +# (a workflow entry, a record's uses:) that now names the same ref twice +# keeps it once. MERGED="$(mktemp)" awk ' + NR == FNR { + if ($0 ~ /^[a-z_]+:/) { p1_sec = $0; p1_k = ""; next } + if (p1_sec ~ /^dependencies:/ && $0 ~ /^ \047[^\047]+\047:/) { + p1_k = $0; sub(/:.*$/, "", p1_k) + occ[p1_k]++ + p1_i = occ[p1_k] + if (!(p1_k in chosen)) chosen[p1_k] = p1_i + next + } + if (p1_sec ~ /^dependencies:/ && p1_k != "" && $0 ~ /^ ref: \047[^\047]+\047$/) { + r = $0; sub(/^ ref: \047/, "", r); sub(/\047$/, "", r) + is_sym = !(length(r) == 40 && r ~ /^[0-9a-f]+$/) + if (is_sym && !chosen_sym[p1_k]) { + chosen[p1_k] = p1_i + chosen_sym[p1_k] = 1 + } + } + next + } /^[a-z_]+:/ { sec = $0; skip = 0; delete items; print; next } /^ \047[^\047]+\047:/ { delete items if (sec ~ /^dependencies:/) { k = $0; sub(/:.*$/, "", k) - if (k in seen) { skip = 1; merged++; next } - seen[k] = 1 + seen[k]++ + if (seen[k] != chosen[k]) { skip = 1; merged++; next } } skip = 0; print; next } @@ -151,7 +173,7 @@ awk ' } { delete items; print } END { print merged + 0 > "/dev/stderr" } -' "$TMPLOCK" > "$MERGED" 2>"$MAP.merged" +' "$TMPLOCK" "$TMPLOCK" > "$MERGED" 2>"$MAP.merged" merged="$(cat "$MAP.merged" 2>/dev/null || echo 0)" rm -f "$MAP.merged" "$TMPLOCK" mv "$MERGED" "$LOCK" diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index 2fd715a9a..fca38724d 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -39,6 +39,13 @@ case "\$2" in if [ -f "\$f" ]; then cat "\$f"; else echo '{"message":"Not Found","status":"404"}'; exit 1; fi ;; repos/o/missing*) echo '{"message":"Not Found","status":"404"}'; exit 1 ;; repos/o/garbled*) echo '{"messa'; exit 0 ;; + repos/o/untagged/tags*) exit 0 ;; + repos/o/tagerror/tags*) echo 'v1.2.3'; exit 1 ;; + repos/o/badtag/tags*) echo '{"message":"bad response"}'; exit 0 ;; + repos/o/laterpage/tags*) + case " \$* " in *' --paginate '*) echo 'v2.3.4' ;; *) exit 0 ;; esac ;; + repos/o/a/tags*|repos/o/b/tags*|repos/o/c/tags*) echo 'v1.0.0' ;; + repos/o/leaf/tags*) printf 'v4\nv4.38.0\n' ;; repos/o/*/commits/v1) echo $C ;; repos/o/*/commits/*) echo '{"message":"No commit found"}'; exit 1 ;; repos/o/*) echo '7 9' ;; @@ -71,6 +78,27 @@ run "$d" >/dev/null grep -qxF " ref: 'v1'" "$d/actions.lock" && grep -qxF " commit: 'sha1-$C'" "$d/actions.lock" \ && ok "tag ref recorded with its resolved commit" || bad "tag ref recorded with its resolved commit" +echo "=== resolves a leaf SHA ref to its upstream semver tag (standards#1271) ===" +d=$(mklock t2b "o/leaf@$A" /dev/null +grep -qxF " 'o/leaf@$A':" "$d/actions.lock" && grep -qxF " ref: 'v4.38.0'" "$d/actions.lock" \ + && grep -qxF " commit: 'sha1-$A'" "$d/actions.lock" \ + && ok "leaf SHA ref records upstream semver tag in ref: ('v4.38.0')" \ + || bad "leaf SHA ref records upstream semver tag in ref:" + +echo "=== leaf tag resolution fails closed ===" +for fixture in untagged tagerror badtag; do + d=$(mklock "tag-$fixture" "o/$fixture@$A" "$WF/actions.lock" <&1); st=$? && ok "a record's uses: list holds the merged ref once" || bad "a record's uses: list holds the merged ref once" [ "$(grep -c "^ commit: 'sha1-$A'" "$L")" = 1 ] \ && ok "the merged record's body appears once" || bad "the merged record's body appears once" +grep -qxF " ref: 'v7'" "$L" && ! grep -qxF " ref: '$A'" "$L" \ + && ok "merged record keeps the symbolic tag ref ('v7'), not the self-SHA ref" \ + || bad "merged record keeps the symbolic tag ref ('v7'), not the self-SHA ref" grep -qxF " 'actions/cache@$C':" "$L" && ! grep -qF "actions/cache@v4" "$L" \ && ok "plain re-key (no SHA record yet) still happens" || bad "plain re-key (no SHA record yet) still happens" grep -qxF " 'o/tagged@v2':" "$L" && grep -qxF " - 'o/tagged@v2'" "$L" \ diff --git a/scripts/tests/validate-actions-lock-test.sh b/scripts/tests/validate-actions-lock-test.sh index 2f20b6bc2..c62c1971b 100755 --- a/scripts/tests/validate-actions-lock-test.sh +++ b/scripts/tests/validate-actions-lock-test.sh @@ -116,6 +116,26 @@ reset_repo printf '{\n jobs: {\n' > "$WORK/repo/.github/workflows/ci.yml" expect "an unparseable workflow fails closed" 1 "ci.yml" +reset_repo +cat >> "$WORK/repo/.github/workflows/actions.lock" < "$WORK/repo/.github/workflows/ci.yml" <reason. 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,66 @@ 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: report each named caller secret missing from +# the reusable's workflow_call.secrets via fail, incrementing the failure count. +# Skip the comparison if either file is unreadable. +check_declared() { + 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,12 +271,43 @@ 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} /^dependencies:/{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" +# --- 8. actions.lock has no leaf self-SHA refs --------------------------- +self_sha_leaves="$(awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + nwo = (n > 0) ? substr(cur, 1, n - 1) : cur + pin = (n > 0) ? substr(cur, n + 1) : "" + if (tolower(nwo) != "hyperpolymath/standards" && length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print cur + } + cur = "" + } +' "$LOCKFILE")" +if [ -n "$self_sha_leaves" ]; then + fail "actions.lock contains leaf action entries with ref equal to their own SHA pin (standards#1271):" + printf '%s\n' "$self_sha_leaves" | sed 's/^/ /' +fi + # --- Verdict --------------------------------------------------------------- if [ "$fails" -gt 0 ]; then echo "::error::no-inherit: $fails check(s) failed"