fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+317 -118
View File
@@ -1,90 +1,145 @@
import { verify, type KeyObject } from "node:crypto";
import { createHash, verify, type KeyObject } from "node:crypto";
import { z } from "zod";
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
import {
canonicalizeSupplyChainValue,
supplyChainDigest,
} from "./supply-chain.ts";
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
export const PROMOTION_VERIFIER_ID =
"clean-architecture-frontend-template/promotion-verifier";
export const PROMOTION_VERIFIER_VERSION = "3";
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
const nonEmptyString = z.string().trim().min(1);
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
const nonEmptyString = z.string().min(1);
const timestamp = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
.refine((value) => new Date(value).toISOString() === value);
const runSchema = z
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
.strict();
const sourceSchema = z
.object({ revision, sourceSetSha256: sha256 })
.strict();
const candidateSchema = z
.object({
archiveSha256: sha256,
bundleSha256: sha256,
distSha256: sha256,
lockfileSha256: sha256,
})
.strict();
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
const signatureSchema = z
.object({
algorithm: z.literal("Ed25519"),
keyId: nonEmptyString,
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
publicKeyFingerprint: fingerprint,
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
})
.strict();
const providerCommon = {
schemaVersion: z.literal(2),
provider: nonEmptyString,
issuedAt: timestamp,
expiresAt: timestamp,
run: providerRunSchema,
source: sourceSchema,
candidate: candidateSchema,
signature: signatureSchema,
} as const;
export const vulnerabilityProviderReportSchema = z
.object({
schemaVersion: z.literal(1),
provider: nonEmptyString,
generatedAt: z.iso.datetime(),
scannedLockfileSha256: sha256,
scannedDistSha256: sha256,
...providerCommon,
evidenceType: z.literal("vulnerability-report"),
findings: z.array(z.record(z.string(), z.json())),
signature: signatureSchema,
})
.strict();
export const provenanceProviderAttestationSchema = z
.object({
schemaVersion: z.literal(1),
provider: nonEmptyString,
...providerCommon,
evidenceType: z.literal("provenance-attestation"),
signer: nonEmptyString,
generatedAt: z.iso.datetime(),
subject: z
.object({
name: z.literal("dist"),
digest: z.object({ sha256 }).strict(),
})
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
.strict(),
signature: signatureSchema,
})
.strict();
const verificationCommon = {
schemaVersion: z.literal(3),
verifiedAt: timestamp,
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
verifier: z
.object({ id: nonEmptyString, version: nonEmptyString })
.strict(),
run: runSchema,
source: sourceSchema,
candidate: candidateSchema,
providerEvidence: z
.object({
vulnerabilityReportSha256: sha256,
provenanceAttestationSha256: sha256,
vulnerabilityInvocationNonce: nonce,
provenanceInvocationNonce: nonce,
vulnerabilityKeyId: nonEmptyString,
vulnerabilityKeyFingerprint: fingerprint,
provenanceKeyId: nonEmptyString,
provenanceKeyFingerprint: fingerprint,
})
.strict(),
trustPolicySha256: sha256,
failures: z.array(z.string()),
} as const;
const providerVerificationV3Schema = z
.object({
...verificationCommon,
artifactType: z.literal("provider-verification"),
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
})
.strict();
const promotionVerificationV3Schema = z
.object({
...verificationCommon,
artifactType: z.literal("promotion-verification"),
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
localEvidenceAssessmentSha256: sha256,
providerVerificationSha256: sha256,
})
.strict();
export const providerVerificationArtifactSchema = z
.object({
schemaVersion: z.literal(2),
artifactType: z.enum(["provider-verification", "promotion-verification"]),
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
lockfileSha256: sha256,
distSha256: sha256,
candidateArchiveSha256: sha256.nullable(),
vulnerabilityReportSha256: sha256.nullable(),
provenanceAttestationSha256: sha256.nullable(),
failures: z.array(z.string()),
})
.strict()
.superRefine((artifact, context) => {
const passing =
artifact.status === "PASS" &&
artifact.vulnerabilityStatus === "PASS" &&
artifact.provenanceAttestationStatus === "PASS" &&
artifact.failures.length === 0;
if ((artifact.status === "PASS") !== passing) {
.discriminatedUnion("artifactType", [
providerVerificationV3Schema,
promotionVerificationV3Schema,
])
.superRefine((record, context) => {
const subordinatePass =
record.artifactType === "provider-verification"
? record.vulnerabilityStatus === "PASS" &&
record.provenanceAttestationStatus === "PASS"
: record.localEvidenceStatus === "PASS";
const coherentPass = subordinatePass && record.failures.length === 0;
if ((record.status === "PASS") !== coherentPass) {
context.addIssue({
code: "custom",
path: ["status"],
message: "verification PASS must agree with provider statuses and failures",
message: "verification PASS must agree with subordinate statuses and failures",
});
}
if (
artifact.status === "PASS" &&
[
artifact.candidateArchiveSha256,
artifact.vulnerabilityReportSha256,
artifact.provenanceAttestationSha256,
].some((digest) => digest === null)
) {
context.addIssue({
code: "custom",
path: ["candidateArchiveSha256"],
message: "passing verification requires every exact input digest",
});
}
if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) {
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
context.addIssue({
code: "custom",
path: ["failures"],
@@ -100,6 +155,20 @@ export type ProviderVerificationArtifactType = z.infer<
export type ProviderTrust = Readonly<{
keyId: string;
publicKey: KeyObject;
publicKeyFingerprint: string;
}>;
export type ExpectedPromotionContext = Readonly<{
run: Readonly<{ id: string; attempt: number }>;
source: Readonly<{ revision: string; sourceSetSha256: string }>;
candidate: Readonly<{
archiveSha256: string;
bundleSha256: string;
distSha256: string;
lockfileSha256: string;
}>;
vulnerabilityInvocationNonce: string;
provenanceInvocationNonce: string;
}>;
export type PromotionEvidenceResult = Readonly<{
@@ -109,35 +178,134 @@ export type PromotionEvidenceResult = Readonly<{
failures: readonly string[];
}>;
export function validateProviderEvidence(input: Readonly<{
kind: "vulnerability" | "provenance";
value: unknown;
expected: ExpectedPromotionContext;
trust: ProviderTrust | null;
nowEpochMs?: () => number;
}>): Readonly<{
evidence: unknown | null;
status: "PASS" | "FAIL_UNVERIFIED";
failures: readonly string[];
}> {
const failures: string[] = [];
const now = (input.nowEpochMs ?? Date.now)();
if (input.kind === "vulnerability") {
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
if (!parsed.success) {
return Object.freeze({
evidence: null,
status: "FAIL_UNVERIFIED",
failures: Object.freeze([
"external vulnerability provider report is missing or invalid",
]),
});
}
validateCommonContext(
"vulnerability report",
parsed.data,
input.expected,
input.expected.vulnerabilityInvocationNonce,
input.trust,
now,
failures,
);
if (parsed.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
return Object.freeze({
evidence: parsed.data,
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
failures: Object.freeze(failures),
});
}
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
if (!parsed.success) {
return Object.freeze({
evidence: null,
status: "FAIL_UNVERIFIED",
failures: Object.freeze([
"external signed provenance attestation is missing or invalid",
]),
});
}
validateCommonContext(
"provenance attestation",
parsed.data,
input.expected,
input.expected.provenanceInvocationNonce,
input.trust,
now,
failures,
);
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
failures.push("provenance attestation subject dist digest mismatch");
}
return Object.freeze({
evidence: parsed.data,
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
failures: Object.freeze(failures),
});
}
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
if (!isRecord(value)) return Buffer.from("null", "utf8");
const { signature: _signature, ...payload } = value;
return Buffer.from(
JSON.stringify(canonicalizeSupplyChainValue(payload)),
"utf8",
);
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
}
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
if (publicKey.asymmetricKeyType !== "ed25519") {
throw new TypeError("provider trust key must be Ed25519");
}
return `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
}
export function createTrustPolicy(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>) {
return Object.freeze({
algorithm: "Ed25519" as const,
vulnerability: Object.freeze({
keyId: input.vulnerabilityTrust.keyId,
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
}),
provenance: Object.freeze({
keyId: input.provenanceTrust.keyId,
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
}),
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
});
}
export function trustPolicySha256(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>): string {
return supplyChainDigest(createTrustPolicy(input));
}
export function evaluatePromotionEvidence(input: Readonly<{
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
currentDistSha256: string;
expected: ExpectedPromotionContext;
localStatus: unknown;
vulnerabilityReport: unknown;
provenanceAttestation: unknown;
vulnerabilityTrust: ProviderTrust | null;
provenanceTrust: ProviderTrust | null;
nowEpochMs?: () => number;
}>): PromotionEvidenceResult {
const failures: string[] = [];
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
"FAIL_UNVERIFIED";
if (input.localStatus !== "PASS") {
failures.push("local supply-chain evidence is not PASS");
}
if (input.currentDistSha256 !== input.candidate.distSha256) {
failures.push("candidate dist bytes changed after immutable build");
}
const now = (input.nowEpochMs ?? Date.now)();
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
input.vulnerabilityReport,
@@ -145,36 +313,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
if (!vulnerability.success) {
failures.push("external vulnerability provider report is missing or invalid");
} else {
if (
vulnerability.data.scannedLockfileSha256 !==
input.candidate.lockfileSha256
) {
failures.push("vulnerability report lockfile digest mismatch");
}
if (
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
) {
failures.push("vulnerability report dist digest mismatch");
}
const before = failures.length;
validateCommonContext(
"vulnerability report",
vulnerability.data,
input.expected,
input.expected.vulnerabilityInvocationNonce,
input.vulnerabilityTrust,
now,
failures,
);
if (vulnerability.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
const signaturePassed = signatureMatches(
vulnerability.data,
input.vulnerabilityTrust,
);
if (!signaturePassed) {
failures.push("vulnerability report signature verification failed");
}
if (
vulnerability.data.scannedLockfileSha256 ===
input.candidate.lockfileSha256 &&
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
vulnerability.data.findings.length === 0 &&
input.currentDistSha256 === input.candidate.distSha256 &&
input.localStatus === "PASS" &&
signaturePassed
) {
if (failures.length === before && input.localStatus === "PASS") {
vulnerabilityStatus = "PASS";
}
}
@@ -185,22 +337,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
if (!provenance.success) {
failures.push("external signed provenance attestation is missing or invalid");
} else {
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
failures.push("provenance attestation dist digest mismatch");
}
const signaturePassed = signatureMatches(
const before = failures.length;
validateCommonContext(
"provenance attestation",
provenance.data,
input.expected,
input.expected.provenanceInvocationNonce,
input.provenanceTrust,
now,
failures,
);
if (!signaturePassed) {
failures.push("provenance attestation signature verification failed");
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
failures.push("provenance attestation subject dist digest mismatch");
}
if (
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
input.currentDistSha256 === input.candidate.distSha256 &&
input.localStatus === "PASS" &&
signaturePassed
) {
if (failures.length === before && input.localStatus === "PASS") {
provenanceAttestationStatus = "PASS";
}
}
@@ -218,29 +368,78 @@ export function evaluatePromotionEvidence(input: Readonly<{
});
}
function signatureMatches(
function validateCommonContext(
label: "vulnerability report" | "provenance attestation",
evidence: z.infer<
| typeof vulnerabilityProviderReportSchema
| typeof provenanceProviderAttestationSchema
>,
expected: ExpectedPromotionContext,
expectedNonce: string,
trust: ProviderTrust | null,
): boolean {
now: number,
failures: string[],
): void {
if (
evidence.run.id !== expected.run.id ||
evidence.run.attempt !== expected.run.attempt
) {
failures.push(`${label} run identity mismatch`);
}
if (evidence.run.invocationNonce !== expectedNonce) {
failures.push(`${label} invocation nonce mismatch`);
}
if (
evidence.source.revision !== expected.source.revision ||
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
) {
failures.push(`${label} source identity mismatch`);
}
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
failures.push(`${label} candidate identity mismatch`);
}
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
if (
!trust ||
evidence.signature.keyId !== trust.keyId ||
trust.publicKey.asymmetricKeyType !== "ed25519"
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
) {
return false;
failures.push(`${label} trust identity mismatch`);
return;
}
try {
return verify(
null,
providerEvidenceSignaturePayload(evidence),
trust.publicKey,
Buffer.from(evidence.signature.value, "base64"),
);
if (
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
!verify(
null,
providerEvidenceSignaturePayload(evidence),
trust.publicKey,
Buffer.from(evidence.signature.value, "base64"),
)
) {
failures.push(`${label} signature verification failed`);
}
} catch {
return false;
failures.push(`${label} signature verification failed`);
}
}
function validateEvidenceTime(
label: string,
issuedAt: string,
expiresAt: string,
now: number,
failures: string[],
): void {
const issued = Date.parse(issuedAt);
const expires = Date.parse(expiresAt);
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
failures.push(`${label} issuedAt exceeds allowed future skew`);
}
if (expires <= now) failures.push(`${label} is expired`);
if (expires <= issued) failures.push(`${label} validity window is not positive`);
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
failures.push(`${label} validity window exceeds two hours`);
}
}