import { createHash, createPublicKey } from "node:crypto"; import path from "node:path"; import { evaluatePromotionEvidence, type ProviderVerificationArtifactType, type ProviderTrust, } from "./provider-evidence.ts"; import { RELEASE_CANDIDATE_MANIFEST_PATH, releaseCandidateManifestSchema, verifyReleaseCandidate, } from "./release-candidate.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; export type VerifyPromotionInputsOptions = Readonly<{ artifactType: ProviderVerificationArtifactType; environment?: NodeJS.ProcessEnv; repositoryRoot?: string; providerEvidenceRoot?: string; trustRoot?: string; verifyLocalEvidence?: LocalEvidenceVerifier; }>; export async function verifyPromotionInputs( options: VerifyPromotionInputsOptions, ) { const environment = options.environment ?? process.env; const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot); const providerEvidenceRoot = path.resolve( options.providerEvidenceRoot ?? repositoryRoot, ); const inputFailures: string[] = []; const archive = await captureOptionalInput( providerEvidenceRoot, environment.CANDIDATE_ARCHIVE_PATH, 268_435_456, "candidate archive", inputFailures, ); if (!environment.CANDIDATE_ARCHIVE_SHA256) { inputFailures.push("candidate archive expected SHA-256 is missing"); } else if ( archive.sha256 && archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256 ) { inputFailures.push("candidate archive SHA-256 does not match immutable output"); } const vulnerabilityCapture = await captureOptionalInput( providerEvidenceRoot, environment.VULNERABILITY_REPORT_PATH, 16_777_216, "vulnerability report", inputFailures, ); const provenanceCapture = await captureOptionalInput( providerEvidenceRoot, environment.PROVENANCE_ATTESTATION_PATH, 16_777_216, "provenance attestation", inputFailures, ); 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 = parseCapturedJson(vulnerabilityCapture.bytes); const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes); const result = evaluatePromotionEvidence({ candidate: manifest, currentDistSha256: candidate.currentDistSha256 ?? "", localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, vulnerabilityTrust: await readProviderTrust( trustRoot, environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_KEY_ID, ), provenanceTrust: await readProviderTrust( trustRoot, environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_KEY_ID, ), }); const failures = [ ...inputFailures, ...candidate.failures, ...localEvidence.failures, ...result.failures, ]; return Object.freeze({ schemaVersion: 2 as const, artifactType: options.artifactType, status: failures.length === 0 && result.status === "PASS" ? ("PASS" as const) : ("FAIL_UNVERIFIED" as const), vulnerabilityStatus: result.vulnerabilityStatus, provenanceAttestationStatus: result.provenanceAttestationStatus, lockfileSha256: manifest.lockfileSha256, distSha256: manifest.distSha256, candidateArchiveSha256: archive.sha256, vulnerabilityReportSha256: vulnerabilityCapture.sha256, provenanceAttestationSha256: provenanceCapture.sha256, failures: Object.freeze(failures), }); } export async function readProviderTrust( repositoryRoot: string, publicKeyPath: string | undefined, keyId: string | undefined, ): Promise { if (!publicKeyPath || !keyId?.trim()) return null; try { return Object.freeze({ keyId, publicKey: createPublicKey( new TextDecoder("utf-8", { fatal: true }).decode( await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576), ), ), }); } catch { return null; } } async function captureOptionalInput( root: string, configuredPath: string | undefined, maxBytes: number, label: string, failures: string[], ): Promise> { if (!configuredPath) { failures.push(`${label} path is missing`); return Object.freeze({ bytes: null, sha256: null }); } try { const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes); return Object.freeze({ bytes, sha256: createHash("sha256").update(bytes).digest("hex"), }); } catch (error) { failures.push( `${label} capture failed: ${error instanceof Error ? error.message : String(error)}`, ); return Object.freeze({ bytes: null, sha256: null }); } } function parseCapturedJson(bytes: Buffer | null): unknown { if (!bytes) return null; try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; } catch { return null; } } async function requiredJson( repositoryRoot: string, file: string, ): Promise> { const value: unknown = JSON.parse( new TextDecoder("utf-8", { fatal: true }).decode( await boundedConfiguredFile(repositoryRoot, file, 8_388_608), ), ); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${file} must be a JSON object`); } return value as Record; } async function boundedConfiguredFile( configuredRoot: string, configuredPath: string, maxBytes: number, ): Promise { const root = path.resolve(configuredRoot); const absolute = path.resolve(root, configuredPath); const relative = path.relative(root, absolute); const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); return readBoundedRegularFile({ root: outside ? path.dirname(absolute) : root, relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), maxBytes, }); }