diff --git a/.github/workflows/acquisition-readiness-scan.yml b/.github/workflows/acquisition-readiness-scan.yml index 840f33f1c..a825687a5 100644 --- a/.github/workflows/acquisition-readiness-scan.yml +++ b/.github/workflows/acquisition-readiness-scan.yml @@ -15,16 +15,33 @@ jobs: steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24" + node-version: "24.19.0" cache: npm - - name: install - run: npm ci + - name: verify package-manager toolchain + shell: bash + run: | + set -euo pipefail + test "$(node --version)" = "v24.19.0" + test "$(npm --version)" = "11.17.0" + + - name: verify tracked acquisition source before evidence generation + shell: bash + run: | + set -euo pipefail + tracked_drift="$(git status --porcelain=v1 --untracked-files=no)" + if [ -n "$tracked_drift" ]; then + printf '::error::Tracked acquisition source changed before evidence generation:\n%s\n' "$tracked_drift" + exit 1 + fi + test "$(git rev-parse HEAD)" = "${{ github.sha }}" - name: build data-room manifest run: npm run acquisition:manifest @@ -44,8 +61,8 @@ jobs: - name: upload acquisition artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: acquisition-readiness-audit path: artifacts/acquisition-readiness/**/* - if-no-files-found: warn + if-no-files-found: error diff --git a/test/acquisition-readiness-scan-toolchain-integrity.test.ts b/test/acquisition-readiness-scan-toolchain-integrity.test.ts new file mode 100644 index 000000000..d5453c355 --- /dev/null +++ b/test/acquisition-readiness-scan-toolchain-integrity.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync( + ".github/workflows/acquisition-readiness-scan.yml", + "utf8", +); + +describe("acquisition-readiness workflow supply-chain integrity", () => { + it("pins trusted actions and refuses persisted checkout credentials", () => { + expect(workflow).toContain( + "uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", + ); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain( + "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", + ); + expect(workflow).toContain( + "uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2", + ); + expect(workflow).not.toMatch( + /uses:\s+actions\/(?:checkout|setup-node|upload-artifact)@v\d+/, + ); + }); + + it("uses exact Node/npm identities without installing dependency code", () => { + expect(workflow).toContain('node-version: "24.19.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).not.toContain('node-version: "24"'); + expect(workflow).not.toMatch(/\bnpm\s+(?:ci|install|i)\b/); + expect(workflow).not.toContain(" - name: install"); + }); + + it("revalidates tracked buyer-evidence source before evidence generation", () => { + const integrityIndex = workflow.indexOf( + " - name: verify tracked acquisition source before evidence generation", + ); + const manifestIndex = workflow.indexOf(" - name: build data-room manifest"); + + expect(integrityIndex).toBeGreaterThan(-1); + expect(manifestIndex).toBeGreaterThan(integrityIndex); + + const integrityBlock = workflow.slice(integrityIndex, manifestIndex); + expect(integrityBlock).toContain("git status --porcelain=v1 --untracked-files=no"); + expect(integrityBlock).toContain('git rev-parse HEAD'); + expect(integrityBlock).toContain('github.sha'); + }); + + it("fails closed when retained acquisition evidence is missing", () => { + const uploadIndex = workflow.indexOf(" - name: upload acquisition artifacts"); + expect(uploadIndex).toBeGreaterThan(-1); + const uploadBlock = workflow.slice(uploadIndex); + expect(uploadBlock).toContain("if-no-files-found: error"); + expect(uploadBlock).not.toContain("if-no-files-found: warn"); + }); +});