334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { createHash, createPublicKey } from "node:crypto";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
PROMOTION_VERIFIER_ID,
|
|
PROMOTION_VERIFIER_VERSION,
|
|
evaluatePromotionEvidence,
|
|
providerPublicKeyFingerprint,
|
|
trustPolicySha256,
|
|
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";
|
|
import { supplyChainDigest } from "./supply-chain.ts";
|
|
|
|
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
|
|
|
export type VerifyPromotionInputsOptions = Readonly<{
|
|
artifactType: ProviderVerificationArtifactType;
|
|
environment?: NodeJS.ProcessEnv;
|
|
repositoryRoot?: string;
|
|
providerEvidenceRoot?: string;
|
|
trustRoot?: string;
|
|
verifyLocalEvidence?: LocalEvidenceVerifier;
|
|
nowEpochMs?: () => number;
|
|
}>;
|
|
|
|
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
|
|
)({ extractionRoot: repositoryRoot, expectedManifest: manifest });
|
|
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
|
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
|
const vulnerabilityTrust = await readProviderTrust(
|
|
trustRoot,
|
|
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
|
environment.VULNERABILITY_KEY_ID,
|
|
);
|
|
const provenanceTrust = await readProviderTrust(
|
|
trustRoot,
|
|
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
|
environment.PROVENANCE_KEY_ID,
|
|
);
|
|
const runId = environment.CI_RUN_ID ?? "missing-run";
|
|
const runAttempt = Number(environment.CI_RUN_ATTEMPT);
|
|
if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing");
|
|
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
|
inputFailures.push("provider expected run attempt is missing or invalid");
|
|
}
|
|
if (!localEvidence.identity) {
|
|
inputFailures.push("archived local evidence identity is unavailable");
|
|
}
|
|
if (
|
|
environment.EXPECTED_SOURCE_REVISION &&
|
|
localEvidence.identity &&
|
|
environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision
|
|
) {
|
|
inputFailures.push(
|
|
`provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`,
|
|
);
|
|
}
|
|
const vulnerabilityInvocationNonce = requiredExpectedNonce(
|
|
environment.VULNERABILITY_INVOCATION_NONCE,
|
|
"vulnerability",
|
|
inputFailures,
|
|
);
|
|
const provenanceInvocationNonce = requiredExpectedNonce(
|
|
environment.PROVENANCE_INVOCATION_NONCE,
|
|
"provenance",
|
|
inputFailures,
|
|
);
|
|
const expected = {
|
|
run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 },
|
|
source: {
|
|
revision:
|
|
localEvidence.identity?.sourceRevision ??
|
|
environment.EXPECTED_SOURCE_REVISION ??
|
|
"0".repeat(40),
|
|
sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64),
|
|
},
|
|
candidate: {
|
|
archiveSha256: archive.sha256 ?? "0".repeat(64),
|
|
bundleSha256: manifest.bundleSha256,
|
|
distSha256: manifest.distSha256,
|
|
lockfileSha256: manifest.lockfileSha256,
|
|
},
|
|
vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce,
|
|
} as const;
|
|
const result = evaluatePromotionEvidence({
|
|
expected,
|
|
localStatus: localEvidence.status,
|
|
vulnerabilityReport,
|
|
provenanceAttestation,
|
|
vulnerabilityTrust,
|
|
provenanceTrust,
|
|
nowEpochMs: options.nowEpochMs,
|
|
});
|
|
const failures = [
|
|
...inputFailures,
|
|
...candidate.failures,
|
|
...localEvidence.failures,
|
|
...result.failures,
|
|
];
|
|
const now = (options.nowEpochMs ?? Date.now)();
|
|
const common = {
|
|
schemaVersion: 3 as const,
|
|
artifactType: options.artifactType,
|
|
verifiedAt: new Date(now).toISOString(),
|
|
status:
|
|
failures.length === 0 && result.status === "PASS"
|
|
? ("PASS" as const)
|
|
: ("FAIL_UNVERIFIED" as const),
|
|
verifier: Object.freeze({
|
|
id: PROMOTION_VERIFIER_ID,
|
|
version: PROMOTION_VERIFIER_VERSION,
|
|
}),
|
|
run: expected.run,
|
|
source: expected.source,
|
|
candidate: expected.candidate,
|
|
providerEvidence: Object.freeze({
|
|
vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64),
|
|
provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64),
|
|
vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce: expected.provenanceInvocationNonce,
|
|
vulnerabilityKeyId:
|
|
vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
|
vulnerabilityKeyFingerprint:
|
|
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
|
provenanceKeyId:
|
|
provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
|
provenanceKeyFingerprint:
|
|
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
|
}),
|
|
trustPolicySha256: verificationTrustPolicySha256(
|
|
vulnerabilityTrust,
|
|
provenanceTrust,
|
|
environment,
|
|
),
|
|
failures: Object.freeze(failures),
|
|
};
|
|
return options.artifactType === "provider-verification"
|
|
? Object.freeze({
|
|
...common,
|
|
artifactType: "provider-verification" as const,
|
|
vulnerabilityStatus: result.vulnerabilityStatus,
|
|
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
|
})
|
|
: Object.freeze({
|
|
...common,
|
|
artifactType: "promotion-verification" as const,
|
|
localEvidenceStatus: localEvidence.status,
|
|
localEvidenceAssessmentSha256:
|
|
localEvidence.identity?.assessmentSha256 ?? "0".repeat(64),
|
|
providerVerificationSha256:
|
|
environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64),
|
|
});
|
|
}
|
|
|
|
function requiredExpectedNonce(
|
|
value: string | undefined,
|
|
label: "vulnerability" | "provenance",
|
|
failures: string[],
|
|
): string {
|
|
if (value && /^[a-f0-9]{64}$/u.test(value)) return value;
|
|
failures.push(`${label} expected invocation nonce is missing or invalid`);
|
|
return "0".repeat(64);
|
|
}
|
|
|
|
function verificationTrustPolicySha256(
|
|
vulnerabilityTrust: ProviderTrust | null,
|
|
provenanceTrust: ProviderTrust | null,
|
|
environment: NodeJS.ProcessEnv,
|
|
): string {
|
|
if (vulnerabilityTrust && provenanceTrust) {
|
|
return trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
|
}
|
|
return supplyChainDigest({
|
|
algorithm: "Ed25519",
|
|
vulnerability: {
|
|
keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
|
publicKeyFingerprint:
|
|
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
|
},
|
|
provenance: {
|
|
keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
|
publicKeyFingerprint:
|
|
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
|
},
|
|
issuedAtFutureSkewMs: 5 * 60 * 1_000,
|
|
maximumLifetimeMs: 2 * 60 * 60 * 1_000,
|
|
});
|
|
}
|
|
|
|
export async function readProviderTrust(
|
|
repositoryRoot: string,
|
|
publicKeyPath: string | undefined,
|
|
keyId: string | undefined,
|
|
): Promise<ProviderTrust | null> {
|
|
if (!publicKeyPath || !keyId?.trim()) return null;
|
|
try {
|
|
const publicKey = createPublicKey(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
|
),
|
|
);
|
|
return Object.freeze({
|
|
keyId,
|
|
publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
|
});
|
|
} 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,
|
|
});
|
|
}
|