435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import {
|
|
createHash,
|
|
createPublicKey,
|
|
randomBytes as cryptoRandomBytes,
|
|
} from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
rm,
|
|
stat,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
PROMOTED_FILE_NAMES,
|
|
type PromotedFileName,
|
|
} from "../contracts/promotion-artifacts.ts";
|
|
import {
|
|
evaluatePromotionEvidence,
|
|
providerPublicKeyFingerprint,
|
|
providerVerificationArtifactSchema,
|
|
PROMOTION_VERIFIER_ID,
|
|
PROMOTION_VERIFIER_VERSION,
|
|
provenanceProviderAttestationSchema,
|
|
trustPolicySha256,
|
|
vulnerabilityProviderReportSchema,
|
|
type ProviderTrust,
|
|
} from "./provider-evidence.ts";
|
|
import {
|
|
captureCiCandidateArchive,
|
|
withVerifiedCapturedCandidate,
|
|
} from "./ci-candidate-archive.ts";
|
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
|
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
|
|
|
type StagedFile = Readonly<{
|
|
name: PromotedFileName;
|
|
bytes: Buffer;
|
|
sha256: string;
|
|
}>;
|
|
|
|
export type FinalizedPromotion = Readonly<{
|
|
stagingRoot: string;
|
|
cleanupToken: string;
|
|
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
|
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
|
}>;
|
|
|
|
export async function finalizeVerifiedPromotion(input: Readonly<{
|
|
repositoryRoot: string;
|
|
archivePath: string;
|
|
expectedArchiveSha256: string;
|
|
vulnerabilityReportPath: string;
|
|
provenanceAttestationPath: string;
|
|
vulnerabilityPublicKeyPath: string;
|
|
vulnerabilityKeyId: string;
|
|
provenancePublicKeyPath: string;
|
|
provenanceKeyId: string;
|
|
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
|
vulnerabilityInvocationNonce: string;
|
|
provenanceInvocationNonce: string;
|
|
runnerTempRoot: string;
|
|
}>, dependencies: Readonly<{
|
|
captureArchive?: typeof captureCiCandidateArchive;
|
|
nowEpochMs?: () => number;
|
|
randomBytes?: (bytes: number) => Buffer;
|
|
afterCapture?: () => Promise<void>;
|
|
beforePublish?: () => Promise<void>;
|
|
afterStagingWrite?: () => Promise<void>;
|
|
}> = {}): Promise<FinalizedPromotion> {
|
|
const root = path.resolve(input.repositoryRoot);
|
|
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
|
archivePath: input.archivePath,
|
|
expectedSha256: input.expectedArchiveSha256,
|
|
});
|
|
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
|
|
await Promise.all([
|
|
capture(root, input.vulnerabilityReportPath, 16_777_216),
|
|
capture(root, input.provenanceAttestationPath, 16_777_216),
|
|
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
|
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
|
]);
|
|
await dependencies.afterCapture?.();
|
|
|
|
const vulnerabilityTrust = capturedTrust(
|
|
input.vulnerabilityKeyId,
|
|
vulnerabilityKeyBytes,
|
|
);
|
|
const provenanceTrust = capturedTrust(
|
|
input.provenanceKeyId,
|
|
provenanceKeyBytes,
|
|
);
|
|
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
|
parseJson(vulnerabilityBytes),
|
|
);
|
|
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
|
parseJson(provenanceBytes),
|
|
);
|
|
const now = (dependencies.nowEpochMs ?? Date.now)();
|
|
const verifiedAt = new Date(now).toISOString();
|
|
|
|
const generated = await withVerifiedCapturedCandidate({
|
|
captured: capturedArchive,
|
|
verify: async ({ extractionRoot, manifest }) => {
|
|
const local = await verifyArchivedLocalEvidence({
|
|
extractionRoot,
|
|
expectedManifest: manifest,
|
|
});
|
|
if (local.status !== "PASS" || !local.identity) {
|
|
throw new Error(
|
|
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
|
|
);
|
|
}
|
|
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
|
throw new Error("captured source revision differs from expected promotion revision");
|
|
}
|
|
const expected = {
|
|
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
|
|
source: {
|
|
revision: local.identity.sourceRevision,
|
|
sourceSetSha256: local.identity.sourceSetSha256,
|
|
},
|
|
candidate: {
|
|
archiveSha256: capturedArchive.archiveSha256,
|
|
bundleSha256: manifest.bundleSha256,
|
|
distSha256: manifest.distSha256,
|
|
lockfileSha256: manifest.lockfileSha256,
|
|
},
|
|
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
|
} as const;
|
|
const reevaluated = evaluatePromotionEvidence({
|
|
expected,
|
|
localStatus: local.status,
|
|
vulnerabilityReport,
|
|
provenanceAttestation,
|
|
vulnerabilityTrust,
|
|
provenanceTrust,
|
|
nowEpochMs: () => now,
|
|
});
|
|
if (reevaluated.status !== "PASS") {
|
|
throw new Error(
|
|
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
|
|
);
|
|
}
|
|
const providerEvidence = {
|
|
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
|
|
provenanceAttestationSha256: sha256(provenanceBytes),
|
|
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
|
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
|
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
|
provenanceKeyId: provenanceTrust.keyId,
|
|
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
|
} as const;
|
|
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
|
const common = {
|
|
schemaVersion: 3 as const,
|
|
verifiedAt,
|
|
status: "PASS" as const,
|
|
verifier: {
|
|
id: PROMOTION_VERIFIER_ID,
|
|
version: PROMOTION_VERIFIER_VERSION,
|
|
},
|
|
run: expected.run,
|
|
source: expected.source,
|
|
candidate: expected.candidate,
|
|
providerEvidence,
|
|
trustPolicySha256: trustDigest,
|
|
failures: [] as const,
|
|
};
|
|
const providerRecord = providerVerificationArtifactSchema.parse({
|
|
...common,
|
|
artifactType: "provider-verification",
|
|
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
|
|
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
|
|
});
|
|
const providerRecordBytes = canonicalJsonBytes(providerRecord);
|
|
const promotionRecord = providerVerificationArtifactSchema.parse({
|
|
...common,
|
|
artifactType: "promotion-verification",
|
|
localEvidenceStatus: local.status,
|
|
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
|
providerVerificationSha256: sha256(providerRecordBytes),
|
|
});
|
|
return Object.freeze({
|
|
providerRecordBytes,
|
|
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
|
});
|
|
},
|
|
});
|
|
|
|
const stagedFiles: readonly StagedFile[] = Object.freeze([
|
|
staged("release-candidate.tar.gz", capturedArchive.bytes),
|
|
staged("vulnerability-report.json", vulnerabilityBytes),
|
|
staged("provenance-attestation.json", provenanceBytes),
|
|
staged("provider-verification.json", generated.providerRecordBytes),
|
|
staged("promotion-verification.json", generated.promotionRecordBytes),
|
|
]);
|
|
if (
|
|
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
|
|
JSON.stringify(PROMOTED_FILE_NAMES)
|
|
) {
|
|
throw new Error("promotion exact-five canonical file order drift");
|
|
}
|
|
await dependencies.beforePublish?.();
|
|
return publishPrivateStaging(
|
|
input.runnerTempRoot,
|
|
input.expectedRun,
|
|
stagedFiles,
|
|
dependencies.randomBytes ?? cryptoRandomBytes,
|
|
dependencies.afterStagingWrite,
|
|
);
|
|
}
|
|
|
|
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
|
|
|
|
export async function cleanupFinalizedPromotion(input: Readonly<{
|
|
runnerTempRoot: string;
|
|
stagingRoot: string;
|
|
cleanupToken: string;
|
|
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
|
}>, dependencies: Readonly<{
|
|
beforeRemove?: () => Promise<void>;
|
|
}> = {}): Promise<void> {
|
|
const parent = path.resolve(input.runnerTempRoot);
|
|
const expected = path.join(parent, input.cleanupToken);
|
|
if (
|
|
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
|
|
path.resolve(input.stagingRoot) !== expected ||
|
|
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
|
|
input.runnerTempIdentity.dev <= 0 ||
|
|
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
|
input.runnerTempIdentity.ino <= 0
|
|
) {
|
|
throw new TypeError("promotion cleanup root/token mismatch");
|
|
}
|
|
const parentHandle = await open(
|
|
parent,
|
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
);
|
|
try {
|
|
const openedParent = await parentHandle.stat();
|
|
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
|
|
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
|
const descriptorMetadata = await stat(descriptorRoot);
|
|
if (!descriptorMetadata.isDirectory()) {
|
|
throw new Error("descriptor-relative cleanup is unavailable");
|
|
}
|
|
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
|
|
let metadata;
|
|
try {
|
|
metadata = await lstat(descriptorExpected);
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ENOENT")) return;
|
|
throw error;
|
|
}
|
|
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
throw new TypeError("promotion cleanup leaf is unsafe");
|
|
}
|
|
await dependencies.beforeRemove?.();
|
|
const visibleParent = await lstat(parent);
|
|
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
|
await rm(descriptorExpected, { recursive: true, force: true });
|
|
const afterParent = await lstat(parent);
|
|
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
|
} finally {
|
|
await parentHandle.close();
|
|
}
|
|
}
|
|
|
|
async function publishPrivateStaging(
|
|
runnerTempRoot: string,
|
|
run: Readonly<{ id: string; attempt: number }>,
|
|
files: readonly StagedFile[],
|
|
randomBytes: (bytes: number) => Buffer,
|
|
afterStagingWrite?: () => Promise<void>,
|
|
): Promise<FinalizedPromotion> {
|
|
const parentPath = path.resolve(runnerTempRoot);
|
|
const before = await lstat(parentPath);
|
|
if (!before.isDirectory() || before.isSymbolicLink()) {
|
|
throw new TypeError("runner temporary root must be a real directory");
|
|
}
|
|
const parentHandle = await open(
|
|
parentPath,
|
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
);
|
|
const tokenBytes = randomBytes(16);
|
|
if (tokenBytes.byteLength !== 16) {
|
|
await parentHandle.close();
|
|
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
|
|
}
|
|
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
|
|
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
|
|
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
|
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
|
const visibleStaging = path.join(parentPath, cleanupToken);
|
|
let ownsStaging = false;
|
|
try {
|
|
const procMetadata = await stat(descriptorRoot);
|
|
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
|
await mkdir(descriptorStaging, { mode: 0o700 });
|
|
ownsStaging = true;
|
|
for (const file of files) {
|
|
const handle = await open(
|
|
path.join(descriptorStaging, file.name),
|
|
constants.O_WRONLY |
|
|
constants.O_CREAT |
|
|
constants.O_EXCL |
|
|
constants.O_NOFOLLOW,
|
|
0o400,
|
|
);
|
|
try {
|
|
await handle.writeFile(file.bytes);
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
await syncDirectory(descriptorStaging);
|
|
await syncHandle(parentHandle);
|
|
await afterStagingWrite?.();
|
|
const after = await lstat(parentPath);
|
|
if (
|
|
after.dev !== before.dev ||
|
|
after.ino !== before.ino ||
|
|
after.isSymbolicLink() ||
|
|
!after.isDirectory()
|
|
) {
|
|
throw new Error("runner temporary parent identity changed during staging");
|
|
}
|
|
const visible = await lstat(visibleStaging);
|
|
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
|
throw new Error("promotion staging visibility identity mismatch");
|
|
}
|
|
ownsStaging = false;
|
|
return Object.freeze({
|
|
stagingRoot: visibleStaging,
|
|
cleanupToken,
|
|
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
|
files: Object.freeze(
|
|
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
|
),
|
|
});
|
|
} finally {
|
|
if (ownsStaging) {
|
|
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
await parentHandle.close();
|
|
}
|
|
}
|
|
|
|
function assertRunnerTempIdentity(
|
|
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
|
|
expected: Readonly<{ dev: number; ino: number }>,
|
|
): void {
|
|
if (
|
|
metadata.dev !== expected.dev ||
|
|
metadata.ino !== expected.ino ||
|
|
!metadata.isDirectory() ||
|
|
metadata.isSymbolicLink?.()
|
|
) {
|
|
throw new Error("runner temporary parent identity changed during cleanup");
|
|
}
|
|
}
|
|
|
|
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
|
|
const publicKey = createPublicKey(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
);
|
|
return Object.freeze({
|
|
keyId,
|
|
publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
|
});
|
|
}
|
|
|
|
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 staged(name: PromotedFileName, bytes: Buffer): StagedFile {
|
|
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
|
|
}
|
|
|
|
function canonicalJsonBytes(value: unknown): Buffer {
|
|
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function parseJson(bytes: Buffer): unknown {
|
|
try {
|
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
} catch {
|
|
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
|
|
}
|
|
}
|
|
|
|
function sha256(bytes: Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
async function syncDirectory(directory: string): Promise<void> {
|
|
const handle = await open(
|
|
directory,
|
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
);
|
|
try {
|
|
await syncHandle(handle);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
|
try {
|
|
await handle.sync();
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
|
}
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
}
|