From ee87f20deb08733caa2b096486766cfa4640ad3d Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Thu, 30 Jul 2026 16:25:09 -0400 Subject: [PATCH] Pin every workflow action to a commit hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code scanning enforces a blanket hash policy, but only against changed files — so it fires on a workflow someone edits and stays silent on the sixteen references already here. Every action in this repo floats on a tag. A tag is a moving pointer. Whoever controls the action repo can repoint `@v3` at new code, and every workflow picks it up on the next run with no diff and no review. That is the shape of the tj-actions/changed-files compromise. A hash cannot move. All sixteen references across four workflows are pinned, each carrying its version in a trailing comment so the file stays readable. Every hash was resolved from its tag and verified against the action repo: the commit each one points at has that version as its message. Two things keep it that way. `test/workflows.test.mjs` fails on any external `uses:` that is not a 40-character hash, or any hash without a version comment — both verified to fire. And a Dependabot config, because pinning without it just freezes actions at whatever they were, security fixes included; Dependabot opens a PR when the tracked tag moves. --- .github/dependabot.yml | 14 ++++++ .github/workflows/create-release-pr.yml | 4 +- .github/workflows/lint-test.yml | 10 ++-- .github/workflows/main.yml | 6 +-- .github/workflows/publish-release.yml | 12 ++--- test/workflows.test.mjs | 66 +++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 test/workflows.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..fadc0d22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 + +updates: + # Actions are pinned to commit hashes, which means they never move on their own — + # a pinned action is frozen at whatever it was when someone typed it, security + # fixes included. Dependabot is what makes pinning safe rather than merely static: + # it opens a PR when the tag a pin tracks moves, keeping the trailing version + # comment and the hash in step. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: 'ci' diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index ef29a321..502fbbfa 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: true @@ -40,7 +40,7 @@ jobs: # branch for all git operations and the release PR. ref: ${{ github.event.inputs.base-branch }} - - uses: MetaMask/action-create-release-pr@v5 + - uses: MetaMask/action-create-release-pr@15b416cc72f8fde9f784896a79d42defba2e2251 # v5.1.0 with: release-type: ${{ github.event.inputs.release-type }} release-version: ${{ github.event.inputs.release-version }} diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 19059b56..5aa7d710 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -15,7 +15,7 @@ jobs: node-version: [20.x, 22.x, 24.x] steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} @@ -30,7 +30,7 @@ jobs: node-version: [24.x] steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} @@ -58,7 +58,7 @@ jobs: node-version: [20.x, 22.x, 24.x] steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} @@ -80,7 +80,7 @@ jobs: node-version: [20.x, 22.x, 24.x] steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} @@ -105,7 +105,7 @@ jobs: node-version: [24.x] steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e7335a7e..c66bddb4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: false - name: Download actionlint @@ -32,7 +32,7 @@ jobs: analyse-code: name: Analyse code needs: check-workflows - uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@v2 + uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@becb242930b3cc271c26da0280050db9c157e291 # v2.1.1 with: scanner-ref: v2 paths-ignored: | @@ -93,7 +93,7 @@ jobs: IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} runs-on: ubuntu-latest steps: - - uses: MetaMask/action-is-release@v2 + - uses: MetaMask/action-is-release@3cd51b98fa98d1347d06f5961299b0172ee31ae8 # v2.3.0 id: is-release publish-release: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 16c31167..382b3781 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: true ref: ${{ github.sha }} - name: Dry Run Publish - uses: MetaMask/action-npm-publish@v6 + uses: MetaMask/action-npm-publish@18df42148c35aabb98e00f9fda127d421af141df # v6.5.0 with: slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} subteam: S042S7RE4AE # @metamask-npm-publishers @@ -37,12 +37,12 @@ jobs: id-token: write steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: true ref: ${{ github.sha }} - name: Publish - uses: MetaMask/action-npm-publish@v6 + uses: MetaMask/action-npm-publish@18df42148c35aabb98e00f9fda127d421af141df # v6.5.0 with: # This `NPM_TOKEN` needs to be manually set to publish a package for # the first time only. @@ -59,10 +59,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 + uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 with: is-high-risk-environment: true ref: ${{ github.sha }} - - uses: MetaMask/action-publish-release@v3 + - uses: MetaMask/action-publish-release@f01f1be110d60fb07d86c880ce3d6bdb353524d3 # v3.3.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/test/workflows.test.mjs b/test/workflows.test.mjs new file mode 100644 index 00000000..fec17406 --- /dev/null +++ b/test/workflows.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; + +const WORKFLOWS = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '.github', + 'workflows', +); + +// `uses: owner/repo@ref` and `uses: owner/repo/path/to.yml@ref`, but not `uses: ./local`. +const EXTERNAL_USES = /^\s*-?\s*uses:\s*([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)@(\S+)/u; +const COMMIT_SHA = /^[0-9a-f]{40}$/u; + +function workflowFiles() { + return readdirSync(WORKFLOWS) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .map((name) => ({ name, body: readFileSync(path.join(WORKFLOWS, name), 'utf8') })); +} + +function externalRefs() { + const refs = []; + for (const { name, body } of workflowFiles()) { + body.split('\n').forEach((line, i) => { + const m = EXTERNAL_USES.exec(line); + if (m) { + refs.push({ file: name, line: i + 1, action: m[1], ref: m[2], raw: line.trim() }); + } + }); + } + return refs; +} + +describe('workflow action references', () => { + // A tag is a moving pointer: whoever controls the action repo can repoint `@v3` at new + // code, and every workflow here picks it up on the next run with no diff and no review. + // That is the vector behind the tj-actions/changed-files compromise. A hash cannot move. + test('every external action is pinned to a commit hash', () => { + const floating = externalRefs() + .filter((r) => !COMMIT_SHA.test(r.ref)) + .map((r) => `${r.file}:${r.line} → ${r.action}@${r.ref}`); + assert.deepEqual( + floating, + [], + 'pin to the full 40-character commit hash, with the tag in a trailing comment ' + + '(e.g. `uses: owner/action@ # v1.2.3`)', + ); + }); + + // A bare hash is unreadable — nobody can tell v3.5.0 from a random commit at a glance, + // and Dependabot uses the comment to know which tag the pin is tracking. + test('every pinned action records its version in a trailing comment', () => { + const unlabelled = externalRefs() + .filter((r) => COMMIT_SHA.test(r.ref)) + .filter((r) => !/#\s*v?\d+(\.\d+)*/u.test(r.raw)) + .map((r) => `${r.file}:${r.line} → ${r.action}`); + assert.deepEqual(unlabelled, [], 'add a trailing `# v` comment beside the hash'); + }); + + test('at least one external reference exists, so the checks above are not vacuous', () => { + assert.ok(externalRefs().length > 0, 'expected external action references to check'); + }); +});