From c5a7cfa72509e0596273b5ecd4b931d61f622f89 Mon Sep 17 00:00:00 2001 From: MongLong0214 Date: Sun, 9 Aug 2026 09:26:28 +0900 Subject: [PATCH] Name every action by a tree that cannot change under it Scoping the release-write grant removed the worst thing a changed action could do; it did not stop one from running. Every workflow referenced `actions/checkout@v4` and `actions/setup-node@v4`, and a major tag is a moving pointer: the same reference can resolve to different code tomorrow, in jobs that read this repository and hold their own tokens. Twenty-six references now name a digest. The versions do not move -- both digests are what `v4` resolves to today, verified against the GitHub API -- so this changes what the reference guarantees rather than what runs. A test fails on any `uses:` that is not a forty-character digest, because the next workflow someone adds will be written by copying an existing line, and copying is how the property would quietly end. One correction rides along. A test was named for keeping the canonical commit at the event sha, which is a property of the workflow wiring rather than of the script it drives; the case itself shows the resolver answering for the commit it is handed. It says that now, and the workflow-shape case next to it asserts the other half. A name that claims more than its body runs is the failure this suite exists to catch. Limit: pinning fixes the tree, not its behaviour -- a pinned action still runs with the job's token and can read this repository, and upgrading now requires deliberately resolving a new digest rather than inheriting one Ruled-out: upgrading to the latest major while pinning | two changes in one, and a version bump that arrives inside a security fix gets reviewed as a security fix Ruled-out: a dependency bot to keep digests current | the pin is what removes silent movement, and a bot that updates it automatically restores the property being removed Warn: no comment can keep a digest honest, so the `# v4` beside each is a label rather than a check; the version it names is verified once, here Blast: system Undo: easy Certainty: firm Verified: both digests resolve to the current `v4` tags through the API, all four workflows still parse as YAML, no unpinned `uses:` remains outside local `./` references, and the thirty-four release-gate cases pass including the new one Provenance: authored Record-Id: r-pindigest --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/demo-lint.yml | 8 ++++---- .github/workflows/demo-preserve.yml | 4 ++-- .github/workflows/release.yml | 22 +++++++++++----------- test/release-tag-binding.test.ts | 28 ++++++++++++++++++++++++++-- 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0a5f53..54d20c1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: # the newest makes the floor a claim nobody checks. node: [22, 24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # test/dogfood.test.ts validates this repository's own commit history. # A shallow clone would truncate it, and the suite is written to fail @@ -27,7 +27,7 @@ jobs: # to make it do that on every run. fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node }} cache: npm @@ -189,10 +189,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -230,10 +230,10 @@ jobs: install-script: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -353,8 +353,8 @@ jobs: # the runner's six-hour default and prints nothing. timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 diff --git a/.github/workflows/demo-lint.yml b/.github/workflows/demo-lint.yml index d2a1a79b..9625fa93 100644 --- a/.github/workflows/demo-lint.yml +++ b/.github/workflows/demo-lint.yml @@ -25,7 +25,7 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # The action refuses a shallow clone rather than lint whatever # subset of history the default depth happened to bring. @@ -43,7 +43,7 @@ jobs: echo "origin carries no refs/notes/commitlore yet -- nothing to mirror" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -67,7 +67,7 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 @@ -79,7 +79,7 @@ jobs: echo "origin carries no refs/notes/commitlore yet -- nothing to mirror" fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm diff --git a/.github/workflows/demo-preserve.yml b/.github/workflows/demo-preserve.yml index dbb443e2..7da60c54 100644 --- a/.github/workflows/demo-preserve.yml +++ b/.github/workflows/demo-preserve.yml @@ -33,7 +33,7 @@ jobs: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # `refs/pull//merge` is gone once the pull request is closed, so # the default ref for this event does not resolve. The merge commit @@ -62,7 +62,7 @@ jobs: git fetch --no-tags --force origin \ '+refs/pull/${{ github.event.pull_request.number }}/head:refs/commitlore/pr-head' - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bf41b40..2851367a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: # Read for the workflow; the ability to create a release belongs to the one job # that creates one. Granting `contents: write` here hands it to the gates too, -# and the gates run `actions/checkout@v4` and `actions/setup-node@v4` — mutable +# and the gates run `actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4` and `actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4` — mutable # major tags whose contents can change under the same reference. Anything # executing inside a gate with a release-write token could create the release # before that gate, or any other, had finished, which would make the ordering @@ -43,11 +43,11 @@ jobs: outputs: commit: ${{ steps.ancestry.outputs.sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.sha }} fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - id: ancestry @@ -69,10 +69,10 @@ jobs: needs: release-target runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.release-target.outputs.commit }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -90,11 +90,11 @@ jobs: needs: release-target runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.release-target.outputs.commit }} fetch-depth: 1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Every required CI check succeeded at the tagged commit @@ -113,11 +113,11 @@ jobs: env: RELEASE_COMMIT: ${{ needs.release-target.outputs.commit }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.release-target.outputs.commit }} fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -269,10 +269,10 @@ jobs: env: RELEASE_COMMIT: ${{ needs.release-target.outputs.commit }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.release-target.outputs.commit }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 diff --git a/test/release-tag-binding.test.ts b/test/release-tag-binding.test.ts index dbd1cb48..7a757a64 100644 --- a/test/release-tag-binding.test.ts +++ b/test/release-tag-binding.test.ts @@ -14,7 +14,7 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,6 +29,7 @@ const TAG_BINDING = join(REPO_ROOT, 'scripts', 'check-tag-binding.mjs'); const RELEASE_TARGET = join(REPO_ROOT, 'scripts', 'check-release-target.mjs'); const EXACT_HEAD_CI = join(REPO_ROOT, 'scripts', 'check-exact-head-ci.mjs'); const RELEASE_WORKFLOW = join(REPO_ROOT, '.github', 'workflows', 'release.yml'); +const WORKFLOW_DIR = join(REPO_ROOT, '.github', 'workflows'); const TAG = 'v9.9.9'; const REF = `refs/tags/${TAG}`; @@ -224,7 +225,11 @@ describe('#499 the published tag is the commit the gates qualified', () => { expect(result.stderr).toContain(`now resolves to ${second}`); }); - it('keeps the canonical commit at the event sha when the tag moves before the resolver runs', () => { + // Paired with the workflow-shape case asserting the resolver is handed + // `$GITHUB_SHA`: this one shows the script answers for the commit it is + // given, not for whatever the tag names now. Neither half claims the other's + // property, and only together do they close the window. + it('answers for the commit it is handed, not the one the tag now names', () => { // The window this closes: a move landing between the push and the first // job. Resolving the tag name here would hand every downstream gate the // attacker's commit and they would all agree, greenly, on the wrong one. @@ -495,6 +500,25 @@ describe('#499 every release boundary consumes one canonical sha', () => { expect(parsed.jobs['publish']?.permissions?.['contents']).toBe('write'); }); + // Scoping the release-write grant removed the worst consequence of a changed + // action; it did not stop one from running. A digest names one immutable + // tree, so an action cannot become something else under the same reference. + it('references every action by digest rather than a movable tag', () => { + const unpinned: string[] = []; + for (const file of readdirSync(WORKFLOW_DIR).filter((name) => name.endsWith('.yml'))) { + const raw = readFileSync(join(WORKFLOW_DIR, file), 'utf8'); + for (const line of raw.split('\n')) { + const match = /uses:\s*(\S+)/.exec(line); + if (match === null) continue; + const ref = match[1] ?? ''; + if (ref.startsWith('./')) continue; + if (!/@[0-9a-f]{40}$/.test(ref)) unpinned.push(`${file}: ${ref}`); + } + } + + expect(unpinned).toEqual([]); + }); + it('grants no privilege for an action the workflow does not contain', () => { const raw = readFileSync(RELEASE_WORKFLOW, 'utf8'); const parsed = load(raw) as { permissions?: Record };