test: lock in the action SHA pins and workflow-level permissions with the ci-stages suite (E00-S05-T01)
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 4m11s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m24s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 51s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m49s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 4m2s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m15s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m43s

- 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
This commit is contained in:
implementer
2026-08-30 06:29:43 +00:00
parent d0c3b6a83d
commit 10ea1ef3db
+98
View File
@@ -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@<sha>". */
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/);
});