201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
import { verify, type KeyLike } from "node:crypto";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
|
|
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
const nonEmptyString = z.string().trim().min(1);
|
|
const signatureSchema = z
|
|
.object({
|
|
algorithm: z.literal("Ed25519"),
|
|
keyId: nonEmptyString,
|
|
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
|
})
|
|
.strict();
|
|
|
|
export const vulnerabilityProviderReportSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(1),
|
|
provider: nonEmptyString,
|
|
generatedAt: z.iso.datetime(),
|
|
scannedLockfileSha256: sha256,
|
|
scannedDistSha256: sha256,
|
|
findings: z.array(z.record(z.string(), z.json())),
|
|
signature: signatureSchema,
|
|
})
|
|
.strict();
|
|
|
|
export const provenanceProviderAttestationSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(1),
|
|
provider: nonEmptyString,
|
|
signer: nonEmptyString,
|
|
generatedAt: z.iso.datetime(),
|
|
subject: z
|
|
.object({
|
|
name: z.literal("dist"),
|
|
digest: z.object({ sha256 }).strict(),
|
|
})
|
|
.strict(),
|
|
signature: signatureSchema,
|
|
})
|
|
.strict();
|
|
|
|
export const providerVerificationArtifactSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(1),
|
|
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
|
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
|
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
|
lockfileSha256: sha256,
|
|
distSha256: sha256,
|
|
failures: z.array(z.string()),
|
|
})
|
|
.strict();
|
|
|
|
export type ProviderTrust = Readonly<{
|
|
keyId: string;
|
|
publicKey: KeyLike;
|
|
}>;
|
|
|
|
export type PromotionEvidenceResult = Readonly<{
|
|
status: "PASS" | "FAIL_UNVERIFIED";
|
|
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
|
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
|
failures: readonly string[];
|
|
}>;
|
|
|
|
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 evaluatePromotionEvidence(input: Readonly<{
|
|
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
|
currentDistSha256: string;
|
|
localStatus: unknown;
|
|
vulnerabilityReport: unknown;
|
|
provenanceAttestation: unknown;
|
|
vulnerabilityTrust: ProviderTrust | null;
|
|
provenanceTrust: ProviderTrust | null;
|
|
}>): 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 vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
|
input.vulnerabilityReport,
|
|
);
|
|
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");
|
|
}
|
|
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
|
|
) {
|
|
vulnerabilityStatus = "PASS";
|
|
}
|
|
}
|
|
|
|
const provenance = provenanceProviderAttestationSchema.safeParse(
|
|
input.provenanceAttestation,
|
|
);
|
|
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(
|
|
provenance.data,
|
|
input.provenanceTrust,
|
|
);
|
|
if (!signaturePassed) {
|
|
failures.push("provenance attestation signature verification failed");
|
|
}
|
|
if (
|
|
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
|
input.currentDistSha256 === input.candidate.distSha256 &&
|
|
input.localStatus === "PASS" &&
|
|
signaturePassed
|
|
) {
|
|
provenanceAttestationStatus = "PASS";
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
status:
|
|
failures.length === 0 &&
|
|
vulnerabilityStatus === "PASS" &&
|
|
provenanceAttestationStatus === "PASS"
|
|
? "PASS"
|
|
: "FAIL_UNVERIFIED",
|
|
vulnerabilityStatus,
|
|
provenanceAttestationStatus,
|
|
failures: Object.freeze(failures),
|
|
});
|
|
}
|
|
|
|
function signatureMatches(
|
|
evidence: z.infer<
|
|
| typeof vulnerabilityProviderReportSchema
|
|
| typeof provenanceProviderAttestationSchema
|
|
>,
|
|
trust: ProviderTrust | null,
|
|
): boolean {
|
|
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
|
|
try {
|
|
return verify(
|
|
null,
|
|
providerEvidenceSignaturePayload(evidence),
|
|
trust.publicKey,
|
|
Buffer.from(evidence.signature.value, "base64"),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|