fix: close immutable promotion trust gaps

This commit is contained in:
DongHyeonka
2026-08-02 06:39:41 +09:00
parent 7c5ed80407
commit 92e5cace5c
16 changed files with 1228 additions and 274 deletions
+52 -13
View File
@@ -1,5 +1,6 @@
import { createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
evaluatePromotionEvidence,
@@ -10,38 +11,63 @@ import {
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(
environment: NodeJS.ProcessEnv = process.env,
options: VerifyPromotionInputsOptions = {},
) {
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
const candidate = await verifyReleaseCandidate(manifestDocument);
const localVerification = await requiredJson(
"artifacts/security/supply-chain-verification.json",
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: localVerification.localStatus,
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, ...result.failures];
const failures = [
...candidate.failures,
...localEvidence.failures,
...result.failures,
];
return Object.freeze({
schemaVersion: 1 as const,
status:
@@ -57,6 +83,7 @@ export async function verifyPromotionInputs(
}
async function readTrust(
repositoryRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
@@ -64,24 +91,36 @@ async function readTrust(
try {
return Object.freeze({
keyId,
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
publicKey: createPublicKey(
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
),
});
} catch {
return null;
}
}
async function optionalJson(file: string | undefined): Promise<unknown> {
async function optionalJson(
repositoryRoot: string,
file: string | undefined,
): Promise<unknown> {
if (!file) return null;
try {
return JSON.parse(await readFile(file, "utf8")) as unknown;
return JSON.parse(
await readFile(path.resolve(repositoryRoot, file), "utf8"),
) as unknown;
} catch {
return null;
}
}
async function requiredJson(file: string): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(await readFile(file, "utf8"));
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`);
}