294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { createHash, createPublicKey, randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
evaluatePromotionEvidence,
|
|
providerVerificationArtifactSchema,
|
|
provenanceProviderAttestationSchema,
|
|
vulnerabilityProviderReportSchema,
|
|
} from "./provider-evidence.ts";
|
|
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
|
import { verifyReleaseCandidate } from "./release-candidate.ts";
|
|
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
|
import {
|
|
assertSafePublishLeaf,
|
|
ensureSafePublishDirectory,
|
|
} from "./ci-gate-log.ts";
|
|
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
|
|
|
|
export { PROMOTED_STAGING_PATHS };
|
|
|
|
type PromotionSource = Readonly<{
|
|
sourcePath: string;
|
|
destinationName: string;
|
|
maxBytes: number;
|
|
validate: (bytes: Buffer) => void;
|
|
}>;
|
|
|
|
type StagedFile = Readonly<{
|
|
destinationName: string;
|
|
bytes: Buffer;
|
|
digest: string;
|
|
}>;
|
|
|
|
export async function stageVerifiedPromotion(input: Readonly<{
|
|
repositoryRoot: string;
|
|
archivePath: string;
|
|
expectedArchiveSha256: string;
|
|
vulnerabilityReportPath: string;
|
|
provenanceAttestationPath: string;
|
|
vulnerabilityPublicKeyPath: string;
|
|
vulnerabilityKeyId: string;
|
|
provenancePublicKeyPath: string;
|
|
provenanceKeyId: string;
|
|
}>, dependencies: Readonly<{
|
|
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
|
afterCapture?: () => Promise<void>;
|
|
beforePublishRename?: () => Promise<void>;
|
|
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
|
|
const root = path.resolve(input.repositoryRoot);
|
|
if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) {
|
|
throw new TypeError("promotion archive SHA-256 is invalid");
|
|
}
|
|
const sources: PromotionSource[] = [
|
|
{
|
|
sourcePath: input.archivePath,
|
|
destinationName: "release-candidate.tar.gz",
|
|
maxBytes: 268_435_456,
|
|
validate: (bytes) => {
|
|
if (sha256(bytes) !== input.expectedArchiveSha256) {
|
|
throw new Error("promotion archive SHA-256 changed before staging");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
sourcePath: input.vulnerabilityReportPath,
|
|
destinationName: "vulnerability-report.json",
|
|
maxBytes: 16_777_216,
|
|
validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)),
|
|
},
|
|
{
|
|
sourcePath: input.provenanceAttestationPath,
|
|
destinationName: "provenance-attestation.json",
|
|
maxBytes: 16_777_216,
|
|
validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)),
|
|
},
|
|
{
|
|
sourcePath: "artifacts/security/provider-verification.json",
|
|
destinationName: "provider-verification.json",
|
|
maxBytes: 4_194_304,
|
|
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
|
},
|
|
{
|
|
sourcePath: "artifacts/security/promotion-verification.json",
|
|
destinationName: "promotion-verification.json",
|
|
maxBytes: 4_194_304,
|
|
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
|
},
|
|
];
|
|
const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([
|
|
Promise.all(
|
|
sources.map(async (source) => {
|
|
const relativePath = repositoryRelative(root, source.sourcePath);
|
|
const bytes = await readBoundedRegularFile({
|
|
root,
|
|
relativePath,
|
|
maxBytes: source.maxBytes,
|
|
});
|
|
source.validate(bytes);
|
|
return Object.freeze({ ...source, bytes, digest: sha256(bytes) });
|
|
}),
|
|
),
|
|
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
|
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
|
]);
|
|
await dependencies.afterCapture?.();
|
|
let capturedLocalStatus: "PASS" | "FAIL" = "FAIL";
|
|
const archive = await verifyCapturedCiCandidateArchive(
|
|
captured[0]!.bytes,
|
|
input.expectedArchiveSha256,
|
|
{
|
|
verifyExtracted: async (extractionRoot, manifest) => {
|
|
const candidate = await verifyReleaseCandidate(manifest, extractionRoot);
|
|
if (candidate.failures.length > 0) {
|
|
throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`);
|
|
}
|
|
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
|
repositoryRoot: extractionRoot,
|
|
candidate: manifest,
|
|
});
|
|
if (local.status !== "PASS" || local.failures.length > 0) {
|
|
throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`);
|
|
}
|
|
capturedLocalStatus = local.status;
|
|
},
|
|
},
|
|
);
|
|
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes));
|
|
const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes));
|
|
const reevaluated = evaluatePromotionEvidence({
|
|
candidate: archive.manifest,
|
|
currentDistSha256: archive.manifest.distSha256,
|
|
localStatus: capturedLocalStatus,
|
|
vulnerabilityReport: vulnerability,
|
|
provenanceAttestation: provenance,
|
|
vulnerabilityTrust: {
|
|
keyId: input.vulnerabilityKeyId,
|
|
publicKey: createPublicKey(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey),
|
|
),
|
|
},
|
|
provenanceTrust: {
|
|
keyId: input.provenanceKeyId,
|
|
publicKey: createPublicKey(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey),
|
|
),
|
|
},
|
|
});
|
|
if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) {
|
|
throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`);
|
|
}
|
|
const expectedBindings = {
|
|
candidateArchiveSha256: captured[0]!.digest,
|
|
vulnerabilityReportSha256: captured[1]!.digest,
|
|
provenanceAttestationSha256: captured[2]!.digest,
|
|
};
|
|
for (const [index, expectedArtifactType] of [
|
|
[3, "provider-verification"],
|
|
[4, "promotion-verification"],
|
|
] as const) {
|
|
const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes));
|
|
if (verification.artifactType !== expectedArtifactType) {
|
|
throw new Error(
|
|
`${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`,
|
|
);
|
|
}
|
|
if (
|
|
verification.status !== reevaluated.status ||
|
|
verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus ||
|
|
verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus ||
|
|
verification.failures.length > 0
|
|
) {
|
|
throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`);
|
|
}
|
|
if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) {
|
|
throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`);
|
|
}
|
|
if (verification.distSha256 !== archive.manifest.distSha256) {
|
|
throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`);
|
|
}
|
|
for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray<
|
|
readonly [keyof typeof expectedBindings, string]
|
|
>) {
|
|
if (verification[binding] !== expectedDigest) {
|
|
throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`);
|
|
}
|
|
}
|
|
}
|
|
const stagedFiles: readonly StagedFile[] = captured;
|
|
|
|
const releaseRoot = path.join(root, ".release");
|
|
const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
|
const stagingRoot = path.join(releaseRoot, "promoted-staging");
|
|
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
|
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
|
const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`));
|
|
let ownsTemporary = true;
|
|
try {
|
|
for (const source of stagedFiles) {
|
|
const handle = await open(
|
|
path.join(temporary, source.destinationName),
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
try {
|
|
await handle.writeFile(source.bytes);
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
await syncDirectory(temporary);
|
|
await dependencies.beforePublishRename?.();
|
|
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
|
if (
|
|
releaseIdentity.dev <= 0 ||
|
|
releaseIdentity.ino <= 0 ||
|
|
currentReleaseIdentity.dev !== releaseIdentity.dev ||
|
|
currentReleaseIdentity.ino !== releaseIdentity.ino
|
|
) {
|
|
throw new Error("promotion staging parent identity changed");
|
|
}
|
|
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
|
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
|
await rename(temporary, stagingRoot);
|
|
ownsTemporary = false;
|
|
await syncDirectory(releaseRoot);
|
|
} finally {
|
|
if (ownsTemporary) await rm(temporary, { recursive: true, force: true });
|
|
}
|
|
return Object.freeze(
|
|
stagedFiles.map(({ destinationName, digest }) =>
|
|
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
|
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,
|
|
});
|
|
}
|
|
|
|
function parseJson(bytes: Buffer): unknown {
|
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
}
|
|
|
|
function repositoryRelative(root: string, configuredPath: string): string {
|
|
const absolute = path.resolve(root, configuredPath);
|
|
const relative = path.relative(root, absolute);
|
|
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
throw new TypeError(`promotion source escapes repository: ${configuredPath}`);
|
|
}
|
|
return relative.replaceAll(path.sep, "/");
|
|
}
|
|
|
|
function sha256(bytes: Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
async function exists(target: string): Promise<boolean> {
|
|
try {
|
|
await lstat(target);
|
|
return true;
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ENOENT")) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function syncDirectory(directory: string): Promise<void> {
|
|
const handle = await open(directory, constants.O_RDONLY);
|
|
try {
|
|
try {
|
|
await handle.sync();
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
|
}
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
}
|