206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
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<ProviderTrust | null> {
|
|
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<Readonly<{ bytes: Buffer | null; sha256: string | null }>> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
async function boundedConfiguredFile(
|
|
configuredRoot: string,
|
|
configuredPath: string,
|
|
maxBytes: number,
|
|
): Promise<Buffer> {
|
|
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,
|
|
});
|
|
}
|