495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
import { createHash, verify, type KeyObject } from "node:crypto";
|
|
|
|
import { z } from "zod";
|
|
|
|
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 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();
|
|
export const secretScanAttestationSchema = z
|
|
.object({
|
|
status: z.literal("PASS"),
|
|
localEvidenceAssessmentSha256: sha256,
|
|
sourceSetSha256: sha256,
|
|
policySha256: sha256,
|
|
sarifSha256: sha256,
|
|
scanInputSha256: sha256,
|
|
})
|
|
.strict();
|
|
const signatureSchema = z
|
|
.object({
|
|
algorithm: z.literal("Ed25519"),
|
|
keyId: nonEmptyString,
|
|
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({
|
|
...providerCommon,
|
|
evidenceType: z.literal("vulnerability-report"),
|
|
secretScanAttestation: secretScanAttestationSchema,
|
|
findings: z.array(z.record(z.string(), z.json())),
|
|
})
|
|
.strict();
|
|
|
|
export const provenanceProviderAttestationSchema = z
|
|
.object({
|
|
...providerCommon,
|
|
evidenceType: z.literal("provenance-attestation"),
|
|
signer: nonEmptyString,
|
|
subject: z
|
|
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
|
|
.strict(),
|
|
})
|
|
.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,
|
|
secretScanAttestation: secretScanAttestationSchema,
|
|
})
|
|
.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
|
|
.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 subordinate statuses and failures",
|
|
});
|
|
}
|
|
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
path: ["failures"],
|
|
message: "failed verification requires a failure diagnostic",
|
|
});
|
|
}
|
|
});
|
|
|
|
export type ProviderVerificationArtifactType = z.infer<
|
|
typeof providerVerificationArtifactSchema
|
|
>["artifactType"];
|
|
|
|
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;
|
|
secretScanAttestation: z.infer<typeof secretScanAttestationSchema>;
|
|
}>;
|
|
|
|
export type PromotionEvidenceResult = Readonly<{
|
|
status: "PASS" | "FAIL_UNVERIFIED";
|
|
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
|
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
|
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 (
|
|
JSON.stringify(parsed.data.secretScanAttestation) !==
|
|
JSON.stringify(input.expected.secretScanAttestation)
|
|
) {
|
|
failures.push("vulnerability report secret scan attestation mismatch");
|
|
}
|
|
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");
|
|
}
|
|
|
|
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;
|
|
}>) {
|
|
assertDistinctProviderTrust(input);
|
|
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 assertDistinctProviderTrust(input: Readonly<{
|
|
vulnerabilityTrust: ProviderTrust;
|
|
provenanceTrust: ProviderTrust;
|
|
}>): void {
|
|
const vulnerabilityFingerprint = providerPublicKeyFingerprint(
|
|
input.vulnerabilityTrust.publicKey,
|
|
);
|
|
const provenanceFingerprint = providerPublicKeyFingerprint(
|
|
input.provenanceTrust.publicKey,
|
|
);
|
|
if (
|
|
input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId ||
|
|
vulnerabilityFingerprint === provenanceFingerprint ||
|
|
input.vulnerabilityTrust.publicKeyFingerprint ===
|
|
input.provenanceTrust.publicKeyFingerprint
|
|
) {
|
|
throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints");
|
|
}
|
|
}
|
|
|
|
export function trustPolicySha256(input: Readonly<{
|
|
vulnerabilityTrust: ProviderTrust;
|
|
provenanceTrust: ProviderTrust;
|
|
}>): string {
|
|
return supplyChainDigest(createTrustPolicy(input));
|
|
}
|
|
|
|
export function evaluatePromotionEvidence(input: Readonly<{
|
|
expected: ExpectedPromotionContext;
|
|
localStatus: unknown;
|
|
vulnerabilityReport: unknown;
|
|
provenanceAttestation: unknown;
|
|
vulnerabilityTrust: ProviderTrust | null;
|
|
provenanceTrust: ProviderTrust | null;
|
|
nowEpochMs?: () => number;
|
|
}>): PromotionEvidenceResult {
|
|
const failures: string[] = [];
|
|
if (input.localStatus !== "PASS") {
|
|
failures.push("local supply-chain evidence is not PASS");
|
|
}
|
|
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,
|
|
);
|
|
if (!vulnerability.success) {
|
|
failures.push("external vulnerability provider report is missing or invalid");
|
|
} else {
|
|
const before = failures.length;
|
|
validateCommonContext(
|
|
"vulnerability report",
|
|
vulnerability.data,
|
|
input.expected,
|
|
input.expected.vulnerabilityInvocationNonce,
|
|
input.vulnerabilityTrust,
|
|
now,
|
|
failures,
|
|
);
|
|
if (
|
|
JSON.stringify(vulnerability.data.secretScanAttestation) !==
|
|
JSON.stringify(input.expected.secretScanAttestation)
|
|
) {
|
|
failures.push("vulnerability report secret scan attestation mismatch");
|
|
}
|
|
if (vulnerability.data.findings.length > 0) {
|
|
failures.push("vulnerability report contains findings");
|
|
}
|
|
if (failures.length === before && input.localStatus === "PASS") {
|
|
vulnerabilityStatus = "PASS";
|
|
}
|
|
}
|
|
|
|
const provenance = provenanceProviderAttestationSchema.safeParse(
|
|
input.provenanceAttestation,
|
|
);
|
|
if (!provenance.success) {
|
|
failures.push("external signed provenance attestation is missing or invalid");
|
|
} else {
|
|
const before = failures.length;
|
|
validateCommonContext(
|
|
"provenance attestation",
|
|
provenance.data,
|
|
input.expected,
|
|
input.expected.provenanceInvocationNonce,
|
|
input.provenanceTrust,
|
|
now,
|
|
failures,
|
|
);
|
|
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
|
failures.push("provenance attestation subject dist digest mismatch");
|
|
}
|
|
if (failures.length === before && input.localStatus === "PASS") {
|
|
provenanceAttestationStatus = "PASS";
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
status:
|
|
failures.length === 0 &&
|
|
vulnerabilityStatus === "PASS" &&
|
|
provenanceAttestationStatus === "PASS"
|
|
? "PASS"
|
|
: "FAIL_UNVERIFIED",
|
|
vulnerabilityStatus,
|
|
provenanceAttestationStatus,
|
|
failures: Object.freeze(failures),
|
|
});
|
|
}
|
|
|
|
function validateCommonContext(
|
|
label: "vulnerability report" | "provenance attestation",
|
|
evidence: z.infer<
|
|
| typeof vulnerabilityProviderReportSchema
|
|
| typeof provenanceProviderAttestationSchema
|
|
>,
|
|
expected: ExpectedPromotionContext,
|
|
expectedNonce: string,
|
|
trust: ProviderTrust | null,
|
|
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 ||
|
|
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
|
|
) {
|
|
failures.push(`${label} trust identity mismatch`);
|
|
return;
|
|
}
|
|
try {
|
|
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 {
|
|
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`);
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|