fix: close immutable promotion trust gaps
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { z, type ZodType } from "zod";
|
||||
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../../src/features/installed-contract-contributions.ts";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
import {
|
||||
buildManifestArtifactSchema,
|
||||
bundlePerformanceArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
releaseManifestArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
runtimeConfigArtifactSchema,
|
||||
sbomArtifactSchema,
|
||||
supplyChainVerificationArtifactSchema,
|
||||
vulnerabilityReportArtifactSchema,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
import {
|
||||
CANONICAL_VITE_MANIFEST_PATH,
|
||||
verifyBuildManifestOutputs,
|
||||
} from "./build-manifest-outputs.ts";
|
||||
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
||||
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
|
||||
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
||||
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
||||
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
||||
import {
|
||||
buildRepositoryFileInventory,
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./repository-file-inventory.ts";
|
||||
import {
|
||||
isValidSha512Integrity,
|
||||
parsePnpmLockfilePackages,
|
||||
supplyChainDigest,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
export const supplyChainCoherenceReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
dependencyCount: z.int().nonnegative(),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
sbomSha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SupplyChainCoherenceReport = z.infer<
|
||||
typeof supplyChainCoherenceReportSchema
|
||||
>;
|
||||
|
||||
export async function verifyLocalSupplyChainEvidence(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<SupplyChainCoherenceReport> {
|
||||
const failures: string[] = [];
|
||||
const inventory = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
dependencyInventoryArtifactSchema,
|
||||
"dependency inventory",
|
||||
failures,
|
||||
);
|
||||
const sbom = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
sbomArtifactSchema,
|
||||
"SBOM",
|
||||
failures,
|
||||
);
|
||||
const provenance = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/provenance.json",
|
||||
provenanceArtifactSchema,
|
||||
"local provenance",
|
||||
failures,
|
||||
);
|
||||
const verification = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
supplyChainVerificationArtifactSchema,
|
||||
"supply-chain verification",
|
||||
failures,
|
||||
);
|
||||
|
||||
for (const [schemaPath, artifact, label] of [
|
||||
[
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
inventory,
|
||||
"dependency inventory",
|
||||
],
|
||||
[
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
verification,
|
||||
"supply-chain verification",
|
||||
],
|
||||
] as const) {
|
||||
if (!artifact) continue;
|
||||
try {
|
||||
assertMatchesJsonSchema(
|
||||
await readJson(repositoryRoot, schemaPath),
|
||||
artifact,
|
||||
label,
|
||||
);
|
||||
} catch {
|
||||
failures.push(`${label} JSON Schema mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
let lockfileText = "";
|
||||
let lockfileSha256 = "0".repeat(64);
|
||||
try {
|
||||
const rawLockfile = await readFile(
|
||||
path.join(repositoryRoot, "pnpm-lock.yaml"),
|
||||
);
|
||||
lockfileText = rawLockfile.toString("utf8");
|
||||
lockfileSha256 = createHash("sha256").update(rawLockfile).digest("hex");
|
||||
} catch {
|
||||
failures.push("raw pnpm-lock.yaml is missing or unreadable");
|
||||
}
|
||||
|
||||
let distDigest = "0".repeat(64);
|
||||
try {
|
||||
distDigest = distSha256(await collectDistOutputs(repositoryRoot));
|
||||
} catch {
|
||||
failures.push("candidate dist is missing or unreadable");
|
||||
}
|
||||
const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64);
|
||||
|
||||
if (inventory && sbom && provenance) {
|
||||
failures.push(
|
||||
...verifySupplyChainCoherence(
|
||||
sbom,
|
||||
inventory,
|
||||
provenance,
|
||||
distDigest,
|
||||
).failures,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!inventory ||
|
||||
!verification ||
|
||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||
verification.lockfileSha256 !== lockfileSha256
|
||||
) {
|
||||
failures.push("inventory/verification lockfile digest mismatch");
|
||||
}
|
||||
if (
|
||||
!verification ||
|
||||
verification.localStatus !== "PASS" ||
|
||||
verification.failures.length > 0 ||
|
||||
verification.distSha256 !== distDigest ||
|
||||
verification.sbomSha256 !== sbomSha256
|
||||
) {
|
||||
failures.push("verification digest/status set is incoherent");
|
||||
}
|
||||
if (inventory && sbom && provenance && verification) {
|
||||
try {
|
||||
const policy = parseRepositoryFileInventoryPolicy(
|
||||
await readJson(
|
||||
repositoryRoot,
|
||||
"config/security/secret-scan-policy.json",
|
||||
),
|
||||
);
|
||||
const repositoryInventory = await buildRepositoryFileInventory({
|
||||
repositoryRoot,
|
||||
trackedRoots: policy.trackedRoots,
|
||||
generatedRoots: policy.generatedRoots,
|
||||
optionalRoots: policy.optionalRoots,
|
||||
});
|
||||
const sourceSetSha256 = await digestReleaseInputFiles(
|
||||
repositoryInventory.trackedFiles,
|
||||
(file) => readFile(path.join(repositoryRoot, file)),
|
||||
);
|
||||
if (
|
||||
verification.sourceSetSha256 !== sourceSetSha256 ||
|
||||
provenance.predicate.materials.sourceSetSha256 !== sourceSetSha256 ||
|
||||
provenance.predicate.materials.sbomSha256 !== sbomSha256
|
||||
) {
|
||||
failures.push("source/SBOM provenance materials are incoherent");
|
||||
}
|
||||
} catch {
|
||||
failures.push("release source inventory is unavailable or unreadable");
|
||||
}
|
||||
}
|
||||
|
||||
const lockRows = parsePnpmLockfilePackages(lockfileText);
|
||||
const inventoryRows = inventory?.dependencies ?? [];
|
||||
const inventoryByIdentity = new Map<string, (typeof inventoryRows)[number]>(
|
||||
inventoryRows.map(
|
||||
(entry) => [`${entry.name}@${entry.version}`, entry] as const,
|
||||
),
|
||||
);
|
||||
if (lockRows.length !== inventoryRows.length) {
|
||||
failures.push("transitive dependency count differs from lockfile");
|
||||
}
|
||||
for (const lockRow of lockRows) {
|
||||
const identity = `${lockRow.name}@${lockRow.version}`;
|
||||
const dependency = inventoryByIdentity.get(identity);
|
||||
if (
|
||||
!dependency ||
|
||||
dependency.integrity !== lockRow.integrity ||
|
||||
!isValidSha512Integrity(lockRow.integrity)
|
||||
) {
|
||||
failures.push(`lockfile inventory integrity mismatch: ${identity}`);
|
||||
}
|
||||
}
|
||||
|
||||
return supplyChainCoherenceReportSchema.parse({
|
||||
schemaVersion: 1,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
dependencyCount: inventoryRows.length,
|
||||
lockfileSha256,
|
||||
distSha256: distDigest,
|
||||
sbomSha256,
|
||||
failures,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
repositoryRoot?: string;
|
||||
candidate: ReleaseCandidateManifest;
|
||||
}>): Promise<Readonly<{
|
||||
status: "PASS" | "FAIL";
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
|
||||
const failures: string[] = [];
|
||||
const supplyReport = await verifyLocalSupplyChainEvidence(repositoryRoot);
|
||||
failures.push(...supplyReport.failures);
|
||||
if (supplyReport.lockfileSha256 !== input.candidate.lockfileSha256) {
|
||||
failures.push("candidate/raw lockfile digest mismatch");
|
||||
}
|
||||
|
||||
const buildManifest = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/build-manifest.json",
|
||||
buildManifestArtifactSchema,
|
||||
"build manifest",
|
||||
failures,
|
||||
);
|
||||
const release = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"dist/release-manifest.json",
|
||||
releaseManifestArtifactSchema,
|
||||
"release manifest",
|
||||
failures,
|
||||
);
|
||||
const runtime = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"dist/config.json",
|
||||
runtimeConfigArtifactSchema,
|
||||
"runtime config",
|
||||
failures,
|
||||
);
|
||||
const storedRelease = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/verification.json",
|
||||
releaseVerificationArtifactSchema,
|
||||
"release verification",
|
||||
failures,
|
||||
);
|
||||
const storedSupply = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
supplyChainCoherenceReportSchema,
|
||||
"supply-chain coherence",
|
||||
failures,
|
||||
);
|
||||
|
||||
await validateSupportingArtifacts(repositoryRoot, failures);
|
||||
if (buildManifest) {
|
||||
try {
|
||||
assertMatchesJsonSchema(
|
||||
await readJson(
|
||||
repositoryRoot,
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
),
|
||||
buildManifest,
|
||||
"build manifest",
|
||||
);
|
||||
} catch {
|
||||
failures.push("build manifest JSON Schema mismatch");
|
||||
}
|
||||
failures.push(
|
||||
...(await verifyBuildManifestOutputs(buildManifest, { repositoryRoot })),
|
||||
);
|
||||
}
|
||||
|
||||
if (release && runtime) {
|
||||
if (!runtime.BUILD_ID || !runtime.RELEASE_ID) {
|
||||
failures.push("runtime release identity is missing");
|
||||
} else {
|
||||
const apiContractVersion =
|
||||
release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime
|
||||
? runtime.API_CONTRACT_VERSION
|
||||
: undefined;
|
||||
if (release.schemaVersion === 1 && apiContractVersion === undefined) {
|
||||
failures.push("runtime API contract identity is missing");
|
||||
}
|
||||
const coherence = await verifyReleaseRuntimeCoherence({
|
||||
release,
|
||||
runtime: {
|
||||
BUILD_ID: runtime.BUILD_ID,
|
||||
RELEASE_ID: runtime.RELEASE_ID,
|
||||
CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION,
|
||||
...(apiContractVersion === undefined
|
||||
? {}
|
||||
: { API_CONTRACT_VERSION: apiContractVersion }),
|
||||
},
|
||||
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
});
|
||||
failures.push(...coherence.mismatches.map((item) => `release:${item}`));
|
||||
}
|
||||
await verifyReleaseOutputs(
|
||||
repositoryRoot,
|
||||
release,
|
||||
buildManifest,
|
||||
failures,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!storedRelease ||
|
||||
!storedRelease.passed ||
|
||||
!storedRelease.artifact.checked ||
|
||||
!storedRelease.artifact.compatible ||
|
||||
storedRelease.artifact.mismatches.length > 0 ||
|
||||
storedRelease.fixtures.length === 0 ||
|
||||
storedRelease.fixtures.some((fixture) => !fixture.passed) ||
|
||||
storedRelease.artifact.releaseId !== release?.releaseId ||
|
||||
storedRelease.generatedAt !== release?.builtAt
|
||||
) {
|
||||
failures.push("stored release verification is not a coherent PASS");
|
||||
}
|
||||
if (
|
||||
!storedSupply ||
|
||||
storedSupply.status !== "PASS" ||
|
||||
storedSupply.failures.length > 0 ||
|
||||
storedSupply.dependencyCount !== supplyReport.dependencyCount ||
|
||||
storedSupply.lockfileSha256 !== supplyReport.lockfileSha256 ||
|
||||
storedSupply.distSha256 !== supplyReport.distSha256 ||
|
||||
storedSupply.sbomSha256 !== supplyReport.sbomSha256
|
||||
) {
|
||||
failures.push("stored supply-chain coherence is not a recomputed PASS");
|
||||
}
|
||||
await verifySecretScan(repositoryRoot, failures);
|
||||
|
||||
return Object.freeze({
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
failures: Object.freeze([...new Set(failures)]),
|
||||
});
|
||||
}
|
||||
|
||||
async function validateSupportingArtifacts(
|
||||
repositoryRoot: string,
|
||||
failures: string[],
|
||||
): Promise<void> {
|
||||
const bundle = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/performance/bundle.json",
|
||||
bundlePerformanceArtifactSchema,
|
||||
"bundle report",
|
||||
failures,
|
||||
);
|
||||
if (bundle) {
|
||||
try {
|
||||
const actual = await collectDistOutputs(repositoryRoot);
|
||||
if (JSON.stringify(bundle.outputs) !== JSON.stringify(actual)) {
|
||||
failures.push("bundle report does not describe current dist bytes");
|
||||
}
|
||||
} catch {
|
||||
failures.push("bundle report dist inputs are unreadable");
|
||||
}
|
||||
}
|
||||
const dependencyDiff = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/security/dependency-diff.json",
|
||||
dependencyDiffArtifactSchema,
|
||||
"dependency diff",
|
||||
failures,
|
||||
);
|
||||
if (dependencyDiff && dependencyDiff.reviewFailures.length > 0) {
|
||||
failures.push("dependency review evidence is not PASS");
|
||||
}
|
||||
const license = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/security/license-report.json",
|
||||
licenseReportArtifactSchema,
|
||||
"license report",
|
||||
failures,
|
||||
);
|
||||
if (license && (license.status !== "PASS" || license.failures.length > 0)) {
|
||||
failures.push("license report is not PASS");
|
||||
}
|
||||
const vulnerability = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/security/vulnerability-report.json",
|
||||
vulnerabilityReportArtifactSchema,
|
||||
"local vulnerability report",
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
vulnerability &&
|
||||
(vulnerability.status !== "FAIL_UNVERIFIED" ||
|
||||
vulnerability.provider !== "UNCONFIGURED")
|
||||
) {
|
||||
failures.push("local vulnerability report may not satisfy promotion");
|
||||
}
|
||||
const provenance = await parseArtifact(
|
||||
repositoryRoot,
|
||||
"artifacts/release/provenance.json",
|
||||
provenanceArtifactSchema,
|
||||
"local provenance",
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
provenance?.predicate.runDetails.metadata.invocationId !== "LOCAL_UNSIGNED"
|
||||
) {
|
||||
failures.push("local provenance must remain LOCAL_UNSIGNED");
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyReleaseOutputs(
|
||||
repositoryRoot: string,
|
||||
release: z.infer<typeof releaseManifestArtifactSchema>,
|
||||
buildManifest: z.infer<typeof buildManifestArtifactSchema> | null,
|
||||
failures: string[],
|
||||
): Promise<void> {
|
||||
let viteManifest: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await readFile(
|
||||
path.join(repositoryRoot, CANONICAL_VITE_MANIFEST_PATH),
|
||||
"utf8",
|
||||
);
|
||||
viteManifest = asRecord(JSON.parse(raw), "Vite manifest");
|
||||
if (createHash("sha256").update(raw).digest("hex") !== release.assetManifestHash) {
|
||||
failures.push("release asset manifest hash mismatch");
|
||||
}
|
||||
} catch {
|
||||
failures.push("Vite manifest is missing or invalid");
|
||||
}
|
||||
if (
|
||||
buildManifest &&
|
||||
(buildManifest.buildId !== release.buildId ||
|
||||
buildManifest.commitSha !== release.commitSha ||
|
||||
buildManifest.releaseId !== release.releaseId ||
|
||||
buildManifest.generatedAt !== release.builtAt)
|
||||
) {
|
||||
failures.push("build/release identity mismatch");
|
||||
}
|
||||
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
|
||||
ROUTE_RUNTIME_CONTRACT;
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
const runtime = runtimeContracts[definition.routeId];
|
||||
const viteEntry = Object.values(viteManifest).find(
|
||||
(entry) =>
|
||||
isRecord(entry) &&
|
||||
entry.name === runtime?.moduleId &&
|
||||
entry.isDynamicEntry === true,
|
||||
);
|
||||
const file = isRecord(viteEntry) ? viteEntry.file : null;
|
||||
if (
|
||||
typeof file !== "string" ||
|
||||
release.routeChunks[definition.chunkId] !== file ||
|
||||
buildManifest?.outputs.routeChunks[definition.chunkId] !== file
|
||||
) {
|
||||
failures.push(`release route chunk mismatch: ${definition.chunkId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySecretScan(
|
||||
repositoryRoot: string,
|
||||
failures: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sarif = asRecord(
|
||||
await readJson(repositoryRoot, "artifacts/security/scan.sarif"),
|
||||
"secret scan SARIF",
|
||||
);
|
||||
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
|
||||
if (
|
||||
sarif.version !== "2.1.0" ||
|
||||
runs.length !== 1 ||
|
||||
!isRecord(runs[0]) ||
|
||||
!Array.isArray(runs[0].results) ||
|
||||
runs[0].results.length !== 0
|
||||
) {
|
||||
failures.push("secret scan SARIF is not an empty PASS");
|
||||
}
|
||||
} catch {
|
||||
failures.push("secret scan SARIF is missing or invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async function parseArtifact<T>(
|
||||
repositoryRoot: string,
|
||||
file: string,
|
||||
schema: ZodType<T>,
|
||||
label: string,
|
||||
failures: string[],
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return schema.parse(await readJson(repositoryRoot, file));
|
||||
} catch {
|
||||
failures.push(`${label} executable schema mismatch`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(repositoryRoot: string, file: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8"));
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user