From ca43509dfc54e42b43e15ccb797c8ad28d58d664 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:45:11 +0100 Subject: [PATCH 1/8] ci: support pull-request Scorecard analysis in the shared workflow --- .github/workflows/actions.lock | 4 +- .github/workflows/scorecard-reusable.yml | 51 ++++++++++++++++++++---- .github/workflows/scorecard.yml | 12 ++++-- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index fb983aa9c..bce8bbfd9 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -182,7 +182,7 @@ dependencies: owner_id: 47606891 repo_id: 331103973 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938': - ref: 'v4.37.9' + ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' owner_id: 9919 repo_id: 259445878 @@ -202,7 +202,7 @@ dependencies: owner_id: 1841483 repo_id: 220799100 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': - ref: 'v2.4.4' + ref: '2d1146689b8cda280b9bc96326124645441f03bc' commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' owner_id: 67707773 repo_id: 421101922 diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index c09abc2c6..c6cddaa97 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -1,3 +1,4 @@ +# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: OSSF Scorecard Reusable Workflow @@ -5,21 +6,22 @@ name: OSSF Scorecard Reusable Workflow on: workflow_call: -# NOTE: This workflow requires security-events: write and id-token: write -# to publish scorecard results. Callers MUST grant these permissions. -# The previous attempt to add only `actions: read` at top-level (0ced540e) failed -# because it exceeded caller grants. The fix is to declare ALL required permissions -# at the top-level so callers know what to grant. +# Callers must grant contents: read, security-events: write and id-token: write. +# Only default-branch publication receives OIDC; PR analysis cannot publish +# unmerged results to the public Scorecard dataset or badge. permissions: contents: read - security-events: write - id-token: write jobs: scorecard: + if: github.event_name != 'pull_request' name: Run Scorecard runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + security-events: write + id-token: write steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -34,7 +36,7 @@ jobs: # publish_results feeds the public OpenSSF API and the badge, and is # INDEPENDENT of results_format -- so emitting SARIF does not cost us # the badge. Both outputs are live with this shape. - publish_results: true + publish_results: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} # RESTORED 2026-08-25. Commit 5a93d9d5 (PR #393, "Ci/gitleaks self hosted # fix", 2026-06-21) deleted this step and switched results_format to json @@ -54,3 +56,36 @@ jobs: name: scorecard-results path: results.sarif retention-days: 90 + + pull-request: + if: github.event_name == 'pull_request' + name: Run Scorecard PR + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout pull request + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # OpenSSF labels pull_request support experimental. Upload actual results; + # a failed scan or upload must fail the workflow, not become a clean gate. + - name: Analyze pull request + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: results.sarif + - name: Retain scan evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-pr-results + path: results.sarif + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0f4b09e49..a0d96c27f 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,8 +1,12 @@ +# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: Scorecard on: + push: + branches: [main] + pull_request: schedule: - cron: '0 0 * * 0' workflow_dispatch: @@ -15,11 +19,13 @@ concurrency: # id-token: write is the OIDC token Scorecard uses to publish results; # security-events: write uploads the SARIF. permissions: - actions: read contents: read - security-events: write - id-token: write jobs: scorecard: + permissions: + actions: read + contents: read + security-events: write + id-token: write uses: ./.github/workflows/scorecard-reusable.yml From 60a64bb464c88b9f52cca2a9f10b3a0a70d2de84 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:15:11 +0100 Subject: [PATCH 2/8] fix(ci): pin the Hypatia scanner to a commit instead of cloning its moving tip `validate-hypatia-baseline` is a REQUIRED status check on roughly 120 consumer repositories, and 342 of the 349 callers pin this reusable to a 40-character SHA. That care was defeated inside the job: the "Resolve Hypatia HEAD commit" step ran `git ls-remote ... HEAD` and the clone step ran `git clone --depth 1`, so every run scanned with whatever hyperpolymath/hypatia's default branch held at that moment. A consumer could turn red with no change on its own side, and the SHA in the caller's `uses:` protected nothing about what actually executed. Now the step emits a fixed HYPATIA_PIN (hypatia main tip 0e913426e20282accb49d2fa5d14d5bedbc5a6c2, "fix(security): validate actions lock integrity (#741)", 2026-09-06) and the clone fetches exactly that commit with `git fetch --depth 1 origin ` followed by `checkout FETCH_HEAD`. The cache key already embedded the resolved SHA, so it keeps working unchanged: a future bump misses the cache and rebuilds the escript by construction. The bump procedure is recorded in-file next to the pin, and a comment records why the v1.0.0 tag must never be used (not an ancestor of main, over 1,000 commits behind). No `uses:` reference changes, so `actions.lock` is untouched. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/governance-reusable.yml | 38 +++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 9f2184ed3..bb51a23b3 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -195,20 +195,27 @@ jobs: elixir-version: '1.19.4' otp-version: '28.3' - - name: Resolve Hypatia HEAD commit + - name: Resolve pinned Hypatia commit if: needs.workflow-staleness.outputs.has_baseline == 'true' id: hypatia-rev run: | - # Pin the cache to the *current* Hypatia main tip. Resolved before the - # cache step because cache restore happens before the clone, so the key - # cannot hash a not-yet-cloned tree — it must hash the remote ref. - sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) - if [ -z "$sha" ]; then - echo "ERROR: could not resolve hypatia HEAD via git ls-remote" >&2 - exit 1 - fi - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Resolved hypatia HEAD: $sha" + # PINNED, not floating. This job is a REQUIRED status check on ~120 + # caller repos, and every caller pins THIS workflow to a SHA. Cloning + # Hypatia's moving tip here defeated all of those pins: the scanner's + # ruleset changed under every consumer whenever hypatia main advanced, + # so a caller could go red with no change on its side. + # + # Bump procedure (one edit, one PR on standards, then re-pin callers): + # 1. sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git refs/heads/main | cut -f1) + # 2. replace HYPATIA_PIN below with that sha (verify it is on main: + # gh api repos/hyperpolymath/hypatia/commits/$sha) + # 3. the cache key below embeds the sha, so a bump misses the cache and + # rebuilds the escript; no manual cache invalidation needed. + # Never pin a tag here: hypatia v1.0.0 (2026-01-10) is not an ancestor + # of main and is >1,000 commits behind it. + HYPATIA_PIN=0e913426e20282accb49d2fa5d14d5bedbc5a6c2 + echo "sha=$HYPATIA_PIN" >> "$GITHUB_OUTPUT" + echo "Pinned hypatia commit: $HYPATIA_PIN" - name: Cache Hex/Mix and Scanner Build if: needs.workflow-staleness.outputs.has_baseline == 'true' @@ -227,11 +234,16 @@ jobs: # the rebuild, reintroducing the staleness. key: hypatia-scanner-v3-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} - - name: Clone Hypatia + - name: Clone Hypatia at the pinned commit if: needs.workflow-staleness.outputs.has_baseline == 'true' run: | + # ~/hypatia is restored from the sha-keyed cache above when present; + # only clone on a cache miss, and only ever the pinned commit. if [ ! -d "$HOME/hypatia" ]; then - git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" + git init -q "$HOME/hypatia" + git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git + git -C "$HOME/hypatia" fetch -q --depth 1 origin "${{ steps.hypatia-rev.outputs.sha }}" + git -C "$HOME/hypatia" checkout -q FETCH_HEAD fi - name: Build Hypatia scanner From 0eda9f2570c1834ea8bb62ccf074c0c0087b5cfe Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:52:37 +0100 Subject: [PATCH 3/8] ci(scorecard): check out the PR head SHA so the analysis satisfies the ruleset The "Shared scanner content safety" ruleset requires a Scorecard code-scanning analysis on the PR's head or merge commit. actions/checkout on a pull_request event checks out refs/pull/N/merge, which GitHub regenerates whenever main moves, so the analysis binds to a merge commit that stops matching before the PR can merge. Checking out github.event.pull_request.head.sha instead binds the upload to the head commit, which is stable for the life of the push. codeql-action's getRef() rewrites refs/pull/N/merge to refs/pull/N/head when HEAD differs from GITHUB_SHA, so the upload lands on the head SHA without any further change. --- .github/workflows/scorecard-reusable.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index c6cddaa97..aa4b92e5e 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -70,6 +70,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} # OpenSSF labels pull_request support experimental. Upload actual results; # a failed scan or upload must fail the workflow, not become a clean gate. - name: Analyze pull request From 703b610b8cb37d82e12caa2f4170c39fa7c008fe Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:53:56 +0100 Subject: [PATCH 4/8] ci(scorecard): fix indentation of the checkout ref key --- .github/workflows/scorecard-reusable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index aa4b92e5e..016fd8cd3 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} # OpenSSF labels pull_request support experimental. Upload actual results; # a failed scan or upload must fail the workflow, not become a clean gate. - name: Analyze pull request From 7d33e8f45ae5018244295e4271cac3bca2320d8d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:28:45 +0100 Subject: [PATCH 5/8] ci(scorecard): give PR scans all three Scorecard categories The "Shared scanner content safety" ruleset evaluates code scanning per (tool, category). scorecard-action forces Local mode on pull_request events, so a PR upload carries only supply-chain/local while the scheduled main run carries branch-protection and online-scm as well. Every PR therefore fails with "expecting 2 results from Scorecard". Local proof with the v5.5.0 CLI: a repo-mode scan at the PR head SHA skips the repo-state checks (2 categories, no branch-protection); only a HEAD scan yields all 3. So the PR job now installs the pinned scorecard CLI (release tarball, sha256-verified), runs exactly the seven repo-level checks at repository HEAD with the policy file taken from scorecard-action's pinned SHA, and appends those two runs to the scorecard-action SARIF before the existing upload. The local category remains the scorecard-action measurement of the checked-out PR tree. The step fails unless exactly three categories result. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/scorecard-reusable.yml | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 016fd8cd3..7f5a28cef 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -79,6 +79,53 @@ jobs: results_file: results.sarif results_format: sarif publish_results: false + # scorecard-action forces Local (filesystem) mode on pull_request events, + # so the PR run only ever produces the supply-chain/local category. The + # code-scanning ruleset evaluates Scorecard per category and main's + # scheduled run also carries supply-chain/branch-protection and + # supply-chain/online-scm, so a PR is "expecting 2 results" forever. + # Those two categories are repo-state properties (branch protection, CI, + # code review, maintenance), not properties of a commit, and scorecard + # refuses to compute them for a non-HEAD --commit. So measure them at + # repository HEAD with the pinned scorecard CLI, restricted to exactly the + # repo-level checks, and merge the runs into the same SARIF. The local + # category stays the scorecard-action measurement of the PR tree. + - name: Install scorecard CLI (pinned + checksum-verified) + run: | + set -euo pipefail + SCORECARD_VERSION=5.5.0 + SHA256=83b90a05c1540ef1390db1cd5711e5fd04be9c1d8537fb84d39d02092d6a8dff + cd "$RUNNER_TEMP" + curl -fsSL -o scorecard.tar.gz \ + "https://github.com/ossf/scorecard/releases/download/v${SCORECARD_VERSION}/scorecard_${SCORECARD_VERSION}_linux_amd64.tar.gz" + echo "${SHA256} scorecard.tar.gz" | sha256sum -c - + tar -xzf scorecard.tar.gz scorecard + chmod +x scorecard + ./scorecard version + - name: Measure repo-level checks at HEAD + env: + ENABLE_SARIF: "1" + GITHUB_AUTH_TOKEN: ${{ github.token }} + # Policy file from scorecard-action at the same pinned SHA as above. + POLICY_URL: https://raw.githubusercontent.com/ossf/scorecard-action/2d1146689b8cda280b9bc96326124645441f03bc/policies/template.yml + POLICY_SHA256: 6e75dcc0df989d333492c0e1f6e484fc5b4d26ab768333ddcd0e3788539689b0 + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/policy.yml" "$POLICY_URL" + echo "${POLICY_SHA256} $RUNNER_TEMP/policy.yml" | sha256sum -c - + "$RUNNER_TEMP/scorecard" \ + --repo="github.com/${GITHUB_REPOSITORY}" \ + --checks=Branch-Protection,CI-Tests,CII-Best-Practices,Code-Review,Contributors,Maintained,Signed-Releases \ + --format=sarif --policy="$RUNNER_TEMP/policy.yml" --show-details \ + > "$RUNNER_TEMP/repo-state.sarif" + # Merge: keep every run from the PR-tree scan, append the repo-level + # runs. Fail if the result is not exactly the three categories. + jq -s '.[0] as $pr | .[1] as $head | $pr | .runs += $head.runs' \ + results.sarif "$RUNNER_TEMP/repo-state.sarif" > merged.sarif + mv merged.sarif results.sarif + jq -r '.runs[].automationDetails.id' results.sarif + n=$(jq '[.runs[].automationDetails.id | split("/")[0:2] | join("/")] | unique | length' results.sarif) + test "$n" -eq 3 || { echo "expected 3 Scorecard categories, got $n" >&2; exit 1; } - name: Upload SARIF to code scanning uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: From fccb5cf5d5c78754a663597c82ba53ccdc91eaa5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:36:20 +0100 Subject: [PATCH 6/8] ci(scorecard): enforce HTTPS on the two pinned downloads SonarCloud (githubactions:S6506) flags curl -L without a protocol restriction: a redirect could in principle land on plain HTTP. Both downloads are checksum-verified afterwards, so a downgrade could not smuggle content in, but there is no reason to permit one at all. Restrict the initial request and any redirect to HTTPS. Verified locally with curl 8.14: both URLs still fetch and both sha256 checks still pass under the restricted protocol set. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/scorecard-reusable.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 7f5a28cef..8750b94ac 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -96,7 +96,7 @@ jobs: SCORECARD_VERSION=5.5.0 SHA256=83b90a05c1540ef1390db1cd5711e5fd04be9c1d8537fb84d39d02092d6a8dff cd "$RUNNER_TEMP" - curl -fsSL -o scorecard.tar.gz \ + curl -fsSL --proto "=https" --proto-redir "=https" -o scorecard.tar.gz \ "https://github.com/ossf/scorecard/releases/download/v${SCORECARD_VERSION}/scorecard_${SCORECARD_VERSION}_linux_amd64.tar.gz" echo "${SHA256} scorecard.tar.gz" | sha256sum -c - tar -xzf scorecard.tar.gz scorecard @@ -111,7 +111,7 @@ jobs: POLICY_SHA256: 6e75dcc0df989d333492c0e1f6e484fc5b4d26ab768333ddcd0e3788539689b0 run: | set -euo pipefail - curl -fsSL -o "$RUNNER_TEMP/policy.yml" "$POLICY_URL" + curl -fsSL --proto "=https" --proto-redir "=https" -o "$RUNNER_TEMP/policy.yml" "$POLICY_URL" echo "${POLICY_SHA256} $RUNNER_TEMP/policy.yml" | sha256sum -c - "$RUNNER_TEMP/scorecard" \ --repo="github.com/${GITHUB_REPOSITORY}" \ From b5c729b69b0759b9fb3e9fb1bdec43fe9a444209 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:54:13 +0100 Subject: [PATCH 7/8] fix(governance-reusable): move token permissions from workflow level to job level SonarCloud githubactions:S8264 on this PR flags the workflow-level `permissions:` block ("Move this read permission from workflow level to job level"). Thirteen of the fifteen jobs already declare their own block, which overrides the workflow-level one entirely; only workflow-staleness and validate-hypatia-baseline inherited it. Give those two jobs an explicit `contents: read` and delete the workflow-level block. Neither job needs `actions: read`: the staleness script only calls the public compare endpoint, and the Hypatia job only checks out, caches and runs the scanner. With every job now explicit, removing the workflow-level default cannot widen any job's token. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/governance-reusable.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index bb51a23b3..e16e2e7fa 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -16,13 +16,11 @@ on: description: Fine-grained token with repository Administration read access required: false -permissions: - actions: read - contents: read - jobs: workflow-staleness: name: Check Workflow Staleness + permissions: + contents: read runs-on: ${{ inputs.runs-on }} timeout-minutes: 10 outputs: @@ -173,6 +171,8 @@ jobs: validate-hypatia-baseline: name: Validate Hypatia Baseline + permissions: + contents: read needs: workflow-staleness # NOTE: deliberately NO job-level `if:`. This context is REQUIRED by branch # protection, and a skipped job never satisfies a required context — so a From 61ab49105868fafccf5655cbb0a0dc1d2190414c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:06:26 +0100 Subject: [PATCH 8/8] fix(governance-reusable): declare an empty workflow-level permissions map The S8264 cure (b5c729b6) removed the workflow-level `permissions:` block because Sonar githubactions:S8264 flags read grants declared at workflow level. That turned two estate gates red on the same file: - the workflow-security self-lint step greps for `^permissions:` and reported "missing top-level 'permissions:' declaration" - Hypatia workflow_audit `missing_permissions` (~r/^permissions:/m) raised one new medium finding, failing Validate Hypatia Baseline `permissions: {}` satisfies both regex gates (a declaration exists) while granting nothing at workflow level, so every grant stays at job level as S8264 requires. All 15 jobs keep their job-level permissions blocks. Whether Sonar tolerates an empty map is verified live on this push; if it does not, the three gates are genuinely contradictory and that is reported, not papered over. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/governance-reusable.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index e16e2e7fa..f65dde72d 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -16,6 +16,8 @@ on: description: Fine-grained token with repository Administration read access required: false +permissions: {} + jobs: workflow-staleness: name: Check Workflow Staleness