From 4dc033cf33a5b6173bbf960d5eb464a406dc4c92 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 13 Aug 2026 16:02:21 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20adapter=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=A4=91..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/quality-gates.yml | 33 +- config/ci/gates.json | 163 ++- docs/operations/ci-quality-gates.md | 186 ++- docs/security/supply-chain.md | 208 ++- ...-08-01-quality-architecture-remediation.md | 6 +- ...6-08-02-promotion-security-review-fixes.md | 598 ++++++++ .../plans/2026-08-02-provider-raw-guardian.md | 499 +++++++ ...2026-08-02-provider-raw-guardian-design.md | 222 +++ package.json | 5 +- scripts/check-architecture.ts | 14 +- scripts/check-bundle.ts | 24 +- scripts/check-ci-contract.ts | 12 +- scripts/check-compatibility.ts | 39 +- scripts/check-design-system.ts | 18 +- scripts/check-diagnostics.ts | 18 +- scripts/check-i18n.ts | 18 +- scripts/check-optional-recipe-fixtures.ts | 15 +- scripts/check-optional-recipes.ts | 14 +- scripts/check-realtime-boundaries.ts | 15 +- .../check-registry-compatibility-fixtures.ts | 13 +- scripts/check-supply-chain-fixtures.ts | 13 +- .../check-supply-chain-provider-fixtures.ts | 567 ++++---- scripts/cleanup-verified-promotion.ts | 4 + scripts/contracts/ci-gates.ts | 140 +- scripts/contracts/release-artifacts.ts | 864 +++++++++++- scripts/generate-ci-workflow.ts | 12 +- scripts/generate-supply-chain.ts | 4 +- scripts/lib/ci-artifact-validator.ts | 309 +++- scripts/lib/exact-promotion-bundle.ts | 353 +++++ scripts/lib/local-release-evidence.ts | 235 +++- scripts/lib/package-script-graph.ts | 972 ++++++++++++- scripts/lib/promotion-stager.ts | 333 ++++- scripts/lib/promotion-verifier.ts | 333 ----- scripts/lib/provider-cgroup.ts | 169 +++ scripts/lib/provider-evidence.ts | 46 + scripts/lib/provider-guardian-client.ts | 763 ++++++++++ scripts/lib/provider-guardian-protocol.ts | 396 ++++++ scripts/lib/provider-output-limiter.ts | 27 + scripts/lib/provider-process-runner.ts | 103 ++ scripts/lib/provider-raw-cleanup.ts | 40 + scripts/lib/provider-raw-guardian.ts | 641 +++++++++ scripts/lib/provider-scope-wrapper.ts | 166 +++ scripts/lib/provider-supervisor.ts | 28 +- scripts/lib/provider-trust.ts | 42 + scripts/lib/release-candidate.ts | 37 + scripts/lib/removal-fixture.ts | 2 +- scripts/lib/secret-scan-evaluator.ts | 13 +- scripts/lib/stage-verified-promotion-cli.ts | 94 ++ scripts/lib/validated-json-artifact.ts | 19 +- scripts/run-and-validate-provider.ts | 554 ++++++-- scripts/run-ci-gate.ts | 20 +- scripts/stage-verified-promotion.ts | 45 +- scripts/verify-a11y-manual.ts | 42 +- scripts/verify-archived-local-evidence.ts | 42 +- scripts/verify-documentation-readiness.ts | 38 +- scripts/verify-exact-promotion-bundle.ts | 94 ++ scripts/verify-hosting-headers.ts | 33 +- scripts/verify-provider-evidence.ts | 26 - scripts/verify-reproducible-build.ts | 41 +- scripts/verify-supply-chain-promotion.ts | 26 - scripts/write-a11y-report.ts | 31 +- .../security-followup-archive.test.ts | 36 +- .../ci-workflow-generation.test.ts.snap | 33 +- tests/unit/bundle-artifact-contract.test.ts | 82 ++ tests/unit/ci-artifact-contract.test.ts | 1237 ++++++++++++++++- tests/unit/ci-workflow-generation.test.ts | 1021 +++++++++++++- .../provider-guardian-transaction.test.ts | 1105 +++++++++++++++ tests/unit/provider-output-limiter.test.ts | 39 + tests/unit/security-followup.test.ts | 1024 +++++++++++++- tests/unit/supply-chain.test.ts | 273 +--- .../unit/task3-selective-integration.test.ts | 213 +++ tests/unit/validated-json-artifact.test.ts | 19 + 72 files changed, 13370 insertions(+), 1549 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-02-promotion-security-review-fixes.md create mode 100644 docs/superpowers/plans/2026-08-02-provider-raw-guardian.md create mode 100644 docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md create mode 100644 scripts/lib/exact-promotion-bundle.ts delete mode 100644 scripts/lib/promotion-verifier.ts create mode 100644 scripts/lib/provider-cgroup.ts create mode 100644 scripts/lib/provider-guardian-client.ts create mode 100644 scripts/lib/provider-guardian-protocol.ts create mode 100644 scripts/lib/provider-output-limiter.ts create mode 100644 scripts/lib/provider-process-runner.ts create mode 100644 scripts/lib/provider-raw-cleanup.ts create mode 100644 scripts/lib/provider-raw-guardian.ts create mode 100644 scripts/lib/provider-scope-wrapper.ts create mode 100644 scripts/lib/provider-trust.ts create mode 100644 scripts/lib/stage-verified-promotion-cli.ts create mode 100644 scripts/verify-exact-promotion-bundle.ts delete mode 100644 scripts/verify-provider-evidence.ts delete mode 100644 scripts/verify-supply-chain-promotion.ts create mode 100644 tests/unit/bundle-artifact-contract.test.ts create mode 100644 tests/unit/provider-guardian-transaction.test.ts create mode 100644 tests/unit/provider-output-limiter.test.ts create mode 100644 tests/unit/task3-selective-integration.test.ts diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index a2785a3..25e012d 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -161,6 +161,35 @@ jobs: artifacts/security/supply-chain-coherence.json \ artifacts/security/supply-chain-verification.json \ artifacts/security/vulnerability-report.json \ + config/security/dependency-baseline.approval.json \ + config/security/dependency-baseline.json \ + config/security/dependency-change-evidence.json \ + config/security/dependency-policy.json \ + config/security/secret-scan-policy.json \ + config/security/vulnerability-exceptions.json \ + config/security/vulnerability-policy.json \ + schemas/artifacts/build-manifest.schema.json \ + schemas/artifacts/dependency-inventory.schema.json \ + schemas/artifacts/supply-chain-verification.schema.json \ + scripts/contracts/release-artifacts.ts \ + scripts/create-release-candidate.ts \ + scripts/generate-supply-chain.ts \ + scripts/lib/build-manifest-outputs.ts \ + scripts/lib/json-schema.ts \ + scripts/lib/local-policy-evidence.ts \ + scripts/lib/local-release-evidence.ts \ + scripts/lib/release-candidate.ts \ + scripts/lib/release-input-evidence.ts \ + scripts/lib/release-runtime-coherence.ts \ + scripts/lib/repository-file-inventory.ts \ + scripts/lib/secret-scan-evaluator.ts \ + scripts/lib/secret-scan-policy.ts \ + scripts/lib/secret-scan.ts \ + scripts/lib/supply-chain.ts \ + scripts/lib/validated-json-artifact.ts \ + src/contracts/release-artifacts.ts \ + src/features/installed-contract-contributions.ts \ + src/features/installed-feature-contracts.ts \ artifacts/release/release-candidate.json node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT" - name: Upload release candidate @@ -327,8 +356,10 @@ jobs: PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }} PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }} PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }} + PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }} + PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }} run: | - if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then + if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then node scripts/cleanup-verified-promotion.ts fi diff --git a/config/ci/gates.json b/config/ci/gates.json index 00ffa5a..d6d806a 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -517,10 +517,100 @@ "executableSchemaId": "field-web-vitals" }, { - "id": "json-generic-json-object", + "id": "json-automated-a11y", "kind": "json", "maxBytes": 67108864, - "executableSchemaId": "generic-json-object" + "executableSchemaId": "automated-a11y" + }, + { + "id": "json-manual-a11y", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "manual-a11y" + }, + { + "id": "json-architecture-dependency-report", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "architecture-dependency-report" + }, + { + "id": "json-design-system-contract", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "design-system-contract" + }, + { + "id": "json-i18n-contract", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "i18n-contract" + }, + { + "id": "json-diagnostics-contract", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "diagnostics-contract" + }, + { + "id": "json-realtime-boundaries", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "realtime-boundaries" + }, + { + "id": "json-optional-recipes", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "optional-recipes" + }, + { + "id": "json-optional-recipe-fixtures", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "optional-recipe-fixtures" + }, + { + "id": "json-registry-compatibility-fixtures", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "registry-compatibility-fixtures" + }, + { + "id": "json-reproducible-build", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "reproducible-build" + }, + { + "id": "json-supply-chain-fixtures", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "supply-chain-fixtures" + }, + { + "id": "json-supply-chain-provider-fixtures", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "supply-chain-provider-fixtures" + }, + { + "id": "json-compatibility-fixtures", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "compatibility-fixtures" + }, + { + "id": "json-documentation-review", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "documentation-review" + }, + { + "id": "json-hosting-headers", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "hosting-headers" }, { "id": "json-ci-contract-report", @@ -909,7 +999,7 @@ { "id": "artifact-artifacts-tests-a11y-json", "path": "artifacts/tests/a11y.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-automated-a11y", "production": "command-generated", "producerCommandIds": [ "test-a11y" @@ -972,7 +1062,7 @@ { "id": "artifact-artifacts-tests-a11y-manual-report-json", "path": "artifacts/tests/a11y-manual/report.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-manual-a11y", "production": "command-generated", "producerCommandIds": [ "review-a11y-manual" @@ -987,7 +1077,7 @@ { "id": "artifact-artifacts-quality-dependency-report-json", "path": "artifacts/quality/dependency-report.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-architecture-dependency-report", "production": "command-generated", "producerCommandIds": [ "check-architecture" @@ -996,7 +1086,7 @@ { "id": "artifact-artifacts-quality-design-system-json", "path": "artifacts/quality/design-system.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-design-system-contract", "production": "command-generated", "producerCommandIds": [ "check-design-system" @@ -1005,7 +1095,7 @@ { "id": "artifact-artifacts-quality-design-system-fixture-json", "path": "artifacts/quality/design-system-fixture.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-design-system-contract", "production": "command-generated", "producerCommandIds": [ "check-design-system-fixture" @@ -1014,7 +1104,7 @@ { "id": "artifact-artifacts-quality-i18n-json", "path": "artifacts/quality/i18n.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-i18n-contract", "production": "command-generated", "producerCommandIds": [ "check-i18n" @@ -1023,7 +1113,7 @@ { "id": "artifact-artifacts-quality-i18n-fixture-json", "path": "artifacts/quality/i18n-fixture.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-i18n-contract", "production": "command-generated", "producerCommandIds": [ "check-i18n-fixture" @@ -1032,7 +1122,7 @@ { "id": "artifact-artifacts-quality-diagnostics-json", "path": "artifacts/quality/diagnostics.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-diagnostics-contract", "production": "command-generated", "producerCommandIds": [ "check-diagnostics" @@ -1041,7 +1131,7 @@ { "id": "artifact-artifacts-quality-diagnostics-fixture-json", "path": "artifacts/quality/diagnostics-fixture.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-diagnostics-contract", "production": "command-generated", "producerCommandIds": [ "check-diagnostics-fixture" @@ -1050,7 +1140,7 @@ { "id": "artifact-artifacts-quality-realtime-boundaries-json", "path": "artifacts/quality/realtime-boundaries.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-realtime-boundaries", "production": "command-generated", "producerCommandIds": [ "check-realtime-boundaries" @@ -1059,7 +1149,7 @@ { "id": "artifact-artifacts-quality-optional-recipes-json", "path": "artifacts/quality/optional-recipes.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-optional-recipes", "production": "command-generated", "producerCommandIds": [ "check-optional-recipes-source", @@ -1069,7 +1159,7 @@ { "id": "artifact-artifacts-quality-optional-recipe-fixtures-json", "path": "artifacts/quality/optional-recipe-fixtures.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-optional-recipe-fixtures", "production": "command-generated", "producerCommandIds": [ "check-optional-recipe-fixtures" @@ -1087,7 +1177,7 @@ { "id": "artifact-artifacts-quality-registry-compatibility-fixtures-json", "path": "artifacts/quality/registry-compatibility-fixtures.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-registry-compatibility-fixtures", "production": "command-generated", "producerCommandIds": [ "check-registries-compatibility-fixtures" @@ -1213,7 +1303,7 @@ { "id": "artifact-artifacts-release-reproducible-build-json", "path": "artifacts/release/reproducible-build.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-reproducible-build", "production": "command-generated", "producerCommandIds": [ "verify-reproducible-build" @@ -1267,7 +1357,7 @@ { "id": "artifact-artifacts-security-supply-chain-fixtures-json", "path": "artifacts/security/supply-chain-fixtures.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-supply-chain-fixtures", "production": "command-generated", "producerCommandIds": [ "check-supply-chain-fixtures" @@ -1276,7 +1366,7 @@ { "id": "artifact-artifacts-security-supply-chain-provider-fixtures-json", "path": "artifacts/security/supply-chain-provider-fixtures.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-supply-chain-provider-fixtures", "production": "command-generated", "producerCommandIds": [ "check-supply-chain-provider-fixtures" @@ -1291,7 +1381,7 @@ { "id": "artifact-artifacts-release-compatibility-json", "path": "artifacts/release/compatibility.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-compatibility-fixtures", "production": "command-generated", "producerCommandIds": [ "verify-compatibility" @@ -1345,7 +1435,7 @@ { "id": "artifact-artifacts-quality-documentation-review-json", "path": "artifacts/quality/documentation-review.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-documentation-review", "production": "command-generated", "producerCommandIds": [ "verify-documentation" @@ -1375,7 +1465,7 @@ { "id": "artifact-artifacts-release-hosting-headers-json", "path": "artifacts/release/hosting-headers.json", - "schemaId": "json-generic-json-object", + "schemaId": "json-hosting-headers", "production": "command-generated", "producerCommandIds": [ "verify-hosting-headers" @@ -2185,6 +2275,35 @@ "artifacts/security/supply-chain-coherence.json", "artifacts/security/supply-chain-verification.json", "artifacts/security/vulnerability-report.json", + "config/security/dependency-baseline.approval.json", + "config/security/dependency-baseline.json", + "config/security/dependency-change-evidence.json", + "config/security/dependency-policy.json", + "config/security/secret-scan-policy.json", + "config/security/vulnerability-exceptions.json", + "config/security/vulnerability-policy.json", + "schemas/artifacts/build-manifest.schema.json", + "schemas/artifacts/dependency-inventory.schema.json", + "schemas/artifacts/supply-chain-verification.schema.json", + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", "artifacts/release/release-candidate.json" ], "archiveOutputName": "archive_sha256", @@ -2383,7 +2502,7 @@ "vulnerability_provider", "provenance_provider" ], - "condition": "always", + "condition": "needs-success", "timeoutMinutes": 45, "gateIds": [], "browserGateIds": [], diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index fdf3425..75f32b1 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -10,6 +10,39 @@ checking, the typed contract checker, and this workflow drift check. The Gitea adapter runs each gate as an independent matrix check with full fan-out and no soft-fail wiring. +Contract loading validates every registered command entry against the +authoritative root package-script graph and lifecycle/install policy before the +runner enters its execution callback. Foreign cwd/workspace script dispatch, +argument-sensitive dispatchers, and ineffective lifecycle suppression therefore +fail preflight even when the later `check:ci` command would never run. +For npm, that root-only boundary also parses options following an explicit +`run`/`run-script` target or implicit `test`/`start`/`stop`/`restart` target: +`--workspace`, `-w`, `--workspaces`, and `--prefix` are foreign manifest scope +and are rejected before execution. The first literal `--` ends npm option +parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ: +their options following the selected script name are forwarded to that script; +their cwd/workspace selectors are rejected only where the manager consumes +them before script selection. + +The npm graph includes existing root-manifest `pre'], + ["playwright.html", { id: "html", kind: "html", maxBytes: 4_096 }, ''], + ] as const)("accepts bounded, well-formed structured %s evidence", async (relative, schema, content) => { + const root = await temporaryRoot("ci-artifact-structured-valid-"); + await writeArtifact(root, relative, content); + await expect( + validateCiArtifact({ + root, + artifact: artifact(relative, schema.id), + schema: schema as CiGateArtifactSchema, + }), + ).resolves.toBeUndefined(); + }); + + it.each([ + ["deep.xml", "junit", `${"".repeat(256)}${"".repeat(256)}`], + ["deep.html", "html", `${"
".repeat(256)}${"
".repeat(256)}`], + ["many.xml", "junit", `${"".repeat(100_000)}`], + ] as const)("rejects parser resource exhaustion in %s", async (relative, kind, content) => { + const root = await temporaryRoot("ci-artifact-structured-bound-"); + await writeArtifact(root, relative, content); + await expect( + validateCiArtifact({ + root, + artifact: artifact(relative, kind), + schema: { id: kind, kind, maxBytes: Buffer.byteLength(content) } as CiGateArtifactSchema, + }), + ).rejects.toThrow(/invalid (?:JUnit|HTML) artifact/u); + }); + + it("registers a semantic validator for every configured structured artifact", async () => { + const contract = parseCiGateContract( + JSON.parse(await readFile("config/ci/gates.json", "utf8")) as unknown, + ); + expect( + contract.artifactSchemas.filter( + (schema) => !hasCiArtifactSemanticValidator(schema), + ), + ).toEqual([]); + expect( + contract.artifactSchemas.some( + (schema) => + schema.kind === "json" && + (schema.executableSchemaId as string) === "generic-json-object", + ), + ).toBe(false); + }); + + it.each([ + ["automated-a11y", { schemaVersion: 1, automatedStatus: "passed" }], + ["manual-a11y", { schemaVersion: 1, coherentRelease: true, passed: true }], + ["architecture-dependency-report", { staticImportGraph: {} }], + ["design-system-contract", { schemaVersion: 1, mode: "source", checkedTokenCount: 1, failures: ["x"], passed: true }], + ["i18n-contract", { schemaVersion: 1, mode: "source", localeCount: 1, messageKeyCount: 1, checkedFiles: 1, failures: ["x"], passed: true }], + ["diagnostics-contract", { schemaVersion: 1, mode: "source", telemetryEventCount: 1, diagnosticEventCount: 1, checkedFiles: 1, failures: ["x"], passed: true }], + ["realtime-boundaries", { schemaVersion: 1, sourceRoot: "src", violations: [{}], passed: true }], + ["optional-recipes", { schemaVersion: 1, decisionId: "VD-10", violations: ["x"], passed: true }], + ["optional-recipe-fixtures", { schemaVersion: 1, results: [], bundleBudgetFixtures: [], passed: true }], + ["registry-compatibility-fixtures", { schemaVersion: 1, results: [{ id: "x", expected: false, actual: true, passed: true }] }], + ["reproducible-build", { schemaVersion: 1, sourceDateEpoch: "1", buildId: "x", commitSha: "x", releaseId: "x", runnerImage: "x", status: "PASS", firstDigest: "BUILD_FAILED", secondDigest: "BUILD_FAILED", restored: true }], + ["supply-chain-fixtures", { schemaVersion: 1, results: [{ id: "x", passed: true }, { id: "x", passed: true }] }], + ["supply-chain-provider-fixtures", { schemaVersion: 1, fixtures: {}, passingFixtureCount: 1, status: "PASS" }], + ["compatibility-fixtures", { schemaVersion: 1, generatedAt: "2026-08-02T00:00:00.000Z", rules: [], results: [] }], + ["documentation-review", { schemaVersion: 1, passed: true, results: [] }], + ["hosting-headers", { schemaVersion: 1, passed: true, results: [{ passed: false }] }], + ] as const)("rejects pattern-matching but semantically invalid %s JSON", async (executableSchemaId, value) => { + const root = await temporaryRoot("ci-artifact-semantic-"); + await writeArtifact(root, "report.json", `${JSON.stringify(value)}\n`); + await expect( + validateCiArtifact({ + root, + artifact: artifact("report.json", executableSchemaId), + schema: { + id: executableSchemaId, + kind: "json", + maxBytes: 32_768, + executableSchemaId, + } as CiGateArtifactSchema, + }), + ).rejects.toThrow(); + }); + it("fails closed when a regular artifact grows after its bounded lstat", async () => { const root = await temporaryRoot("ci-artifact-growth-"); await writeArtifact(root, "report.txt", "1234"); @@ -543,6 +642,35 @@ describe("candidate archive and provider upload boundaries", () => { await expect(readFile(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it.each(["invalid trust", "invalid archive", "provider failure"] as const)( + "leaves no stale raw report after %s and permits a clean retry", + async (failure) => { + const fixture = await createProviderFixture(); + const archiveBytes = await readFile(fixture.archivePath); + const publicKeyBytes = await readFile(fixture.publicKeyPath); + await writeFile(fixture.providerWriter, providerV2WriterSource()); + await rm(fixture.reportPath); + if (failure === "invalid trust") await writeFile(fixture.publicKeyPath, "not a public key\n"); + if (failure === "invalid archive") await writeFile(fixture.archivePath, "not the captured archive\n"); + const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json"); + const failed = runProviderSupervisor(fixture, { + command: failure === "provider failure" ? "exit 9" : `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + }); + expect(failed.status).not.toBe(0); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + + await writeFile(fixture.archivePath, archiveBytes); + await writeFile(fixture.publicKeyPath, publicKeyBytes); + const retried = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + }); + expect(retried.status, retried.stderr).toBe(0); + }, + 20_000, + ); + it("drains and kills provider background processes before sealing evidence", async () => { const fixture = await createProviderFixture(); await rm(fixture.reportPath); @@ -604,9 +732,402 @@ describe("candidate archive and provider upload boundaries", () => { expect(result.status, result.stderr).toBe(0); await expect(readFile(canary, "utf8")).resolves.toBe("host-secret\n"); }, 10_000); + + it("applies effective aggregate cgroup limits without exposing command or credentials", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const credential = "live-provider-credential-sentinel"; + const providerScript = path.join(fixture.root, "provider-live-cgroup.mjs"); + await writeFile(providerScript, [ + `if (process.env.VULNERABILITY_PROVIDER_SECRET !== ${JSON.stringify(credential)}) process.exit(9);`, + `process.stdout.write(${JSON.stringify(credential)});`, + `process.stderr.write(${JSON.stringify(credential)});`, + providerV2WriterSource(), + "setTimeout(() => process.exit(0), 1500);", + ].join("\n")); + const command = `node ${JSON.stringify(providerScript)}`; + const execution = startProviderSupervisor(fixture, { + command, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + environment: { + PROVIDER_SUPERVISOR_TIMEOUT_MS: "4000", + VULNERABILITY_PROVIDER_SECRET: credential, + }, + }); + const completionStarted = Date.now(); + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + const properties = showProviderUnit(unit); + expect(properties).toMatchObject({ + ActiveState: "active", + CPUQuotaPerSecUSec: "1s", + CPUQuotaPeriodUSec: "100ms", + KillMode: "control-group", + MemoryMax: "1073741824", + MemorySwapMax: "0", + SendSIGKILL: "yes", + TasksMax: "64", + }); + const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${properties.ControlGroup}`); + await expect(readFile(path.join(cgroupRoot, "memory.max"), "utf8")).resolves.toBe("1073741824\n"); + await expect(readFile(path.join(cgroupRoot, "memory.swap.max"), "utf8")).resolves.toBe("0\n"); + await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n"); + await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n"); + expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u")); + expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u")); + const cgroupPids = await readCgroupPids(cgroupRoot); + const processArguments = cgroupPids.map((pid) => showProcessArguments(pid)); + const directChildPids = await waitForDirectProviderChildren(execution.child.pid!); + const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid)); + const observedArguments = [ + showProcessArguments(execution.child.pid), + ...directChildArguments, + ...processArguments, + ]; + expect(observedArguments.join("\n")).not.toContain(credential); + expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1); + expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1); + expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1); + expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length) + .toBeGreaterThan(0); + const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) => + /provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_), + ); + expect(infrastructureArguments.join("\n")).not.toContain(command); + expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1); + const reportIdentity = await lstat(fixture.reportPath); + const result = await execution.completion; + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).not.toContain(credential); + expect(result.stderr).not.toContain(credential); + expect(Date.now() - completionStarted).toBeLessThan(4_000); + await expectProviderUnitGone(unit); + await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true); + expect(directChildPids.every((pid) => !processExists(pid))).toBe(true); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]); + }, 10_000); + + it("kills and collects an active provider when its guardian dies", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json"); + const providerScript = path.join(fixture.root, "provider-active-guardian-death.mjs"); + await writeFile(providerScript, [ + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`, + "setInterval(() => {}, 1000);", + ].join("\n")); + const execution = startProviderSupervisor(fixture, { + command: `node ${JSON.stringify(providerScript)}`, + sealedPath, + environment: { PROVIDER_SUPERVISOR_TIMEOUT_MS: "5000" }, + }); + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + await waitForFileContent(fixture.reportPath, "started\n"); + const guardianPid = await waitForDirectChildMatching( + execution.child.pid!, + "provider-raw-guardian.ts", + ); + process.kill(guardianPid, "SIGKILL"); + + const result = await execution.completion; + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/provider raw guardian failed|provider guardian failed|SIGKILL/iu); + await expectProviderUnitGone(unit); + await waitForProcessGone(guardianPid); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(sealedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-"))) + .toEqual([]); + + const retried = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + }); + expect(retried.status, retried.stderr).toBe(0); + }, 15_000); + + it("collects the whole provider scope when its supervisor dies", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const providerScript = path.join(fixture.root, "provider-parent-death.mjs"); + const detachedMarker = path.join(fixture.root, "detached-marker"); + const detachedDescendant = [ + "const {appendFileSync,writeFileSync}=require('node:fs');", + `setTimeout(()=>appendFileSync(${JSON.stringify(fixture.reportPath)},'detached-survived\\n'),800);`, + `setTimeout(()=>writeFileSync(${JSON.stringify(detachedMarker)},'survived\\n'),800);`, + "setInterval(()=>{},1000);", + ].join("\n"); + await writeFile(providerScript, [ + "import { spawn } from 'node:child_process';", + "import { appendFileSync, writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`, + `const child=spawn(process.execPath,['-e',${JSON.stringify(detachedDescendant)}],{detached:true,stdio:'ignore'}); child.unref();`, + `setTimeout(() => appendFileSync(${JSON.stringify(fixture.reportPath)}, 'survived\\n'), 800);`, + "setInterval(() => {}, 1000);", + ].join("\n")); + const execution = startProviderSupervisor(fixture, { + command: `node ${JSON.stringify(providerScript)}`, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + }); + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + await waitForFileContent(fixture.reportPath, "started\n"); + const reportIdentity = await lstat(fixture.reportPath); + const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${showProviderUnit(unit).ControlGroup}`); + const cgroupPids = await readCgroupPids(cgroupRoot); + expect(execution.child.kill("SIGKILL")).toBe(true); + const result = await execution.completion; + expect(result.signal).toBe("SIGKILL"); + await expectProviderUnitGone(unit); + await delay(900); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(detachedMarker)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true); + await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]); + }, 10_000); + + it("fails closed when the guardian dies after scope collection and before commit", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + await writeFile(fixture.providerWriter, providerV2WriterSource()); + const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json"); + const outputPipe = path.join(fixture.root, "blocked-github-output"); + const fifo = spawnSync("mkfifo", [outputPipe], { encoding: "utf8" }); + if (fifo.error) throw fifo.error; + if (fifo.status !== 0) throw new Error(`mkfifo failed: ${fifo.stderr}`); + const execution = startProviderSupervisor(fixture, { + command: `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + environment: { GITHUB_OUTPUT: outputPipe }, + }); + let completed = false; + let outputReader: Awaited> | undefined; + try { + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + await expectProviderUnitGone(unit); + await waitForExistingPath(sealedPath); + const guardianPid = await waitForDirectChildMatching( + execution.child.pid!, + "provider-raw-guardian.ts", + ); + expect(showProcessArguments(guardianPid)).not.toContain(fixture.reportPath); + process.kill(guardianPid, "SIGKILL"); + outputReader = await open(outputPipe, constants.O_RDONLY | constants.O_NONBLOCK); + const result = await execution.completion; + completed = true; + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/provider guardian failed|SIGKILL/iu); + await waitForProcessGone(guardianPid); + await waitForMissingPath(fixture.reportPath); + await waitForMissingPath(sealedPath); + expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-"))) + .toEqual([]); + } finally { + await outputReader?.close(); + if (!completed && execution.child.exitCode === null && execution.child.signalCode === null) { + execution.child.kill("SIGKILL"); + await execution.completion; + } + } + }, 10_000); + + it("cleans published evidence when the supervisor dies and permits same-workspace retry", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + await writeFile(fixture.providerWriter, providerV2WriterSource()); + const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json"); + const outputPipe = path.join(fixture.root, "supervisor-death-github-output"); + const fifo = spawnSync("mkfifo", [outputPipe], { encoding: "utf8" }); + if (fifo.error) throw fifo.error; + if (fifo.status !== 0) throw new Error(`mkfifo failed: ${fifo.stderr}`); + const execution = startProviderSupervisor(fixture, { + command: `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + environment: { GITHUB_OUTPUT: outputPipe }, + }); + let completed = false; + try { + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + await expectProviderUnitGone(unit); + await waitForExistingPath(sealedPath); + const guardianPid = await waitForDirectChildMatching( + execution.child.pid!, + "provider-raw-guardian.ts", + ); + expect(execution.child.kill("SIGKILL")).toBe(true); + const result = await execution.completion; + completed = true; + expect(result.signal).toBe("SIGKILL"); + await waitForProcessGone(guardianPid); + await waitForMissingPath(fixture.reportPath); + await waitForMissingPath(sealedPath); + expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-"))) + .toEqual([]); + + const retried = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(fixture.providerWriter)}`, + sealedPath, + }); + expect(retried.status, retried.stderr).toBe(0); + } finally { + if (!completed && execution.child.exitCode === null && execution.child.signalCode === null) { + execution.child.kill("SIGKILL"); + await execution.completion; + } + } + }, 15_000); + + it("observes EMFILE at the provider FD limit and still emits valid evidence", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const adapter = path.join(fixture.root, "provider-fd-limit.mjs"); + await writeFile(adapter, [ + "import { closeSync, openSync } from 'node:fs';", + "const descriptors = [];", + "let observed = false;", + "for (let index=0; index<128; index+=1) { try { descriptors.push(openSync('/dev/null','r')); } catch (error) { if (error?.code === 'EMFILE') observed=true; break; } }", + "if (!observed) process.exit(9);", + "for (const descriptor of descriptors) closeSync(descriptor);", + providerV2WriterSource(), + ].join("\n")); + const result = runProviderSupervisor(fixture, { + command: `node ${JSON.stringify(adapter)}`, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + }); + expect(result.status, result.stderr).toBe(0); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, 10_000); + + it("enforces CPU RLIMIT before the independent wall-clock timeout", async () => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const started = Date.now(); + const result = runProviderSupervisor(fixture, { + command: "node -e 'while(true){}'", + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + environment: { PROVIDER_SUPERVISOR_CPU_SECONDS: "1", PROVIDER_SUPERVISOR_TIMEOUT_MS: "5000" }, + spawnTimeoutMs: 7_000, + }); + expect(Date.now() - started).toBeLessThan(5_000); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/exit=(?:137|152)|signal=SIG(?:XCPU|KILL)/iu); + expect(result.stderr).not.toMatch(/timed out/iu); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, 10_000); + + it.each(["output", "timeout"] as const)( + "kills descendants and collects the scope on provider %s termination", + async (reason) => { + const fixture = await createProviderFixture(); + await rm(fixture.reportPath); + const providerScript = path.join(fixture.root, `provider-${reason}-descendant.mjs`); + const descendant = [ + "const {appendFileSync}=require('node:fs');", + `setTimeout(()=>appendFileSync(${JSON.stringify(fixture.reportPath)},'survived\\n'),1600);`, + "setInterval(()=>{},1000);", + ].join("\n"); + await writeFile(providerScript, [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`, + `const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{detached:true,stdio:'ignore'}); child.unref();`, + ...(reason === "output" + ? ["setTimeout(()=>{process.stdout.write('o'.repeat(700000));process.stderr.write('e'.repeat(700000));},400);"] + : []), + "setInterval(()=>{},1000);", + ].join("\n")); + const execution = startProviderSupervisor(fixture, { + command: `node ${JSON.stringify(providerScript)}`, + sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"), + environment: { PROVIDER_SUPERVISOR_TIMEOUT_MS: reason === "timeout" ? "500" : "5000" }, + }); + const unit = await waitForProviderUnit("vulnerability", execution.child.pid); + await waitForFileContent(fixture.reportPath, "started\n"); + const reportIdentity = await lstat(fixture.reportPath); + const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${showProviderUnit(unit).ControlGroup}`); + const cgroupPids = await readCgroupPids(cgroupRoot); + const result = await execution.completion; + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(reason === "timeout" ? /timed out/iu : /output exceeded/iu); + expect(Buffer.byteLength(`${result.stdout}${result.stderr}`)).toBeLessThan(1_200_000); + await expectProviderUnitGone(unit); + await delay(1_300); + await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true); + await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]); + }, + 10_000, + ); + + it.each(["vulnerability", "provenance"] as const)( + "runs the %s adapter offline and collects its unit", + async (kind) => { + const fixture = await createProviderFixture(); + const canary = await startLoopbackCanary(fixture.root); + try { + const reportPath = kind === "vulnerability" + ? fixture.reportPath + : path.join(fixture.root, "provider-evidence/untrusted/provenance-attestation.json"); + await rm(reportPath, { force: true }); + const adapter = path.join(fixture.root, `offline-${kind}-adapter.mjs`); + await writeFile(adapter, [ + "import { readFileSync, writeFileSync } from 'node:fs';", + "import { createConnection } from 'node:net';", + `if (!/0::.*\\/ca-provider-${kind}-[1-9][0-9]*-[0-9a-f]{24}\\.scope(?:\\n|$)/u.test(readFileSync('/proc/self/cgroup','utf8'))) process.exit(9);`, + `await new Promise((resolve) => { const socket=createConnection({host:'127.0.0.1',port:Number(process.env.${kind.toUpperCase()}_PROVIDER_LOOPBACK_PORT)}); socket.once('connect',()=>process.exit(9)); socket.once('error',()=>resolve()); });`, + providerV2WriterSource({ kind, importFs: false }), + ].join("\n")); + const execution = startProviderSupervisor(fixture, { + kind, + command: `node ${JSON.stringify(adapter)}`, + reportPath, + sealedPath: path.join( + fixture.root, + `provider-evidence/${kind === "vulnerability" + ? "vulnerability-report.json" + : "provenance-attestation.json"}`, + ), + environment: { [`${kind.toUpperCase()}_PROVIDER_LOOPBACK_PORT`]: String(canary.port) }, + }); + const unit = await waitForProviderUnit(kind, execution.child.pid); + const result = await execution.completion; + expect(result.code, result.stderr).toBe(0); + await expectProviderUnitGone(unit); + await expect(lstat(canary.marker)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + canary.child.kill("SIGKILL"); + await waitForChildClose(canary.child); + } + }, + 10_000, + ); }); describe("verified promotion finalizer", () => { + it("enforces exact private modes even under a fully restrictive umask", async () => { + const fixture = await createPromotionStagingFixture(); + let previous: number | undefined; + let finalized: Awaited>; + try { + finalized = await stageVerifiedPromotion(fixture.input, { + ...fixture.dependencies, + beforePublish: async () => { + previous = process.umask(0o777); + }, + }); + } finally { + if (previous !== undefined) process.umask(previous); + } + expect((await lstat(finalized.stagingRoot)).mode & 0o777).toBe(0o700); + for (const file of finalized.files) { + expect((await lstat(path.join(finalized.stagingRoot, file.name))).mode & 0o777).toBe(0o400); + } + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + }, 30_000); + it("creates no input records and publishes deterministic exact-five strict v3 bindings", async () => { const fixture = await createPromotionStagingFixture(); let archiveCaptureCount = 0; @@ -656,6 +1177,18 @@ describe("verified promotion finalizer", () => { expect(provider.vulnerabilityStatus).toBe("PASS"); expect(provider.provenanceAttestationStatus).toBe("PASS"); + const exactFive = Object.fromEntries( + await Promise.all( + PROMOTED_FILE_NAMES.map(async (name) => [ + name, + await readFile(path.join(finalized.stagingRoot, name)), + ] as const), + ), + ); + await expect( + verifyExactPromotionBundle(exactFive, fixture.bundleVerification), + ).resolves.toMatchObject({ status: "PASS" }); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); const repeated = await stageVerifiedPromotion(fixture.input, dependencies); expect(archiveCaptureCount).toBe(2); @@ -666,6 +1199,167 @@ describe("verified promotion finalizer", () => { await cleanupFinalizedPromotion(finalizedCleanup(fixture, repeated)); }, 30_000); + it("passes the downstream CLI for a real finalized exact-five with external expected identity", async () => { + const fixture = await createPromotionStagingFixture({ nowEpochMs: Date.now() }); + const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies); + const result = spawnSync( + process.execPath, + [path.resolve("scripts/verify-exact-promotion-bundle.ts")], + { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + PROMOTION_BUNDLE_ROOT: finalized.stagingRoot, + VULNERABILITY_PUBLIC_KEY_PATH: fixture.input.vulnerabilityPublicKeyPath, + VULNERABILITY_KEY_ID: fixture.input.vulnerabilityKeyId, + PROVENANCE_PUBLIC_KEY_PATH: fixture.input.provenancePublicKeyPath, + PROVENANCE_KEY_ID: fixture.input.provenanceKeyId, + EXPECTED_PROMOTION_RUN_ID: fixture.expectedContext.run.id, + EXPECTED_PROMOTION_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt), + EXPECTED_PROMOTION_SOURCE_REVISION: fixture.expectedContext.source.revision, + EXPECTED_PROMOTION_ARCHIVE_SHA256: fixture.expectedContext.candidate.archiveSha256, + EXPECTED_PROMOTION_SOURCE_SET_SHA256: fixture.expectedContext.source.sourceSetSha256, + EXPECTED_PROMOTION_BUNDLE_SHA256: fixture.expectedContext.candidate.bundleSha256, + EXPECTED_PROMOTION_DIST_SHA256: fixture.expectedContext.candidate.distSha256, + EXPECTED_PROMOTION_LOCKFILE_SHA256: fixture.expectedContext.candidate.lockfileSha256, + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Exact promotion bundle verification: PASS"); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + }, 30_000); + + it("rejects every exact-five role, subordinate, digest, and shared-context substitution", async () => { + const fixture = await createPromotionStagingFixture(); + const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies); + const valid = Object.fromEntries( + await Promise.all( + PROMOTED_FILE_NAMES.map(async (name) => [ + name, + await readFile(path.join(finalized.stagingRoot, name)), + ] as const), + ), + ) as Record<(typeof PROMOTED_FILE_NAMES)[number], Buffer>; + const jsonBytes = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const mutateJson = ( + name: "provider-verification.json" | "promotion-verification.json", + mutate: (value: Record) => void, + ) => { + const files = { ...valid }; + const value = JSON.parse(files[name].toString("utf8")) as Record; + mutate(value); + files[name] = jsonBytes(value); + return files; + }; + + const providerFail = mutateJson("provider-verification.json", (value) => { + value.status = "FAIL_UNVERIFIED"; + value.vulnerabilityStatus = "FAIL_UNVERIFIED"; + value.failures = ["fixture provider failure"]; + }); + const arbitraryHash = mutateJson("promotion-verification.json", (value) => { + value.providerVerificationSha256 = "f".repeat(64); + }); + const sharedMismatch = mutateJson("promotion-verification.json", (value) => { + value.run = { ...value.run, id: "other-run" }; + }); + const roleSwap = { + ...valid, + "provider-verification.json": valid["promotion-verification.json"], + "promotion-verification.json": valid["provider-verification.json"], + }; + const reportMismatch = { + ...valid, + "vulnerability-report.json": Buffer.from("{}\n"), + }; + const absent = { ...valid } as Partial; + delete absent["provider-verification.json"]; + const jointlyRewritten = { ...valid }; + const rewrittenVulnerability = JSON.parse( + jointlyRewritten["vulnerability-report.json"].toString("utf8"), + ) as Record; + rewrittenVulnerability.provider = "attacker-rewritten-provider"; + jointlyRewritten["vulnerability-report.json"] = jsonBytes(rewrittenVulnerability); + const rewrittenProvider = JSON.parse( + jointlyRewritten["provider-verification.json"].toString("utf8"), + ) as Record; + rewrittenProvider.providerEvidence.vulnerabilityReportSha256 = sha256( + jointlyRewritten["vulnerability-report.json"], + ); + jointlyRewritten["provider-verification.json"] = jsonBytes(rewrittenProvider); + const rewrittenPromotion = JSON.parse( + jointlyRewritten["promotion-verification.json"].toString("utf8"), + ) as Record; + rewrittenPromotion.providerEvidence.vulnerabilityReportSha256 = + rewrittenProvider.providerEvidence.vulnerabilityReportSha256; + rewrittenPromotion.providerVerificationSha256 = sha256( + jointlyRewritten["provider-verification.json"], + ); + jointlyRewritten["promotion-verification.json"] = jsonBytes(rewrittenPromotion); + + for (const [label, files, diagnostic] of [ + ["provider FAIL", providerFail, /provider verification.*PASS/u], + ["arbitrary provider hash", arbitraryHash, /provider verification.*hash/u], + ["shared context", sharedMismatch, /shared.*run/u], + ["role swap", roleSwap, /artifact role/u], + ["report digest", reportMismatch, /vulnerability report.*digest/u], + ["provider absent", absent, /exact five|missing/u], + ["jointly rewritten invalid signature", jointlyRewritten, /signature.*not PASS|signature verification/u], + ] as const) { + await expect( + verifyExactPromotionBundle(files as any, fixture.bundleVerification), + label, + ).rejects.toThrow(diagnostic); + } + + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + }, 30_000); + + it("rejects one Ed25519 key reused for both provider roles", async () => { + const fixture = await createPromotionStagingFixture(); + await writeFile( + fixture.input.provenancePublicKeyPath, + await readFile(fixture.input.vulnerabilityPublicKeyPath), + ); + expect(fixture.input.provenanceKeyId).not.toBe(fixture.input.vulnerabilityKeyId); + await expect( + stageVerifiedPromotion(fixture.input, fixture.dependencies), + ).rejects.toThrow(/distinct|role.*key|same.*key/u); + await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); + + it("rejects a correctly re-signed secret scan attestation mismatch in the real finalizer", async () => { + const fixture = await createPromotionStagingFixture(); + const parsed = JSON.parse( + await readFile(fixture.input.vulnerabilityReportPath, "utf8"), + ) as Record; + const { signature: currentSignature, ...unsigned } = parsed; + unsigned.secretScanAttestation = { + ...unsigned.secretScanAttestation, + sarifSha256: "0".repeat(64), + }; + const value = sign( + null, + providerEvidenceSignaturePayload(unsigned), + fixture.vulnerabilityPrivateKey, + ).toString("base64"); + await writeFile( + fixture.input.vulnerabilityReportPath, + `${JSON.stringify({ + ...unsigned, + signature: { ...currentSignature, value }, + })}\n`, + ); + + await expect( + stageVerifiedPromotion(fixture.input, fixture.dependencies), + ).rejects.toThrow(/secret scan attestation mismatch/u); + await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); + it("stages captured archive and reports and validates captured keys after source mutation", async () => { const fixture = await createPromotionStagingFixture(); const finalized = await stageVerifiedPromotion(fixture.input, { @@ -736,6 +1430,43 @@ describe("verified promotion finalizer", () => { await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); }, 30_000); + it("pins the staging leaf during writes and rejects a replacement at final visibility", async () => { + const fixture = await createPromotionStagingFixture(); + const visible = path.join(fixture.runnerTempRoot, fixture.cleanupToken); + const displaced = `${visible}-displaced`; + let writes = 0; + await expect( + stageVerifiedPromotion(fixture.input, { + ...fixture.dependencies, + afterFileWrite: async () => { + writes += 1; + if (writes === 1) { + await rename(visible, displaced); + await mkdir(visible, { mode: 0o700 }); + } + }, + }), + ).rejects.toThrow(/staging leaf identity changed/u); + expect(writes).toBe(PROMOTED_FILE_NAMES.length); + await expect(readdir(displaced)).resolves.toEqual([]); + await expect(readdir(visible)).resolves.toEqual([]); + }, 30_000); + + it("fails and cleans staging when provider evidence expires during staging writes", async () => { + const fixture = await createPromotionStagingFixture(); + let now = Date.parse("2026-08-02T01:00:00.000Z"); + await expect( + stageVerifiedPromotion(fixture.input, { + ...fixture.dependencies, + nowEpochMs: () => now, + beforeSeal: async () => { + now = Date.parse("2026-08-02T02:00:00.000Z"); + }, + }), + ).rejects.toThrow(/expired|freshness.*not PASS/u); + await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); + it("cleanup is token-bound and removes only the finalized private directory", async () => { const fixture = await createPromotionStagingFixture(); const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies); @@ -746,6 +1477,7 @@ describe("verified promotion finalizer", () => { stagingRoot: finalized.stagingRoot, cleanupToken: `${finalized.cleanupToken}-wrong`, runnerTempIdentity: finalized.runnerTempIdentity, + stagingIdentity: finalized.stagingIdentity, })).rejects.toThrow(/root\/token mismatch/u); await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); @@ -771,43 +1503,392 @@ describe("verified promotion finalizer", () => { const outside = await temporaryRoot("promotion-cleanup-outside-"); const canary = path.join(outside, "canary"); await writeFile(canary, "unchanged\n"); - await rename(linkedFinalized.stagingRoot, saved); - await symlink(outside, linkedFinalized.stagingRoot); - await expect(cleanupFinalizedPromotion(finalizedCleanup(linked, linkedFinalized))) - .rejects.toThrow(/leaf is unsafe/u); + await expect( + cleanupFinalizedPromotion(finalizedCleanup(linked, linkedFinalized), { + beforeRemove: async () => { + await rename(linkedFinalized.stagingRoot, saved); + await symlink(outside, linkedFinalized.stagingRoot); + }, + }), + ).rejects.toThrow(/leaf.*identity|staging leaf/u); await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + await expect(readdir(saved)).resolves.toEqual([]); }, 30_000); + + it("never deletes an unrelated leaf substituted after cleanup validation", async () => { + const fixture = await createPromotionStagingFixture(); + const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies); + const displaced = `${finalized.stagingRoot}-owned`; + const canary = path.join(finalized.stagingRoot, "unrelated-canary"); + + await expect(cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized), { + beforeRemove: async () => { + await rename(finalized.stagingRoot, displaced); + await mkdir(finalized.stagingRoot, { mode: 0o700 }); + await writeFile(canary, "must survive\n"); + }, + })).rejects.toThrow(/identity changed|substituted/u); + await expect(readFile(canary, "utf8")).resolves.toBe("must survive\n"); + await expect(readdir(displaced)).resolves.toEqual( + expect.arrayContaining([...PROMOTED_FILE_NAMES]), + ); + }, 30_000); + }); +type ProviderSupervisorInput = Readonly<{ + kind?: "vulnerability" | "provenance"; + command: string; + reportPath?: string; + sealedPath: string; + environment?: Readonly>; + spawnTimeoutMs?: number; + maxBuffer?: number; +}>; + function runProviderSupervisor( fixture: Awaited>, - input: Readonly<{ command: string; sealedPath: string }>, + input: ProviderSupervisorInput, ) { + const kind = input.kind ?? "vulnerability"; return spawnSync( process.execPath, - [path.resolve("scripts/run-and-validate-provider.ts"), "--kind", "vulnerability"], + [path.resolve("scripts/run-and-validate-provider.ts"), "--kind", kind], { cwd: fixture.root, encoding: "utf8", - timeout: 8_000, - env: { - ...process.env, - VULNERABILITY_PROVIDER_COMMAND: input.command, - VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath, - VULNERABILITY_PUBLIC_KEY_PATH: fixture.publicKeyPath, - VULNERABILITY_KEY_ID: fixture.keyId, - VULNERABILITY_REPORT_PATH: fixture.reportPath, - VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath, - CANDIDATE_ARCHIVE_PATH: fixture.archivePath, - CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256, - CI_RUN_ID: fixture.expectedContext.run.id, - CI_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt), - EXPECTED_SOURCE_REVISION: fixture.expectedContext.source.revision, - }, + timeout: input.spawnTimeoutMs ?? 8_000, + maxBuffer: input.maxBuffer, + env: providerSupervisorEnvironment(fixture, input), }, ); } +function startProviderSupervisor( + fixture: Awaited>, + input: ProviderSupervisorInput, +): Readonly<{ + child: ChildProcess; + completion: Promise>; +}> { + const kind = input.kind ?? "vulnerability"; + const child = spawn( + process.execPath, + [path.resolve("scripts/run-and-validate-provider.ts"), "--kind", kind], + { + cwd: fixture.root, + env: providerSupervisorEnvironment(fixture, input), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stderr = ""; + let stdout = ""; + child.stderr?.setEncoding("utf8"); + child.stdout?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { stderr += chunk; }); + child.stdout?.on("data", (chunk: string) => { stdout += chunk; }); + const completion = new Promise>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal, stderr, stdout })); + }); + return Object.freeze({ child, completion }); +} + +function providerSupervisorEnvironment( + fixture: Awaited>, + input: ProviderSupervisorInput, +): NodeJS.ProcessEnv { + const kind = input.kind ?? "vulnerability"; + const reportPath = input.reportPath ?? fixture.reportPath; + return { + ...process.env, + ...input.environment, + ...(kind === "vulnerability" + ? { + VULNERABILITY_PROVIDER_COMMAND: input.command, + VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath, + VULNERABILITY_PUBLIC_KEY_PATH: fixture.publicKeyPath, + VULNERABILITY_KEY_ID: fixture.keyId, + VULNERABILITY_REPORT_PATH: reportPath, + } + : { + PROVENANCE_PROVIDER_COMMAND: input.command, + PROVENANCE_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath, + PROVENANCE_PUBLIC_KEY_PATH: fixture.publicKeyPath, + PROVENANCE_KEY_ID: fixture.keyId, + PROVENANCE_ATTESTATION_PATH: reportPath, + }), + VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath, + CANDIDATE_ARCHIVE_PATH: fixture.archivePath, + CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256, + CI_RUN_ID: fixture.expectedContext.run.id, + CI_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt), + EXPECTED_SOURCE_REVISION: fixture.expectedContext.source.revision, + }; +} + +async function waitForProviderUnit( + kind: "vulnerability" | "provenance", + supervisorPid: number | undefined, +): Promise { + if (!supervisorPid) throw new Error("provider supervisor did not expose its PID"); + const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`; + for (let attempt = 0; attempt < 120; attempt += 1) { + const units = listProviderUnits(pattern); + if (units.length === 1) return units[0]!; + if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`); + await delay(25); + } + throw new Error(`provider cgroup unit did not become active: ${pattern}`); +} + +async function expectProviderUnitGone(unitName: string): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + if (listProviderUnits(unitName).length === 0) return; + await delay(25); + } + throw new Error(`provider scope survived completion: ${unitName}`); +} + +async function waitForFileContent(filename: string, expected: string): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + if ((await readFile(filename, "utf8")) === expected) return; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + await delay(25); + } + throw new Error(`provider output did not reach expected content: ${filename}`); +} + +async function readCgroupPids(cgroupRoot: string): Promise { + return (await readFile(path.join(cgroupRoot, "cgroup.procs"), "utf8")) + .trim().split("\n").filter(Boolean).map(Number); +} + +async function waitForDirectProviderChildren(supervisorPid: number): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`; + const children = (await readFile(childrenPath, "utf8")) + .trim().split(/\s+/u).filter(Boolean).map(Number); + const arguments_ = children.flatMap((pid) => { + try { + return [showProcessArguments(pid)]; + } catch (error) { + if (!processExists(pid)) return []; + throw error; + } + }); + if ( + arguments_.some((value) => value.includes("/usr/bin/systemd-run")) && + arguments_.some((value) => value.includes("provider-raw-guardian.ts")) + ) { + return children; + } + await delay(25); + } + throw new Error("provider supervisor children were not simultaneously observable"); +} + +async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + const children = (await readFile(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8")) + .trim().split(/\s+/u).filter(Boolean).map(Number); + for (const pid of children) { + if (processExists(pid) && showProcessArguments(pid).includes(pattern)) return pid; + } + await delay(25); + } + throw new Error(`provider supervisor child was not observable: ${pattern}`); +} + +async function waitForExistingPath(target: string): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + await lstat(target); + return; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + await delay(25); + } + throw new Error(`expected path did not appear: ${target}`); +} + +async function waitForMissingPath(target: string): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + await lstat(target); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return; + throw error; + } + await delay(25); + } + throw new Error(`expected path survived: ${target}`); +} + +async function waitForProcessGone(pid: number): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + if (!processExists(pid)) return; + await delay(25); + } + throw new Error(`expected process survived: ${pid}`); +} + +async function findProviderRawReferences( + reportPath: string, + identity: Readonly<{ dev: number; ino: number }>, +): Promise { + const references: string[] = []; + const processDirectories = (await readdir("/proc", { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && /^[1-9][0-9]*$/u.test(entry.name)) + .map((entry) => entry.name); + for (const pid of processDirectories) { + let descriptors: string[]; + try { + descriptors = await readdir(`/proc/${pid}/fd`); + } catch (error) { + if (isTransientProcError(error)) continue; + throw error; + } + for (const descriptor of descriptors) { + try { + const metadata = await stat(`/proc/${pid}/fd/${descriptor}`); + if (metadata.dev === identity.dev && metadata.ino === identity.ino) { + references.push(`${pid}/fd/${descriptor}`); + } + } catch (error) { + if (!isTransientProcError(error)) throw error; + } + } + try { + const mountInfo = await readFile(`/proc/${pid}/mountinfo`, "utf8"); + if (mountInfo.includes(reportPath)) references.push(`${pid}/mountinfo`); + } catch (error) { + if (!isTransientProcError(error)) throw error; + } + } + return references.sort(); +} + +function isTransientProcError(error: unknown): boolean { + return hasErrorCode(error, "EACCES") || hasErrorCode(error, "ENOENT") || hasErrorCode(error, "EPERM"); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (hasErrorCode(error, "ESRCH")) return false; + if (hasErrorCode(error, "EPERM")) return true; + throw error; + } +} + +function listProviderUnits(pattern: string): string[] { + const result = spawnSync( + "/usr/bin/systemctl", + ["--user", "list-units", "--all", "--type=scope", "--plain", "--no-legend", pattern], + { encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 }, + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`systemctl list-units failed: ${result.stderr.trim()}`); + return result.stdout + .split("\n") + .map((line) => line.trim().split(/\s+/u)[0] ?? "") + .filter(Boolean); +} + +function showProviderUnit(unitName: string): Readonly> { + const properties = [ + "ActiveState", "ControlGroup", "CPUQuotaPerSecUSec", "CPUQuotaPeriodUSec", + "KillMode", "MemoryMax", "MemorySwapMax", "SendSIGKILL", "TasksMax", + ]; + const result = spawnSync( + "/usr/bin/systemctl", + ["--user", "show", unitName, ...properties.map((name) => `--property=${name}`)], + { encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 }, + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`systemctl show failed: ${result.stderr.trim()}`); + return Object.freeze(Object.fromEntries(result.stdout.trim().split("\n").map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }))); +} + +function readProviderUnitMetadata(unitName: string): string { + const result = spawnSync( + "/usr/bin/systemctl", + ["--user", "show", unitName], + { encoding: "utf8", maxBuffer: 262_144, timeout: 2_000 }, + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`systemctl show failed: ${result.stderr.trim()}`); + return result.stdout; +} + +function showProcessArguments(pid: number | undefined): string { + if (!pid) throw new Error("provider supervisor did not expose its PID"); + const result = spawnSync( + "/usr/bin/ps", + ["-o", "args=", "-p", String(pid)], + { encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 }, + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`ps failed: ${result.stderr.trim()}`); + return result.stdout.trim(); +} + +async function startLoopbackCanary(root: string): Promise> { + const portFile = path.join(root, `loopback-port-${Date.now()}`); + const marker = `${portFile}-reached`; + const child = spawn(process.execPath, ["-e", [ + "const {createServer}=require('node:http');", + "const {writeFileSync}=require('node:fs');", + `const server=createServer((_request,response)=>{writeFileSync(${JSON.stringify(marker)},'reached\\n');response.end('ok');});`, + `server.listen(0,'127.0.0.1',()=>writeFileSync(${JSON.stringify(portFile)},String(server.address().port)));`, + ].join("\n")], { stdio: "ignore" }); + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const port = Number(await readFile(portFile, "utf8")); + if (Number.isInteger(port) && port > 0) return Object.freeze({ child, marker, port }); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + await delay(25); + } + child.kill("SIGKILL"); + await waitForChildClose(child); + throw new Error("loopback canary did not become ready"); +} + +async function waitForChildClose(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => child.once("close", () => resolve())); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + function providerValidationInput( fixture: Awaited>, capturedReport: Buffer = fixture.reportBytes, @@ -825,8 +1906,18 @@ function providerValidationInput( } function providerV2WriterSource( - options: Readonly<{ importFs?: boolean }> = {}, + options: Readonly<{ + importFs?: boolean; + kind?: "vulnerability" | "provenance"; + }> = {}, ): string { + const providerKind = options.kind ?? "vulnerability"; + const privateKeyEnvironment = providerKind === "vulnerability" + ? "VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH" + : "PROVENANCE_PROVIDER_PRIVATE_KEY_PATH"; + const reportEnvironment = providerKind === "vulnerability" + ? "VULNERABILITY_REPORT_PATH" + : "PROVENANCE_ATTESTATION_PATH"; return [ "import { createPrivateKey, sign } from 'node:crypto';", ...(options.importFs === false @@ -846,11 +1937,19 @@ function providerV2WriterSource( " run: { id: process.env.CI_RUN_ID, attempt: Number(process.env.CI_RUN_ATTEMPT), invocationNonce: process.env.PROVIDER_INVOCATION_NONCE },", " source: { revision: process.env.SOURCE_REVISION, sourceSetSha256: process.env.SOURCE_SET_SHA256 },", " candidate: { archiveSha256: process.env.CANDIDATE_ARCHIVE_SHA256, bundleSha256: process.env.CANDIDATE_BUNDLE_SHA256, distSha256: process.env.CANDIDATE_DIST_SHA256, lockfileSha256: process.env.CANDIDATE_LOCKFILE_SHA256 },", - " findings: [],", + ...(providerKind === "vulnerability" + ? [ + " secretScanAttestation: { status: process.env.SECRET_SCAN_STATUS, localEvidenceAssessmentSha256: process.env.SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256, sourceSetSha256: process.env.SECRET_SCAN_SOURCE_SET_SHA256, policySha256: process.env.SECRET_SCAN_POLICY_SHA256, sarifSha256: process.env.SECRET_SCAN_SARIF_SHA256, scanInputSha256: process.env.SECRET_SCAN_INPUT_SHA256 },", + " findings: [],", + ] + : [ + " signer: 'fixture-signer',", + " subject: { name: 'dist', digest: { sha256: process.env.CANDIDATE_DIST_SHA256 } },", + ]), "};", - "const privateKey = createPrivateKey(readFileSync(process.env.VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH));", + `const privateKey = createPrivateKey(readFileSync(process.env.${privateKeyEnvironment}));`, "const value = sign(null, Buffer.from(JSON.stringify(canonical(unsigned))), privateKey).toString('base64');", - "writeFileSync(process.env.VULNERABILITY_REPORT_PATH, `${JSON.stringify({ ...unsigned, signature: { algorithm: 'Ed25519', keyId: process.env.PROVIDER_KEY_ID, publicKeyFingerprint: process.env.PROVIDER_PUBLIC_KEY_FINGERPRINT, value } })}\\n`);", + `writeFileSync(process.env.${reportEnvironment}, \`\${JSON.stringify({ ...unsigned, signature: { algorithm: 'Ed25519', keyId: process.env.PROVIDER_KEY_ID, publicKeyFingerprint: process.env.PROVIDER_PUBLIC_KEY_FINGERPRINT, value } })}\\n\`);`, ].join("\n"); } @@ -948,6 +2047,16 @@ async function createProviderFixture() { }, vulnerabilityInvocationNonce: "1".repeat(64), provenanceInvocationNonce: "0".repeat(64), + secretScanAttestation: { + status: "PASS" as const, + localEvidenceAssessmentSha256: sha256( + await readFile(path.join(root, LOCAL_EVIDENCE_ASSESSMENT_PATH)), + ), + sourceSetSha256: assessment.source.sourceSetSha256, + policySha256: assessment.secretScan.policySha256, + sarifSha256: assessment.secretScan.sarifSha256, + scanInputSha256: assessment.secretScan.scanInputSha256, + }, } as const; const unsigned = { schemaVersion: 2 as const, @@ -958,6 +2067,7 @@ async function createProviderFixture() { run: { ...expectedContext.run, invocationNonce: expectedContext.vulnerabilityInvocationNonce }, source: expectedContext.source, candidate: expectedContext.candidate, + secretScanAttestation: expectedContext.secretScanAttestation, findings: [], }; const report = { @@ -1066,7 +2176,9 @@ async function ensureProviderBaseFixture(): Promise { return root; } -async function createPromotionStagingFixture() { +async function createPromotionStagingFixture( + options: Readonly<{ nowEpochMs?: number }> = {}, +) { const base = await ensureProviderBaseFixture(); const root = await temporaryRoot("promotion-finalizer-"); const archivePath = path.join(root, "inputs/release-candidate.tar.gz"); @@ -1084,6 +2196,9 @@ async function createPromotionStagingFixture() { const provenanceKeyId = "fixture-provenance"; const vulnerabilityInvocationNonce = "5".repeat(64); const provenanceInvocationNonce = "6".repeat(64); + const fixtureNow = options.nowEpochMs ?? Date.parse("2026-08-02T01:00:00.000Z"); + const issuedAt = new Date(fixtureNow).toISOString(); + const expiresAt = new Date(fixtureNow + 60 * 60 * 1_000).toISOString(); const expectedContext = { run: { id: "fixture-run", attempt: 1 }, source: { @@ -1096,16 +2211,25 @@ async function createPromotionStagingFixture() { distSha256: candidate.manifest.distSha256, lockfileSha256: candidate.manifest.lockfileSha256, }, + secretScanAttestation: { + status: "PASS" as const, + localEvidenceAssessmentSha256: sha256(assessmentBytes), + sourceSetSha256: assessment.source.sourceSetSha256, + policySha256: assessment.secretScan.policySha256, + sarifSha256: assessment.secretScan.sarifSha256, + scanInputSha256: assessment.secretScan.scanInputSha256, + }, } as const; const vulnerabilityUnsigned = { schemaVersion: 2 as const, evidenceType: "vulnerability-report" as const, provider: "fixture-vulnerability", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", + issuedAt, + expiresAt, run: { ...expectedContext.run, invocationNonce: vulnerabilityInvocationNonce }, source: expectedContext.source, candidate: expectedContext.candidate, + secretScanAttestation: expectedContext.secretScanAttestation, findings: [], }; const vulnerabilityReport = { @@ -1126,8 +2250,8 @@ async function createPromotionStagingFixture() { evidenceType: "provenance-attestation" as const, provider: "fixture-provenance", signer: "fixture-signer", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", + issuedAt, + expiresAt, run: { ...expectedContext.run, invocationNonce: provenanceInvocationNonce }, source: expectedContext.source, candidate: expectedContext.candidate, @@ -1193,6 +2317,7 @@ async function createPromotionStagingFixture() { vulnerabilityKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), provenanceKeyId, provenanceKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey), + secretScanAttestation: expectedContext.secretScanAttestation, }; return { root, @@ -1201,13 +2326,36 @@ async function createPromotionStagingFixture() { assessmentSha256: sha256(assessmentBytes), expectedContext, expectedProviderEvidence, + vulnerabilityPrivateKey: vulnerabilityKeys.privateKey, + bundleVerification: { + vulnerabilityTrust: { + keyId: vulnerabilityKeyId, + publicKey: vulnerabilityKeys.publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), + }, + provenanceTrust: { + keyId: provenanceKeyId, + publicKey: provenanceKeys.publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey), + }, + expected: { + run: expectedContext.run, + sourceRevision: expectedContext.source.revision, + sourceSetSha256: expectedContext.source.sourceSetSha256, + archiveSha256: expectedContext.candidate.archiveSha256, + bundleSha256: expectedContext.candidate.bundleSha256, + distSha256: expectedContext.candidate.distSha256, + lockfileSha256: expectedContext.candidate.lockfileSha256, + }, + nowEpochMs: () => fixtureNow, + }, capturedSources: new Map([ ["release-candidate.tar.gz", candidateArchiveBytes], ["vulnerability-report.json", vulnerabilityReportBytes], ["provenance-attestation.json", provenanceAttestationBytes], ]), dependencies: { - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + nowEpochMs: () => fixtureNow, randomBytes: (bytes: number) => Buffer.alloc(bytes, 0x2a), }, input: { @@ -1241,5 +2389,6 @@ function finalizedCleanup( stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, + stagingIdentity: finalized.stagingIdentity, }; } diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 5f18be9..036d9b3 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -14,6 +14,7 @@ import { parseCiActionRegistry, parseCiGateContract, resolveCiActionUses, + withCiGatePreflight, } from "../../scripts/contracts/ci-gates.ts"; import { createCiWorkflowGenerator, @@ -21,9 +22,94 @@ import { renderCiWorkflow, type CiWorkflowFileSystem, } from "../../scripts/generate-ci-workflow.ts"; -import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts"; +import { + validateInstallScriptPolicy, + validatePackageScriptGraph, +} from "../../scripts/lib/package-script-graph.ts"; const temporaryRoots: string[] = []; +const npmPostScriptForeignScopeCommands = [ + "npm run nested --workspace fixture", + "npm run nested --workspace=fixture", + "npm run nested -w fixture", + "npm run nested --prefix fixture", + "npm run nested --prefix=fixture", + "npm test --workspace fixture", + "npm start --prefix fixture", +] as const; +const npmLifecycleHookDispatches = [ + ["npm run nested", "nested"], + ["npm run-script nested", "nested"], + ["npm start", "start"], + ["npm stop", "stop"], + ["npm test", "test"], + ["npm restart", "restart"], +] as const; +const npmDispatchExpansionCommands = [ + "SCOPE=--workspace; npm run nested $SCOPE fixture", + "SCOPE=--workspace; npm run nested ${SCOPE} fixture", + "npm run nested $SCOPE fixture", + "npm test ${SCOPE}", + "npm run nested --if-present=${FLAG}", +] as const; +const npmDispatchScopeEnvironmentCommands = [ + "npm_config_workspace=fixture npm run nested", + "NPM_CONFIG_WORKSPACES=true npm test", + "env npm_config_prefix=fixture npm run nested", + "/usr/bin/env npm_config_workspace=fixture npm run nested", + "export NpM_CoNfIg_PrEfIx=fixture && npm run nested", + "/bin/env npm_config_prefix=fixture npm run nested", + "command env npm_config_workspace=fixture npm run nested", + "command /opt/reviewer/bin/env NPM_CONFIG_WORKSPACES=true npm test", + "npm_config_prefix=fixture; export npm_config_prefix; npm run nested", + "npm_config_workspace=fixture && export npm_config_workspace && npm run nested", + "export npm_config_workspaces; npm_config_workspaces=true; npm test", + "set -a; npm_config_prefix=fixture; npm run nested", + "DYNAMIC_SCOPE=workspace; env npm_config_${DYNAMIC_SCOPE}=fixture npm run nested", + "DYNAMIC_SCOPE=prefix; npm_config_${DYNAMIC_SCOPE}=fixture npm run nested", + "DYNAMIC_SCOPE=workspaces; export npm_config_${DYNAMIC_SCOPE}=true; npm test", + "ENV_WRAPPER=env; $ENV_WRAPPER npm_config_prefix=fixture npm run nested", + "set +a; npm run nested", + "unset npm_config_prefix; npm run nested", + "export -n npm_config_workspace; npm run nested", + "eval 'export npm_config_prefix=fixture'; npm run nested", + ". ./scope-env.sh; npm run nested", + "source ./scope-env.sh; npm run nested", +] as const; +const npmShellPrefixScopeEnvironmentCommands = [ + "exec env npm_config_workspace=fixture npm run nested", + "exec /bin/env npm_config_prefix=fixture npm run nested", + "command exec env npm_config_workspaces=true npm test", + "command -- env npm_config_prefix=fixture npm run nested", + "command -p env npm_config_workspace=fixture npm run nested", + "SAFE=1 export npm_config_prefix=fixture; npm run nested", + "SAFE=1 set -a; npm_config_prefix=fixture; npm run nested", + "command --unknown env npm_config_prefix=fixture npm run nested", + "exec -a reviewer env npm_config_workspace=fixture npm run nested", +] as const; +const npmIndirectConfigAuthorityCommands = [ + "npm --userconfig fixture run nested", + "npm --globalconfig=fixture test", + "npm --userconfig fixture ci --ignore-scripts", + "npm --globalconfig fixture audit", + "NPM_CONFIG_USERCONFIG=fixture npm run nested", + "npm_config_globalconfig=fixture npm test", + "env NPM_CONFIG_USERCONFIG=fixture npm ci --ignore-scripts", + "/usr/bin/env npm_config_globalconfig=fixture npm audit", +] as const; +const npmPreDelimiterGlobCommands = [ + "npm run nested *", + "npm test ?", + "npm run nested [a-z]*", +] as const; +const unmodeledManagerPrefixCommands = [ + "nice env npm_config_workspace=fixture npm run nested", + "/usr/bin/nice env npm_config_prefix=fixture npm run nested", + "nohup env npm_config_workspaces=true npm test", + "SAFE=1 nice env npm_config_workspace=fixture npm run nested", + "nice npm run nested", + "time pnpm nested", +] as const; afterEach(async () => { await Promise.all( @@ -31,6 +117,27 @@ afterEach(async () => { ); }); +async function createUnsafeCiGateFixture( + command: string, + additionalScripts: Readonly> = {}, +): Promise { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-unsafe-command-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + contract.commands[0].script = "unsafe:preflight"; + packageDocument.scripts["unsafe:preflight"] = command; + Object.assign(packageDocument.scripts, additionalScripts); + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + return root; +} + describe("CI gate contract", () => { it.each([ ["unknown-field.json", /unrecognized|unknown/i], @@ -148,6 +255,7 @@ describe("CI gate contract", () => { ["job kind drift", (value: Record) => (value.jobs[0].kind = "gate-single"), /job graph drift/i], ["job needs drift", (value: Record) => (value.jobs[1].needs = []), /job graph drift/i], ["job condition drift", (value: Record) => (value.jobs[1].condition = "always"), /job graph drift/i], + ["promotion success condition drift", (value: Record) => (value.jobs.find((job: any) => job.id === "promotion").condition = "always"), /job graph drift|promotion.*condition/i], ["candidate output identity drift", (value: Record) => { const job = value.jobs.find((candidate: any) => candidate.id === "immutable_build"); job.steps.find((step: any) => step.kind === "archive-candidate").archiveOutputName = "renamed"; }, /candidate output identity drift/i], ["stage cycle", (value: Record) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i], ["provider adapter target drift", (value: Record) => (value.providerAdapter = "package.json"), /canonical generated workflow/i], @@ -278,6 +386,290 @@ describe("CI gate contract", () => { await expect(loadCiGateContract(root)).rejects.toThrow(/exact canonical non-recursive orchestration/i); }); + it.each([ + "npm ci --ignore-scripts --no-ignore-scripts", + "pnpm install --ignore-scripts --config.ignore-scripts=false", + "pnpm ln fixture", + ])("rejects an unsafe lifecycle in every contract command entry: %s", async (command) => { + const root = await createUnsafeCiGateFixture(command); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|ignore-scripts/i, + ); + }); + + it("does not enter the production runner callback when contract preflight fails", async () => { + const root = await createUnsafeCiGateFixture( + "npm ci --ignore-scripts --no-ignore-scripts", + ); + const contracts = await import("../../scripts/contracts/ci-gates.ts"); + const preflight = (contracts as typeof contracts & { + withCiGatePreflight?: ( + root: string, + gateId: string | undefined, + execute: (context: unknown) => Promise, + ) => Promise; + }).withCiGatePreflight; + expect(preflight).toBeTypeOf("function"); + if (!preflight) return; + let executed = false; + await expect( + preflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|ignore-scripts/i); + expect(executed).toBe(false); + }); + + it.each(npmPostScriptForeignScopeCommands)( + "rejects post-script foreign npm scope before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + start: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it.each([ + ...npmDispatchExpansionCommands, + ...npmDispatchScopeEnvironmentCommands, + ])( + "rejects dynamic npm dispatch scope before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it.each(npmShellPrefixScopeEnvironmentCommands)( + "rejects unsafe npm shell prefix before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it.each(unmodeledManagerPrefixCommands)( + "rejects an unmodeled manager prefix before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it.each(npmIndirectConfigAuthorityCommands)( + "rejects indirect npm config authority before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it.each(npmPreDelimiterGlobCommands)( + "rejects pre-delimiter npm glob expansion before entering the runner callback: %s", + async (command) => { + const root = await createUnsafeCiGateFixture(command, { + nested: "echo root-only safe", + test: "echo root-only safe", + }); + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|not safely parseable/i, + ); + + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }, + ); + + it("rejects scoped npm lifecycle before entering the runner callback", async () => { + const root = await createUnsafeCiGateFixture( + "npm_config_prefix=fixture npm ci --ignore-scripts", + ); + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|not safely parseable/i); + expect(executed).toBe(false); + }); + + it.each(npmLifecycleHookDispatches)( + "rejects unsafe npm hooks before entering the runner callback: %s", + async (command, scriptName) => { + const root = await createUnsafeCiGateFixture(command, { + [`pre${scriptName}`]: "npm ci", + [scriptName]: "echo root main safe", + [`post${scriptName}`]: "npm ci", + }); + + await expect(loadCiGateContract(root)).rejects.toThrow( + /install policy|install-bearing|ignore-scripts/i, + ); + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|ignore-scripts/i); + expect(executed).toBe(false); + }, + ); + + it("rejects inherited npm scope configuration before entering the runner callback", async () => { + const variable = "NpM_CoNfIg_WoRkSpAcE"; + const previous = process.env[variable]; + process.env[variable] = "fixture"; + let executed = false; + try { + await expect( + withCiGatePreflight(process.cwd(), "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/npm.*scope.*environment|npm_config_workspace/i); + expect(executed).toBe(false); + } finally { + if (previous === undefined) delete process.env[variable]; + else process.env[variable] = previous; + } + }); + + it.each(["NPM_CONFIG_USERCONFIG", "npm_config_globalconfig"])( + "rejects inherited indirect npm config authority before callback: %s", + async (variable) => { + const previous = process.env[variable]; + process.env[variable] = "fixture"; + let executed = false; + try { + await expect( + withCiGatePreflight(process.cwd(), "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/npm.*scope.*environment|npm_config/i); + expect(executed).toBe(false); + } finally { + if (previous === undefined) delete process.env[variable]; + else process.env[variable] = previous; + } + }, + ); + + it.each([ + ["npm run nested --ignore-scripts=false", "nested"], + ["npm test -- --ignore-scripts=true", "test"], + ])( + "keeps unsafe npm hooks inside contract preflight without effective suppression: %s", + async (command, scriptName) => { + const root = await createUnsafeCiGateFixture(command, { + [`pre${scriptName}`]: "npm ci", + [scriptName]: "echo root main safe", + [`post${scriptName}`]: "npm ci", + }); + let executed = false; + await expect( + withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }), + ).rejects.toThrow(/install policy|install-bearing|ignore-scripts/i); + expect(executed).toBe(false); + }, + ); + + it.each([ + ["npm --ignore-scripts=true run nested", "nested"], + ["npm test --ignore-scripts=true", "test"], + ["npm restart --ignore-scripts true", "restart"], + ["npm --ignore-scripts run nested", "nested"], + ["npm run nested --ignore-scripts", "nested"], + ["npm run-script nested --ignore-scripts", "nested"], + ["npm start --ignore-scripts", "start"], + ["npm stop --ignore-scripts", "stop"], + ["npm restart --ignore-scripts", "restart"], + ])( + "allows contract execution when effective true suppresses npm hooks: %s", + async (command, scriptName) => { + const root = await createUnsafeCiGateFixture(command, { + [`pre${scriptName}`]: "npm ci", + [scriptName]: "echo root main safe", + [`post${scriptName}`]: "npm ci", + }); + let executed = false; + await withCiGatePreflight(root, "FE-GATE-001", async () => { + executed = true; + }); + expect(executed).toBe(true); + }, + ); + it("detects reachable alias cycles and option-form ci:gate invocations", () => { expect(validatePackageScriptGraph({ "check:ci": "pnpm alias", alias: "pnpm check:ci" }, "check:ci")) .toEqual(expect.arrayContaining([expect.stringMatching(/cycle/i)])); @@ -285,6 +677,622 @@ describe("CI gate contract", () => { .toEqual(expect.arrayContaining([expect.stringMatching(/must not invoke ci:gate/i)])); }); + it.each(npmLifecycleHookDispatches)( + "traverses existing npm pre/main/post scripts in lifecycle order: %s", + (gate, scriptName) => { + const preScript = `pre${scriptName}`; + const postScript = `post${scriptName}`; + const scripts = { + gate, + [preScript]: "npm run missing:pre", + [scriptName]: "npm run missing:main", + [postScript]: "npm run missing:post", + }; + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([ + `package script missing: ${preScript} -> missing:pre`, + `package script missing: ${scriptName} -> missing:main`, + `package script missing: ${postScript} -> missing:post`, + ]); + }, + ); + + it.each(npmLifecycleHookDispatches)( + "applies install policy to existing npm pre/main/post scripts: %s", + (gate, scriptName) => { + const preScript = `pre${scriptName}`; + const postScript = `post${scriptName}`; + const scripts = { + gate, + [preScript]: "npm ci", + [scriptName]: "npm ci", + [postScript]: "npm ci", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([ + `install-bearing package script must use --ignore-scripts: ${preScript}`, + `install-bearing package script must use --ignore-scripts: ${scriptName}`, + `install-bearing package script must use --ignore-scripts: ${postScript}`, + ]); + }, + ); + + it.each([ + ["npm --ignore-scripts=true run nested", "nested"], + ["npm --ignore-scripts true run nested", "nested"], + ["npm run nested --ignore-scripts=true", "nested"], + ["npm test --ignore-scripts=true", "test"], + ["npm restart --ignore-scripts true", "restart"], + ["npm --ignore-scripts run nested", "nested"], + ["npm run nested --ignore-scripts", "nested"], + ["npm run-script nested --ignore-scripts", "nested"], + ["npm start --ignore-scripts", "start"], + ["npm stop --ignore-scripts", "stop"], + ["npm restart --ignore-scripts", "restart"], + ])("omits npm hooks for effective suppression: %s", (gate, scriptName) => { + const scripts = { + gate, + [`pre${scriptName}`]: "npm ci", + [scriptName]: "echo root main safe", + [`post${scriptName}`]: "npm run missing:post", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + ["npm --ignore-scripts=false run nested", "nested"], + ["npm test --ignore-scripts=false", "test"], + ["npm --ignore-scripts=true run nested --no-ignore-scripts", "nested"], + ["npm restart --ignore-scripts=true --ignore-scripts=false", "restart"], + ["npm run nested -- --ignore-scripts=true", "nested"], + ["npm test -- --ignore-scripts=true", "test"], + ])("keeps npm hooks for non-effective or post-delimiter suppression: %s", (gate, scriptName) => { + const preScript = `pre${scriptName}`; + const postScript = `post${scriptName}`; + const scripts = { + gate, + [preScript]: "npm ci", + [scriptName]: "echo root main safe", + [postScript]: "npm run missing:post", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + `install-bearing package script must use --ignore-scripts: ${preScript}`, + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + `package script missing: ${postScript} -> missing:post`, + ); + }); + + it("rejects reachable nested package installs without lifecycle suppression", () => { + const unsafe = { + gate: "corepack pnpm nested", + nested: "corepack pnpm install --frozen-lockfile", + }; + const safe = { + ...unsafe, + nested: "corepack pnpm install --frozen-lockfile --ignore-scripts", + }; + expect(validateInstallScriptPolicy(unsafe, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: nested", + ); + expect(validateInstallScriptPolicy(safe, ["gate"])).toEqual([]); + }); + + it.each([ + "corepack pnpm install --frozen-lockfile && echo --ignore-scripts", + "corepack pnpm install && corepack pnpm install --ignore-scripts", + ])("rejects an unsafe install invocation masked by another token: %s", (nested) => { + const scripts = { + gate: "corepack pnpm nested", + nested, + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: nested", + ); + }); + + it.each([ + "corepack pnpm --dir=fixture install", + "corepack pnpm --dir fixture install", + "pnpm -C fixture install", + "pnpm --dir . install", + "pnpm i", + "npm --prefix fixture install", + "npm ci", + "npm i", + "yarn --cwd fixture install", + ])("rejects install aliases and manager-global-option forms: %s", (nested) => { + expect(validateInstallScriptPolicy({ gate: "pnpm nested", nested }, ["gate"])) + .toContain("install-bearing package script must use --ignore-scripts: nested"); + }); + + it.each([ + "corepack pnpm --dir", + "pnpm --unknown-manager-option install --ignore-scripts", + ])("fails closed for an unparsed reachable manager invocation: %s", (nested) => { + expect(validateInstallScriptPolicy({ gate: "pnpm nested", nested }, ["gate"])) + .toContain("install-bearing package script must use --ignore-scripts: nested"); + }); + + it("treats a single ampersand as a command boundary for install policy", () => { + const scripts = { + gate: "pnpm install & pnpm install --ignore-scripts", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + }); + + it.each([ + "pnpm install --ignore-scripts=false", + "pnpm install --ignore-scripts --ignore-scripts=false", + "npm ci --ignore-scripts=false", + "npm install --ignore-scripts=true --ignore-scripts=false", + "yarn install --ignore-scripts=false", + "yarn install --ignore-scripts --ignore-scripts=false", + "pnpm install --ignore-scripts=", + "npm ci --ignore-scripts=maybe", + "yarn install --ignore-scripts false", + "pnpm install --ignore-scripts=false --ignore-scripts=true", + ])("rejects ineffective or contradictory lifecycle suppression: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + }); + + it.each([ + "pnpm install --ignore-scripts=true", + "npm ci --ignore-scripts", + "yarn install --ignore-scripts=true --ignore-scripts=true", + ])("accepts an unambiguous effective ignore-scripts true: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toEqual([]); + }); + + it.each([ + "npm --silent run nested", + "yarn nested", + "yarn run nested", + "corepack yarn nested", + ])("follows manager-option and yarn script invocations into unsafe installs: %s", (gate) => { + const scripts = { gate, nested: "npm ci" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: nested", + ); + }); + + it.each([ + "npm --silent run missing", + "corepack yarn run missing", + ])("uses the manager parser for package graph dependencies: %s", (gate) => { + expect(validatePackageScriptGraph({ gate }, "gate")).toContain( + "package script missing: gate -> missing", + ); + }); + + it.each([ + "yarn workspace fixture run nested", + "yarn workspace fixture nested", + "yarn workspaces foreach -A run nested", + ])("fails closed for an unmodeled Yarn workspace dispatcher: %s", (gate) => { + const scripts = { gate, nested: "npm ci" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "pnpm install # --ignore-scripts", + "npm ci # --ignore-scripts=true", + ])("fails closed when an unquoted shell comment masks lifecycle suppression: %s", (gate) => { + const scripts = { gate }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it("keeps a quoted hash as ordinary lifecycle argument content", () => { + const scripts = { gate: "pnpm install '#' --ignore-scripts" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "npm ci --ignore-scripts --no-ignore-scripts", + "pnpm install --ignore-scripts --config.ignore-scripts=false", + "pnpm install --ignore-scripts --unknown-lifecycle-setting", + "pnpm install --config.ignore-scripts=maybe --ignore-scripts", + ])("rejects contradictory, malformed, or unknown lifecycle settings: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + }); + + it.each([ + "npm ci --ignore-scripts true", + "pnpm install --config.ignore-scripts", + "pnpm install --config.ignore-scripts=true", + "pnpm install --config.ignore-scripts true --frozen-lockfile", + ])("accepts a supported canonical lifecycle suppression form: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toEqual([]); + }); + + it("canonicalizes a lifecycle builtin alias before package-script lookup", () => { + const scripts = { + gate: "pnpm ln fixture", + ln: "npm ci", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([ + "install-bearing package script must use --ignore-scripts: gate", + ]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "pnpm frobnicate", + "yarn frobnicate", + ])("fails closed for an unknown manager subcommand absent from root scripts: %s", (gate) => { + const scripts = { gate }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "pnpm --filter fixture run nested", + "pnpm --dir fixture run nested", + "pnpm -C fixture nested", + "npm --workspace fixture run nested", + "npm --prefix fixture run nested", + "yarn --cwd fixture run nested", + ])("fails closed when package-script dispatch changes authoritative manifest scope: %s", (gate) => { + const scripts = { gate, nested: "echo root-only safe" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each(npmPostScriptForeignScopeCommands)( + "fails closed when post-script npm options change package-script scope: %s", + (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + start: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }, + ); + + it.each([ + ...npmDispatchExpansionCommands, + ...npmDispatchScopeEnvironmentCommands, + ])("fails closed for dynamic npm dispatch scope: %s", (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each(npmShellPrefixScopeEnvironmentCommands)( + "fails closed for unsafe npm shell prefix: %s", + (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }, + ); + + it.each(unmodeledManagerPrefixCommands)( + "fails closed for an unmodeled manager prefix: %s", + (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }, + ); + + it.each(npmIndirectConfigAuthorityCommands)( + "fails closed for indirect npm config authority: %s", + (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }, + ); + + it.each(npmPreDelimiterGlobCommands)( + "fails closed for unquoted glob expansion before npm's literal delimiter: %s", + (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }, + ); + + it.each([ + "npm_config_prefix=fixture npm ci --ignore-scripts", + "env npm_config_workspace=fixture npm audit", + ])("rejects npm scope environment for every npm command class: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph({ gate }, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "npm run nested --workspaces", + "npm run nested -wfixture", + "npm run nested -w=fixture", + "npm run nested --unknown-manager-option", + ])("fails closed for additional or unknown pre-delimiter npm options: %s", (gate) => { + const scripts = { gate, nested: "echo root-only safe" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "pnpm run nested --filter foreign", + "pnpm nested --dir foreign", + "yarn run nested --cwd foreign", + "yarn nested --cwd foreign", + ])("treats other-manager tokens after a script name as script arguments: %s", (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "npm run nested -- --workspace foreign", + "npm run-script nested -- --prefix foreign", + "npm test -- --workspace foreign", + "npm start -- --unknown-manager-option", + "SCOPE=--workspace; npm run nested -- $SCOPE fixture", + "SCOPE=--prefix; npm test -- ${SCOPE} fixture", + ])("treats npm tokens after the script-argument delimiter as script arguments: %s", (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + start: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "SAFE=value npm run nested", + "npm_config_loglevel=warn npm run nested", + "/bin/env npm_config_loglevel=warn npm run nested", + "command env SAFE=value npm run nested", + "command -- env SAFE=value npm run nested", + "command -p env SAFE=value npm run nested", + "command /opt/reviewer/bin/env SAFE=value npm run nested", + "exec env SAFE=value npm run nested", + "exec /bin/env SAFE=value npm run nested", + "command exec env SAFE=value npm run nested", + "SAFE=1 command -- npm run nested", + "command exec env SAFE=value corepack npm run nested", + "SAFE=1 corepack pnpm nested", + "exec corepack pnpm nested", + "MESSAGE='npm_config_workspace=fixture' npm run nested", + "MESSAGE=\"npm_config_${SAFE_NAME}=fixture\" npm run nested", + "echo 'npm_config_workspace=fixture' && npm run nested", + "echo 'set -a; npm_config_prefix=fixture' && npm run nested", + "export npm_config_loglevel=warn && npm run nested", + "SAFE=value; export SAFE; npm run nested", + "SAFE=1 export npm_config_loglevel=warn; npm run nested", + "SAFE=1 set -a; SAFE_TWO=2; npm run nested", + "command npm run nested", + "exec npm run nested", + "env -i SAFE=value npm run nested", + "/usr/bin/env --ignore-environment npm test", + "env -u npm_config_workspace npm run nested", + "env --unset=NPM_CONFIG_PREFIX npm test", + "env -- npm run nested", + "env '$NAME=fixture' npm run nested", + "SCOPE=--workspace; pnpm run nested $SCOPE fixture", + "SCOPE=--workspace; yarn run nested ${SCOPE} fixture", + ])("preserves unrelated environment and other-manager script arguments: %s", (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "npm run nested '*'", + 'npm test "?"', + "npm run nested \\*", + "npm run nested -- *", + "npm test -- [a-z]*", + ])("preserves quoted, escaped, or post-delimiter glob arguments: %s", (gate) => { + const scripts = { gate, nested: "echo root-only safe", test: "echo root-only safe" }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "npm run nested", + "npm run nested --if-present", + "npm run nested --ignore-scripts=true", + "npm test --foreground-scripts", + "npm start", + ])("keeps current-tree npm dispatch and exact harmless options supported: %s", (gate) => { + const scripts = { + gate, + nested: "echo root-only safe", + start: "echo root-only safe", + test: "echo root-only safe", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph(scripts, "gate")).toEqual([]); + }); + + it.each([ + "pnpm --dir fixture install --ignore-scripts", + "npm --prefix fixture ci --ignore-scripts", + "yarn --cwd fixture install --ignore-scripts", + ])("still classifies externally scoped lifecycle commands by suppression: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph({ gate }, "gate")).toEqual([]); + }); + + it.each([ + "npm init unreviewed-tool", + "npm explore fixture -- npm ci", + "npm audit fix", + "yarn npm publish", + ])("fails closed for an argument-sensitive or mutating builtin dispatcher: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph({ gate }, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each(["npm audit", "pnpm audit"])( + "accepts only an exact bare read-only audit builtin: %s", + (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toEqual([]); + expect(validatePackageScriptGraph({ gate }, "gate")).toEqual([]); + }, + ); + + it.each([ + "npm --silent audit", + "npm --prefix fixture audit", + "npm -- audit", + "npm audit --silent", + "npm audit --", + "pnpm --silent audit", + "pnpm --dir fixture audit", + "pnpm -- audit", + "pnpm audit --silent", + "pnpm audit --", + ])("rejects a read-only builtin with manager syntax or arguments: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph({ gate }, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "pnpm exec npm ci", + "corepack pnpm exec npm install", + "pnpm dlx npm ci", + "npm exec pnpm install", + "pnpm exec unreviewed-tool", + ])("fails closed for nested manager and unknown executable dispatchers: %s", (gate) => { + expect(validateInstallScriptPolicy({ gate }, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph({ gate }, "gate")).toContain( + "package script manager invocation is not safely parseable: gate", + ); + }); + + it.each([ + "pnpm add fixture", + "npm update fixture", + "yarn rebuild fixture", + "corepack pnpm rebuild fixture", + ])("treats lifecycle mutation builtins as unsafe even when a script has the same name: %s", (gate) => { + const scripts = { + gate, + add: "echo script", + update: "echo script", + rebuild: "echo script", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: gate", + ); + expect(validatePackageScriptGraph(scripts, "gate")).not.toEqual( + expect.arrayContaining([expect.stringMatching(/package script missing|cycle/u)]), + ); + }); + + it("allows an explicit run of a script whose name collides with a manager builtin", () => { + const scripts = { + gate: "pnpm run add", + add: "npm ci", + }; + expect(validateInstallScriptPolicy(scripts, ["gate"])).toContain( + "install-bearing package script must use --ignore-scripts: add", + ); + }); + it("rejects an invalid package graph before the gate runner can spawn it", async () => { const root = await mkdtemp(path.join(tmpdir(), "ci-contract-runner-preflight-")); temporaryRoots.push(root); @@ -460,6 +1468,13 @@ describe("CI workflow generation", () => { expect(first).toContain('PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"'); expect(first).toContain("${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz"); expect(first).not.toContain(".release/promoted-staging"); + const promotionJobStart = first.indexOf(" promotion:\n"); + const productionJobStart = first.indexOf(" production_gate:\n"); + expect(promotionJobStart).toBeGreaterThan(0); + expect(productionJobStart).toBeGreaterThan(promotionJobStart); + const promotionJob = first.slice(promotionJobStart, productionJobStart); + expect(promotionJob.slice(0, promotionJob.indexOf(" steps:\n"))).not.toMatch(/^\s+if:/mu); + expect(promotionJob).not.toContain("cancelled()"); const finalizerIndex = first.indexOf("node scripts/stage-verified-promotion.ts"); const promotedUploadIndex = first.indexOf("Upload promoted release"); const cleanupStepIndex = first.indexOf("- name: Always remove private promotion staging"); @@ -470,6 +1485,9 @@ describe("CI workflow generation", () => { expect(cleanupIndex).toBeGreaterThan(cleanupStepIndex); expect(first.slice(promotedUploadIndex, cleanupStepIndex)).not.toContain("if: always()"); expect(first.slice(cleanupStepIndex, cleanupIndex)).toContain("if: always()"); + expect(first.slice(cleanupStepIndex, cleanupIndex)).toContain( + 'if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then', + ); const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]); expect(actionUses).toHaveLength(32); expect(new Set(actionUses)).toEqual( @@ -576,6 +1594,7 @@ describe("CI workflow generation", () => { writeFile: async () => { throw new Error("injected write failure"); }, + chmod: async () => undefined, sync: async () => undefined, close: async () => undefined, }; diff --git a/tests/unit/provider-guardian-transaction.test.ts b/tests/unit/provider-guardian-transaction.test.ts new file mode 100644 index 0000000..611e2d1 --- /dev/null +++ b/tests/unit/provider-guardian-transaction.test.ts @@ -0,0 +1,1105 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, constants, fchmodSync, openSync, watch } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + readlink, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("provider guardian transaction protocol", () => { + it("encodes a v2 guard without accepting filesystem paths or identities", async () => { + const protocol = await import("../../scripts/lib/provider-guardian-protocol.ts") as + Record; + expect(protocol.encodeProviderGuardianGuard).toBeTypeOf("function"); + const encode = protocol.encodeProviderGuardianGuard as (input: Readonly<{ + kind: "vulnerability"; + nonce: Buffer; + deadlineEpochMs: number; + }>) => Buffer; + const nonce = Buffer.alloc(32, 0x5a); + const encoded = encode({ + kind: "vulnerability", + nonce, + deadlineEpochMs: 1_800_000_000_000, + }); + + expect(encoded.readUInt32BE(0)).toBe(encoded.byteLength - 4); + expect(JSON.parse(encoded.subarray(4).toString("utf8"))).toEqual({ + type: "guard", + version: 2, + kind: "vulnerability", + nonce: "5a".repeat(32), + deadlineEpochMs: 1_800_000_000_000, + }); + expect( + (protocol.providerGuardianSealedTempLeaf as (kind: "vulnerability", value: Buffer) => string)( + "vulnerability", + nonce, + ), + ).toBe(`.vulnerability-report.json.guardian-${"5a".repeat(16)}.tmp`); + expect( + (protocol.providerGuardianRawStagingLeaf as ( + kind: "vulnerability", + value: Buffer, + ) => string)("vulnerability", nonce), + ).toBe(`.vulnerability-report.json.guardian-${"5a".repeat(16)}.raw.tmp`); + }); + + it("decodes only the exact canonical v2 guard within the lease window", async () => { + const protocol = await import("../../scripts/lib/provider-guardian-protocol.ts") as + Record; + expect(protocol.decodeProviderGuardianGuard).toBeTypeOf("function"); + const decode = protocol.decodeProviderGuardianGuard as ( + payload: Buffer, + options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>, + ) => Readonly<{ kind: string; nonce: Buffer; deadlineEpochMs: number }>; + const canonical = JSON.stringify({ + type: "guard", + version: 2, + kind: "provenance", + nonce: "3c".repeat(32), + deadlineEpochMs: 5_000, + }); + + expect(decode(Buffer.from(canonical), { nowEpochMs: 1_000, maxLeaseMs: 5_000 })) + .toEqual({ + kind: "provenance", + nonce: Buffer.alloc(32, 0x3c), + deadlineEpochMs: 5_000, + }); + for (const invalid of [ + canonical.replace('"nonce":', '"nonce":"3c'.concat('"'.repeat(0), ',"nonce":')), + canonical.replace(/\}$/u, ',"rawIno":9}'), + `${canonical} `, + canonical.replace("5000", "1000"), + canonical.replace("5000", "7000"), + canonical.replace("3c".repeat(32), "3c".repeat(31)), + ]) { + expect(() => decode(Buffer.from(invalid), { nowEpochMs: 1_000, maxLeaseMs: 5_000 })) + .toThrow(/provider guardian/u); + } + expect(() => decode(Buffer.from([0xff]), { nowEpochMs: 1_000, maxLeaseMs: 5_000 })) + .toThrow(/provider guardian/u); + }); + + it("authenticates READY, publish, PUBLISHED, and v2 commit with exact fields", async () => { + const protocol = await import("../../scripts/lib/provider-guardian-protocol.ts") as + Record; + for (const name of [ + "decodeProviderGuardianReady", + "encodeProviderGuardianPublish", + "decodeProviderGuardianPublish", + "encodeProviderGuardianPublished", + "decodeProviderGuardianPublished", + ]) { + expect(protocol[name], name).toBeTypeOf("function"); + } + const nonce = Buffer.alloc(32, 0x7a); + const readyPayload = Buffer.from(JSON.stringify({ + type: "ready", + version: 2, + nonce: "7a".repeat(32), + rawDev: 12, + rawIno: 34, + sealedTempLeaf: ".vulnerability-report.json.guardian-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.tmp", + sealedDev: 56, + sealedIno: 78, + })); + const decodeReady = protocol.decodeProviderGuardianReady as ( + payload: Buffer, + expectedNonce: Buffer, + ) => Record; + expect(decodeReady(readyPayload, nonce)).toEqual({ + nonce, + rawDev: 12, + rawIno: 34, + sealedTempLeaf: ".vulnerability-report.json.guardian-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.tmp", + sealedDev: 56, + sealedIno: 78, + }); + expect(() => decodeReady(readyPayload, Buffer.alloc(32, 0x7b))) + .toThrow(/authentication/u); + + const encodePublish = protocol.encodeProviderGuardianPublish as (input: Record) => Buffer; + const publishFrame = encodePublish({ + nonce, + sealedDev: 56, + sealedIno: 78, + size: 123, + sha256: "ab".repeat(32), + }); + expect(JSON.parse(publishFrame.subarray(4).toString("utf8"))).toEqual({ + type: "publish", + version: 2, + nonce: "7a".repeat(32), + sealedDev: 56, + sealedIno: 78, + size: 123, + sha256: "ab".repeat(32), + }); + const decodePublish = protocol.decodeProviderGuardianPublish as ( + payload: Buffer, + expectedNonce: Buffer, + ) => Record; + expect(decodePublish(publishFrame.subarray(4), nonce)).toEqual({ + nonce, + sealedDev: 56, + sealedIno: 78, + size: 123, + sha256: "ab".repeat(32), + }); + + const encodePublished = protocol.encodeProviderGuardianPublished as ( + input: Readonly<{ nonce: Buffer; sealedDev: number; sealedIno: number }>, + ) => Buffer; + const published = encodePublished({ nonce, sealedDev: 56, sealedIno: 78 }); + const decodePublished = protocol.decodeProviderGuardianPublished as ( + payload: Buffer, + expectedNonce: Buffer, + expectedIdentity: Readonly<{ dev: number; ino: number }>, + ) => void; + expect(() => decodePublished(published.subarray(4), nonce, { dev: 56, ino: 78 })) + .not.toThrow(); + + const encodeCommit = protocol.encodeProviderGuardianCommit as (value: Buffer) => Buffer; + expect(JSON.parse(encodeCommit(nonce).subarray(4).toString("utf8"))).toEqual({ + type: "commit", + version: 2, + nonce: "7a".repeat(32), + }); + }); + + it("creates and returns owned raw and sealed-temp identities before READY", async () => { + const { + decodeProviderGuardianReady, + encodeProviderGuardianGuard, + } = await import("../../scripts/lib/provider-guardian-protocol.ts"); + const workspace = await createWorkspace("provider-guardian-ready-"); + const rawPath = path.join( + workspace, + "provider-evidence/untrusted/vulnerability-report.json", + ); + const nonce = Buffer.alloc(32, 0x4d); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + const readyPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 2_000, + })); + + const ready = decodeProviderGuardianReady( + await within(readyPayload, 1_000, "guardian READY"), + nonce, + ); + const raw = await lstat(rawPath); + const sealedTempPath = path.join(workspace, "provider-evidence", ready.sealedTempLeaf); + const sealedTemp = await lstat(sealedTempPath); + expect({ dev: raw.dev, ino: raw.ino, mode: raw.mode & 0o777 }).toEqual({ + dev: ready.rawDev, + ino: ready.rawIno, + mode: 0o600, + }); + expect({ dev: sealedTemp.dev, ino: sealedTemp.ino, mode: sealedTemp.mode & 0o777 }) + .toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 }); + + child.stdin!.end(); + await expect(within(completion, 1_000, "guardian abort")).resolves.toEqual({ + code: 125, + signal: null, + }); + await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); + }); + + it("creates no evidence for empty or truncated guard input", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + for (const [label, input, expectedCode] of [ + ["empty", undefined, 125], + ["truncated-prefix", Buffer.from([0, 0]), 126], + ] as const) { + const workspace = await createWorkspace(`provider-guardian-${label}-`); + const child = spawnGuardian(workspace); + const completion = waitForChild(child); + if (input) child.stdin!.end(input); + else child.stdin!.end(); + + await expect(within(completion, 1_000, `${label} guardian EOF`)).resolves.toEqual({ + code: expectedCode, + signal: null, + }); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual([ + "untrusted", + ]); + + const retry = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + await retry.abort(); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))) + .resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))) + .resolves.toEqual(["untrusted"]); + } + }); + + it.each(["raw", "sealed"] as const)( + "cleans the independently bound %s private alias when peer bootstrap validation fails", + async (boundKind) => { + const workspace = await createWorkspace(`provider-guardian-partial-bootstrap-${boundKind}-`); + const nonce = Buffer.alloc(32, boundKind === "raw" ? 0x71 : 0x72); + const fixture = spawnGuardianWithPrivateModes(workspace, nonce, { + rawMode: boundKind === "raw" ? 0o600 : 0o400, + sealedMode: boundKind === "sealed" ? 0o600 : 0o400, + }); + + await expect(within( + waitForChild(fixture.child), + 1_000, + `${boundKind} partial bootstrap exit`, + )).resolves.toEqual({ code: 126, signal: null }); + const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath; + const invalidPath = boundKind === "raw" ? fixture.sealedTempPath : fixture.rawStagingPath; + await expect(lstat(boundPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(invalidPath)).resolves.toEqual(expect.objectContaining({ + mode: expect.any(Number), + })); + await rm(invalidPath); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))) + .resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))) + .resolves.toEqual(["untrusted"]); + }, + ); + + it("cleans guardian-created objects when the parent closes immediately after guard", async () => { + const { encodeProviderGuardianGuard } = await import( + "../../scripts/lib/provider-guardian-protocol.ts" + ); + const workspace = await createWorkspace("provider-guardian-parent-startup-death-"); + const nonce = Buffer.alloc(32, 0x2b); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + child.stdin!.end(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 2_000, + })); + + await expect(within(completion, 1_000, "guardian parent startup death")) + .resolves.toEqual({ code: 125, signal: null }); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]); + }); + + it("publishes the pinned sealed inode and preserves it only after commit EOF", async () => { + const { + decodeProviderGuardianPublished, + decodeProviderGuardianReady, + encodeProviderGuardianCommit, + encodeProviderGuardianGuard, + encodeProviderGuardianPublish, + } = await import("../../scripts/lib/provider-guardian-protocol.ts"); + const workspace = await createWorkspace("provider-guardian-publish-"); + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawPath = path.join(evidenceRoot, "untrusted/vulnerability-report.json"); + const sealedPath = path.join(evidenceRoot, "vulnerability-report.json"); + const nonce = Buffer.alloc(32, 0x62); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + const readyPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 3_000, + })); + const ready = decodeProviderGuardianReady( + await within(readyPayload, 1_000, "guardian READY"), + nonce, + ); + const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); + const bytes = Buffer.from('{"validated":true}\n'); + const handle = await open( + sealedTempPath, + constants.O_WRONLY | constants.O_NOFOLLOW, + ); + try { + const before = await handle.stat(); + expect({ dev: before.dev, ino: before.ino }).toEqual({ + dev: ready.sealedDev, + ino: ready.sealedIno, + }); + await handle.writeFile(bytes); + await handle.chmod(0o400); + await handle.sync(); + } finally { + await handle.close(); + } + + const publishedPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianPublish({ + nonce, + sealedDev: ready.sealedDev, + sealedIno: ready.sealedIno, + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + })); + decodeProviderGuardianPublished( + await within(publishedPayload, 1_000, "guardian PUBLISHED"), + nonce, + { dev: ready.sealedDev, ino: ready.sealedIno }, + ); + await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); + const published = await lstat(sealedPath); + expect({ dev: published.dev, ino: published.ino, mode: published.mode & 0o777 }) + .toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o400 }); + await expect(readFile(sealedPath)).resolves.toEqual(bytes); + + child.stdin!.write(encodeProviderGuardianCommit(nonce)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(child.exitCode).toBeNull(); + child.stdin!.end(); + await expect(within(completion, 1_000, "guardian commit EOF")).resolves.toEqual({ + code: 0, + signal: null, + }); + await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(sealedPath)).resolves.toEqual(bytes); + }); + + it("owns raw and sealed publication through the authenticated client lease", async () => { + const client = await import("../../scripts/lib/provider-guardian-client.ts") as + Record; + expect(client.startProviderGuardian).toBeTypeOf("function"); + const start = client.startProviderGuardian as (input: Readonly<{ + kind: "vulnerability"; + workspaceRoot: string; + leaseMs: number; + guardianScript: string; + }>) => Promise; + sealedPath: string; + sealedTempPath: string; + sealedIdentity: Readonly<{ dev: number; ino: number }>; + publish(bytes: Buffer): Promise; + commit(): Promise; + }>>; + const workspace = await createWorkspace("provider-guardian-client-v2-"); + const guardianScript = path.resolve("scripts/lib/provider-raw-guardian.ts"); + const lease = await start({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript, + }); + expect((await readFile(`/proc/${lease.pid}/cmdline`)).toString("utf8") + .split("\0").filter(Boolean)).toEqual([process.execPath, guardianScript]); + await expect(readlink(`/proc/${lease.pid}/fd/3`)).resolves.toBe( + path.join(workspace, "provider-evidence/untrusted"), + ); + await expect(readlink(`/proc/${lease.pid}/fd/4`)).resolves.toBe( + path.join(workspace, "provider-evidence"), + ); + const rawMetadata = await lstat(lease.rawPath); + expect({ dev: rawMetadata.dev, ino: rawMetadata.ino }).toEqual(lease.rawIdentity); + const sealedTempMetadata = await lstat(lease.sealedTempPath); + expect({ dev: sealedTempMetadata.dev, ino: sealedTempMetadata.ino }) + .toEqual(lease.sealedIdentity); + + const bytes = Buffer.from('{"validated":"client"}\n'); + await lease.publish(bytes); + await expect(lstat(lease.sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(lease.sealedPath)).resolves.toEqual(bytes); + await lease.commit(); + await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(lease.sealedPath)).resolves.toEqual(bytes); + }); + + it("cleans published evidence on EOF before commit and permits a same-workspace retry", async () => { + const workspace = await createWorkspace("provider-guardian-published-eof-"); + const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31)); + + transaction.child.stdin!.end(); + await expect(within(transaction.completion, 1_000, "published guardian EOF")) + .resolves.toEqual({ code: 125, signal: null }); + await assertTransactionAbsent(transaction); + + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const retry = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 2_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + await retry.abort(); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]); + }); + + it("fails closed when a separate trailing frame arrives after commit", async () => { + const { encodeProviderGuardianCommit } = await import( + "../../scripts/lib/provider-guardian-protocol.ts" + ); + const workspace = await createWorkspace("provider-guardian-commit-trailing-"); + const nonce = Buffer.alloc(32, 0x47); + const transaction = await establishPublishedGuardian(workspace, nonce); + + transaction.child.stdin!.write(encodeProviderGuardianCommit(nonce)); + await new Promise((resolve) => setImmediate(resolve)); + transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b])); + transaction.child.stdin!.end(); + + await expect(within(transaction.completion, 1_000, "guardian trailing frame")) + .resolves.toEqual({ code: 126, signal: null }); + await assertTransactionAbsent(transaction); + }); + + it("uses pinned fallback identities after a guardian hard death", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const workspace = await createWorkspace("provider-guardian-hard-death-"); + const lease = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + + process.kill(lease.pid, "SIGKILL"); + await expect(within(lease.prematureExit, 1_000, "guardian hard death")) + .resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) })); + await lease.abort(); + await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(lease.sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(lease.sealedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("cleans a linked raw inode when killed before READY and retries immediately", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const workspace = await createWorkspace("provider-guardian-pre-ready-death-"); + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawDirectory = path.join(evidenceRoot, "untrusted"); + const rawPath = path.join(rawDirectory, "vulnerability-report.json"); + const guardianScript = await createPausedGuardianFixture(workspace); + const existingChildren = await directChildPids(process.pid); + const starting = startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript, + }); + const guardianPid = await waitForNewDirectChild(existingChildren); + await waitForStoppedProcess(guardianPid); + const rawLink = waitForDirectoryEntry(rawDirectory, path.basename(rawPath), () => { + process.kill(guardianPid, "SIGKILL"); + }); + process.kill(guardianPid, "SIGCONT"); + + await within(rawLink, 1_000, "pre-READY canonical raw link"); + await expect(within(starting, 2_000, "pre-READY guardian rejection")) + .rejects.toThrow(/provider guardian/u); + const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) => + leaf.startsWith(".vulnerability-report.json.guardian-") + ); + expect(possibleTempLeaves).toEqual([]); + await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + + const retry = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + expect(path.basename(retry.sealedTempPath)).toMatch( + /^\.vulnerability-report\.json\.guardian-[0-9a-f]{32}\.tmp$/u, + ); + await retry.abort(); + await expect(readdir(rawDirectory)).resolves.toEqual([]); + await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]); + }); + + it("preserves an external raw canary created after startup checks", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const workspace = await createWorkspace("provider-guardian-external-canary-"); + const rawPath = path.join( + workspace, + "provider-evidence/untrusted/vulnerability-report.json", + ); + const { guardianScript, markerPath } = await createStalledGuardianFixture(workspace); + const starting = startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript, + }); + const guardianPid = Number(await waitForFile(markerPath)); + const canaryBytes = Buffer.from("external-canary\n"); + const canaryHandle = await open( + rawPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + await canaryHandle.writeFile(canaryBytes); + const canaryIdentity = await canaryHandle.stat(); + await canaryHandle.close(); + + process.kill(guardianPid, "SIGKILL"); + await expect(within(starting, 2_000, "external-canary guardian rejection")) + .rejects.toThrow(/provider guardian/u); + expect(await readFile(rawPath)).toEqual(canaryBytes); + expect(await lstat(rawPath)).toMatchObject({ + dev: canaryIdentity.dev, + ino: canaryIdentity.ino, + }); + await rm(rawPath); + + const retry = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 3_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + await retry.abort(); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))) + .resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))) + .resolves.toEqual(["untrusted"]); + }); + + it("allows exactly one same-kind guardian without deleting the winner", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const workspace = await createWorkspace("provider-guardian-concurrent-kind-"); + const input = { + kind: "vulnerability" as const, + workspaceRoot: workspace, + leaseMs: 10_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }; + + const results = await Promise.allSettled([ + startProviderGuardian(input), + startProviderGuardian(input), + ]); + const winners = results.filter((result) => result.status === "fulfilled"); + const losers = results.filter((result) => result.status === "rejected"); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(1); + const winner = winners[0]!.value; + const rawMetadata = await lstat(winner.rawPath); + expect({ dev: rawMetadata.dev, ino: rawMetadata.ino }).toEqual(winner.rawIdentity); + await winner.abort(); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))) + .resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))) + .resolves.toEqual(["untrusted"]); + + const retry = await startProviderGuardian(input); + await retry.abort(); + await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))) + .resolves.toEqual([]); + await expect(readdir(path.join(workspace, "provider-evidence"))) + .resolves.toEqual(["untrusted"]); + }); + + it("exits nonzero after cleanup when the diagnostic stderr pipe is closed", async () => { + const { + decodeProviderGuardianReady, + encodeProviderGuardianGuard, + } = await import("../../scripts/lib/provider-guardian-protocol.ts"); + const workspace = await createWorkspace("provider-guardian-closed-stderr-"); + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawDirectory = path.join(evidenceRoot, "untrusted"); + const nonce = Buffer.alloc(32, 0x63); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + const readyPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 3_000, + })); + const ready = decodeProviderGuardianReady( + await within(readyPayload, 1_000, "closed-stderr guardian READY"), + nonce, + ); + child.stderr!.destroy(); + await new Promise((resolve) => child.stderr!.once("close", resolve)); + child.stdin!.end(); + + await expect(within(completion, 1_000, "closed-stderr guardian exit")) + .resolves.toEqual({ code: 125, signal: null }); + await expect(lstat(path.join(rawDirectory, "vulnerability-report.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(path.join(evidenceRoot, ready.sealedTempLeaf))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("expires an uncommitted lease only after cleaning every owned object", async () => { + const { + decodeProviderGuardianReady, + encodeProviderGuardianGuard, + } = await import("../../scripts/lib/provider-guardian-protocol.ts"); + const workspace = await createWorkspace("provider-guardian-deadline-v2-"); + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawPath = path.join(evidenceRoot, "untrusted/vulnerability-report.json"); + const nonce = Buffer.alloc(32, 0x58); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + const readyPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 350, + })); + const ready = decodeProviderGuardianReady( + await within(readyPayload, 1_000, "deadline guardian READY"), + nonce, + ); + const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); + + await expect(within(completion, 1_000, "guardian deadline")) + .resolves.toEqual({ code: null, signal: "SIGKILL" }); + await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]); + }); + + it("still publishes near the lease deadline when post-processing completes in time", async () => { + const { startProviderGuardian } = await import( + "../../scripts/lib/provider-guardian-client.ts" + ); + const workspace = await createWorkspace("provider-guardian-near-timeout-"); + const lease = await startProviderGuardian({ + kind: "vulnerability", + workspaceRoot: workspace, + leaseMs: 2_000, + guardianScript: path.resolve("scripts/lib/provider-raw-guardian.ts"), + }); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + const bytes = Buffer.from('{"validated":"near-timeout"}\n'); + + await lease.publish(bytes); + await lease.commit(); + await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(lease.sealedPath)).resolves.toEqual(bytes); + }); + + it("signals guardian exit only while the provider scope latch is active", async () => { + const lifecycle = await import("../../scripts/lib/provider-guardian-client.ts") as + Record; + expect(lifecycle.createProviderScopeGuardianLatch).toBeTypeOf("function"); + const createLatch = lifecycle.createProviderScopeGuardianLatch as ( + guardianExit: Promise, + ) => Readonly<{ + activeFailure: Promise; + close(): Promise; + failure(): Error | undefined; + }>; + + let failAtBoundary!: (error: Error) => void; + const boundaryExit = new Promise((resolve) => { failAtBoundary = resolve; }); + const boundary = createLatch(boundaryExit); + const boundaryError = new Error("guardian died at the collection boundary"); + failAtBoundary(boundaryError); + await boundary.close(); + await expect(boundary.activeFailure).resolves.toBe(boundaryError); + expect(boundary.failure()).toBe(boundaryError); + + let failActive!: (error: Error) => void; + const activeExit = new Promise((resolve) => { failActive = resolve; }); + const active = createLatch(activeExit); + const activeError = new Error("guardian died while scope active"); + failActive(activeError); + await expect(active.activeFailure).resolves.toBe(activeError); + expect(active.failure()).toBe(activeError); + + let failLate!: (error: Error) => void; + const lateExit = new Promise((resolve) => { failLate = resolve; }); + const late = createLatch(lateExit); + await late.close(); + const lateError = new Error("guardian died after scope collection"); + failLate(lateError); + await Promise.resolve(); + expect(late.failure()).toBe(lateError); + await expect(Promise.race([ + late.activeFailure.then(() => "active"), + new Promise((resolve) => setTimeout(() => resolve("inactive"), 25)), + ])).resolves.toBe("inactive"); + }); + + it("rejects a guardian lease whose derived sealed path differs from the configured target", async () => { + const lifecycle = await import("../../scripts/lib/provider-guardian-client.ts") as + Record; + expect(lifecycle.assertProviderGuardianLeasePaths).toBeTypeOf("function"); + const assertPaths = lifecycle.assertProviderGuardianLeasePaths as ( + lease: Readonly<{ rawPath: string; sealedPath: string }>, + expected: Readonly<{ rawPath: string; sealedPath: string }>, + ) => void; + const canonical = { + rawPath: "/workspace/provider-evidence/untrusted/vulnerability-report.json", + sealedPath: "/workspace/provider-evidence/vulnerability-report.json", + }; + + expect(() => assertPaths(canonical, canonical)).not.toThrow(); + expect(() => assertPaths(canonical, { + ...canonical, + sealedPath: "/workspace/provider-evidence/configured-alias.json", + })).toThrow(/noncanonical sealed path/u); + }); +}); + +async function createWorkspace(prefix: string): Promise { + const workspace = await mkdtemp(path.join(tmpdir(), prefix)); + roots.push(workspace); + await mkdir(path.join(workspace, "provider-evidence/untrusted"), { recursive: true }); + return workspace; +} + +async function createStalledGuardianFixture( + workspace: string, +): Promise> { + const guardianScript = path.join(workspace, "stalled-guardian.mjs"); + const markerPath = path.join(workspace, "guardian-spawned"); + await writeFile(guardianScript, [ + 'import { writeFileSync } from "node:fs";', + 'import path from "node:path";', + 'writeFileSync(path.join(process.cwd(), "guardian-spawned"), String(process.pid));', + "setInterval(() => undefined, 1_000);", + "", + ].join("\n")); + return { guardianScript, markerPath }; +} + +async function createPausedGuardianFixture(workspace: string): Promise { + const guardianScript = path.join(workspace, "paused-guardian.mjs"); + const realGuardian = pathToFileURL( + path.resolve("scripts/lib/provider-raw-guardian.ts"), + ).href; + await writeFile(guardianScript, [ + 'process.kill(process.pid, "SIGSTOP");', + `await import(${JSON.stringify(realGuardian)});`, + "", + ].join("\n")); + return guardianScript; +} + +async function waitForFile(target: string): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() <= deadline) { + try { + return await readFile(target); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`file did not appear: ${target}`); +} + +type PublishedGuardian = Readonly<{ + child: ReturnType; + completion: Promise>; + rawPath: string; + sealedPath: string; + sealedTempPath: string; +}>; + +async function establishPublishedGuardian( + workspace: string, + nonce: Buffer, +): Promise { + const { + decodeProviderGuardianPublished, + decodeProviderGuardianReady, + encodeProviderGuardianGuard, + encodeProviderGuardianPublish, + } = await import("../../scripts/lib/provider-guardian-protocol.ts"); + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawPath = path.join(evidenceRoot, "untrusted/vulnerability-report.json"); + const sealedPath = path.join(evidenceRoot, "vulnerability-report.json"); + const child = spawnGuardian(workspace, nonce); + const completion = waitForChild(child); + const readyPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianGuard({ + kind: "vulnerability", + nonce, + deadlineEpochMs: Date.now() + 3_000, + })); + const ready = decodeProviderGuardianReady( + await within(readyPayload, 1_000, "guardian READY"), + nonce, + ); + const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); + const bytes = Buffer.from('{"validated":"pending-commit"}\n'); + const handle = await open(sealedTempPath, constants.O_WRONLY | constants.O_NOFOLLOW); + try { + await handle.writeFile(bytes); + await handle.chmod(0o400); + await handle.sync(); + } finally { + await handle.close(); + } + const publishedPayload = readFrame(child.stdout!); + child.stdin!.write(encodeProviderGuardianPublish({ + nonce, + sealedDev: ready.sealedDev, + sealedIno: ready.sealedIno, + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + })); + decodeProviderGuardianPublished( + await within(publishedPayload, 1_000, "guardian PUBLISHED"), + nonce, + { dev: ready.sealedDev, ino: ready.sealedIno }, + ); + return { child, completion, rawPath, sealedPath, sealedTempPath }; +} + +async function assertTransactionAbsent(transaction: PublishedGuardian): Promise { + await expect(lstat(transaction.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(transaction.sealedPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(transaction.sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); +} + +async function readFrame(stream: NodeJS.ReadableStream): Promise { + return await new Promise((resolve, reject) => { + let pending = Buffer.alloc(0); + const onData = (chunk: Buffer | string): void => { + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (pending.byteLength < 4) return; + const payloadBytes = pending.readUInt32BE(0); + if (pending.byteLength < payloadBytes + 4) return; + cleanup(); + if (pending.byteLength !== payloadBytes + 4) { + reject(new Error("guardian acknowledgement contained trailing bytes")); + return; + } + resolve(pending.subarray(4)); + }; + const onEnd = (): void => { + cleanup(); + reject(new Error("guardian closed before acknowledgement")); + }; + const cleanup = (): void => { + stream.removeListener("data", onData); + stream.removeListener("end", onEnd); + }; + stream.on("data", onData); + stream.once("end", onEnd); + }); +} + +async function waitForChild( + child: ReturnType, +): Promise> { + return await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); +} + +function spawnGuardian( + workspace: string, + nonce: Buffer = Buffer.alloc(32, 0x7d), +): ReturnType { + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawDirectory = path.join(evidenceRoot, "untrusted"); + const noncePrefix = nonce.subarray(0, 16).toString("hex"); + const openedFds: number[] = []; + try { + openedFds.push(openSync( + rawDirectory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + )); + openedFds.push(openSync( + evidenceRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + )); + openedFds.push(openSync( + path.join( + rawDirectory, + `.vulnerability-report.json.guardian-${noncePrefix}.raw.tmp`, + ), + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + )); + openedFds.push(openSync( + path.join( + evidenceRoot, + `.vulnerability-report.json.guardian-${noncePrefix}.tmp`, + ), + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + )); + return spawn(process.execPath, [path.resolve("scripts/lib/provider-raw-guardian.ts")], { + cwd: workspace, + env: {}, + stdio: ["pipe", "pipe", "pipe", ...openedFds], + }); + } finally { + for (const fd of openedFds) closeSync(fd); + } +} + +function spawnGuardianWithPrivateModes( + workspace: string, + nonce: Buffer, + modes: Readonly<{ rawMode: number; sealedMode: number }>, +): Readonly<{ + child: ReturnType; + rawStagingPath: string; + sealedTempPath: string; +}> { + const evidenceRoot = path.join(workspace, "provider-evidence"); + const rawDirectory = path.join(evidenceRoot, "untrusted"); + const noncePrefix = nonce.subarray(0, 16).toString("hex"); + const rawStagingPath = path.join( + rawDirectory, + `.vulnerability-report.json.guardian-${noncePrefix}.raw.tmp`, + ); + const sealedTempPath = path.join( + evidenceRoot, + `.vulnerability-report.json.guardian-${noncePrefix}.tmp`, + ); + const openedFds: number[] = []; + try { + openedFds.push(openSync( + rawDirectory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + )); + openedFds.push(openSync( + evidenceRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + )); + const rawStagingFd = openSync( + rawStagingPath, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + openedFds.push(rawStagingFd); + fchmodSync(rawStagingFd, modes.rawMode); + const sealedTempFd = openSync( + sealedTempPath, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + openedFds.push(sealedTempFd); + fchmodSync(sealedTempFd, modes.sealedMode); + const child = spawn(process.execPath, [path.resolve("scripts/lib/provider-raw-guardian.ts")], { + cwd: workspace, + env: {}, + stdio: ["pipe", "pipe", "pipe", ...openedFds], + }); + return { child, rawStagingPath, sealedTempPath }; + } finally { + for (const fd of openedFds) closeSync(fd); + } +} + +async function waitForDirectoryEntry( + directory: string, + expectedLeaf: string, + onEntry: () => void, +): Promise { + await new Promise((resolve, reject) => { + const watcher = watch(directory, (_event, filename) => { + if (filename?.toString() !== expectedLeaf) return; + try { + onEntry(); + resolve(); + } catch (error) { + reject(error); + } finally { + watcher.close(); + } + }); + watcher.once("error", (error) => { + watcher.close(); + reject(error); + }); + }); +} + +async function within(operation: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function directChildPids(parentPid: number): Promise> { + const value = await readFile(`/proc/${parentPid}/task/${parentPid}/children`, "utf8"); + return new Set(value.trim().split(/\s+/u).filter(Boolean).map(Number)); +} + +async function waitForNewDirectChild(previous: ReadonlySet): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() <= deadline) { + for (const pid of await directChildPids(process.pid)) { + if (!previous.has(pid)) return pid; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("provider guardian child did not appear"); +} + +async function waitForStoppedProcess(pid: number): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() <= deadline) { + const status = await readFile(`/proc/${pid}/status`, "utf8"); + if (/^State:\s+T/mu.test(status)) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("provider guardian child did not stop before bootstrap"); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/tests/unit/provider-output-limiter.test.ts b/tests/unit/provider-output-limiter.test.ts new file mode 100644 index 0000000..237065d --- /dev/null +++ b/tests/unit/provider-output-limiter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("provider output limiter", () => { + it("counts secret-bearing bytes without retaining or forwarding them", async () => { + const { createProviderOutputLimiter } = await import( + "../../scripts/lib/provider-output-limiter.ts" + ); + const exceeded = vi.fn(); + const limiter = createProviderOutputLimiter(64, exceeded); + const credential = Buffer.from("provider-credential-must-not-reach-ci"); + const stdout = vi.spyOn(process.stdout, "write"); + const stderr = vi.spyOn(process.stderr, "write"); + try { + limiter.consume(credential); + expect(limiter.bytes()).toBe(credential.byteLength); + expect(exceeded).not.toHaveBeenCalled(); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + expect(JSON.stringify(limiter)).not.toContain(credential.toString("utf8")); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + + it("signals once when aggregate stdout and stderr exceed the byte budget", async () => { + const { createProviderOutputLimiter } = await import( + "../../scripts/lib/provider-output-limiter.ts" + ); + const exceeded = vi.fn(); + const limiter = createProviderOutputLimiter(5, exceeded); + limiter.consume(Buffer.from("abc")); + limiter.consume("def"); + limiter.consume("ignored"); + + expect(limiter.bytes()).toBe(6); + expect(exceeded).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/security-followup.test.ts b/tests/unit/security-followup.test.ts index 6f39ccd..56d6de9 100644 --- a/tests/unit/security-followup.test.ts +++ b/tests/unit/security-followup.test.ts @@ -4,23 +4,44 @@ import { sign, type KeyObject, } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; +import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts"; import { evaluatePromotionEvidence, providerEvidenceSignaturePayload, providerPublicKeyFingerprint, + trustPolicySha256, + validateProviderEvidence, } from "../../scripts/lib/provider-evidence.ts"; -import { readProviderTrust } from "../../scripts/lib/promotion-verifier.ts"; +import { readProviderTrust } from "../../scripts/lib/provider-trust.ts"; import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts"; +import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts"; +import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts"; +import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts"; +import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts"; import { LOCAL_EVIDENCE_ASSESSMENT_PATH, + LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, distSha256, type ReleaseCandidateManifest, } from "../../scripts/lib/release-candidate.ts"; @@ -51,6 +72,11 @@ function passingAssessment(): any { lockfileSha256: digest("lockfile"), sbomSha256: digest("sbom"), }, + secretScan: { + policySha256: digest("secret policy"), + sarifSha256: digest("secret sarif"), + scanInputSha256: digest("secret scan input"), + }, policyInputs: [ { path: "config/security/dependency-policy.json", @@ -85,7 +111,7 @@ describe("security follow-up contracts", () => { expect(localEvidenceAssessmentArtifactSchema.parse(passingAssessment()).status).toBe("PASS"); }); - it("passes archived verification from extracted members without checkout source or policy paths", async () => { + it("rejects archived verification when independently required policy bytes are absent", async () => { const fixture = await createArchivedAssessmentFixture(); try { const result = await verifyArchivedLocalEvidence({ @@ -93,15 +119,150 @@ describe("security follow-up contracts", () => { expectedManifest: fixture.manifest, }); - expect(result).toEqual({ - status: "PASS", - identity: { - sourceRevision: "a".repeat(40), - sourceSetSha256: digest("source set"), - assessmentSha256: fixture.assessmentSha256, - }, - failures: [], + expect(result.status).toBe("FAIL"); + expect(result.failures.join("\n")).toMatch(/archived local check|policy|missing/u); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("binds the executable secret-scan rule source and rejects missing or changed archived bytes", async () => { + expect(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS).toContain("scripts/lib/secret-scan.ts"); + const fixture = await createArchivedAssessmentFixture(); + const ruleSourcePath = path.join(fixture.root, "scripts/lib/secret-scan.ts"); + try { + const missing = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: fixture.manifest, }); + expect(missing.failures).toContain( + "archived policy input is missing or invalid: scripts/lib/secret-scan.ts", + ); + + await mkdir(path.dirname(ruleSourcePath), { recursive: true }); + await writeFile(ruleSourcePath, "export const secretScanRules = () => [];\n"); + const changed = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: fixture.manifest, + }); + expect(changed.failures).toContain( + "archived policy input binding mismatch: scripts/lib/secret-scan.ts", + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("keeps every direct runtime import of the archived local verifier in its source binding", async () => { + const verifierPath = path.join(process.cwd(), "scripts/lib/local-release-evidence.ts"); + const source = await readFile(verifierPath, "utf8"); + const directRuntimeSources = [ + ...source.matchAll(/\bfrom\s+"(\.{1,2}\/[^"\n]+\.ts)"/gu), + ].map((match) => + path + .relative(process.cwd(), path.resolve(path.dirname(verifierPath), match[1]!)) + .replaceAll(path.sep, "/"), + ); + expect(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS).toEqual( + expect.arrayContaining(directRuntimeSources), + ); + }); + + it("rejects a rehashed PASS assessment over contradictory archived subordinate FAIL evidence", async () => { + const fixture = await createArchivedAssessmentFixture(); + try { + const supplyPath = "artifacts/security/supply-chain-verification.json"; + const coherencePath = "artifacts/security/supply-chain-coherence.json"; + const supply = JSON.parse( + await readFile(path.join(fixture.root, supplyPath), "utf8"), + ) as Record; + const coherence = { + schemaVersion: 1, + status: "FAIL", + dependencyCount: 0, + lockfileSha256: fixture.manifest.lockfileSha256, + distSha256: fixture.manifest.distSha256, + sbomSha256: digestBytes( + await readFile(path.join(fixture.root, "artifacts/release/sbom.cdx.json")), + ), + failures: ["fixture subordinate failure"], + }; + const changed = new Map([ + [ + supplyPath, + Buffer.from( + `${JSON.stringify({ + ...supply, + localStatus: "FAIL", + failures: ["fixture subordinate failure"], + })}\n`, + ), + ], + [coherencePath, Buffer.from(`${JSON.stringify(coherence)}\n`)], + ]); + for (const [memberPath, bytes] of changed) { + await mkdir(path.dirname(path.join(fixture.root, memberPath)), { recursive: true }); + await writeFile(path.join(fixture.root, memberPath), bytes); + } + + const assessmentPath = path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH); + const assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse(await readFile(assessmentPath, "utf8")) as unknown, + ); + assessment.evidenceInputs = assessment.evidenceInputs + .filter(({ path: memberPath }) => memberPath !== coherencePath) + .map((row) => { + const bytes = changed.get(row.path); + return bytes + ? { path: row.path, bytes: bytes.byteLength, sha256: digestBytes(bytes) } + : row; + }); + const coherenceBytes = changed.get(coherencePath)!; + assessment.evidenceInputs.push({ + path: coherencePath, + bytes: coherenceBytes.byteLength, + sha256: digestBytes(coherenceBytes), + }); + assessment.evidenceInputs.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); + const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`); + await writeFile(assessmentPath, assessmentBytes); + + const changedWithAssessment = new Map(changed); + changedWithAssessment.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes); + const files = fixture.manifest.files + .filter(({ path: memberPath }) => memberPath !== coherencePath) + .map((row) => { + const bytes = changedWithAssessment.get(row.path); + return bytes + ? { path: row.path, bytes: bytes.byteLength, sha256: digestBytes(bytes) } + : row; + }); + files.push({ + path: coherencePath, + bytes: coherenceBytes.byteLength, + sha256: digestBytes(coherenceBytes), + }); + files.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); + const manifest = { + ...fixture.manifest, + files, + bundleSha256: supplyChainDigest(files), + }; + await writeFile( + path.join(fixture.root, "artifacts/release/release-candidate.json"), + `${JSON.stringify(manifest)}\n`, + ); + + const result = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: manifest, + }); + expect(result.status).toBe("FAIL"); + expect(result.failures.join("\n")).toMatch(/supply-chain.*not.*PASS|subordinate/u); } finally { await rm(fixture.root, { recursive: true, force: true }); } @@ -180,13 +341,15 @@ describe("security follow-up contracts", () => { const expected = providerExpectedContext(); const vulnerability = signedProviderV2( { - ...expected, + source: expected.source, + candidate: expected.candidate, schemaVersion: 2, evidenceType: "vulnerability-report", provider: "fixture-vulnerability", issuedAt: "2026-08-02T01:00:00.000Z", expiresAt: "2026-08-02T02:00:00.000Z", run: { ...expected.run, invocationNonce: "1".repeat(64) }, + secretScanAttestation: expected.secretScanAttestation, findings: [], }, "vulnerability-key", @@ -195,7 +358,8 @@ describe("security follow-up contracts", () => { ); const provenance = signedProviderV2( { - ...expected, + source: expected.source, + candidate: expected.candidate, schemaVersion: 2, evidenceType: "provenance-attestation", provider: "fixture-provenance", @@ -252,6 +416,112 @@ describe("security follow-up contracts", () => { ); }); + it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const secretScanAttestation = { + status: "PASS" as const, + localEvidenceAssessmentSha256: digest("assessment"), + sourceSetSha256: expected.source.sourceSetSha256, + policySha256: digest("secret policy"), + sarifSha256: digest("real sarif"), + scanInputSha256: digest("scan input"), + }; + const report = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "1".repeat(64) }, + source: expected.source, + candidate: expected.candidate, + secretScanAttestation, + findings: [], + }, + "vulnerability-key", + keys.publicKey, + keys.privateKey, + ); + const validated = validateProviderEvidence({ + kind: "vulnerability", + value: report, + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: { + ...secretScanAttestation, + sarifSha256: digest("forged empty sarif"), + }, + }, + trust: trust("vulnerability-key", keys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(validated.status).toBe("FAIL_UNVERIFIED"); + expect(validated.failures).toContain( + "vulnerability report secret scan attestation mismatch", + ); + const forged = structuredClone(report); + forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif"); + const forgedValidation = validateProviderEvidence({ + kind: "vulnerability", + value: forged, + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: forged.secretScanAttestation, + }, + trust: trust("vulnerability-key", keys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(forgedValidation.failures).toContain( + "vulnerability report signature verification failed", + ); + + const provenanceKeys = generateKeyPairSync("ed25519"); + const provenance = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance", + signer: "fixture-workload", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "2".repeat(64) }, + source: expected.source, + candidate: expected.candidate, + subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, + }, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const evaluated = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: { + ...secretScanAttestation, + sarifSha256: digest("forged empty sarif"), + }, + }, + localStatus: "PASS", + vulnerabilityReport: report, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", keys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED"); + expect(evaluated.failures).toContain( + "vulnerability report secret scan attestation mismatch", + ); + }); + it.each(["vulnerability", "provenance"] as const)( "rejects correctly re-signed %s v2 context/time/replay drift", (kind) => { @@ -525,6 +795,11 @@ describe("security follow-up contracts", () => { sourceRevision: expected.source.revision, sourceSetSha256: expected.source.sourceSetSha256, assessmentSha256: digest("assessment"), + secretScan: { + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, }, failures: [], }), @@ -552,21 +827,723 @@ describe("security follow-up contracts", () => { ); expect(result.evidence).toEqual({ sealed: true }); }); + + it("samples provider freshness after report capture instead of reusing issuance time", async () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: expected.candidate.distSha256, + lockfileSha256: expected.candidate.lockfileSha256, + bundleSha256: expected.candidate.bundleSha256, + files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], + }; + const issuedSample = Date.parse("2026-08-02T01:00:00.000Z"); + const validationSample = Date.parse("2026-08-02T02:00:00.001Z"); + const samples = [issuedSample, validationSample]; + let issuedAt = ""; + await expect( + superviseProviderEvidence( + { + kind: "vulnerability", + archivePath: "/downloads/candidate.tar.gz", + expectedArchiveSha256: expected.candidate.archiveSha256, + expectedRun: { + id: expected.run.id, + attempt: expected.run.attempt, + sourceRevision: expected.source.revision, + }, + trust: trust("vulnerability-key", keys.publicKey), + executeProvider: async ({ environment }) => { + issuedAt = environment.PROVIDER_ISSUED_AT!; + }, + captureReport: async () => Buffer.from("{}\n"), + }, + { + captureArchive: async () => ({ + bytes: Buffer.from("captured archive"), + archiveSha256: expected.candidate.archiveSha256, + }), + withVerifiedCandidate: (async (input: any) => + input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, + verifyLocalEvidence: async () => ({ + status: "PASS", + identity: { + sourceRevision: expected.source.revision, + sourceSetSha256: expected.source.sourceSetSha256, + assessmentSha256: digest("assessment"), + secretScan: { + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, + }, + failures: [], + }), + validateUpload: (async (input: any) => { + expect(input.nowEpochMs()).toBe(validationSample); + throw new Error("provider report expired during execution"); + }) as any, + randomBytes: () => Buffer.alloc(32, 0x33), + nowEpochMs: () => samples.shift()!, + }, + ), + ).rejects.toThrow(/expired during execution/u); + expect(issuedAt).toBe("2026-08-02T01:00:00.000Z"); + }); + + it("kills a timed-out provider but settles only after the child closes", async () => { + const child = new EventEmitter() as EventEmitter & { + kill(signal: NodeJS.Signals): boolean; + }; + let killedWith: NodeJS.Signals | undefined; + child.kill = (signal) => { + killedWith = signal; + return true; + }; + let fireTimeout: (() => void) | undefined; + let settled = false; + const running = runProviderProcess( + { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, + { + spawnChild: () => child as any, + setTimer: (callback) => { + fireTimeout = callback; + return 1 as any; + }, + clearTimer: () => undefined, + }, + ).finally(() => { + settled = true; + }); + fireTimeout?.(); + await Promise.resolve(); + expect(killedWith).toBe("SIGKILL"); + expect(settled).toBe(false); + child.emit("close", null, "SIGKILL"); + await expect(running).rejects.toThrow(/timed out/u); + expect(settled).toBe(true); + }); + + it("captures process-group kill errors, attempts child fallback, and settles after close", async () => { + const child = new EventEmitter() as EventEmitter & { + pid: number; + kill(signal: NodeJS.Signals): boolean; + }; + child.pid = 12_346; + let fallbackSignal: NodeJS.Signals | undefined; + child.kill = (signal) => { + fallbackSignal = signal; + return true; + }; + let fireTimeout: (() => void) | undefined; + const running = runProviderProcess( + { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, + { + spawnChild: () => child as any, + setTimer: (callback) => { + fireTimeout = callback; + return 1 as any; + }, + clearTimer: () => undefined, + killProcessGroup: () => { + throw Object.assign(new Error("group kill denied"), { code: "EPERM" }); + }, + }, + ); + expect(() => fireTimeout?.()).not.toThrow(); + expect(fallbackSignal).toBe("SIGKILL"); + child.emit("close", null, "SIGKILL"); + await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u); + }); + + it("kills and reaps a stubborn provider process group including its descendant", async () => { + const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-")); + const descendantPidPath = path.join(root, "descendant.pid"); + try { + const source = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });", + "writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));", + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join("\n"); + const running = runProviderProcess({ + executable: process.execPath, + arguments: ["-e", source], + environment: { + PATH: process.env.PATH, + DESCENDANT_PID_PATH: descendantPidPath, + }, + timeoutMs: 250, + }); + await expect(running).rejects.toThrow(/timed out.*process close/u); + const descendantPid = Number(await readFile(descendantPidPath, "utf8")); + expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true); + expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each(["open failure", "partial write failure"])( + "cleans finalized staging from memory when GITHUB_OUTPUT has a %s", + async (failureKind) => { + const finalized = { + stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + runnerTempIdentity: { dev: 10, ino: 20 }, + stagingIdentity: { dev: 30, ino: 40 }, + files: [], + } as const; + let cleanupInput: unknown; + let appendCalls = 0; + const environment = { + CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz", + CANDIDATE_ARCHIVE_SHA256: "a".repeat(64), + VULNERABILITY_REPORT_PATH: "vulnerability.json", + PROVENANCE_ATTESTATION_PATH: "provenance.json", + VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", + VULNERABILITY_KEY_ID: "vulnerability-key", + PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", + PROVENANCE_KEY_ID: "provenance-key", + CI_RUN_ID: "run", + CI_RUN_ATTEMPT: "1", + VITE_COMMIT_SHA: "b".repeat(40), + VULNERABILITY_INVOCATION_NONCE: "c".repeat(64), + PROVENANCE_INVOCATION_NONCE: "d".repeat(64), + RUNNER_TEMP: "/runner", + GITHUB_OUTPUT: "/runner/github-output", + }; + await expect( + runStageVerifiedPromotionCli(environment, { + cwd: () => "/workspace", + finalize: async () => finalized as any, + appendOutput: async () => { + appendCalls += 1; + if (failureKind === "partial write failure") { + // The output sink accepted an unspecified prefix before rejecting. + } + throw new Error(failureKind); + }, + cleanup: async (input) => { + cleanupInput = input; + }, + writeStdout: () => undefined, + }), + ).rejects.toThrow(new RegExp(failureKind, "u")); + expect(appendCalls).toBe(1); + expect(cleanupInput).toEqual({ + runnerTempRoot: "/runner", + stagingRoot: finalized.stagingRoot, + cleanupToken: finalized.cleanupToken, + runnerTempIdentity: finalized.runnerTempIdentity, + stagingIdentity: finalized.stagingIdentity, + }); + }, + ); + + it("forces exact private staging modes in an isolated child with umask 077", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-")); + try { + const stagerUrl = pathToFileURL( + path.join(process.cwd(), "scripts/lib/promotion-stager.ts"), + ).href; + const contractsUrl = pathToFileURL( + path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"), + ).href; + const childPath = path.join(root, "umask-child.mjs"); + const resultPath = path.join(root, "result.json"); + await writeFile(resultPath, "{}\n", { mode: 0o600 }); + await writeFile( + childPath, + [ + `import { lstat, writeFile } from "node:fs/promises";`, + `import path from "node:path";`, + `import { createHash } from "node:crypto";`, + `import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`, + `import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`, + `process.umask(Number.parseInt(process.argv[2], 8));`, + `const runnerTempRoot = process.argv[3];`, + `const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`, + `const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`, + `const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`, + `const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`, + `await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`, + `await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`, + ].join("\n"), + ); + const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + }); + expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); + expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({ + directoryMode: 0o700, + fileModes: [0o400, 0o400, 0o400, 0o400, 0o400], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a staged file unlinked and recreated after its original write", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-")); + const files = privatePromotionFiles(); + const token = `promotion-seal-1-${"11".repeat(16)}`; + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "seal", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x11), + undefined, + async (name) => { + if (name !== PROMOTED_FILE_NAMES.at(-1)) return; + const first = path.join(root, token, PROMOTED_FILE_NAMES[0]); + await rm(first); + await writeFile(first, "replacement bytes\n", { mode: 0o400 }); + }, + ), + ).rejects.toThrow(/staged.*digest|inode|seal/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects staged mode drift before returning the upload root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-")); + const files = privatePromotionFiles(); + const token = `promotion-seal-1-${"12".repeat(16)}`; + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "seal", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x12), + undefined, + async (name) => { + if (name === PROMOTED_FILE_NAMES.at(-1)) { + await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600); + } + }, + ), + ).rejects.toThrow(/mode.*0400|staged.*mode|seal/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects staging leaf replacement between mkdir and descriptor open", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-1-${"13".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "preopen", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x13), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ), + ).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + await expect(readdir(displaced)).resolves.toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-bound-1-${"14".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + for (let offset = 0; offset < 4_097; offset += 128) { + await Promise.all( + Array.from( + { length: Math.min(128, 4_097 - offset) }, + (_, index) => + mkdir( + path.join( + root, + `noise-${String(offset + index).padStart(4, "0")}`, + ), + ), + ), + ); + } + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-bound", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x14), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(readdir(displaced)).resolves.toEqual([]); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 20_000); + + it("leaves a non-empty moved original untouched after pre-open mismatch", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const ownedResidual = path.join(displaced, "owned-residual"); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-nonempty", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x15), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await writeFile(ownedResidual, "owned residual\n"); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(readFile(ownedResidual, "utf8")).resolves.toBe( + "owned residual\n", + ); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not search outside the parent for a moved unverified original", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-")); + const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-missing-1-${"16".repeat(16)}`; + const displaced = path.join(outside, token); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-missing", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x16), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(lstat(displaced)).resolves.toEqual( + expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }), + ); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => { + const fixture = syntheticSignedPromotionBundle(); + await expect( + verifyExactPromotionBundle(fixture.files, { + ...fixture.verification, + expected: { + ...fixture.verification.expected, + run: { id: "different-run", attempt: 1 }, + }, + }), + ).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u); + }); + + it("requires every external expected identity variable at the exact promotion CLI", async () => { + const fixture = syntheticSignedPromotionBundle(); + const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-")); + const bundleRoot = path.join(root, "bundle"); + try { + await mkdir(bundleRoot); + for (const [name, bytes] of Object.entries(fixture.files)) { + await writeFile(path.join(bundleRoot, name), bytes); + } + await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem); + await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem); + const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts"); + const baseEnvironment: NodeJS.ProcessEnv = { + ...process.env, + PROMOTION_BUNDLE_ROOT: bundleRoot, + VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", + VULNERABILITY_KEY_ID: "synthetic-vulnerability", + PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", + PROVENANCE_KEY_ID: "synthetic-provenance", + EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id, + EXPECTED_PROMOTION_RUN_ATTEMPT: String( + fixture.verification.expected.run.attempt, + ), + EXPECTED_PROMOTION_SOURCE_REVISION: + fixture.verification.expected.sourceRevision, + EXPECTED_PROMOTION_ARCHIVE_SHA256: + fixture.verification.expected.archiveSha256, + }; + const requiredExpected = [ + "EXPECTED_PROMOTION_RUN_ID", + "EXPECTED_PROMOTION_RUN_ATTEMPT", + "EXPECTED_PROMOTION_SOURCE_REVISION", + "EXPECTED_PROMOTION_ARCHIVE_SHA256", + ] as const; + for (const missing of requiredExpected) { + const environment = { ...baseEnvironment }; + delete environment[missing]; + const result = spawnSync(process.execPath, [cliPath], { + cwd: root, + encoding: "utf8", + env: environment, + }); + expect(result.status, missing).not.toBe(0); + expect(result.stderr, missing).toContain( + `exact promotion verification environment is missing ${missing}`, + ); + } + for (const [name, value, diagnostic] of [ + ["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u], + ["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u], + ["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u], + ["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u], + ] as const) { + const result = spawnSync(process.execPath, [cliPath], { + cwd: root, + encoding: "utf8", + env: { ...baseEnvironment, [name]: value }, + }); + expect(result.status, name).not.toBe(0); + expect(result.stderr, name).toMatch(diagnostic); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); function providerExpectedContext() { return { - run: { id: "run-42", attempt: 1 }, - source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") }, + run: { id: "run-42", attempt: 1 }, + source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") }, candidate: { archiveSha256: digest("archive"), bundleSha256: digest("bundle"), distSha256: digest("provider dist"), lockfileSha256: digest("provider lockfile"), }, + secretScanAttestation: { + status: "PASS" as const, + localEvidenceAssessmentSha256: digest("provider assessment"), + sourceSetSha256: digest("provider source"), + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, } as const; } +function privatePromotionFiles() { + return PROMOTED_FILE_NAMES.map((name) => { + const bytes = Buffer.from(`${name}\n`); + return { name, bytes, sha256: digestBytes(bytes) }; + }); +} + +function syntheticSignedPromotionBundle() { + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const archiveBytes = Buffer.from("synthetic signed replay archive\n"); + const run = { id: "signed-run", attempt: 1 } as const; + const source = { + revision: "a".repeat(40), + sourceSetSha256: digest("synthetic-source-set"), + } as const; + const candidate = { + archiveSha256: digestBytes(archiveBytes), + bundleSha256: digest("synthetic-bundle"), + distSha256: digest("synthetic-dist"), + lockfileSha256: digest("synthetic-lock"), + } as const; + const vulnerability = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "synthetic-vulnerability-provider", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...run, invocationNonce: "1".repeat(64) }, + source, + candidate, + secretScanAttestation: { + status: "PASS", + localEvidenceAssessmentSha256: digest("synthetic-assessment"), + sourceSetSha256: source.sourceSetSha256, + policySha256: digest("synthetic-policy"), + sarifSha256: digest("synthetic-sarif"), + scanInputSha256: digest("synthetic-scan-input"), + }, + findings: [], + }, + "synthetic-vulnerability", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const provenance = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "synthetic-provenance-provider", + signer: "synthetic-signer", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...run, invocationNonce: "2".repeat(64) }, + source, + candidate, + subject: { name: "dist", digest: { sha256: candidate.distSha256 } }, + }, + "synthetic-provenance", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`); + const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`); + const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey); + const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey); + const providerEvidence = { + vulnerabilityReportSha256: digestBytes(vulnerabilityBytes), + provenanceAttestationSha256: digestBytes(provenanceBytes), + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + vulnerabilityKeyId: vulnerabilityTrust.keyId, + vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, + provenanceKeyId: provenanceTrust.keyId, + provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, + secretScanAttestation: vulnerability.secretScanAttestation, + }; + const common = { + schemaVersion: 3, + verifiedAt: "2026-08-02T01:00:00.000Z", + status: "PASS", + verifier: { + id: "clean-architecture-frontend-template/promotion-verifier", + version: "3", + }, + run, + source, + candidate, + providerEvidence, + trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }), + failures: [], + }; + const providerBytes = Buffer.from( + `${JSON.stringify({ + ...common, + artifactType: "provider-verification", + vulnerabilityStatus: "PASS", + provenanceAttestationStatus: "PASS", + }, null, 2)}\n`, + ); + const promotionBytes = Buffer.from( + `${JSON.stringify({ + ...common, + artifactType: "promotion-verification", + localEvidenceStatus: "PASS", + localEvidenceAssessmentSha256: digest("synthetic-assessment"), + providerVerificationSha256: digestBytes(providerBytes), + }, null, 2)}\n`, + ); + return { + files: { + "release-candidate.tar.gz": archiveBytes, + "vulnerability-report.json": vulnerabilityBytes, + "provenance-attestation.json": provenanceBytes, + "provider-verification.json": providerBytes, + "promotion-verification.json": promotionBytes, + }, + verification: { + vulnerabilityTrust, + provenanceTrust, + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + expected: { + run, + sourceRevision: source.revision, + sourceSetSha256: source.sourceSetSha256, + archiveSha256: candidate.archiveSha256, + bundleSha256: candidate.bundleSha256, + distSha256: candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + }, + }, + vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }), + provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), + }; +} + function fingerprint(publicKey: KeyObject): string { return `sha256:${createHash("sha256") .update(publicKey.export({ type: "spki", format: "der" })) @@ -583,7 +1560,7 @@ function signedProviderV2( publicKey: KeyObject, privateKey: KeyObject, fingerprintOverride?: string, -) { +): Record { const { signature: existingSignature, ...payload } = unsigned; const value = { ...payload, @@ -612,7 +1589,8 @@ function providerUnsigned( expected: ReturnType, ): Record { const common = { - ...expected, + source: expected.source, + candidate: expected.candidate, schemaVersion: 2, evidenceType: kind === "vulnerability" @@ -627,7 +1605,11 @@ function providerUnsigned( }, }; return kind === "vulnerability" - ? { ...common, findings: [] } + ? { + ...common, + secretScanAttestation: expected.secretScanAttestation, + findings: [], + } : { ...common, signer: "fixture-workload", @@ -785,9 +1767,12 @@ async function createArchivedAssessmentFixture(): Promise<{ "scripts/lib/repository-file-inventory.ts", "scripts/lib/secret-scan-evaluator.ts", "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", "scripts/lib/supply-chain.ts", "scripts/lib/validated-json-artifact.ts", "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", ]; const sbomRow = evidenceInputs.find( ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", @@ -811,9 +1796,12 @@ async function createArchivedAssessmentFixture(): Promise<{ "scripts/lib/repository-file-inventory.ts", "scripts/lib/secret-scan-evaluator.ts", "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", "scripts/lib/supply-chain.ts", "scripts/lib/validated-json-artifact.ts", "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", ]); const assessment = localEvidenceAssessmentArtifactSchema.parse({ ...passingAssessment(), diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index be6770f..5759895 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -24,7 +24,6 @@ import { evaluatePromotionEvidence, providerEvidenceSignaturePayload, providerPublicKeyFingerprint, - providerVerificationArtifactSchema, } from "../../scripts/lib/provider-evidence.ts"; import { createReleaseCandidateManifest, @@ -32,7 +31,6 @@ import { verifyReleaseCandidate, } from "../../scripts/lib/release-candidate.ts"; import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts"; -import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts"; import { indexCiGateContract, loadCiGateContract, @@ -57,11 +55,6 @@ const sourceIdentity = Object.freeze({ revision: "a".repeat(40), sourceSetSha256: "b".repeat(64), }); -const localIdentity = Object.freeze({ - sourceRevision: sourceIdentity.revision, - sourceSetSha256: sourceIdentity.sourceSetSha256, - assessmentSha256: "c".repeat(64), -}); const expectedProviderContext = Object.freeze({ run: Object.freeze({ id: "fixture-run", attempt: 1 }), source: sourceIdentity, @@ -73,6 +66,14 @@ const expectedProviderContext = Object.freeze({ }), vulnerabilityInvocationNonce: "5".repeat(64), provenanceInvocationNonce: "6".repeat(64), + secretScanAttestation: Object.freeze({ + status: "PASS" as const, + localEvidenceAssessmentSha256: "7".repeat(64), + sourceSetSha256: sourceIdentity.sourceSetSha256, + policySha256: "8".repeat(64), + sarifSha256: "9".repeat(64), + scanInputSha256: "a".repeat(64), + }), }); function signedProviderEvidence( @@ -118,6 +119,7 @@ function providerPair(input: Readonly<{ run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce }, source: expectedProviderContext.source, candidate, + secretScanAttestation: expectedProviderContext.secretScanAttestation, findings: [], }, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint), provenanceAttestation: signedProviderEvidence({ @@ -143,264 +145,7 @@ function providerTrust( return { keyId, publicKey, publicKeyFingerprint }; } -async function createMinimalCandidateTree(root: string) { - const rawLockfile = "lockfileVersion: '9.0'\n"; - const rawLockfileSha256 = createHash("sha256") - .update(rawLockfile) - .digest("hex"); - await mkdir(path.join(root, "dist"), { recursive: true }); - await writeFile(path.join(root, "dist/app.js"), "immutable\n"); - await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile); - for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { - if (file === "pnpm-lock.yaml") continue; - await mkdir(path.dirname(path.join(root, file)), { recursive: true }); - const value = - file === "artifacts/release/dependency-inventory.json" - ? { lockfileSha256: rawLockfileSha256 } - : file === "artifacts/security/supply-chain-verification.json" - ? { localStatus: "PASS" } - : { fixture: file }; - await writeFile(path.join(root, file), `${JSON.stringify(value)}\n`); - } - const manifest = await createReleaseCandidateManifest(root); - await writeFile( - path.join(root, "artifacts/release/release-candidate.json"), - `${JSON.stringify(manifest)}\n`, - ); - return manifest; -} - -async function writeProviderEnvironment( - root: string, - candidate: Awaited>, - overrides: Readonly<{ distSha256?: string }> = {}, -) { - const vulnerabilityKeys = generateKeyPairSync("ed25519"); - const provenanceKeys = generateKeyPairSync("ed25519"); - const archiveBytes = "fixture archive\n"; - const candidateIdentity = { - archiveSha256: createHash("sha256").update(archiveBytes).digest("hex"), - bundleSha256: candidate.bundleSha256, - distSha256: overrides.distSha256 ?? candidate.distSha256, - lockfileSha256: candidate.lockfileSha256, - }; - const vulnerabilityReport = signedProviderEvidence( - { - schemaVersion: 2, - evidenceType: "vulnerability-report", - provider: "fixture-vulnerability-provider", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { id: "fixture-run", attempt: 1, invocationNonce: "5".repeat(64) }, - source: sourceIdentity, - candidate: candidateIdentity, - findings: [], - }, - "fixture-vulnerability-key", - vulnerabilityKeys.privateKey, - providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), - ); - const provenanceAttestation = signedProviderEvidence( - { - schemaVersion: 2, - evidenceType: "provenance-attestation", - provider: "fixture-provenance-provider", - signer: "fixture-workload-identity", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { id: "fixture-run", attempt: 1, invocationNonce: "6".repeat(64) }, - source: sourceIdentity, - candidate: candidateIdentity, - subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } }, - }, - "fixture-provenance-key", - provenanceKeys.privateKey, - providerPublicKeyFingerprint(provenanceKeys.publicKey), - ); - await mkdir(path.join(root, "provider"), { recursive: true }); - await Promise.all([ - writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes), - writeFile( - path.join(root, "provider/vulnerability.json"), - `${JSON.stringify(vulnerabilityReport)}\n`, - ), - writeFile( - path.join(root, "provider/provenance.json"), - `${JSON.stringify(provenanceAttestation)}\n`, - ), - writeFile( - path.join(root, "provider/vulnerability.pem"), - vulnerabilityKeys.publicKey - .export({ type: "spki", format: "pem" }) - .toString(), - ), - writeFile( - path.join(root, "provider/provenance.pem"), - provenanceKeys.publicKey - .export({ type: "spki", format: "pem" }) - .toString(), - ), - ]); - return { - CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz", - CANDIDATE_ARCHIVE_SHA256: createHash("sha256") - .update(archiveBytes) - .digest("hex"), - CI_RUN_ID: "fixture-run", - CI_RUN_ATTEMPT: "1", - EXPECTED_SOURCE_REVISION: sourceIdentity.revision, - VULNERABILITY_INVOCATION_NONCE: "5".repeat(64), - PROVENANCE_INVOCATION_NONCE: "6".repeat(64), - VULNERABILITY_REPORT_PATH: "provider/vulnerability.json", - PROVENANCE_ATTESTATION_PATH: "provider/provenance.json", - VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem", - VULNERABILITY_KEY_ID: "fixture-vulnerability-key", - PROVENANCE_PUBLIC_KEY_PATH: "provider/provenance.pem", - PROVENANCE_KEY_ID: "fixture-provenance-key", - } satisfies NodeJS.ProcessEnv; -} - describe("supply-chain policy", () => { - it("emits a strict role-bound v3 verification record from exact input bytes", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v3-")); - try { - const manifest = await createMinimalCandidateTree(root); - const environment = await writeProviderEnvironment(root, manifest); - const report = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment, - verifyLocalEvidence: async () => ({ - status: "PASS" as const, - identity: localIdentity, - failures: [] as const, - }), - nowEpochMs: () => NOW, - }); - expect(providerVerificationArtifactSchema.parse(report)).toEqual( - expect.objectContaining({ - schemaVersion: 3, - artifactType: "provider-verification", - candidate: expect.objectContaining({ - archiveSha256: environment.CANDIDATE_ARCHIVE_SHA256, - }), - providerEvidence: expect.objectContaining({ - vulnerabilityReportSha256: createHash("sha256") - .update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!))) - .digest("hex"), - provenanceAttestationSha256: createHash("sha256") - .update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!))) - .digest("hex"), - }), - }), - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-")); - try { - const manifest = await createMinimalCandidateTree(root); - const validEnvironment = await writeProviderEnvironment(root, manifest); - const acceptLocalEvidence = async () => ({ - status: "PASS" as const, - identity: localIdentity, - failures: [] as const, - }); - const valid = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment: validEnvironment, - verifyLocalEvidence: acceptLocalEvidence, - nowEpochMs: () => NOW, - }); - const absent = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment: {}, - verifyLocalEvidence: acceptLocalEvidence, - nowEpochMs: () => NOW, - }); - const replayedNonce = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment: { - ...validEnvironment, - VULNERABILITY_INVOCATION_NONCE: "9".repeat(64), - }, - verifyLocalEvidence: acceptLocalEvidence, - nowEpochMs: () => NOW, - }); - const wrongEnvironment = await writeProviderEnvironment(root, manifest, { - distSha256: "3".repeat(64), - }); - const wrongDigest = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment: wrongEnvironment, - verifyLocalEvidence: acceptLocalEvidence, - nowEpochMs: () => NOW, - }); - await writeFile(path.join(root, "dist/app.js"), "mutated\n"); - const postAttestationMutation = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment: validEnvironment, - verifyLocalEvidence: acceptLocalEvidence, - nowEpochMs: () => NOW, - }); - - expect({ - valid: valid.status, - absent: absent.status, - wrongDigest: wrongDigest.status, - replayedNonce: replayedNonce.status, - postAttestationMutation: postAttestationMutation.status, - }).toEqual({ - valid: "PASS", - absent: "FAIL_UNVERIFIED", - wrongDigest: "FAIL_UNVERIFIED", - replayedNonce: "FAIL_UNVERIFIED", - postAttestationMutation: "FAIL_UNVERIFIED", - }); - expect(replayedNonce.failures).toContain("vulnerability report invocation nonce mismatch"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects a self-consistent candidate that merely claims localStatus PASS", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-")); - try { - const manifest = await createMinimalCandidateTree(root); - const environment = await writeProviderEnvironment(root, manifest); - const localVerificationPath = path.join( - root, - "artifacts/security/supply-chain-verification.json", - ); - const before = await readFile(localVerificationPath, "utf8"); - const result = await verifyPromotionInputs({ - artifactType: "provider-verification", - repositoryRoot: root, - environment, - nowEpochMs: () => NOW, - }); - - expect(result.status).toBe("FAIL_UNVERIFIED"); - expect(result.failures).toEqual( - expect.arrayContaining([ - "local evidence assessment is missing or invalid", - "local supply-chain evidence is not PASS", - ]), - ); - expect(await readFile(localVerificationPath, "utf8")).toBe(before); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - it("derives a stable supply-chain timestamp from the immutable build epoch", () => { const input = { generatedAt: "2026-08-01T00:00:00.000Z", diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts new file mode 100644 index 0000000..cfe7606 --- /dev/null +++ b/tests/unit/task3-selective-integration.test.ts @@ -0,0 +1,213 @@ +import { spawn } from "node:child_process"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + loadCiGateContract, + parseCiGateContract, +} from "../../scripts/contracts/ci-gates.ts"; +import { generateCiWorkflow } from "../../scripts/generate-ci-workflow.ts"; +import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("selective Task 3 contract closure", () => { + it("builds a private offline aggregate-cgroup provider launch without argv secrets", async () => { + const { + encodeProviderBwrapInput, + encodeProviderScopeFrame, + formatProviderCgroupUnitName, + systemctlKillProviderArguments, + systemdRunProviderArguments, + } = await import("../../scripts/lib/provider-cgroup.ts"); + const unit = formatProviderCgroupUnitName( + "vulnerability", + 42, + "0123456789abcdef01234567", + ); + const command = "node provider.mjs --token command-secret"; + const credential = "credential-secret"; + const launch = systemdRunProviderArguments( + unit, + 1_800_000, + 1_200, + "/trusted/node", + "/workspace/scripts/lib/provider-scope-wrapper.ts", + "/exact/report.json", + 12, + 34, + ); + + expect(launch).toEqual(expect.arrayContaining([ + "--scope", + "--property=MemoryMax=1073741824", + "--property=MemorySwapMax=0", + "--property=TasksMax=64", + "--property=CPUQuota=100%", + "--property=KillMode=control-group", + "/trusted/node", + "/workspace/scripts/lib/provider-scope-wrapper.ts", + ])); + expect(launch.join("\0")).not.toContain(command); + expect(launch.join("\0")).not.toContain(credential); + expect( + encodeProviderBwrapInput( + ["--unshare-net", "--bind", "/exact/report.json", "/exact/report.json"], + { PROVIDER_COMMAND: command, PROVIDER_CREDENTIAL: credential }, + ), + ).toEqual(expect.any(Buffer)); + expect(systemctlKillProviderArguments(unit)).toEqual([ + "--user", + "kill", + "--kill-whom=all", + "--signal=SIGKILL", + unit, + ]); + const frame = encodeProviderScopeFrame({ + bwrapInput: Buffer.from("private-bwrap-vector\0"), + reportPath: "/exact/report.json", + reportDev: 12, + reportIno: 34, + }); + expect(frame.readUInt32BE(0)).toBe(frame.byteLength - 4); + expect(frame.subarray(4).toString("utf8")).toContain( + Buffer.from("private-bwrap-vector\0").toString("base64"), + ); + expect(launch.join("\0")).not.toContain("private-bwrap-vector"); + }); + + it("removes only the pinned raw inode during parent-loss cleanup", async () => { + const { cleanupOwnedProviderReport } = await import( + "../../scripts/lib/provider-raw-cleanup.ts" + ); + const root = await mkdtemp(path.join(tmpdir(), "provider-raw-cleanup-")); + roots.push(root); + const reportPath = path.join(root, "raw.json"); + const originalPath = path.join(root, "original.json"); + await writeFile(reportPath, "owned\n"); + const identity = await lstat(reportPath); + await rename(reportPath, originalPath); + await writeFile(reportPath, "unrelated\n"); + + await expect(cleanupOwnedProviderReport({ + reportPath, + reportDev: identity.dev, + reportIno: identity.ino, + })).resolves.toBe(false); + await expect(readFile(reportPath, "utf8")).resolves.toBe("unrelated\n"); + + await rm(reportPath); + await rename(originalPath, reportPath); + await expect(cleanupOwnedProviderReport({ + reportPath, + reportDev: identity.dev, + reportIno: identity.ino, + })).resolves.toBe(true); + await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readdir(root)).resolves.toEqual([]); + }); + + it("uses early liveness EOF to clean the pinned raw file without waiting for a command frame", async () => { + const root = await mkdtemp(path.join(tmpdir(), "provider-scope-eof-")); + roots.push(root); + const reportPath = path.join(root, "raw.json"); + await writeFile(reportPath, "partial\n"); + const identity = await lstat(reportPath); + const child = spawn(process.execPath, [ + path.resolve("scripts/lib/provider-scope-wrapper.ts"), + "1", + reportPath, + String(identity.dev), + String(identity.ino), + ], { stdio: ["pipe", "pipe", "pipe"] }); + const completion = waitForChildResult(child); + await new Promise((resolve) => setTimeout(resolve, 75)); + expect(child.exitCode).toBeNull(); + await expect(readFile(reportPath, "utf8")).resolves.toBe("partial\n"); + child.stdin.end(); + const result = await within(completion, 1_000, "provider scope EOF close"); + expect(result).toEqual({ code: 125, signal: null }); + await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readdir(root)).resolves.toEqual([]); + }); + + it("tracks npm run-script dependencies instead of bypassing the graph", () => { + expect(validatePackageScriptGraph({ root: "npm run-script missing" }, "root")) + .toContain("package script missing: root -> missing"); + }); + + it("accepts only the canonical exact-count authority and rejects orphan retention", async () => { + const canonical = await loadCiGateContract(process.cwd()); + expect(canonical.gates).toHaveLength(26); + expect(canonical.commands).toHaveLength(81); + expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93); + expect(canonical.artifacts).toHaveLength(105); + expect(canonical.stages).toHaveLength(5); + expect(canonical.retention.classes).toHaveLength(5); + + const orphan = JSON.parse(JSON.stringify(canonical)) as Record; + orphan.retention.classes.push({ id: "unused", policy: "never referenced" }); + expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u); + }); + + it("rejects the retired validate-candidate-archive grammar", async () => { + const canonical = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + canonical.jobs.find((job: Record) => job.id === "vulnerability_provider") + .steps.splice(4, 0, { + kind: "validate-candidate-archive", + archivePath: ".release/candidate/release-candidate.tar.gz", + }); + expect(() => parseCiGateContract(canonical)).toThrow( + /invalid discriminator|forbidden|canonical job step sequence/iu, + ); + }); + + it("publishes a generated workflow as exactly 0644 under a restrictive umask", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-mode-")); + roots.push(root); + await mkdir(path.join(root, ".gitea/workflows"), { recursive: true }); + const previous = process.umask(0o777); + try { + const contract = await loadCiGateContract(process.cwd()); + await generateCiWorkflow({ root, contract, check: false }); + } finally { + process.umask(previous); + } + const target = path.join(root, ".gitea/workflows/quality-gates.yml"); + const metadata = await lstat(target); + expect(metadata.mode & 0o777).toBe(0o644); + expect((await readFile(target, "utf8")).startsWith("# GENERATED FILE")).toBe(true); + }); +}); + +async function waitForChildResult( + child: ReturnType, +): Promise> { + return await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); +} + +async function within(operation: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/tests/unit/validated-json-artifact.test.ts b/tests/unit/validated-json-artifact.test.ts index 47b94ef..06dfda2 100644 --- a/tests/unit/validated-json-artifact.test.ts +++ b/tests/unit/validated-json-artifact.test.ts @@ -42,6 +42,25 @@ afterEach(async () => { }); describe("validated JSON artifact writer", () => { + it("returns the exact schema-validated bytes used by durable publication", async () => { + const artifact = await import("../../scripts/lib/validated-json-artifact.ts") as + Record; + expect(artifact.serializeValidatedJsonArtifact).toBeTypeOf("function"); + const serializeValidatedJsonArtifact = artifact.serializeValidatedJsonArtifact as ( + input: Readonly<{ path: string; schema: z.ZodType; value: unknown }>, + ) => Buffer; + expect(serializeValidatedJsonArtifact({ + path: "/unused/artifact.json", + schema: z.object({ value: z.string() }).strict(), + value: { value: "sealed" }, + })).toEqual(Buffer.from('{\n "value": "sealed"\n}\n')); + expect(() => serializeValidatedJsonArtifact({ + path: "/unused/artifact.json", + schema: z.object({ value: z.string() }).strict(), + value: { value: 7 }, + })).toThrow(); + }); + it("syncs an O_NOFOLLOW exclusive temp and its directory around rename", async () => { const events: string[] = []; let openFlags = 0;