fix: harden provider and promotion evidence
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
} from "../../scripts/lib/ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts";
|
||||
import {
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
|
||||
it(
|
||||
"builds a real candidate assessment and passes the default archived verifier from the captured archive",
|
||||
async () => {
|
||||
const sourceRoot = process.cwd();
|
||||
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "security-followup-producer-"));
|
||||
try {
|
||||
await cp(sourceRoot, fixtureRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const relative = path.relative(sourceRoot, source);
|
||||
if (!relative) return true;
|
||||
const first = relative.split(path.sep)[0];
|
||||
return ![
|
||||
".release",
|
||||
"artifacts",
|
||||
"dist",
|
||||
"node_modules",
|
||||
].includes(first ?? "");
|
||||
},
|
||||
});
|
||||
await cp(path.join(sourceRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(fixtureRoot, "artifacts/release"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await symlink(path.join(sourceRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: sourceRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(git.status, git.stderr).toBe(0);
|
||||
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
|
||||
const build = spawnSync(
|
||||
"corepack",
|
||||
["pnpm", "build:release-candidate"],
|
||||
{
|
||||
cwd: fixtureRoot,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
VITE_BUILD_ID: "security-followup-integration",
|
||||
VITE_COMMIT_SHA: revision,
|
||||
RELEASE_ID: "security-followup-integration",
|
||||
SOURCE_DATE_EPOCH: sourceDateEpoch,
|
||||
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0);
|
||||
const manifest = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
|
||||
const archived = spawnSync(
|
||||
"/usr/bin/tar",
|
||||
[
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"-czf",
|
||||
archivePath,
|
||||
"dist",
|
||||
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
],
|
||||
{ cwd: fixtureRoot, encoding: "utf8" },
|
||||
);
|
||||
expect(archived.status, archived.stderr).toBe(0);
|
||||
const archiveBytes = await readFile(archivePath);
|
||||
const expectedSha256 = await import("node:crypto").then(({ createHash }) =>
|
||||
createHash("sha256").update(archiveBytes).digest("hex"),
|
||||
);
|
||||
const captured = await captureCiCandidateArchive({ archivePath, expectedSha256 });
|
||||
const verified = await withVerifiedCapturedCandidate({
|
||||
captured,
|
||||
verify: ({ extractionRoot, manifest: extractedManifest }) =>
|
||||
verifyArchivedLocalEvidence({
|
||||
extractionRoot,
|
||||
expectedManifest: extractedManifest,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(manifest.files).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "artifacts/security/local-evidence-assessment.json",
|
||||
}),
|
||||
);
|
||||
expect(verified).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "PASS",
|
||||
identity: expect.objectContaining({ sourceRevision: revision }),
|
||||
failures: [],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
@@ -159,6 +159,7 @@ jobs:
|
||||
artifacts/release/sbom.cdx.json \\
|
||||
artifacts/security/dependency-diff.json \\
|
||||
artifacts/security/license-report.json \\
|
||||
artifacts/security/local-evidence-assessment.json \\
|
||||
artifacts/security/scan.sarif \\
|
||||
artifacts/security/supply-chain-coherence.json \\
|
||||
artifacts/security/supply-chain-verification.json \\
|
||||
@@ -177,11 +178,16 @@ jobs:
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
invocation_nonce: \${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||
CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}"
|
||||
CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml
|
||||
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||
EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}"
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||
VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}"
|
||||
VULNERABILITY_PROVIDER_COMMAND: "\${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
||||
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||
@@ -201,9 +207,8 @@ jobs:
|
||||
with:
|
||||
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||
path: .release/vulnerability-candidate
|
||||
- name: Verify and extract the candidate through one inode-bound operation
|
||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability"
|
||||
- name: Run and validate external vulnerability provider in one trusted supervisor
|
||||
id: supervise_vulnerability
|
||||
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
||||
- name: Confirm sealed vulnerability provider evidence
|
||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||
@@ -219,11 +224,16 @@ jobs:
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
invocation_nonce: \${{ steps.supervise_provenance.outputs.invocation_nonce }}
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||
CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}"
|
||||
CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml
|
||||
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||
EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}"
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||
PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}"
|
||||
PROVENANCE_PROVIDER_COMMAND: "\${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
||||
@@ -243,9 +253,8 @@ jobs:
|
||||
with:
|
||||
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||
path: .release/provenance-candidate
|
||||
- name: Verify and extract the candidate through one inode-bound operation
|
||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance"
|
||||
- name: Run and validate external provenance provider in one trusted supervisor
|
||||
id: supervise_provenance
|
||||
run: node scripts/run-and-validate-provider.ts --kind provenance
|
||||
- name: Confirm sealed provenance provider evidence
|
||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||
@@ -264,13 +273,16 @@ jobs:
|
||||
env:
|
||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||
CANDIDATE_ROOT: "\${{ gitea.workspace }}/.release/verified-candidate"
|
||||
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||
VULNERABILITY_REPORT_PATH: "\${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
||||
PROVENANCE_ATTESTATION_PATH: "\${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||
VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}"
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||
PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}"
|
||||
VULNERABILITY_INVOCATION_NONCE: "\${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
|
||||
PROVENANCE_INVOCATION_NONCE: "\${{ needs.provenance_provider.outputs.invocation_nonce }}"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
@@ -297,21 +309,31 @@ jobs:
|
||||
with:
|
||||
name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||
path: .release/provenance
|
||||
- name: Verify and extract the candidate through one inode-bound operation
|
||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate"
|
||||
- name: Finalize verified promotion from inode-bound captured inputs
|
||||
id: finalize
|
||||
run: node scripts/stage-verified-promotion.ts
|
||||
- name: Upload promoted release
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||
with:
|
||||
name: "promoted-release-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||
path: |
|
||||
.release/promoted-staging/release-candidate.tar.gz
|
||||
.release/promoted-staging/vulnerability-report.json
|
||||
.release/promoted-staging/provenance-attestation.json
|
||||
.release/promoted-staging/provider-verification.json
|
||||
.release/promoted-staging/promotion-verification.json
|
||||
\${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
|
||||
\${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
|
||||
\${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
|
||||
\${{ steps.finalize.outputs.staging_root }}/provider-verification.json
|
||||
\${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
|
||||
if-no-files-found: error
|
||||
- name: Always remove private promotion staging
|
||||
if: always()
|
||||
env:
|
||||
PROMOTION_STAGING_ROOT: \${{ steps.finalize.outputs.staging_root }}
|
||||
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 }}
|
||||
run: |
|
||||
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then
|
||||
node scripts/cleanup-verified-promotion.ts
|
||||
fi
|
||||
|
||||
production_gate:
|
||||
name: "\${{ matrix.gate }} / \${{ matrix.name }}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,16 +151,19 @@ describe("CI gate contract", () => {
|
||||
["candidate output identity drift", (value: Record<string, any>) => { 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<string, any>) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i],
|
||||
["provider adapter target drift", (value: Record<string, any>) => (value.providerAdapter = "package.json"), /canonical generated workflow/i],
|
||||
["workflow root extraction", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "extract").targetRoot = ".."), /unsafe workflow path/i],
|
||||
["promotion standalone extraction", (value: Record<string, any>) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(6, 0, { kind: "extract", archivePath: ".release/candidate/candidate.tar.gz", targetRoot: ".release/verified-candidate" }), /step kind extract is forbidden|job step sequence drift/i],
|
||||
["normalized upload root", (value: Record<string, any>) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i],
|
||||
["immutable archive field drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "immutable_build").steps.find((step: any) => step.kind === "archive-candidate").archivePath = ".release/other.tar.gz"), /candidate output identity drift|archive and upload fields must remain linked/i],
|
||||
["provider role drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").provider = "provenance"), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
||||
["provider archive SHA environment drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").environment.find((entry: any) => entry.name === "CANDIDATE_ARCHIVE_SHA256").value = "0".repeat(64)), /job environment binding drift/i],
|
||||
["promotion transfer swap", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download and extraction fields must remain linked|duplicate.*download/i],
|
||||
["promotion transfer swap", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download fields.*remain linked|duplicate.*download/i],
|
||||
["raw provider upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "upload").paths = ["provider-evidence/untrusted/vulnerability-report.json"]), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
||||
["intervening promotion step", (value: Record<string, any>) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(-1, 0, { kind: "frozen-install" }), /promotion verification and upload must be immediately adjacent/i],
|
||||
["promotion upload path drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i],
|
||||
["always promotion upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).always = true), /promotion upload must not use always/i],
|
||||
["intervening promotion step", (value: Record<string, any>) => { const steps = value.jobs.find((candidate: any) => candidate.id === "promotion").steps; steps.splice(steps.findIndex((step: any) => step.kind === "upload"), 0, { kind: "frozen-install" }); }, /promotion verification and upload must be immediately adjacent/i],
|
||||
["promotion upload path drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i],
|
||||
["always promotion upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").always = true), /promotion upload must not use always/i],
|
||||
["provider nonce output step drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").stepId = "renamed"), /provider.*linked|step identity/i],
|
||||
["promotion nonce binding drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").environment.find((entry: any) => entry.name === "VULNERABILITY_INVOCATION_NONCE").value = "5".repeat(64)), /job environment binding drift/i],
|
||||
["missing promotion cleanup", (value: Record<string, any>) => { const job = value.jobs.find((candidate: any) => candidate.id === "promotion"); job.steps = job.steps.filter((step: any) => step.kind !== "cleanup-promotion"); }, /job step sequence drift|cleanup/i],
|
||||
])("rejects semantic mutation: %s", async (_name, mutate, diagnostic) => {
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
const candidate = JSON.parse(JSON.stringify(contract)) as Record<string, any>;
|
||||
@@ -448,10 +451,25 @@ describe("CI workflow generation", () => {
|
||||
).toHaveLength(9);
|
||||
expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu);
|
||||
expect(first).toContain("verify-ci-candidate-archive.ts --archive");
|
||||
expect(first).toContain("--extract-to");
|
||||
expect(first).not.toContain("--extract-to");
|
||||
expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u);
|
||||
expect(first).toContain("node scripts/stage-verified-promotion.ts");
|
||||
expect(first).toContain(".release/promoted-staging/release-candidate.tar.gz");
|
||||
expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}");
|
||||
expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}");
|
||||
expect(first).toContain('VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"');
|
||||
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 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");
|
||||
const cleanupIndex = first.indexOf("node scripts/cleanup-verified-promotion.ts");
|
||||
expect(finalizerIndex).toBeGreaterThan(0);
|
||||
expect(promotedUploadIndex).toBeGreaterThan(finalizerIndex);
|
||||
expect(cleanupStepIndex).toBeGreaterThan(promotedUploadIndex);
|
||||
expect(cleanupIndex).toBeGreaterThan(cleanupStepIndex);
|
||||
expect(first.slice(promotedUploadIndex, cleanupStepIndex)).not.toContain("if: always()");
|
||||
expect(first.slice(cleanupStepIndex, cleanupIndex)).toContain("if: always()");
|
||||
const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]);
|
||||
expect(actionUses).toHaveLength(32);
|
||||
expect(new Set(actionUses)).toEqual(
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
||||
import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts";
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
providerPublicKeyFingerprint,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import { readProviderTrust } from "../../scripts/lib/promotion-verifier.ts";
|
||||
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
|
||||
import {
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
distSha256,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||
|
||||
const digest = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const digestBytes = (value: Buffer): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function passingAssessment(): any {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
artifactType: "local-evidence-assessment" as const,
|
||||
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||
status: "PASS" as const,
|
||||
verifier: {
|
||||
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||
version: "1",
|
||||
sourceSha256: digest("verifier source"),
|
||||
},
|
||||
source: {
|
||||
revision: "a".repeat(40),
|
||||
sourceSetSha256: digest("source set"),
|
||||
},
|
||||
candidate: {
|
||||
distSha256: digest("dist"),
|
||||
lockfileSha256: digest("lockfile"),
|
||||
sbomSha256: digest("sbom"),
|
||||
},
|
||||
policyInputs: [
|
||||
{
|
||||
path: "config/security/dependency-policy.json",
|
||||
bytes: 3,
|
||||
sha256: digest("{}\n"),
|
||||
},
|
||||
],
|
||||
evidenceInputs: [
|
||||
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
|
||||
],
|
||||
checks: {
|
||||
release: "PASS" as const,
|
||||
supplyChain: "PASS" as const,
|
||||
dependencyPolicy: "PASS" as const,
|
||||
licensePolicy: "PASS" as const,
|
||||
vulnerabilityPolicy: "PASS" as const,
|
||||
secretScan: "PASS" as const,
|
||||
},
|
||||
failures: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
describe("security follow-up contracts", () => {
|
||||
it("rejects a PASS local assessment with a failed check or failure diagnostic", () => {
|
||||
const failedCheck = passingAssessment();
|
||||
failedCheck.checks.secretScan = "FAIL";
|
||||
const failureDiagnostic = passingAssessment();
|
||||
failureDiagnostic.failures.push("secret scan failed");
|
||||
|
||||
expect(localEvidenceAssessmentArtifactSchema.safeParse(failedCheck).success).toBe(false);
|
||||
expect(localEvidenceAssessmentArtifactSchema.safeParse(failureDiagnostic).success).toBe(false);
|
||||
expect(localEvidenceAssessmentArtifactSchema.parse(passingAssessment()).status).toBe("PASS");
|
||||
});
|
||||
|
||||
it("passes archived verification from extracted members without checkout source or policy paths", async () => {
|
||||
const fixture = await createArchivedAssessmentFixture();
|
||||
try {
|
||||
const result = await verifyArchivedLocalEvidence({
|
||||
extractionRoot: fixture.root,
|
||||
expectedManifest: fixture.manifest,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "PASS",
|
||||
identity: {
|
||||
sourceRevision: "a".repeat(40),
|
||||
sourceSetSha256: digest("source set"),
|
||||
assessmentSha256: fixture.assessmentSha256,
|
||||
},
|
||||
failures: [],
|
||||
});
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects archived verification when the assessment is absent", async () => {
|
||||
const fixture = await createArchivedAssessmentFixture();
|
||||
try {
|
||||
await rm(path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH));
|
||||
const result = await verifyArchivedLocalEvidence({
|
||||
extractionRoot: fixture.root,
|
||||
expectedManifest: fixture.manifest,
|
||||
});
|
||||
expect(result.status).toBe("FAIL");
|
||||
expect(result.failures).toContain("local evidence assessment is missing or invalid");
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects digest-bound raw identity evidence that is not valid under its strict producer schema", async () => {
|
||||
const fixture = await createArchivedAssessmentFixture();
|
||||
try {
|
||||
const provenancePath = "artifacts/release/provenance.json";
|
||||
const malformed = Buffer.from(
|
||||
`${JSON.stringify({ predicate: { materials: { sourceSetSha256: digest("source set") } } })}\n`,
|
||||
);
|
||||
await writeFile(path.join(fixture.root, provenancePath), malformed);
|
||||
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.map((row) =>
|
||||
row.path === provenancePath
|
||||
? { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) }
|
||||
: row,
|
||||
);
|
||||
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||
await writeFile(assessmentPath, assessmentBytes);
|
||||
const files = fixture.manifest.files.map((row) => {
|
||||
if (row.path === provenancePath) {
|
||||
return { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) };
|
||||
}
|
||||
if (row.path === LOCAL_EVIDENCE_ASSESSMENT_PATH) {
|
||||
return {
|
||||
path: LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
bytes: assessmentBytes.byteLength,
|
||||
sha256: digestBytes(assessmentBytes),
|
||||
};
|
||||
}
|
||||
return row;
|
||||
});
|
||||
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).toContain(
|
||||
"archived source/build/provenance identities are missing or invalid",
|
||||
);
|
||||
} finally {
|
||||
await rm(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
|
||||
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const vulnerability = signedProviderV2(
|
||||
{
|
||||
...expected,
|
||||
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) },
|
||||
findings: [],
|
||||
},
|
||||
"vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenance = signedProviderV2(
|
||||
{
|
||||
...expected,
|
||||
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) },
|
||||
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||
},
|
||||
"provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "PASS",
|
||||
vulnerabilityStatus: "PASS",
|
||||
provenanceAttestationStatus: "PASS",
|
||||
failures: [],
|
||||
});
|
||||
const replayed = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
run: { id: expected.run.id, attempt: 2 },
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
expect(replayed.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(replayed.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
"vulnerability report run identity mismatch",
|
||||
"provenance attestation run identity mismatch",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["vulnerability", "provenance"] as const)(
|
||||
"rejects correctly re-signed %s v2 context/time/replay drift",
|
||||
(kind) => {
|
||||
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const baseVulnerability = providerUnsigned("vulnerability", expected);
|
||||
const baseProvenance = providerUnsigned("provenance", expected);
|
||||
const validVulnerability = signedProviderV2(
|
||||
baseVulnerability,
|
||||
"vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const validProvenance = signedProviderV2(
|
||||
baseProvenance,
|
||||
"provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const rawCases: Array<readonly [
|
||||
string,
|
||||
(value: Record<string, any>) => Record<string, any>,
|
||||
RegExp,
|
||||
]> = [
|
||||
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
|
||||
[
|
||||
"evidence type",
|
||||
(value) => ({
|
||||
...value,
|
||||
evidenceType:
|
||||
kind === "vulnerability"
|
||||
? "provenance-attestation"
|
||||
: "vulnerability-report",
|
||||
}),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
|
||||
(field) => [
|
||||
`candidate ${field}`,
|
||||
(value: Record<string, any>) => ({
|
||||
...value,
|
||||
candidate: { ...value.candidate, [field]: "f".repeat(64) },
|
||||
...(kind === "provenance" && field === "distSha256"
|
||||
? {
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: "f".repeat(64) },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
/candidate identity|subject dist/u,
|
||||
] as const,
|
||||
),
|
||||
[
|
||||
"different archive with same dist and lockfile",
|
||||
(value) => ({
|
||||
...value,
|
||||
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
|
||||
}),
|
||||
/candidate identity/u,
|
||||
],
|
||||
[
|
||||
"source revision",
|
||||
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
|
||||
/source identity/u,
|
||||
],
|
||||
[
|
||||
"source set",
|
||||
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
|
||||
/source identity/u,
|
||||
],
|
||||
[
|
||||
"run id",
|
||||
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
|
||||
/run identity/u,
|
||||
],
|
||||
[
|
||||
"run attempt replay",
|
||||
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
|
||||
/run identity/u,
|
||||
],
|
||||
[
|
||||
"different nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
|
||||
/invocation nonce/u,
|
||||
],
|
||||
[
|
||||
"missing nonce",
|
||||
(value) => {
|
||||
const run = { ...value.run };
|
||||
delete run.invocationNonce;
|
||||
return { ...value, run };
|
||||
},
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"uppercase nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"short nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"issued future boundary",
|
||||
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
|
||||
/future skew/u,
|
||||
],
|
||||
[
|
||||
"expiry equality",
|
||||
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
|
||||
/expired/u,
|
||||
],
|
||||
[
|
||||
"expiry past",
|
||||
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
|
||||
/expired/u,
|
||||
],
|
||||
[
|
||||
"zero lifetime",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:01:00.000Z",
|
||||
expiresAt: "2026-08-02T01:01:00.000Z",
|
||||
}),
|
||||
/not positive/u,
|
||||
],
|
||||
[
|
||||
"negative lifetime",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:02:00.000Z",
|
||||
expiresAt: "2026-08-02T01:01:59.999Z",
|
||||
}),
|
||||
/not positive/u,
|
||||
],
|
||||
[
|
||||
"lifetime above two hours",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T03:00:00.001Z",
|
||||
}),
|
||||
/exceeds two hours/u,
|
||||
],
|
||||
[
|
||||
"wrong fingerprint",
|
||||
(value) => ({
|
||||
...value,
|
||||
signature: {
|
||||
...value.signature,
|
||||
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
|
||||
},
|
||||
}),
|
||||
/trust identity/u,
|
||||
],
|
||||
];
|
||||
const cases = rawCases.map(([name, mutate, failure]) => ({
|
||||
name,
|
||||
mutate,
|
||||
failure,
|
||||
}));
|
||||
|
||||
for (const testCase of cases) {
|
||||
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
|
||||
const mutated = testCase.mutate(structuredClone(base));
|
||||
const resigned = signedProviderV2(
|
||||
mutated,
|
||||
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
|
||||
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
|
||||
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
|
||||
"signature" in mutated && mutated.signature?.publicKeyFingerprint
|
||||
? mutated.signature.publicKeyFingerprint
|
||||
: undefined,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport:
|
||||
kind === "vulnerability" ? resigned : validVulnerability,
|
||||
provenanceAttestation:
|
||||
kind === "provenance" ? resigned : validProvenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
|
||||
try {
|
||||
const ed25519 = generateKeyPairSync("ed25519").publicKey;
|
||||
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
|
||||
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
|
||||
const wrapped = (width: number) =>
|
||||
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
|
||||
await writeFile(path.join(root, "a.pem"), wrapped(64));
|
||||
await writeFile(path.join(root, "b.pem"), wrapped(32));
|
||||
const first = await readProviderTrust(root, "a.pem", "fixture-key");
|
||||
const second = await readProviderTrust(root, "b.pem", "fixture-key");
|
||||
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
|
||||
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
|
||||
|
||||
const ed448 = generateKeyPairSync("ed448").publicKey;
|
||||
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
|
||||
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
|
||||
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
let captureCount = 0;
|
||||
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
|
||||
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 result = await 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 }) => {
|
||||
receivedEnvironment = environment;
|
||||
},
|
||||
captureReport: async () => Buffer.from("{}\n"),
|
||||
},
|
||||
{
|
||||
captureArchive: async (input) => {
|
||||
captureCount += 1;
|
||||
expect(input).toEqual({
|
||||
archivePath: "/downloads/candidate.tar.gz",
|
||||
expectedSha256: expected.candidate.archiveSha256,
|
||||
});
|
||||
return {
|
||||
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"),
|
||||
},
|
||||
failures: [],
|
||||
}),
|
||||
validateUpload: (async (input: any) => {
|
||||
expect("archivePath" in input).toBe(false);
|
||||
return { sealed: true };
|
||||
}) as any,
|
||||
randomBytes: () => Buffer.alloc(32, 0x11),
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(captureCount).toBe(1);
|
||||
expect(receivedEnvironment).toEqual(
|
||||
expect.objectContaining({
|
||||
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
|
||||
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
|
||||
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
|
||||
CI_RUN_ID: expected.run.id,
|
||||
CI_RUN_ATTEMPT: "1",
|
||||
SOURCE_REVISION: expected.source.revision,
|
||||
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
|
||||
}),
|
||||
);
|
||||
expect(result.evidence).toEqual({ sealed: true });
|
||||
});
|
||||
});
|
||||
|
||||
function providerExpectedContext() {
|
||||
return {
|
||||
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"),
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function fingerprint(publicKey: KeyObject): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function trust(keyId: string, publicKey: KeyObject) {
|
||||
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
|
||||
}
|
||||
|
||||
function signedProviderV2(
|
||||
unsigned: Record<string, unknown>,
|
||||
keyId: string,
|
||||
publicKey: KeyObject,
|
||||
privateKey: KeyObject,
|
||||
fingerprintOverride?: string,
|
||||
) {
|
||||
const { signature: existingSignature, ...payload } = unsigned;
|
||||
const value = {
|
||||
...payload,
|
||||
signature: {
|
||||
algorithm: "Ed25519" as const,
|
||||
keyId,
|
||||
publicKeyFingerprint:
|
||||
fingerprintOverride ??
|
||||
(existingSignature && typeof existingSignature === "object" &&
|
||||
"publicKeyFingerprint" in existingSignature
|
||||
? String(existingSignature.publicKeyFingerprint)
|
||||
: fingerprint(publicKey)),
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
value.signature.value = sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
privateKey,
|
||||
).toString("base64");
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerUnsigned(
|
||||
kind: "vulnerability" | "provenance",
|
||||
expected: ReturnType<typeof providerExpectedContext>,
|
||||
): Record<string, any> {
|
||||
const common = {
|
||||
...expected,
|
||||
schemaVersion: 2,
|
||||
evidenceType:
|
||||
kind === "vulnerability"
|
||||
? "vulnerability-report"
|
||||
: "provenance-attestation",
|
||||
provider: `fixture-${kind}`,
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: {
|
||||
...expected.run,
|
||||
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
|
||||
},
|
||||
};
|
||||
return kind === "vulnerability"
|
||||
? { ...common, findings: [] }
|
||||
: {
|
||||
...common,
|
||||
signer: "fixture-workload",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: expected.candidate.distSha256 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createArchivedAssessmentFixture(): Promise<{
|
||||
root: string;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
assessmentSha256: string;
|
||||
}> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
|
||||
const sourceRevision = "a".repeat(40);
|
||||
const sourceSetSha256 = digest("source set");
|
||||
const releaseManifestBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
appVersion: "1.0.0",
|
||||
buildId: "build-1",
|
||||
commitSha: sourceRevision,
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: digest("vite manifest"),
|
||||
releaseId: "release-1",
|
||||
builtAt: "2026-08-02T00:00:00.000Z",
|
||||
routeChunks: { home: "assets/home.js" },
|
||||
})}\n`,
|
||||
);
|
||||
const distInputs = [
|
||||
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
|
||||
{
|
||||
path: "dist/release-manifest.json",
|
||||
bytes: releaseManifestBytes.byteLength,
|
||||
sha256: digestBytes(releaseManifestBytes),
|
||||
gzipBytes: 0,
|
||||
},
|
||||
];
|
||||
const candidateDist = distSha256(distInputs);
|
||||
const sbomBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
bomFormat: "CycloneDX",
|
||||
specVersion: "1.6",
|
||||
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: { type: "application", name: "fixture", version: "1.0.0" },
|
||||
properties: [],
|
||||
},
|
||||
components: [],
|
||||
dependencies: [],
|
||||
})}\n`,
|
||||
);
|
||||
const sbomSha256 = digestBytes(sbomBytes);
|
||||
const lockfileBytes = Buffer.from("lockfile\n");
|
||||
const lockfileDigest = digestBytes(lockfileBytes);
|
||||
const buildManifest = {
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
commitSha: sourceRevision,
|
||||
releaseId: "release-1",
|
||||
moduleInventoryHash: digest("module inventory"),
|
||||
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||
buildContext: {
|
||||
nodeVersion: "v24.0.0",
|
||||
packageManagerVersion: "11.0.0",
|
||||
runnerImage: "linux-x64",
|
||||
sourceDateEpoch: "1785638400",
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||
routeChunks: { home: "assets/home.js" },
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
const provenance = {
|
||||
_type: "https://in-toto.io/Statement/v1",
|
||||
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
|
||||
predicateType: "https://slsa.dev/provenance/v1",
|
||||
predicate: {
|
||||
buildDefinition: {
|
||||
buildType: "https://vite.dev/build/v1",
|
||||
externalParameters: {},
|
||||
internalParameters: {},
|
||||
resolvedDependencies: [
|
||||
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
|
||||
],
|
||||
},
|
||||
runDetails: {
|
||||
builder: { id: "fixture-builder" },
|
||||
metadata: { invocationId: "LOCAL_UNSIGNED" },
|
||||
},
|
||||
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
|
||||
},
|
||||
};
|
||||
const supplyVerification = {
|
||||
schemaVersion: 1,
|
||||
localStatus: "PASS",
|
||||
promotionStatus: "FAIL_UNVERIFIED",
|
||||
lockfileSha256: lockfileDigest,
|
||||
sourceSetSha256,
|
||||
distSha256: candidateDist,
|
||||
sbomSha256,
|
||||
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
|
||||
highRiskReview: [],
|
||||
vulnerabilityStatus: "FAIL_UNVERIFIED",
|
||||
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||
failures: [],
|
||||
};
|
||||
const members = new Map<string, Buffer>([
|
||||
["dist/app.js", Buffer.from("app\n")],
|
||||
["dist/release-manifest.json", releaseManifestBytes],
|
||||
["pnpm-lock.yaml", lockfileBytes],
|
||||
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
|
||||
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
|
||||
[
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
|
||||
],
|
||||
["artifacts/release/sbom.cdx.json", sbomBytes],
|
||||
]);
|
||||
const evidenceInputs = [...members.entries()]
|
||||
.map(([memberPath, bytes]) => ({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: digestBytes(bytes),
|
||||
}))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
const policyPaths = [
|
||||
"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/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
];
|
||||
const sbomRow = evidenceInputs.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
)!;
|
||||
const policyInputs = policyPaths.map((policyPath) => ({
|
||||
path: policyPath,
|
||||
bytes: 2,
|
||||
sha256: digest(`policy:${policyPath}`),
|
||||
}));
|
||||
const verifierPaths = new Set([
|
||||
"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/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
]);
|
||||
const assessment = localEvidenceAssessmentArtifactSchema.parse({
|
||||
...passingAssessment(),
|
||||
verifier: {
|
||||
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||
version: "1",
|
||||
sourceSha256: supplyChainDigest(
|
||||
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
|
||||
),
|
||||
},
|
||||
source: { revision: sourceRevision, sourceSetSha256 },
|
||||
candidate: {
|
||||
distSha256: candidateDist,
|
||||
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
|
||||
.sha256,
|
||||
sbomSha256: sbomRow.sha256,
|
||||
},
|
||||
policyInputs,
|
||||
evidenceInputs,
|
||||
});
|
||||
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
|
||||
for (const [memberPath, bytes] of members) {
|
||||
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
|
||||
await writeFile(path.join(root, memberPath), bytes);
|
||||
}
|
||||
const files = [...members.entries()]
|
||||
.map(([memberPath, bytes]) => ({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: digestBytes(bytes),
|
||||
}))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
const manifest: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: assessment.candidate.distSha256,
|
||||
lockfileSha256: assessment.candidate.lockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
};
|
||||
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, "artifacts/release/release-candidate.json"),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
|
||||
}
|
||||
+201
-195
@@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import {
|
||||
@@ -51,17 +52,41 @@ const dependency = {
|
||||
|
||||
const candidateDistSha256 = "1".repeat(64);
|
||||
const lockfileSha256 = "2".repeat(64);
|
||||
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
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,
|
||||
candidate: Object.freeze({
|
||||
archiveSha256: "3".repeat(64),
|
||||
bundleSha256: "4".repeat(64),
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
}),
|
||||
vulnerabilityInvocationNonce: "5".repeat(64),
|
||||
provenanceInvocationNonce: "6".repeat(64),
|
||||
});
|
||||
|
||||
function signedProviderEvidence(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||
publicKeyFingerprint: string,
|
||||
) {
|
||||
return {
|
||||
...value,
|
||||
signature: {
|
||||
algorithm: "Ed25519",
|
||||
keyId,
|
||||
publicKeyFingerprint,
|
||||
value: sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
@@ -71,6 +96,53 @@ function signedProviderEvidence(
|
||||
};
|
||||
}
|
||||
|
||||
function providerPair(input: Readonly<{
|
||||
vulnerabilityKeys: ReturnType<typeof generateKeyPairSync>;
|
||||
provenanceKeys: ReturnType<typeof generateKeyPairSync>;
|
||||
candidate?: typeof expectedProviderContext.candidate;
|
||||
vulnerabilityFingerprint?: string;
|
||||
provenanceFingerprint?: string;
|
||||
}>) {
|
||||
const candidate = input.candidate ?? expectedProviderContext.candidate;
|
||||
const vulnerabilityFingerprint = input.vulnerabilityFingerprint ??
|
||||
providerPublicKeyFingerprint(input.vulnerabilityKeys.publicKey);
|
||||
const provenanceFingerprint = input.provenanceFingerprint ??
|
||||
providerPublicKeyFingerprint(input.provenanceKeys.publicKey);
|
||||
return {
|
||||
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: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce },
|
||||
source: expectedProviderContext.source,
|
||||
candidate,
|
||||
findings: [],
|
||||
}, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint),
|
||||
provenanceAttestation: signedProviderEvidence({
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance-provider",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.provenanceInvocationNonce },
|
||||
source: expectedProviderContext.source,
|
||||
candidate,
|
||||
signer: "fixture-workload-identity",
|
||||
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
|
||||
}, "fixture-provenance-key", input.provenanceKeys.privateKey, provenanceFingerprint),
|
||||
};
|
||||
}
|
||||
|
||||
function providerTrust(
|
||||
keyId: string,
|
||||
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
|
||||
publicKeyFingerprint = providerPublicKeyFingerprint(publicKey),
|
||||
) {
|
||||
return { keyId, publicKey, publicKeyFingerprint };
|
||||
}
|
||||
|
||||
async function createMinimalCandidateTree(root: string) {
|
||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||
const rawLockfileSha256 = createHash("sha256")
|
||||
@@ -100,37 +172,54 @@ async function createMinimalCandidateTree(root: string) {
|
||||
|
||||
async function writeProviderEnvironment(
|
||||
root: string,
|
||||
distDigest: string,
|
||||
candidateLockfileSha256: string,
|
||||
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
|
||||
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: 1,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: candidateLockfileSha256,
|
||||
scannedDistSha256: distDigest,
|
||||
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: 1,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: { name: "dist", digest: { sha256: distDigest } },
|
||||
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"), "fixture archive\n"),
|
||||
writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes),
|
||||
writeFile(
|
||||
path.join(root, "provider/vulnerability.json"),
|
||||
`${JSON.stringify(vulnerabilityReport)}\n`,
|
||||
@@ -155,8 +244,13 @@ async function writeProviderEnvironment(
|
||||
return {
|
||||
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
|
||||
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
||||
.update("fixture archive\n")
|
||||
.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",
|
||||
@@ -167,32 +261,37 @@ async function writeProviderEnvironment(
|
||||
}
|
||||
|
||||
describe("supply-chain policy", () => {
|
||||
it("emits a strict role-bound v2 verification record from exact input bytes", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-"));
|
||||
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.distSha256,
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
const environment = await writeProviderEnvironment(root, manifest);
|
||||
const report = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment,
|
||||
verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }),
|
||||
} as Parameters<typeof verifyPromotionInputs>[0]);
|
||||
verifyLocalEvidence: async () => ({
|
||||
status: "PASS" as const,
|
||||
identity: localIdentity,
|
||||
failures: [] as const,
|
||||
}),
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
artifactType: "provider-verification",
|
||||
candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
|
||||
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"),
|
||||
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 {
|
||||
@@ -204,13 +303,10 @@ describe("supply-chain policy", () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
||||
try {
|
||||
const manifest = await createMinimalCandidateTree(root);
|
||||
const validEnvironment = await writeProviderEnvironment(
|
||||
root,
|
||||
manifest.distSha256,
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
const validEnvironment = await writeProviderEnvironment(root, manifest);
|
||||
const acceptLocalEvidence = async () => ({
|
||||
status: "PASS" as const,
|
||||
identity: localIdentity,
|
||||
failures: [] as const,
|
||||
});
|
||||
const valid = await verifyPromotionInputs({
|
||||
@@ -218,23 +314,34 @@ describe("supply-chain policy", () => {
|
||||
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 wrongEnvironment = await writeProviderEnvironment(
|
||||
root,
|
||||
"3".repeat(64),
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
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({
|
||||
@@ -242,19 +349,23 @@ describe("supply-chain policy", () => {
|
||||
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 });
|
||||
}
|
||||
@@ -264,11 +375,7 @@ describe("supply-chain policy", () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-"));
|
||||
try {
|
||||
const manifest = await createMinimalCandidateTree(root);
|
||||
const environment = await writeProviderEnvironment(
|
||||
root,
|
||||
manifest.distSha256,
|
||||
manifest.lockfileSha256,
|
||||
);
|
||||
const environment = await writeProviderEnvironment(root, manifest);
|
||||
const localVerificationPath = path.join(
|
||||
root,
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
@@ -278,12 +385,13 @@ describe("supply-chain policy", () => {
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: root,
|
||||
environment,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/executable schema mismatch/u),
|
||||
"local evidence assessment is missing or invalid",
|
||||
"local supply-chain evidence is not PASS",
|
||||
]),
|
||||
);
|
||||
@@ -393,16 +501,13 @@ describe("supply-chain policy", () => {
|
||||
|
||||
it("fails promotion when external provider evidence is absent", () => {
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
expected: expectedProviderContext,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: null,
|
||||
provenanceAttestation: null,
|
||||
vulnerabilityTrust: null,
|
||||
provenanceTrust: null,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
@@ -411,50 +516,25 @@ describe("supply-chain policy", () => {
|
||||
it("passes only signed provider evidence for the exact immutable candidate", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: candidateDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: candidateDistSha256 },
|
||||
},
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||
vulnerabilityKeys,
|
||||
provenanceKeys,
|
||||
});
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
expected: expectedProviderContext,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
vulnerabilityTrust: providerTrust(
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
),
|
||||
provenanceTrust: providerTrust(
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
),
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
@@ -469,54 +549,27 @@ describe("supply-chain policy", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const wrongDistSha256 = "3".repeat(64);
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: wrongDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: { name: "dist", digest: { sha256: wrongDistSha256 } },
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||
vulnerabilityKeys,
|
||||
provenanceKeys,
|
||||
candidate: { ...expectedProviderContext.candidate, distSha256: wrongDistSha256 },
|
||||
});
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
expected: expectedProviderContext,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
"vulnerability report dist digest mismatch",
|
||||
"provenance attestation dist digest mismatch",
|
||||
"vulnerability report candidate identity mismatch",
|
||||
"provenance attestation candidate identity mismatch",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -524,103 +577,56 @@ describe("supply-chain policy", () => {
|
||||
it("rejects candidate bytes changed after provider attestation", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: candidateDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: candidateDistSha256 },
|
||||
},
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||
vulnerabilityKeys,
|
||||
provenanceKeys,
|
||||
});
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
expected: {
|
||||
...expectedProviderContext,
|
||||
candidate: { ...expectedProviderContext.candidate, distSha256: "4".repeat(64) },
|
||||
},
|
||||
currentDistSha256: "4".repeat(64),
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures).toContain(
|
||||
"candidate dist bytes changed after immutable build",
|
||||
);
|
||||
expect(result.failures).toContain("vulnerability report candidate identity mismatch");
|
||||
});
|
||||
|
||||
it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed448");
|
||||
const provenanceKeys = generateKeyPairSync("ed448");
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: candidateDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: candidateDistSha256 },
|
||||
},
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const fakeFingerprint = `sha256:${"7".repeat(64)}`;
|
||||
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||
vulnerabilityKeys,
|
||||
provenanceKeys,
|
||||
vulnerabilityFingerprint: fakeFingerprint,
|
||||
provenanceFingerprint: fakeFingerprint,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluatePromotionEvidence({
|
||||
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
|
||||
currentDistSha256: candidateDistSha256,
|
||||
expected: expectedProviderContext,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
publicKeyFingerprint: fakeFingerprint,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
publicKeyFingerprint: fakeFingerprint,
|
||||
},
|
||||
nowEpochMs: () => NOW,
|
||||
}).status,
|
||||
).toBe("FAIL_UNVERIFIED");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user