From 10ea1ef3db678f606ae42c1848336bd174fc031c Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:29:43 +0000 Subject: [PATCH] test: lock in the action SHA pins and workflow-level permissions with the ci-stages suite (E00-S05-T01) - assertHardening: every uses: ref is a full 40-char commit SHA equal to the committed PINNED_ACTIONS table (no floating tags) and the workflow declares a top-level permissions: contents: read block - mutation probes: reverting a pin to @v4, swapping a pinned SHA or removing the permissions block all fail --- tests/ci-stages.test.mjs | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/ci-stages.test.mjs b/tests/ci-stages.test.mjs index 4794284..0103250 100644 --- a/tests/ci-stages.test.mjs +++ b/tests/ci-stages.test.mjs @@ -15,6 +15,13 @@ * compiles the whole apps group (`./apps/**` — apps/server today, the * admin app when E06-S01 lands) and verifies the compiled server * artifact. + * - "CI workflow pins third-party actions (actions/checkout, + * actions/setup-node) to full commit SHAs, not floating tags" → every + * `uses:` ref is a 40-char commit SHA pinned to the committed + * PINNED_ACTIONS values — no `@v4`-style floating tags. + * - "CI workflow declares minimal permissions (`permissions: contents: + * read`) at the workflow level" → the workflow declares a top-level + * `permissions:` block granting exactly `contents: read`. * - every committed test suite under tests/ is wired into exactly one stage * (`pnpm lint` runs the formatting-policy suite; every other suite is * named by a `node --test` run in the unit, architecture or @@ -54,6 +61,16 @@ const REQUIRED_STAGES = [ /** The root lint command the formatting-lint stage must run. */ const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs'; +/** + * The third-party actions the workflow may use, pinned to the full commit + * SHA of a released version (E00-S05-T01 hardening). Updating a pin means + * updating this table and the workflow together, in the same PR. + */ +const PINNED_ACTIONS = { + 'actions/checkout': '11bd71901bbe5b1630ceea73d27597364c9af683', // v4.2.2 + 'actions/setup-node': '1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a', // v4.2.0 +}; + /** * Parses the workflow's `jobs:` section (the committed file is 2-space * indented YAML) into `{ order, jobs }` where `order` lists job keys in @@ -106,6 +123,56 @@ function suitesNamedInRuns(runs) { return out; } +/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@". */ +function usesRefs(yamlText) { + const refs = []; + for (const line of yamlText.split('\n')) { + // `uses:` appears either as a bare key or as a sequence item ("- uses:"). + const match = /^\s*(?:-\s+)?uses:\s*(\S+)/.exec(line); + if (match) refs.push(match[1]); + } + return refs; +} + +/** + * Asserts the E00-S05-T01 hardening criteria: every third-party `uses:` ref + * is pinned to a full 40-char commit SHA (exactly the committed PINNED_ACTIONS + * values — no floating tags) and the workflow declares `permissions: + * contents: read` at the top level. Throws an AssertionError describing the + * first violated invariant. + */ +function assertHardening(yamlText) { + const refs = usesRefs(yamlText); + assert.ok(refs.length > 0, 'the workflow must use at least one third-party action'); + for (const ref of refs) { + const match = /^([\w.-]+\/[\w.-]+)@([0-9a-f]{40})$/.exec(ref); + assert.ok( + match, + `every third-party action must be pinned to a full 40-char commit SHA, not a floating tag (got "${ref}")`, + ); + assert.ok( + Object.hasOwn(PINNED_ACTIONS, match[1]), + `unexpected third-party action "${match[1]}" — add it to the PINNED_ACTIONS policy table if it is approved`, + ); + assert.equal( + match[2], + PINNED_ACTIONS[match[1]], + `"${match[1]}" must be pinned to the committed full commit SHA ${PINNED_ACTIONS[match[1]]} (got ${match[2]})`, + ); + } + for (const action of Object.keys(PINNED_ACTIONS)) { + assert.ok( + refs.some((ref) => ref.startsWith(`${action}@`)), + `the workflow must use "${action}" pinned to a full commit SHA`, + ); + } + assert.match( + yamlText, + /^permissions:\n[ \t]+contents: read$/m, + 'the workflow must declare a top-level "permissions: contents: read" block', + ); +} + /** * Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an * AssertionError describing the first violated invariant. @@ -231,6 +298,10 @@ test('the root lint script runs the formatting-policy suite', () => { ); }); +test('the workflow pins third-party actions to full commit SHAs and declares minimal permissions', () => { + assertHardening(read(WORKFLOW)); +}); + // --------------------------------------------------------------------------- // Mutation probes — the baseline assertions are non-vacuous // --------------------------------------------------------------------------- @@ -270,3 +341,30 @@ test('reordering the stages fails the baseline (mutation probe)', () => { assert.notEqual(mutated, text, 'the probe must mutate the workflow'); assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/); }); + +test('reverting an action pin to a floating tag fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace( + `actions/checkout@${PINNED_ACTIONS['actions/checkout']}`, + 'actions/checkout@v4', + ); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /full 40-char commit SHA/); +}); + +test('changing a pinned action SHA fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace( + `actions/setup-node@${PINNED_ACTIONS['actions/setup-node']}`, + `actions/setup-node@${'a'.repeat(40)}`, + ); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /must be pinned to the committed full commit SHA/); +}); + +test('removing the workflow-level permissions block fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace(/^permissions:\n contents: read\n\n/m, ''); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /permissions: contents: read/); +});