diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index ce1eea4..da62e95 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -25,7 +25,9 @@ import { verifySupplyChainCoherence, } from "./lib/supply-chain.ts"; import { + createLocalVulnerabilityReport, distChecksumsText, + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS, recomputeDependencyEvidence, recomputeLicenseEvidence, } from "./lib/local-policy-evidence.ts"; @@ -186,16 +188,9 @@ const licenseEvidence = recomputeLicenseEvidence({ policy: licensePolicy, }); -const vulnerabilityReport = { - schemaVersion: 1, - provider: "UNCONFIGURED", - scannedLockfileSha256: inventory.lockfileSha256, - status: "FAIL_UNVERIFIED", - findings: [], - exceptionsApplied: [], - failures: ["external vulnerability provider report is missing"], - blocking: [], -}; +const vulnerabilityReport = createLocalVulnerabilityReport( + inventory.lockfileSha256, +); const sourceFiles = [...repositoryInventory.trackedFiles]; const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles); @@ -301,15 +296,13 @@ const localPassed = localFailures.length === 0; const verification = { schemaVersion: 1, localStatus: localPassed ? "PASS" : "FAIL", - promotionStatus: "FAIL_UNVERIFIED", + ...LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS, lockfileSha256: inventory.lockfileSha256, sourceSetSha256, distSha256: distDigest, sbomSha256: supplyChainDigest(sbom), dependencyDiff: dependencyPolicy.dependencyDiff, highRiskReview: dependencyPolicy.highRisk, - vulnerabilityStatus: vulnerabilityReport.status, - provenanceAttestationStatus: "FAIL_UNVERIFIED", failures: localFailures, }; const bundleReport = { diff --git a/scripts/lib/local-policy-evidence.ts b/scripts/lib/local-policy-evidence.ts index 68dc957..739b4d1 100644 --- a/scripts/lib/local-policy-evidence.ts +++ b/scripts/lib/local-policy-evidence.ts @@ -2,6 +2,8 @@ import { dependencyDiffArtifactSchema, dependencyInventoryArtifactSchema, licenseReportArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, } from "../contracts/release-artifacts.ts"; import type { DistOutput } from "./release-candidate.ts"; import { @@ -13,6 +15,60 @@ import { type Document = Record; +export const LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS = Object.freeze({ + promotionStatus: "FAIL_UNVERIFIED" as const, + vulnerabilityStatus: "FAIL_UNVERIFIED" as const, + provenanceAttestationStatus: "FAIL_UNVERIFIED" as const, +}); + +export function verifyLocalSupplyChainDefaults(stored: unknown): string[] { + const parsed = supplyChainVerificationArtifactSchema.safeParse(stored); + if ( + !parsed.success || + parsed.data.promotionStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.promotionStatus || + parsed.data.vulnerabilityStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.vulnerabilityStatus || + parsed.data.provenanceAttestationStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.provenanceAttestationStatus + ) { + return [ + "supply-chain verification provider defaults are not local FAIL_UNVERIFIED", + ]; + } + return []; +} + +export function createLocalVulnerabilityReport(lockfileSha256: string) { + return vulnerabilityReportArtifactSchema.parse({ + schemaVersion: 1, + provider: "UNCONFIGURED", + scannedLockfileSha256: lockfileSha256, + status: "FAIL_UNVERIFIED", + findings: [], + exceptionsApplied: [], + failures: ["external vulnerability provider report is missing"], + blocking: [], + }); +} + +export function compareStoredLocalVulnerabilityReport( + lockfileSha256: string, + stored: unknown, +): string[] { + const parsed = vulnerabilityReportArtifactSchema.safeParse(stored); + if ( + !parsed.success || + supplyChainDigest(parsed.data) !== + supplyChainDigest(createLocalVulnerabilityReport(lockfileSha256)) + ) { + return [ + "local vulnerability report does not match exact unconfigured defaults", + ]; + } + return []; +} + export function recomputeDependencyEvidence(input: Readonly<{ inventory: unknown; baseline: unknown; diff --git a/scripts/lib/local-release-evidence.ts b/scripts/lib/local-release-evidence.ts index d5dc04a..9406079 100644 --- a/scripts/lib/local-release-evidence.ts +++ b/scripts/lib/local-release-evidence.ts @@ -35,8 +35,10 @@ import { digestReleaseInputFiles } from "./release-input-evidence.ts"; import { compareStoredDependencyEvidence, compareStoredLicenseEvidence, + compareStoredLocalVulnerabilityReport, recomputeDependencyEvidence, recomputeLicenseEvidence, + verifyLocalSupplyChainDefaults, verifyStoredDistChecksums, } from "./local-policy-evidence.ts"; import { @@ -175,6 +177,9 @@ export async function verifyLocalSupplyChainEvidence( ) { failures.push("verification digest/status set is incoherent"); } + if (verification) { + failures.push(...verifyLocalSupplyChainDefaults(verification)); + } if (inventory && sbom && provenance && verification) { try { const policy = parseRepositoryFileInventoryPolicy( @@ -336,7 +341,11 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{ failures, ); - await validateSupportingArtifacts(repositoryRoot, failures); + await validateSupportingArtifacts( + repositoryRoot, + failures, + supplyReport.lockfileSha256, + ); if (buildManifest) { try { assertMatchesJsonSchema( @@ -423,6 +432,7 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{ async function validateSupportingArtifacts( repositoryRoot: string, failures: string[], + lockfileSha256: string, ): Promise { let actualOutputs: Awaited> | null = null; @@ -526,12 +536,10 @@ async function validateSupportingArtifacts( "local vulnerability report", failures, ); - if ( - vulnerability && - (vulnerability.status !== "FAIL_UNVERIFIED" || - vulnerability.provider !== "UNCONFIGURED") - ) { - failures.push("local vulnerability report may not satisfy promotion"); + if (vulnerability) { + failures.push( + ...compareStoredLocalVulnerabilityReport(lockfileSha256, vulnerability), + ); } const provenance = await parseArtifact( repositoryRoot, diff --git a/tests/unit/local-promotion-evidence.test.ts b/tests/unit/local-promotion-evidence.test.ts index 702975c..62536b2 100644 --- a/tests/unit/local-promotion-evidence.test.ts +++ b/tests/unit/local-promotion-evidence.test.ts @@ -2,12 +2,18 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; +import { + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../../scripts/contracts/release-artifacts.ts"; import { compareStoredDependencyEvidence, compareStoredLicenseEvidence, + compareStoredLocalVulnerabilityReport, distChecksumsText, recomputeDependencyEvidence, recomputeLicenseEvidence, + verifyLocalSupplyChainDefaults, verifyStoredDistChecksums, } from "../../scripts/lib/local-policy-evidence.ts"; import { @@ -173,4 +179,74 @@ describe("recomputed local promotion evidence", () => { ]), ); }); + + it("rejects schema-valid verified provider statuses in local supply-chain evidence", () => { + const localVerification = { + schemaVersion: 1 as const, + localStatus: "PASS" as const, + promotionStatus: "FAIL_UNVERIFIED" as const, + lockfileSha256: "1".repeat(64), + sourceSetSha256: "2".repeat(64), + distSha256: "3".repeat(64), + sbomSha256: "4".repeat(64), + dependencyDiff: { + added: [], + removed: [], + changed: [], + upgrades: [], + }, + highRiskReview: [], + vulnerabilityStatus: "FAIL_UNVERIFIED" as const, + provenanceAttestationStatus: "FAIL_UNVERIFIED" as const, + failures: [], + }; + const tampered = [ + { ...localVerification, promotionStatus: "PASS" as const }, + { ...localVerification, vulnerabilityStatus: "PASS" as const }, + { + ...localVerification, + provenanceAttestationStatus: "PASS" as const, + }, + ].map((value) => supplyChainVerificationArtifactSchema.parse(value)); + + for (const value of tampered) { + expect(verifyLocalSupplyChainDefaults(value)).toEqual([ + "supply-chain verification provider defaults are not local FAIL_UNVERIFIED", + ]); + } + }); + + it("rejects every schema-valid drift from the exact local vulnerability report", () => { + const currentLockfileSha256 = "1".repeat(64); + const localReport = { + schemaVersion: 1 as const, + provider: "UNCONFIGURED", + scannedLockfileSha256: currentLockfileSha256, + status: "FAIL_UNVERIFIED" as const, + findings: [], + exceptionsApplied: [], + failures: ["external vulnerability provider report is missing"], + blocking: [], + }; + const tampered = [ + { ...localReport, provider: "forged-provider" }, + { ...localReport, scannedLockfileSha256: "2".repeat(64) }, + { ...localReport, status: "PASS" as const }, + { ...localReport, findings: [{ id: "forged" }] }, + { ...localReport, exceptionsApplied: [{ id: "forged" }] }, + { ...localReport, failures: [] }, + { ...localReport, blocking: ["forged"] }, + ].map((value) => vulnerabilityReportArtifactSchema.parse(value)); + + for (const value of tampered) { + expect( + compareStoredLocalVulnerabilityReport( + currentLockfileSha256, + value, + ), + ).toEqual([ + "local vulnerability report does not match exact unconfigured defaults", + ]); + } + }); });