fix: close immutable promotion trust gaps

This commit is contained in:
DongHyeonka
2026-08-02 06:39:41 +09:00
parent 7c5ed80407
commit 92e5cace5c
16 changed files with 1228 additions and 274 deletions
+6
View File
@@ -191,8 +191,14 @@ for (const requiredToken of [
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
"VULNERABILITY_REPORT_PATH:",
"PROVENANCE_ATTESTATION_PATH:",
"CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml",
"VULNERABILITY_PROVIDER_COMMAND:",
"PROVENANCE_PROVIDER_COMMAND:",
"VULNERABILITY_PUBLIC_KEY_PATH:",
"VULNERABILITY_KEY_ID:",
"PROVENANCE_PUBLIC_KEY_PATH:",
"PROVENANCE_KEY_ID:",
" pnpm-lock.yaml \\",
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
"corepack pnpm verify:provider-evidence",
"corepack pnpm verify:promotion",
+189 -107
View File
@@ -1,30 +1,199 @@
import { generateKeyPairSync, sign } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts";
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
import {
evaluatePromotionEvidence,
providerEvidenceSignaturePayload,
} from "./lib/provider-evidence.ts";
createReleaseCandidateManifest,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "./lib/release-candidate.ts";
const candidateDistSha256 = "1".repeat(64);
const lockfileSha256 = "2".repeat(64);
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const trust = {
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
};
const fixtureRoot = await mkdtemp(
path.join(tmpdir(), "supply-chain-provider-fixture-"),
);
try {
const rawLockfile = "lockfileVersion: '9.0'\n";
const lockfileSha256 = createHash("sha256")
.update(rawLockfile)
.digest("hex");
await mkdir(path.join(fixtureRoot, "dist"), { recursive: true });
await writeFile(path.join(fixtureRoot, "dist/app.js"), "immutable\n");
await writeFile(path.join(fixtureRoot, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(fixtureRoot, file)), {
recursive: true,
});
const value =
file === "artifacts/release/dependency-inventory.json"
? { lockfileSha256 }
: { fixture: file };
await writeFile(
path.join(fixtureRoot, file),
`${JSON.stringify(value)}\n`,
);
}
const candidate = await createReleaseCandidateManifest(fixtureRoot);
await writeFile(
path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
`${JSON.stringify(candidate)}\n`,
);
const validEnvironment = await writeProviderEnvironment(
fixtureRoot,
"valid",
candidate.distSha256,
candidate.lockfileSha256,
);
const wrongEnvironment = await writeProviderEnvironment(
fixtureRoot,
"wrong",
"3".repeat(64),
candidate.lockfileSha256,
);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
failures: [] as const,
});
const fixtures = {
absent: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
}),
validImmutable: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
}),
wrongDigest: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
}),
postAttestationMutation: null as Awaited<
ReturnType<typeof verifyPromotionInputs>
> | null,
};
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
fixtures.postAttestationMutation = await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
const passed =
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
{ status: result?.status, failures: result?.failures },
]),
),
passingFixtureCount: Object.values(fixtures).filter(
(result) => result?.status === "PASS",
).length,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Supply-chain provider fixtures failed closed incorrectly\n",
);
process.exitCode = 1;
} else {
process.stdout.write(
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
async function writeProviderEnvironment(
repositoryRoot: string,
name: string,
distDigest: string,
lockfileSha256: string,
): Promise<NodeJS.ProcessEnv> {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const directory = `provider/${name}`;
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
const vulnerability = signedEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenance = signedEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
await Promise.all([
writeFile(
path.join(repositoryRoot, directory, "vulnerability.json"),
`${JSON.stringify(vulnerability)}\n`,
),
writeFile(
path.join(repositoryRoot, directory, "provenance.json"),
`${JSON.stringify(provenance)}\n`,
),
writeFile(
path.join(repositoryRoot, directory, "vulnerability.pem"),
vulnerabilityKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
writeFile(
path.join(repositoryRoot, directory, "provenance.pem"),
provenanceKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
]);
return {
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
VULNERABILITY_KEY_ID: "fixture-vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: `${directory}/provenance.pem`,
PROVENANCE_KEY_ID: "fixture-provenance-key",
};
}
function signedEvidence(
value: Record<string, unknown>,
keyId: string,
privateKey: typeof vulnerabilityKeys.privateKey,
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
) {
return {
...value,
@@ -39,90 +208,3 @@ function signedEvidence(
},
};
}
function evidenceFor(distDigest: string) {
return {
vulnerabilityReport: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
),
provenanceAttestation: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
),
};
}
const base = {
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
localStatus: "PASS",
...trust,
};
const validEvidence = evidenceFor(candidateDistSha256);
const fixtures = {
absent: evaluatePromotionEvidence({
...base,
vulnerabilityReport: null,
provenanceAttestation: null,
}),
validImmutable: evaluatePromotionEvidence({ ...base, ...validEvidence }),
wrongDigest: evaluatePromotionEvidence({
...base,
...evidenceFor("3".repeat(64)),
}),
postAttestationMutation: evaluatePromotionEvidence({
...base,
...validEvidence,
currentDistSha256: "4".repeat(64),
}),
};
const passed =
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
{ status: result.status, failures: result.failures },
]),
),
passingFixtureCount: Object.values(fixtures).filter(
(result) => result.status === "PASS",
).length,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n");
process.exit(1);
}
process.stdout.write(
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
);
+9 -1
View File
@@ -8,6 +8,7 @@ import {
import {
bundlePerformanceArtifactSchema,
buildManifestArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
@@ -34,6 +35,7 @@ import {
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts";
type Document = Record<string, unknown>;
@@ -138,6 +140,9 @@ export async function buildDependencyInventory() {
}
const packageJson = await jsonDocument("package.json");
const buildManifest = buildManifestArtifactSchema.parse(
await jsonDocument("artifacts/release/build-manifest.json"),
);
const secretScanPolicy = documentValue(
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
"secret scan policy",
@@ -332,7 +337,10 @@ const verification = {
};
const bundleReport = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
generatedAt: deterministicSupplyChainGeneratedAt({
generatedAt: buildManifest.generatedAt,
sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch,
}),
context: {
nodeVersion: process.version,
packageManager: String(packageJson.packageManager ?? ""),
+533
View File
@@ -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);
}
+52 -13
View File
@@ -1,5 +1,6 @@
import { createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
evaluatePromotionEvidence,
@@ -10,38 +11,63 @@ import {
releaseCandidateManifestSchema,
verifyReleaseCandidate,
} from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
export type VerifyPromotionInputsOptions = Readonly<{
environment?: NodeJS.ProcessEnv;
repositoryRoot?: string;
verifyLocalEvidence?: LocalEvidenceVerifier;
}>;
export async function verifyPromotionInputs(
environment: NodeJS.ProcessEnv = process.env,
options: VerifyPromotionInputsOptions = {},
) {
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
const candidate = await verifyReleaseCandidate(manifestDocument);
const localVerification = await requiredJson(
"artifacts/security/supply-chain-verification.json",
const environment = options.environment ?? process.env;
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
const manifestDocument = await requiredJson(
repositoryRoot,
RELEASE_CANDIDATE_MANIFEST_PATH,
);
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
const candidate = await verifyReleaseCandidate(
manifestDocument,
repositoryRoot,
);
const localEvidence = await (
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
)({ repositoryRoot, candidate: manifest });
const vulnerabilityReport = await optionalJson(
repositoryRoot,
environment.VULNERABILITY_REPORT_PATH,
);
const provenanceAttestation = await optionalJson(
repositoryRoot,
environment.PROVENANCE_ATTESTATION_PATH,
);
const result = evaluatePromotionEvidence({
candidate: manifest,
currentDistSha256: candidate.currentDistSha256 ?? "",
localStatus: localVerification.localStatus,
localStatus: localEvidence.status,
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: await readTrust(
repositoryRoot,
environment.VULNERABILITY_PUBLIC_KEY_PATH,
environment.VULNERABILITY_KEY_ID,
),
provenanceTrust: await readTrust(
repositoryRoot,
environment.PROVENANCE_PUBLIC_KEY_PATH,
environment.PROVENANCE_KEY_ID,
),
});
const failures = [...candidate.failures, ...result.failures];
const failures = [
...candidate.failures,
...localEvidence.failures,
...result.failures,
];
return Object.freeze({
schemaVersion: 1 as const,
status:
@@ -57,6 +83,7 @@ export async function verifyPromotionInputs(
}
async function readTrust(
repositoryRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
@@ -64,24 +91,36 @@ async function readTrust(
try {
return Object.freeze({
keyId,
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
publicKey: createPublicKey(
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
),
});
} catch {
return null;
}
}
async function optionalJson(file: string | undefined): Promise<unknown> {
async function optionalJson(
repositoryRoot: string,
file: string | undefined,
): Promise<unknown> {
if (!file) return null;
try {
return JSON.parse(await readFile(file, "utf8")) as unknown;
return JSON.parse(
await readFile(path.resolve(repositoryRoot, file), "utf8"),
) as unknown;
} catch {
return null;
}
}
async function requiredJson(file: string): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(await readFile(file, "utf8"));
async function requiredJson(
repositoryRoot: string,
file: string,
): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(
await readFile(path.join(repositoryRoot, file), "utf8"),
);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${file} must be a JSON object`);
}
+9 -3
View File
@@ -1,4 +1,4 @@
import { verify, type KeyLike } from "node:crypto";
import { verify, type KeyObject } from "node:crypto";
import { z } from "zod";
@@ -56,7 +56,7 @@ export const providerVerificationArtifactSchema = z
export type ProviderTrust = Readonly<{
keyId: string;
publicKey: KeyLike;
publicKey: KeyObject;
}>;
export type PromotionEvidenceResult = Readonly<{
@@ -182,7 +182,13 @@ function signatureMatches(
>,
trust: ProviderTrust | null,
): boolean {
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
if (
!trust ||
evidence.signature.keyId !== trust.keyId ||
trust.publicKey.asymmetricKeyType !== "ed25519"
) {
return false;
}
try {
return verify(
null,
+13 -1
View File
@@ -34,6 +34,7 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH =
"artifacts/release/release-candidate.json";
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
"pnpm-lock.yaml",
"artifacts/performance/bundle.json",
"artifacts/quality/vite-module-inventory.json",
"artifacts/release/build-manifest.json",
@@ -113,10 +114,21 @@ export async function createReleaseCandidateManifest(
"utf8",
),
) as { lockfileSha256?: unknown };
const rawLockfileSha256 = evidence.find(
(file) => file.path === "pnpm-lock.yaml",
)?.sha256;
if (
typeof rawLockfileSha256 !== "string" ||
dependencyInventory.lockfileSha256 !== rawLockfileSha256
) {
throw new Error(
"raw pnpm-lock digest mismatch with dependency inventory",
);
}
return releaseCandidateManifestSchema.parse({
schemaVersion: 1,
distSha256: distSha256(outputs),
lockfileSha256: dependencyInventory.lockfileSha256,
lockfileSha256: rawLockfileSha256,
bundleSha256: supplyChainDigest(files),
files,
});
+24
View File
@@ -0,0 +1,24 @@
export function deterministicSupplyChainGeneratedAt(input: Readonly<{
generatedAt: string;
sourceDateEpoch: string | null;
}>): string {
const generatedAtMs = Date.parse(input.generatedAt);
if (!Number.isFinite(generatedAtMs)) {
throw new TypeError("build manifest generatedAt must be an ISO timestamp");
}
if (input.sourceDateEpoch !== null) {
if (!/^(?:0|[1-9]\d*)$/u.test(input.sourceDateEpoch)) {
throw new TypeError("SOURCE_DATE_EPOCH must be whole seconds");
}
const epoch = Number(input.sourceDateEpoch);
if (
!Number.isSafeInteger(epoch) ||
new Date(epoch * 1000).toISOString() !== input.generatedAt
) {
throw new TypeError(
"build manifest generatedAt must match SOURCE_DATE_EPOCH",
);
}
}
return new Date(generatedAtMs).toISOString();
}
+19
View File
@@ -0,0 +1,19 @@
import { readFile } from "node:fs/promises";
import { verifyArchivedLocalEvidence } from "./lib/local-release-evidence.ts";
import {
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
const candidate = releaseCandidateManifestSchema.parse(
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
);
const result = await verifyArchivedLocalEvidence({ candidate });
if (result.status !== "PASS") {
process.stderr.write(
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write("Archived local evidence verification: PASS\n");
+14 -128
View File
@@ -1,138 +1,24 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import {
collectDistOutputs,
distSha256,
} from "./lib/release-candidate.ts";
supplyChainCoherenceReportSchema,
verifyLocalSupplyChainEvidence,
} from "./lib/local-release-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type Document = Record<string, unknown>;
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseDocument(text: string, label: string): Document {
const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
return parsed;
}
function recordRows(value: unknown): Document[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
async function readDocument(file: string): Promise<Document> {
return parseDocument(await readFile(file, "utf8"), file);
}
const inventory = await readDocument(
"artifacts/release/dependency-inventory.json",
);
const sbom = await readDocument("artifacts/release/sbom.cdx.json");
const provenance = await readDocument("artifacts/release/provenance.json");
const verification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
const artifactSchemaFailures: string[] = [];
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) {
try {
assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label);
} catch {
artifactSchemaFailures.push(`${label} JSON Schema mismatch`);
}
}
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures: string[] = [
...artifactSchemaFailures,
...coherence.failures,
];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
) {
failures.push("inventory/verification lockfile digest mismatch");
}
if (
verification.distSha256 !== distDigest ||
verification.sbomSha256 !== supplyChainDigest(sbom)
) {
failures.push("verification digest set is incoherent");
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows = recordRows(inventory.dependencies);
const inventoryByIdentity = new Map<string, Document>(
inventoryRows.map(
(entry) => [
`${String(entry.name ?? "")}@${String(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}`);
}
}
const report = {
schemaVersion: 1,
status: failures.length === 0 ? "PASS" : "FAIL",
dependencyCount: inventoryRows.length,
lockfileSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
failures,
};
const report = await verifyLocalSupplyChainEvidence();
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-coherence.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
await writeValidatedJsonArtifact({
path: "artifacts/security/supply-chain-coherence.json",
schema: supplyChainCoherenceReportSchema,
value: report,
});
if (report.status !== "PASS") {
process.stderr.write(
`Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`,
`Supply-chain artifact coherence failed:\n- ${report.failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`,
`Supply-chain artifact coherence: PASS (${report.dependencyCount} dependencies)\n`,
);