From c54c24ae59f4e8f877256891717b562925a9302b Mon Sep 17 00:00:00 2001 From: Zk-nd3r Date: Tue, 4 Aug 2026 11:01:33 -0700 Subject: [PATCH] Harden npm release supply chain --- .github/workflows/ci.yml | 21 +-- .github/workflows/publish.yml | 73 ++++++++++- PROVENANCE.md | 16 +++ package.json | 2 +- scripts/check-release.mjs | 12 +- scripts/check-supply-chain.mjs | 200 +++++++++++++++++++++++++++++ scripts/test-prepublish-gate.mjs | 58 +++++++++ scripts/test-release-gate.mjs | 8 ++ scripts/test-supply-chain-gate.mjs | 198 ++++++++++++++++++++++++++++ scripts/verify-prepublish.mjs | 46 +++++++ 10 files changed, 619 insertions(+), 15 deletions(-) create mode 100644 scripts/check-supply-chain.mjs create mode 100644 scripts/test-prepublish-gate.mjs create mode 100644 scripts/test-supply-chain-gate.mjs create mode 100644 scripts/verify-prepublish.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e238307..6bda82e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,28 +8,33 @@ on: jobs: node: + permissions: + contents: read runs-on: ubuntu-latest strategy: matrix: node: [18, 20, 22] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: ${{ matrix.node }} - cache: npm - - run: npm ci + package-manager-cache: false + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm audit signatures - run: npm test - run: npm pack --dry-run browser: + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - cache: npm - - run: npm ci + package-manager-cache: false + - run: npm ci --ignore-scripts --no-audit --no-fund - run: npx --no-install playwright install --with-deps chromium - run: npm run test:packed diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4e9db5..12652dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,15 +7,18 @@ on: permissions: contents: read - id-token: write concurrency: group: npm-publish-${{ github.ref }} cancel-in-progress: false jobs: - npm: + verify: + permissions: + contents: read runs-on: ubuntu-latest + outputs: + preflight: ${{ steps.preflight.outputs.payload }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -26,15 +29,75 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" package-manager-cache: false - - run: npm ci + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm audit signatures - run: npm test - run: npx --no-install playwright install --with-deps chromium - run: npm run test:packed env: ZAP1_PACK_RESULT: ${{ runner.temp }}/zap1-preflight.json - - name: Publish from exact tagged checkout + - run: git diff --exit-code -- dist + - id: preflight shell: bash - run: npm publish --access public --provenance --json > "$RUNNER_TEMP/zap1-publish.raw.json" + run: echo "payload=$(base64 -w0 "$RUNNER_TEMP/zap1-preflight.json")" >> "$GITHUB_OUTPUT" + + publish: + needs: verify + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + outputs: + result: ${{ steps.publish.outputs.payload }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + fetch-tags: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + - name: Decode verified preflight + shell: bash + run: printf '%s' '${{ needs.verify.outputs.preflight }}' | base64 -d > "$RUNNER_TEMP/zap1-preflight.json" + - name: Verify exact tagged checkout + shell: bash + run: INIT_CWD="$PWD" node scripts/check-release.mjs + - name: Pack exact checkout without lifecycle scripts + shell: bash + run: npm pack --ignore-scripts --json > "$RUNNER_TEMP/zap1-publish.pack.json" + - name: Match preflight artifact before publication + shell: bash + run: node scripts/verify-prepublish.mjs "$RUNNER_TEMP/zap1-preflight.json" "$RUNNER_TEMP/zap1-publish.pack.json" + - name: Publish verified checkout without lifecycle scripts + shell: bash + run: npm publish --ignore-scripts --access public --provenance --json > "$RUNNER_TEMP/zap1-publish.raw.json" + - name: Export publication result + id: publish + shell: bash + run: echo "payload=$(base64 -w0 "$RUNNER_TEMP/zap1-publish.raw.json")" >> "$GITHUB_OUTPUT" + + verify-registry: + needs: [verify, publish] + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + fetch-tags: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false - name: Verify registry source and artifact provenance shell: bash + run: printf '%s' '${{ needs.verify.outputs.preflight }}' | base64 -d > "$RUNNER_TEMP/zap1-preflight.json" + - shell: bash + run: printf '%s' '${{ needs.publish.outputs.result }}' | base64 -d > "$RUNNER_TEMP/zap1-publish.raw.json" + - shell: bash run: node scripts/verify-published.mjs "$RUNNER_TEMP/zap1-preflight.json" "$RUNNER_TEMP/zap1-publish.raw.json" diff --git a/PROVENANCE.md b/PROVENANCE.md index f966834..b599c87 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -1,5 +1,21 @@ # Release provenance +## Future release pipeline hardening (August 4, 2026) + +Dependency installation runs with lifecycle scripts disabled against an exact +three-entry development lock graph. CI actions are pinned by full commit SHA. +Verification, browser testing, and source-derived preflight packing run without +OIDC publication authority. + +The isolated OIDC job executes no project dependency or downloaded +browser/test tool; it uses only full-SHA-pinned GitHub setup actions and the +runner's Node/npm. It accepts only an annotated tag reachable from +`origin/main`, packs the committed checkout with lifecycle scripts disabled, +and requires its name, version, file set, integrity, and shasum to match the +verified preflight before directory publication. Publication also disables +lifecycle scripts. Registry source and provenance checks run afterward in a +separate job without OIDC. + ## 0.2.1 The `0.2.0` npm artifact was byte-identical to the GitHub release asset and all diff --git a/package.json b/package.json index 668ac99..f2f566f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepublishOnly": "node scripts/check-release.mjs", "postpack": "node scripts/check-release-postpack.mjs", "prepack": "npm run clean && npm run build && npm test", - "test": "node test/test.js && node scripts/test-release-gate.mjs", + "test": "node scripts/check-supply-chain.mjs && node scripts/test-supply-chain-gate.mjs && node scripts/test-prepublish-gate.mjs && node test/test.js && node scripts/test-release-gate.mjs", "test:browser": "node test/browser-test.mjs", "test:packed": "node scripts/test-packed.mjs" }, diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index a04cf65..49e69e1 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { readFile, realpath } from "node:fs/promises"; import { join } from "node:path"; @@ -57,6 +57,16 @@ const tagTarget = git( `refs/tags/${expectedTag}^{commit}`, ); assert.equal(head, tagTarget, `${expectedTag} must resolve to HEAD`); +const mainAncestry = spawnSync( + "git", + ["merge-base", "--is-ancestor", head, "refs/remotes/origin/main"], + { cwd: packageRoot, stdio: "ignore" }, +); +assert.equal( + mainAncestry.status, + 0, + `${expectedTag} must point to a commit reachable from origin/main`, +); if (process.env.GITHUB_ACTIONS === "true") { assert.equal( diff --git a/scripts/check-supply-chain.mjs b/scripts/check-supply-chain.mjs new file mode 100644 index 0000000..7ce4799 --- /dev/null +++ b/scripts/check-supply-chain.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +const packageJson = JSON.parse(await readFile("package.json", "utf8")); +const packageLock = JSON.parse(await readFile("package-lock.json", "utf8")); + +for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "bundledDependencies", + "bundleDependencies", +]) { + assert.equal( + Object.keys(packageJson[field] ?? {}).length, + 0, + `${field} must remain empty: ZAP1 ships with zero runtime dependencies`, + ); +} + +for (const hook of ["preinstall", "install", "postinstall", "prepare"]) { + assert.equal( + Object.hasOwn(packageJson.scripts ?? {}, hook), + false, + `consumer-executed lifecycle hook is forbidden: ${hook}`, + ); +} + +assert.equal(packageLock.lockfileVersion, 3); +assert.equal(packageLock.packages?.[""]?.name, packageJson.name); +assert.equal(packageLock.packages?.[""]?.version, packageJson.version); +assert.deepEqual(packageLock.packages?.[""]?.devDependencies, { + playwright: "1.61.1", +}); + +const expectedPackages = new Map([ + ["node_modules/fsevents", { + version: "2.3.2", + resolved: "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + integrity: "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + }], + ["node_modules/playwright", { + version: "1.61.1", + resolved: "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + integrity: "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + }], + ["node_modules/playwright-core", { + version: "1.61.1", + resolved: "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + integrity: "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + }], +]); + +const installedPackages = Object.entries(packageLock.packages ?? {}).filter( + ([path]) => path !== "", +); +assert.deepEqual( + installedPackages.map(([path]) => path).sort(), + [...expectedPackages.keys()].sort(), + "lock graph changed; review and explicitly update the supply-chain policy", +); +for (const [path, expected] of expectedPackages) { + const actual = packageLock.packages[path]; + assert.deepEqual( + { + version: actual?.version, + resolved: actual?.resolved, + integrity: actual?.integrity, + }, + expected, + `${path} identity or registry integrity changed`, + ); +} + +const scriptedDependencies = installedPackages + .filter(([, value]) => value.hasInstallScript === true) + .map(([path]) => path); +assert.deepEqual(scriptedDependencies, ["node_modules/fsevents"]); +assert.equal(packageLock.packages["node_modules/fsevents"].optional, true); +assert.deepEqual(packageLock.packages["node_modules/fsevents"].os, ["darwin"]); + +const lockGraphDigest = createHash("sha256") + .update(JSON.stringify(packageLock.packages)) + .digest("hex"); +assert.equal( + lockGraphDigest, + "ad24c330e040d801b7b7785e397d1acc2290f4f83c04aeac2b56bf8858cb1fa3", + "canonical lock graph digest changed", +); + +const workflowDirectory = join(".github", "workflows"); +const workflowFiles = (await readdir(workflowDirectory)).filter((name) => + /\.ya?ml$/u.test(name), +); +const allowedActions = new Set([ + "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + "actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38", +]); +const workflowSources = new Map(); +let installCommands = 0; +for (const name of workflowFiles) { + const source = await readFile(join(workflowDirectory, name), "utf8"); + workflowSources.set(name, source); + assert.doesNotMatch(source, /\t/u, `${name} may not contain tabs`); + assert.doesNotMatch( + source, + /^\s*-\s*\{/mu, + `${name} may not use flow-style step maps`, + ); + assert.doesNotMatch( + source, + /^\s*(?:-\s*)?["'](?:run|uses)["']\s*:/mu, + `${name} may not quote critical step keys`, + ); + assert.doesNotMatch( + source, + /^\s*<<\s*:/mu, + `${name} may not use YAML merge aliases`, + ); + assert.doesNotMatch( + source, + /^\s*(?:-\s*)?run\s*:\s*[>|]\s*$/mu, + `${name} uses a block-scalar run command that the policy cannot inspect`, + ); + const usesKeys = source.match(/^\s*(?:-\s*)?uses\s*:/gmu) ?? []; + const uses = [ + ...source.matchAll(/^\s*(?:-\s*)?uses\s*:\s+([^\s#]+)/gmu), + ]; + assert.equal( + uses.length, + usesKeys.length, + `${name} contains an unparseable uses key`, + ); + for (const match of uses) { + assert.ok( + allowedActions.has(match[1]), + `${name} uses an unapproved or mutable action reference: ${match[1]}`, + ); + } + for (const line of source.split(/\r?\n/u)) { + const run = line.match(/^\s*(?:-\s*)?run\s*:\s+(.+?)\s*$/u); + if (run && /(?:^|\s)npm ci(?:\s|$)/u.test(run[1])) { + installCommands += 1; + assert.equal( + run[1], + "npm ci --ignore-scripts --no-audit --no-fund", + `${name} must suppress dependency lifecycle scripts`, + ); + } + } +} +assert.ok(installCommands > 0, "no npm ci commands were inspected"); + +const publishWorkflow = workflowSources.get("publish.yml"); +assert.ok(publishWorkflow, "publish.yml is required"); +assert.equal( + publishWorkflow.match(/id-token:\s*write/gu)?.length ?? 0, + 1, + "exactly one job may receive an OIDC token", +); +assert.doesNotMatch( + publishWorkflow.slice(0, publishWorkflow.indexOf("\njobs:")), + /id-token:\s*write/u, + "workflow-wide OIDC permission is forbidden", +); + +const publishStart = publishWorkflow.search(/^ publish:\s*$/mu); +assert.notEqual(publishStart, -1, "publish job is required"); +const afterPublish = publishWorkflow.slice(publishStart + 1); +const nextJobOffset = afterPublish.search(/^ [a-zA-Z0-9_-]+:\s*$/mu); +assert.notEqual(nextJobOffset, -1, "publish job boundary is missing"); +const publishJob = afterPublish.slice(0, nextJobOffset); +assert.match(publishJob, /\n\s{6}id-token:\s*write\s*$/mu); +assert.doesNotMatch( + publishJob, + /(?:^|\s)(?:npm ci|npm test|npx|pnpm|yarn|curl|wget|pip)(?:\s|$)/u, + "OIDC publish job may not install dependencies or run third-party tools", +); + +const publishRuns = [ + ...publishJob.matchAll(/^\s*(?:-\s*)?run\s*:\s+(.+?)\s*$/gmu), +].map((match) => match[1]); +assert.deepEqual( + publishRuns, + [ + "printf '%s' '${{ needs.verify.outputs.preflight }}' | base64 -d > \"$RUNNER_TEMP/zap1-preflight.json\"", + "INIT_CWD=\"$PWD\" node scripts/check-release.mjs", + "npm pack --ignore-scripts --json > \"$RUNNER_TEMP/zap1-publish.pack.json\"", + "node scripts/verify-prepublish.mjs \"$RUNNER_TEMP/zap1-preflight.json\" \"$RUNNER_TEMP/zap1-publish.pack.json\"", + "npm publish --ignore-scripts --access public --provenance --json > \"$RUNNER_TEMP/zap1-publish.raw.json\"", + "echo \"payload=$(base64 -w0 \"$RUNNER_TEMP/zap1-publish.raw.json\")\" >> \"$GITHUB_OUTPUT\"", + ], + "OIDC publish job command set changed", +); + +console.log( + `supply-chain policy passed: zero runtime deps, ${installedPackages.length} exact dev lock entries, lifecycle scripts suppressed`, +); diff --git a/scripts/test-prepublish-gate.mjs b/scripts/test-prepublish-gate.mjs new file mode 100644 index 0000000..73b2279 --- /dev/null +++ b/scripts/test-prepublish-gate.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const fixture = await mkdtemp(join(tmpdir(), "zap1-prepublish-gate-")); +const script = resolve("scripts/verify-prepublish.mjs"); +const preflightPath = join(fixture, "preflight.json"); +const packPath = join(fixture, "pack.json"); +const tarballName = "candidate.tgz"; +const tarballPath = join(fixture, tarballName); +const base = { + name: "@frontiercompute/zap1", + version: "9.9.9", + integrity: "sha512-fixture", + shasum: "fixture", + filename: tarballName, + files: [ + { path: "dist/index.js", size: 10, mode: 420 }, + { path: "package.json", size: 20, mode: 420 }, + ], +}; + +function gate() { + return spawnSync(process.execPath, [script, preflightPath, packPath], { + cwd: fixture, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +async function stage(candidate = base) { + await writeFile(preflightPath, `${JSON.stringify(base)}\n`, "utf8"); + await writeFile(packPath, `${JSON.stringify([candidate])}\n`, "utf8"); + await writeFile(tarballPath, "fixture", "utf8"); +} + +try { + await stage(); + assert.equal(gate().status, 0, "identical pack must pass"); + await assert.rejects(readFile(tarballPath), { code: "ENOENT" }); + + await stage({ ...base, integrity: "sha512-mismatch" }); + assert.notEqual(gate().status, 0, "integrity mismatch must fail"); + await assert.rejects(readFile(tarballPath), { code: "ENOENT" }); + + await stage({ + ...base, + files: [...base.files, { path: "setup.mjs", size: 100, mode: 420 }], + }); + assert.notEqual(gate().status, 0, "file-set mismatch must fail"); + await assert.rejects(readFile(tarballPath), { code: "ENOENT" }); + + console.log("prepublication equality mutation fixtures passed"); +} finally { + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/test-release-gate.mjs b/scripts/test-release-gate.mjs index 354dd85..547f82a 100644 --- a/scripts/test-release-gate.mjs +++ b/scripts/test-release-gate.mjs @@ -62,6 +62,7 @@ try { ); git(fixture, "add", "check-release.mjs", "package.json"); git(fixture, "commit", "-q", "-m", "fixture"); + git(fixture, "update-ref", "refs/remotes/origin/main", "HEAD"); git(fixture, "tag", "v9.9.9"); assert.notEqual(gate(fixture, fixture).status, 0, "lightweight tag must fail"); @@ -69,6 +70,13 @@ try { git(fixture, "tag", "-a", "v9.9.9", "-m", "fixture release"); assert.equal(gate(fixture, fixture).status, 0, "annotated exact tag must pass"); + git(fixture, "update-ref", "-d", "refs/remotes/origin/main"); + assert.notEqual( + gate(fixture, fixture).status, + 0, + "tag outside origin/main ancestry must fail", + ); + git(fixture, "update-ref", "refs/remotes/origin/main", "HEAD"); assert.notEqual( gate(fixture, dirname(fixture)).status, 0, diff --git a/scripts/test-supply-chain-gate.mjs b/scripts/test-supply-chain-gate.mjs new file mode 100644 index 0000000..8ecf694 --- /dev/null +++ b/scripts/test-supply-chain-gate.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const fixture = await mkdtemp(join(tmpdir(), "zap1-supply-chain-gate-")); +const files = [ + "package.json", + "package-lock.json", + ".github/workflows/ci.yml", + ".github/workflows/publish.yml", + "scripts/check-supply-chain.mjs", +]; + +async function restore(path) { + const target = join(fixture, path); + await mkdir(dirname(target), { recursive: true }); + await copyFile(path, target); +} + +function gate() { + return spawnSync(process.execPath, ["scripts/check-supply-chain.mjs"], { + cwd: fixture, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +async function mustReject(path, mutate, label) { + const target = join(fixture, path); + const original = await readFile(target, "utf8"); + await writeFile(target, mutate(original), "utf8"); + const result = gate(); + assert.notEqual(result.status, 0, `${label} must fail closed`); + await writeFile(target, original, "utf8"); +} + +try { + for (const path of files) await restore(path); + const baseline = gate(); + assert.equal( + baseline.status, + 0, + `baseline gate failed:\n${baseline.stderr || baseline.stdout}`, + ); + + await mustReject( + "package.json", + (source) => { + const value = JSON.parse(source); + value.scripts.postinstall = "node setup.mjs"; + return `${JSON.stringify(value, null, 2)}\n`; + }, + "consumer lifecycle hook", + ); + await mustReject( + "package.json", + (source) => { + const value = JSON.parse(source); + value.dependencies = { keyv: "6.0.0" }; + return `${JSON.stringify(value, null, 2)}\n`; + }, + "runtime dependency", + ); + await mustReject( + "package-lock.json", + (source) => { + const value = JSON.parse(source); + value.packages["node_modules/keyv"] = { + version: "6.0.0", + resolved: "https://registry.npmjs.org/keyv/-/keyv-6.0.0.tgz", + integrity: "sha512-incident-fixture", + hasInstallScript: true, + }; + return `${JSON.stringify(value, null, 2)}\n`; + }, + "unexpected lock entry", + ); + await mustReject( + "package-lock.json", + (source) => + source.replace( + "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "sha512-tampered", + ), + "lock integrity mutation", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "npm ci --ignore-scripts --no-audit --no-fund", + "npm ci --no-audit --no-fund", + ), + "lifecycle-enabled dependency install", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- run: npm ci --ignore-scripts --no-audit --no-fund", + "- run: |\n npm ci --ignore-scripts --no-audit --no-fund", + ), + "block-scalar command", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + "actions/checkout@v6", + ), + "mutable GitHub Action reference", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6", + "- name: injected action\n uses: attacker/action@v1", + ), + "named-step mutable action", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6", + "- { uses: attacker/action@v1 }", + ), + "flow-style action", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- run: npm ci --ignore-scripts --no-audit --no-fund", + "- { run: npm ci }", + ), + "flow-style command", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6", + '- "uses": attacker/action@v1', + ), + "quoted critical key", + ); + await mustReject( + ".github/workflows/ci.yml", + (source) => + source.replace( + "- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6", + "- uses : attacker/action@v1", + ), + "spaced mutable action key", + ); + await mustReject( + ".github/workflows/publish.yml", + (source) => + source.replace( + "permissions:\n contents: read", + "permissions:\n contents: read\n id-token: write", + ), + "workflow-wide OIDC permission", + ); + await mustReject( + ".github/workflows/publish.yml", + (source) => + source.replace( + "npm publish --ignore-scripts --access public", + "npm publish --access public", + ), + "lifecycle-enabled publication", + ); + await mustReject( + ".github/workflows/publish.yml", + (source) => + source.replace( + 'run: INIT_CWD="$PWD" node scripts/check-release.mjs', + 'run: npx attacker-tool\n - run: INIT_CWD="$PWD" node scripts/check-release.mjs', + ), + "third-party execution in OIDC job", + ); + console.log("supply-chain gate mutation fixtures passed"); +} finally { + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/verify-prepublish.mjs b/scripts/verify-prepublish.mjs new file mode 100644 index 0000000..c63e708 --- /dev/null +++ b/scripts/verify-prepublish.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFile, rm } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; + +const [preflightPath, publishPackPath] = process.argv.slice(2); +assert.ok(preflightPath, "verified preflight result is required"); +assert.ok(publishPackPath, "publish-job pack result is required"); + +const parseJson = async (path) => + JSON.parse((await readFile(path, "utf8")).replace(/^\uFEFF/u, "")); +const preflight = await parseJson(preflightPath); +const publishPacks = await parseJson(publishPackPath); +assert.equal(publishPacks.length, 1); +const candidate = publishPacks[0]; +assert.equal( + candidate.filename, + basename(candidate.filename), + "npm pack filename must be a basename", +); +const tarball = resolve(candidate.filename); +assert.equal( + dirname(tarball), + resolve("."), + "npm pack tarball must stay inside the working directory", +); + +try { + for (const field of ["name", "version", "integrity", "shasum"]) { + assert.equal( + candidate[field], + preflight[field], + `publish-job ${field} differs from the verified preflight`, + ); + } + const fileIdentity = ({ path, size, mode }) => ({ path, size, mode }); + assert.deepEqual( + candidate.files.map(fileIdentity).sort((a, b) => a.path.localeCompare(b.path)), + preflight.files.map(fileIdentity).sort((a, b) => a.path.localeCompare(b.path)), + "publish-job tarball files differ from the verified preflight", + ); + console.log( + `prepublication artifact matched verified preflight: ${candidate.integrity}`, + ); +} finally { + await rm(tarball, { force: true }); +}