Files
clean-architecture-frontend…/scripts/lib/promotion-verifier.ts
T

129 lines
3.6 KiB
TypeScript

import { createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
evaluatePromotionEvidence,
type ProviderTrust,
} from "./provider-evidence.ts";
import {
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
verifyReleaseCandidate,
} from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
export type VerifyPromotionInputsOptions = Readonly<{
environment?: NodeJS.ProcessEnv;
repositoryRoot?: string;
verifyLocalEvidence?: LocalEvidenceVerifier;
}>;
export async function verifyPromotionInputs(
options: VerifyPromotionInputsOptions = {},
) {
const environment = options.environment ?? process.env;
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
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 = await optionalJson(
repositoryRoot,
environment.VULNERABILITY_REPORT_PATH,
);
const provenanceAttestation = await optionalJson(
repositoryRoot,
environment.PROVENANCE_ATTESTATION_PATH,
);
const result = evaluatePromotionEvidence({
candidate: manifest,
currentDistSha256: candidate.currentDistSha256 ?? "",
localStatus: localEvidence.status,
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: await readTrust(
repositoryRoot,
environment.VULNERABILITY_PUBLIC_KEY_PATH,
environment.VULNERABILITY_KEY_ID,
),
provenanceTrust: await readTrust(
repositoryRoot,
environment.PROVENANCE_PUBLIC_KEY_PATH,
environment.PROVENANCE_KEY_ID,
),
});
const failures = [
...candidate.failures,
...localEvidence.failures,
...result.failures,
];
return Object.freeze({
schemaVersion: 1 as const,
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,
failures: Object.freeze(failures),
});
}
async function readTrust(
repositoryRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
if (!publicKeyPath || !keyId?.trim()) return null;
try {
return Object.freeze({
keyId,
publicKey: createPublicKey(
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
),
});
} catch {
return null;
}
}
async function optionalJson(
repositoryRoot: string,
file: string | undefined,
): Promise<unknown> {
if (!file) return null;
try {
return JSON.parse(
await readFile(path.resolve(repositoryRoot, file), "utf8"),
) as unknown;
} catch {
return null;
}
}
async function requiredJson(
repositoryRoot: string,
file: string,
): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(
await readFile(path.join(repositoryRoot, file), "utf8"),
);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${file} must be a JSON object`);
}
return value as Record<string, unknown>;
}