376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import {
|
|
createHash,
|
|
generateKeyPairSync,
|
|
sign,
|
|
type KeyObject,
|
|
} from "node:crypto";
|
|
import {
|
|
cp,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
localEvidenceAssessmentArtifactSchema,
|
|
supplyChainProviderFixturesArtifactSchema,
|
|
} from "./contracts/release-artifacts.ts";
|
|
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
|
|
import {
|
|
captureCiCandidateArchive,
|
|
} from "./lib/ci-candidate-archive.ts";
|
|
import {
|
|
cleanupFinalizedPromotion,
|
|
finalizeVerifiedPromotion,
|
|
} from "./lib/promotion-stager.ts";
|
|
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
|
|
import {
|
|
providerEvidenceSignaturePayload,
|
|
providerPublicKeyFingerprint,
|
|
} from "./lib/provider-evidence.ts";
|
|
import {
|
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
releaseCandidateManifestSchema,
|
|
} from "./lib/release-candidate.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
const repositoryRoot = process.cwd();
|
|
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
|
|
try {
|
|
await cp(repositoryRoot, fixtureRoot, {
|
|
recursive: true,
|
|
filter: (source) => {
|
|
const relative = path.relative(repositoryRoot, source);
|
|
if (!relative) return true;
|
|
const first = relative.split(path.sep)[0];
|
|
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
|
|
},
|
|
});
|
|
await cp(path.join(repositoryRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
|
|
recursive: true,
|
|
});
|
|
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
|
|
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
|
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
|
cwd: repositoryRoot,
|
|
encoding: "utf8",
|
|
});
|
|
if (git.status !== 0) throw new Error(`provider fixture git identity failed: ${git.stderr}`);
|
|
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
|
|
if (!revision || !sourceDateEpoch) throw new Error("provider fixture git identity is incomplete");
|
|
const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], {
|
|
cwd: fixtureRoot,
|
|
encoding: "utf8",
|
|
timeout: 120_000,
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
env: {
|
|
...process.env,
|
|
CI: "true",
|
|
VITE_BUILD_ID: "provider-exact-five-fixture",
|
|
VITE_COMMIT_SHA: revision,
|
|
RELEASE_ID: "provider-exact-five-fixture",
|
|
SOURCE_DATE_EPOCH: sourceDateEpoch,
|
|
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
|
|
},
|
|
});
|
|
if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`);
|
|
const manifest = releaseCandidateManifestSchema.parse(
|
|
JSON.parse(
|
|
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
|
) as unknown,
|
|
);
|
|
const assessment = localEvidenceAssessmentArtifactSchema.parse(
|
|
JSON.parse(
|
|
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
|
) as unknown,
|
|
);
|
|
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
|
|
const tar = spawnSync(
|
|
"/usr/bin/tar",
|
|
[
|
|
"--sort=name",
|
|
"--mtime=@0",
|
|
"--owner=0",
|
|
"--group=0",
|
|
"--numeric-owner",
|
|
"-czf",
|
|
archivePath,
|
|
"dist",
|
|
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
],
|
|
{ cwd: fixtureRoot, encoding: "utf8" },
|
|
);
|
|
if (tar.status !== 0) throw new Error(`provider fixture real tar failed: ${tar.stderr}`);
|
|
const archiveBytes = await readFile(archivePath);
|
|
const archiveSha256 = sha256(archiveBytes);
|
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
|
const vulnerabilityKeyId = "fixture-vulnerability-key";
|
|
const provenanceKeyId = "fixture-provenance-key";
|
|
const vulnerabilityNonce = "1".repeat(64);
|
|
const provenanceNonce = "2".repeat(64);
|
|
const context = {
|
|
run: { id: "fixture-run", attempt: 1 },
|
|
source: {
|
|
revision: assessment.source.revision,
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
},
|
|
candidate: {
|
|
archiveSha256,
|
|
bundleSha256: manifest.bundleSha256,
|
|
distSha256: manifest.distSha256,
|
|
lockfileSha256: manifest.lockfileSha256,
|
|
},
|
|
} as const;
|
|
const vulnerability = signedEvidence(
|
|
{
|
|
schemaVersion: 2,
|
|
evidenceType: "vulnerability-report",
|
|
provider: "fixture-vulnerability-provider",
|
|
issuedAt: new Date(now).toISOString(),
|
|
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
|
|
run: { ...context.run, invocationNonce: vulnerabilityNonce },
|
|
source: context.source,
|
|
candidate: context.candidate,
|
|
secretScanAttestation: {
|
|
status: "PASS",
|
|
localEvidenceAssessmentSha256: sha256(
|
|
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
|
),
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
policySha256: assessment.secretScan.policySha256,
|
|
sarifSha256: assessment.secretScan.sarifSha256,
|
|
scanInputSha256: assessment.secretScan.scanInputSha256,
|
|
},
|
|
findings: [],
|
|
},
|
|
vulnerabilityKeyId,
|
|
vulnerabilityKeys.publicKey,
|
|
vulnerabilityKeys.privateKey,
|
|
);
|
|
const provenance = signedEvidence(
|
|
{
|
|
schemaVersion: 2,
|
|
evidenceType: "provenance-attestation",
|
|
provider: "fixture-provenance-provider",
|
|
signer: "fixture-workload-identity",
|
|
issuedAt: new Date(now).toISOString(),
|
|
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
|
|
run: { ...context.run, invocationNonce: provenanceNonce },
|
|
source: context.source,
|
|
candidate: context.candidate,
|
|
subject: { name: "dist", digest: { sha256: manifest.distSha256 } },
|
|
},
|
|
provenanceKeyId,
|
|
provenanceKeys.publicKey,
|
|
provenanceKeys.privateKey,
|
|
);
|
|
await Promise.all([
|
|
write(fixtureRoot, "provider/vulnerability.json", `${JSON.stringify(vulnerability)}\n`),
|
|
write(fixtureRoot, "provider/provenance.json", `${JSON.stringify(provenance)}\n`),
|
|
write(
|
|
fixtureRoot,
|
|
"provider/vulnerability.pem",
|
|
vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
|
|
),
|
|
write(
|
|
fixtureRoot,
|
|
"provider/provenance.pem",
|
|
provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
|
|
),
|
|
]);
|
|
const runnerTempRoot = path.join(fixtureRoot, "runner-temp");
|
|
await mkdir(runnerTempRoot, { mode: 0o700 });
|
|
const finalized = await finalizeVerifiedPromotion(
|
|
{
|
|
repositoryRoot: fixtureRoot,
|
|
archivePath,
|
|
expectedArchiveSha256: archiveSha256,
|
|
vulnerabilityReportPath: "provider/vulnerability.json",
|
|
provenanceAttestationPath: "provider/provenance.json",
|
|
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
|
|
vulnerabilityKeyId,
|
|
provenancePublicKeyPath: "provider/provenance.pem",
|
|
provenanceKeyId,
|
|
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
|
|
vulnerabilityInvocationNonce: vulnerabilityNonce,
|
|
provenanceInvocationNonce: provenanceNonce,
|
|
runnerTempRoot,
|
|
},
|
|
{
|
|
nowEpochMs: () => now,
|
|
randomBytes: (bytes) => Buffer.alloc(bytes, 0x4a),
|
|
afterCapture: async () => {
|
|
await writeFile(path.join(fixtureRoot, "dist/index.html"), "contradictory external tree\n");
|
|
},
|
|
},
|
|
);
|
|
const validImmutable =
|
|
finalized.files.length === 5 &&
|
|
(await readdir(finalized.stagingRoot)).length === 5;
|
|
const exactFiles = Object.fromEntries(
|
|
await Promise.all(
|
|
PROMOTED_FILE_NAMES.map(async (name) => [
|
|
name,
|
|
await readFile(path.join(finalized.stagingRoot, name)),
|
|
] as const),
|
|
),
|
|
);
|
|
const bundleTrust = {
|
|
vulnerabilityTrust: {
|
|
keyId: vulnerabilityKeyId,
|
|
publicKey: vulnerabilityKeys.publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
|
},
|
|
provenanceTrust: {
|
|
keyId: provenanceKeyId,
|
|
publicKey: provenanceKeys.publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
|
},
|
|
expected: {
|
|
run: context.run,
|
|
sourceRevision: context.source.revision,
|
|
sourceSetSha256: context.source.sourceSetSha256,
|
|
archiveSha256: context.candidate.archiveSha256,
|
|
bundleSha256: context.candidate.bundleSha256,
|
|
distSha256: context.candidate.distSha256,
|
|
lockfileSha256: context.candidate.lockfileSha256,
|
|
},
|
|
nowEpochMs: () => now,
|
|
} as const;
|
|
const absentFiles = { ...exactFiles } as Partial<typeof exactFiles>;
|
|
delete absentFiles["provider-verification.json"];
|
|
const absent = await captureRejection(async () => {
|
|
await verifyExactPromotionBundle(absentFiles, bundleTrust);
|
|
});
|
|
|
|
const invalidPath = path.join(fixtureRoot, "invalid.tar.gz");
|
|
const invalidBytes = Buffer.from("arbitrary non-tar bytes\n");
|
|
await writeFile(invalidPath, invalidBytes);
|
|
const invalidTar = await captureRejection(async () => {
|
|
await finalizeVerifiedPromotion(
|
|
{
|
|
repositoryRoot: fixtureRoot,
|
|
archivePath: invalidPath,
|
|
expectedArchiveSha256: sha256(invalidBytes),
|
|
vulnerabilityReportPath: "provider/vulnerability.json",
|
|
provenanceAttestationPath: "provider/provenance.json",
|
|
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
|
|
vulnerabilityKeyId,
|
|
provenancePublicKeyPath: "provider/provenance.pem",
|
|
provenanceKeyId,
|
|
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
|
|
vulnerabilityInvocationNonce: vulnerabilityNonce,
|
|
provenanceInvocationNonce: provenanceNonce,
|
|
runnerTempRoot,
|
|
},
|
|
{ nowEpochMs: () => now },
|
|
);
|
|
});
|
|
const wrongDigest = await captureRejection(async () => {
|
|
await captureCiCandidateArchive({ archivePath, expectedSha256: "0".repeat(64) });
|
|
});
|
|
const passed =
|
|
validImmutable && absent.rejected && invalidTar.rejected && wrongDigest.rejected;
|
|
await cleanupFinalizedPromotion({
|
|
runnerTempRoot,
|
|
stagingRoot: finalized.stagingRoot,
|
|
cleanupToken: finalized.cleanupToken,
|
|
runnerTempIdentity: finalized.runnerTempIdentity,
|
|
stagingIdentity: finalized.stagingIdentity,
|
|
});
|
|
await mkdir(path.join(repositoryRoot, "artifacts/security"), { recursive: true });
|
|
await writeValidatedJsonArtifact({
|
|
path: path.join(repositoryRoot, "artifacts/security/supply-chain-provider-fixtures.json"),
|
|
schema: supplyChainProviderFixturesArtifactSchema,
|
|
value: {
|
|
schemaVersion: 1,
|
|
actualDefaultVerifier: {
|
|
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
|
|
failures: validImmutable ? [] : ["canonical exact-five finalizer failed"],
|
|
},
|
|
fixtures: {
|
|
absent: {
|
|
status: absent.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
|
failures: [absent.failure],
|
|
},
|
|
validImmutable: { status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", failures: [] },
|
|
wrongDigest: {
|
|
status: wrongDigest.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
|
failures: [wrongDigest.failure],
|
|
},
|
|
invalidTar: {
|
|
status: invalidTar.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
|
failures: [invalidTar.failure],
|
|
},
|
|
},
|
|
externalTreeCanary: {
|
|
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
|
|
failures: validImmutable ? [] : ["external tree canary influenced captured archive"],
|
|
},
|
|
passingFixtureCount: validImmutable ? 1 : 0,
|
|
status: passed ? "PASS" : "FAIL",
|
|
},
|
|
});
|
|
if (!passed) throw new Error("canonical exact-five provider fixtures failed closed incorrectly");
|
|
process.stdout.write("Supply-chain provider real-tar signed exact-five fixtures: PASS\n");
|
|
} finally {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
function signedEvidence(
|
|
value: Record<string, unknown>,
|
|
keyId: string,
|
|
publicKey: KeyObject,
|
|
privateKey: KeyObject,
|
|
) {
|
|
return {
|
|
...value,
|
|
signature: {
|
|
algorithm: "Ed25519",
|
|
keyId,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(value),
|
|
privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function write(root: string, relative: string, value: string | Buffer): Promise<void> {
|
|
const absolute = path.join(root, relative);
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, value);
|
|
}
|
|
|
|
async function captureRejection(
|
|
operation: () => Promise<void>,
|
|
): Promise<Readonly<{ rejected: boolean; failure: string }>> {
|
|
try {
|
|
await operation();
|
|
return Object.freeze({ rejected: false, failure: "fixture unexpectedly passed" });
|
|
} catch (error) {
|
|
return Object.freeze({
|
|
rejected: true,
|
|
failure: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
function sha256(bytes: Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|