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
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:
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user