fix: harden provider and promotion evidence
This commit is contained in:
@@ -3,15 +3,31 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts";
|
||||
import {
|
||||
providerEvidenceSignaturePayload,
|
||||
providerPublicKeyFingerprint,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const FIXTURE_SOURCE = Object.freeze({
|
||||
revision: "a".repeat(40),
|
||||
sourceSetSha256: "b".repeat(64),
|
||||
});
|
||||
const FIXTURE_LOCAL_IDENTITY = Object.freeze({
|
||||
sourceRevision: FIXTURE_SOURCE.revision,
|
||||
sourceSetSha256: FIXTURE_SOURCE.sourceSetSha256,
|
||||
assessmentSha256: "c".repeat(64),
|
||||
});
|
||||
|
||||
const fixtureRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
||||
);
|
||||
@@ -25,18 +41,27 @@ try {
|
||||
),
|
||||
) as unknown,
|
||||
);
|
||||
const actualAssessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
const actualProviderEnvironment = absoluteProviderEnvironment(
|
||||
fixtureRoot,
|
||||
await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"actual",
|
||||
actualCandidate.distSha256,
|
||||
actualCandidate.lockfileSha256,
|
||||
actualCandidate,
|
||||
{
|
||||
revision: actualAssessment.source.revision,
|
||||
sourceSetSha256: actualAssessment.source.sourceSetSha256,
|
||||
},
|
||||
),
|
||||
);
|
||||
const actualDefaultVerifier = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
environment: actualProviderEnvironment,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||
@@ -69,17 +94,19 @@ try {
|
||||
const validEnvironment = await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"valid",
|
||||
candidate.distSha256,
|
||||
candidate.lockfileSha256,
|
||||
candidate,
|
||||
FIXTURE_SOURCE,
|
||||
);
|
||||
const wrongEnvironment = await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"wrong",
|
||||
"3".repeat(64),
|
||||
candidate.lockfileSha256,
|
||||
candidate,
|
||||
FIXTURE_SOURCE,
|
||||
{ distSha256: "3".repeat(64) },
|
||||
);
|
||||
const acceptLocalEvidence = async () => ({
|
||||
status: "PASS" as const,
|
||||
identity: FIXTURE_LOCAL_IDENTITY,
|
||||
failures: [] as const,
|
||||
});
|
||||
const fixtures = {
|
||||
@@ -88,18 +115,21 @@ try {
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: {},
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
validImmutable: await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
wrongDigest: await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: wrongEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
postAttestationMutation: null as Awaited<
|
||||
ReturnType<typeof verifyPromotionInputs>
|
||||
@@ -111,6 +141,7 @@ try {
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
const passed =
|
||||
@@ -179,40 +210,63 @@ function absoluteProviderEnvironment(
|
||||
async function writeProviderEnvironment(
|
||||
repositoryRoot: string,
|
||||
name: string,
|
||||
distDigest: string,
|
||||
lockfileSha256: string,
|
||||
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
|
||||
source: Readonly<{ revision: string; sourceSetSha256: string }>,
|
||||
overrides: Readonly<{ distSha256?: string }> = {},
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const directory = `provider/${name}`;
|
||||
const archiveBytes = `fixture archive ${name}\n`;
|
||||
const archiveSha256 = createHash("sha256").update(archiveBytes).digest("hex");
|
||||
const candidateIdentity = {
|
||||
archiveSha256,
|
||||
bundleSha256: candidate.bundleSha256,
|
||||
distSha256: overrides.distSha256 ?? candidate.distSha256,
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
};
|
||||
const sourceIdentity = {
|
||||
revision: source.revision,
|
||||
sourceSetSha256: source.sourceSetSha256,
|
||||
};
|
||||
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
|
||||
const vulnerability = signedEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: distDigest,
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { id: "fixture-run", attempt: 1, invocationNonce: "1".repeat(64) },
|
||||
source: sourceIdentity,
|
||||
candidate: candidateIdentity,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenance = signedEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: { name: "dist", digest: { sha256: distDigest } },
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { id: "fixture-run", attempt: 1, invocationNonce: "2".repeat(64) },
|
||||
source: sourceIdentity,
|
||||
candidate: candidateIdentity,
|
||||
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "candidate.tar.gz"),
|
||||
"fixture archive\n",
|
||||
archiveBytes,
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "vulnerability.json"),
|
||||
@@ -237,9 +291,12 @@ async function writeProviderEnvironment(
|
||||
]);
|
||||
return {
|
||||
CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`,
|
||||
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
||||
.update("fixture archive\n")
|
||||
.digest("hex"),
|
||||
CANDIDATE_ARCHIVE_SHA256: archiveSha256,
|
||||
CI_RUN_ID: "fixture-run",
|
||||
CI_RUN_ATTEMPT: "1",
|
||||
EXPECTED_SOURCE_REVISION: source.revision,
|
||||
VULNERABILITY_INVOCATION_NONCE: "1".repeat(64),
|
||||
PROVENANCE_INVOCATION_NONCE: "2".repeat(64),
|
||||
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
|
||||
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
||||
@@ -252,6 +309,7 @@ async function writeProviderEnvironment(
|
||||
function signedEvidence(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
|
||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||
) {
|
||||
return {
|
||||
@@ -259,6 +317,7 @@ function signedEvidence(
|
||||
signature: {
|
||||
algorithm: "Ed25519",
|
||||
keyId,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
value: sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cleanupFinalizedPromotion } from "./lib/promotion-stager.ts";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new TypeError(`promotion cleanup environment is missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
const requiredIdentity = (name: string): number => {
|
||||
const value = Number(required(name));
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new TypeError(`promotion cleanup environment has invalid ${name}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
await cleanupFinalizedPromotion({
|
||||
runnerTempRoot: required("RUNNER_TEMP"),
|
||||
stagingRoot: required("PROMOTION_STAGING_ROOT"),
|
||||
cleanupToken: required("PROMOTION_CLEANUP_TOKEN"),
|
||||
runnerTempIdentity: {
|
||||
dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"),
|
||||
ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"),
|
||||
},
|
||||
});
|
||||
process.stdout.write("Promotion staging cleanup: PASS\n");
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../lib/release-candidate.ts";
|
||||
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
|
||||
import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts";
|
||||
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
|
||||
|
||||
const ciActionRegistrationSchema = z
|
||||
.object({
|
||||
@@ -332,7 +332,11 @@ const extractStep = z
|
||||
})
|
||||
.strict();
|
||||
const providerStep = z
|
||||
.object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) })
|
||||
.object({
|
||||
kind: z.literal("run-provider"),
|
||||
provider: z.enum(["vulnerability", "provenance"]),
|
||||
stepId: id,
|
||||
})
|
||||
.strict();
|
||||
const validateProviderStep = z
|
||||
.object({
|
||||
@@ -340,7 +344,12 @@ const validateProviderStep = z
|
||||
provider: z.enum(["vulnerability", "provenance"]),
|
||||
})
|
||||
.strict();
|
||||
const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict();
|
||||
const promotionStep = z
|
||||
.object({ kind: z.literal("verify-promotion"), stepId: id })
|
||||
.strict();
|
||||
const cleanupPromotionStep = z
|
||||
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
|
||||
.strict();
|
||||
|
||||
const jobStepSchema = z.discriminatedUnion("kind", [
|
||||
checkoutStep,
|
||||
@@ -356,6 +365,7 @@ const jobStepSchema = z.discriminatedUnion("kind", [
|
||||
providerStep,
|
||||
validateProviderStep,
|
||||
promotionStep,
|
||||
cleanupPromotionStep,
|
||||
]);
|
||||
|
||||
const jobSchema = z
|
||||
@@ -756,9 +766,9 @@ function validateContractSemantics(
|
||||
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
|
||||
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
||||
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
||||
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"],
|
||||
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"],
|
||||
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||
@@ -776,8 +786,11 @@ function validateContractSemantics(
|
||||
vulnerability_provider: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
|
||||
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
|
||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
|
||||
@@ -785,8 +798,11 @@ function validateContractSemantics(
|
||||
provenance_provider: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
|
||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
|
||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
|
||||
@@ -794,13 +810,16 @@ function validateContractSemantics(
|
||||
promotion: [
|
||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||
{ name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" },
|
||||
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
|
||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
|
||||
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||
{ name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" },
|
||||
{ name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" },
|
||||
],
|
||||
production_gate: [],
|
||||
field_gate: [
|
||||
@@ -875,12 +894,14 @@ function validateContractSemantics(
|
||||
if (upload?.kind === "upload" && upload.always) {
|
||||
issue("promotion upload must not use always");
|
||||
}
|
||||
const cleanupIndex = order.indexOf("cleanup-promotion");
|
||||
if (
|
||||
order.indexOf("extract") < order.lastIndexOf("download") ||
|
||||
order.indexOf("verify-promotion") < order.indexOf("extract") ||
|
||||
order.indexOf("upload") < order.indexOf("verify-promotion")
|
||||
order.includes("extract") ||
|
||||
verificationIndex < order.lastIndexOf("download") ||
|
||||
uploadIndex < verificationIndex ||
|
||||
cleanupIndex !== uploadIndex + 1
|
||||
) {
|
||||
issue("promotion formula order must download, verify, then upload");
|
||||
issue("promotion formula order must download, finalize, upload, then cleanup without extraction");
|
||||
}
|
||||
}
|
||||
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
|
||||
@@ -905,7 +926,7 @@ function validateContractSemantics(
|
||||
const promotionUpload = promotion?.steps.find(
|
||||
(step) => step.kind === "upload" && step.transferId === "promoted-release",
|
||||
);
|
||||
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) {
|
||||
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) {
|
||||
issue("promotion upload bundle must contain the exact five typed paths");
|
||||
}
|
||||
|
||||
@@ -934,10 +955,9 @@ function validateCanonicalStepFields(
|
||||
const providerExpectations = {
|
||||
vulnerability_provider: {
|
||||
provider: "vulnerability",
|
||||
stepId: "supervise_vulnerability",
|
||||
downloadPath: ".release/vulnerability-candidate",
|
||||
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||
targetRoot: ".release/verified-vulnerability",
|
||||
lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml",
|
||||
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
|
||||
rawName: "VULNERABILITY_REPORT_PATH",
|
||||
sealedPath: "provider-evidence/vulnerability-report.json",
|
||||
@@ -945,10 +965,9 @@ function validateCanonicalStepFields(
|
||||
},
|
||||
provenance_provider: {
|
||||
provider: "provenance",
|
||||
stepId: "supervise_provenance",
|
||||
downloadPath: ".release/provenance-candidate",
|
||||
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||
targetRoot: ".release/verified-provenance",
|
||||
lockfilePath: ".release/verified-provenance/pnpm-lock.yaml",
|
||||
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
|
||||
rawName: "PROVENANCE_ATTESTATION_PATH",
|
||||
sealedPath: "provider-evidence/provenance-attestation.json",
|
||||
@@ -959,7 +978,6 @@ function validateCanonicalStepFields(
|
||||
const job = contract.jobs.find(({ id }) => id === jobId);
|
||||
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
|
||||
const download = job?.steps.find(({ kind }) => kind === "download");
|
||||
const extract = job?.steps.find(({ kind }) => kind === "extract");
|
||||
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
|
||||
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
|
||||
const upload = job?.steps.find(
|
||||
@@ -967,11 +985,9 @@ function validateCanonicalStepFields(
|
||||
);
|
||||
if (
|
||||
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
|
||||
!extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot ||
|
||||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider ||
|
||||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId ||
|
||||
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
|
||||
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
|
||||
environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath ||
|
||||
environment.get(expected.rawName) !== expected.rawPath ||
|
||||
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
|
||||
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
|
||||
@@ -987,15 +1003,18 @@ function validateCanonicalStepFields(
|
||||
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
|
||||
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
|
||||
];
|
||||
const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract");
|
||||
const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion");
|
||||
const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion");
|
||||
if (
|
||||
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
|
||||
!promotionExtract ||
|
||||
promotionExtract.kind !== "extract" ||
|
||||
promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
|
||||
promotionExtract.targetRoot !== ".release/verified-candidate"
|
||||
!promotionFinalizer ||
|
||||
promotionFinalizer.kind !== "verify-promotion" ||
|
||||
promotionFinalizer.stepId !== "finalize" ||
|
||||
!promotionCleanup ||
|
||||
promotionCleanup.kind !== "cleanup-promotion" ||
|
||||
promotionCleanup.finalizerStepId !== "finalize"
|
||||
) {
|
||||
issue("promotion download and extraction fields must remain linked");
|
||||
issue("promotion download fields and finalizer/cleanup step identities must remain linked");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,7 +1027,7 @@ function validateJobStepKinds(
|
||||
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
|
||||
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
|
||||
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]),
|
||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
|
||||
};
|
||||
for (const step of job.steps) {
|
||||
if (!allowed[job.kind].has(step.kind)) {
|
||||
@@ -1016,16 +1035,12 @@ function validateJobStepKinds(
|
||||
}
|
||||
}
|
||||
const kinds = job.steps.map(({ kind }) => kind);
|
||||
const extractIndex = kinds.indexOf("extract");
|
||||
if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) {
|
||||
issue(`verified extraction step is missing: ${job.id}`);
|
||||
}
|
||||
if (job.kind === "provider") {
|
||||
const providerIndex = kinds.indexOf("run-provider");
|
||||
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
|
||||
const uploadIndex = kinds.indexOf("upload");
|
||||
if (
|
||||
providerIndex < extractIndex ||
|
||||
providerIndex < kinds.lastIndexOf("download") ||
|
||||
validateProviderIndex < providerIndex ||
|
||||
uploadIndex < validateProviderIndex
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export const PROMOTED_STAGING_PATHS = Object.freeze([
|
||||
".release/promoted-staging/release-candidate.tar.gz",
|
||||
".release/promoted-staging/vulnerability-report.json",
|
||||
".release/promoted-staging/provenance-attestation.json",
|
||||
".release/promoted-staging/provider-verification.json",
|
||||
".release/promoted-staging/promotion-verification.json",
|
||||
export const PROMOTED_FILE_NAMES = Object.freeze([
|
||||
"release-candidate.tar.gz",
|
||||
"vulnerability-report.json",
|
||||
"provenance-attestation.json",
|
||||
"provider-verification.json",
|
||||
"promotion-verification.json",
|
||||
] as const);
|
||||
|
||||
export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number];
|
||||
|
||||
export const PROMOTED_UPLOAD_PATHS = Object.freeze(
|
||||
PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,131 @@ const timestamp = z.iso.datetime();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const jsonObject = z.record(z.string(), z.json());
|
||||
|
||||
const canonicalTimestamp = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||
.refine((value) => new Date(value).toISOString() === value, {
|
||||
message: "must be a canonical ISO-8601 UTC timestamp",
|
||||
});
|
||||
const safeRepositoryPath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1_024)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.startsWith("-") &&
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("\\") &&
|
||||
!value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") &&
|
||||
![...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
}),
|
||||
{ message: "must be a safe canonical repository-relative path" },
|
||||
);
|
||||
const assessmentInputRowSchema = z
|
||||
.object({
|
||||
path: safeRepositoryPath,
|
||||
bytes: z.int().nonnegative().max(268_435_456),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
const assessmentStatusSchema = z.enum(["PASS", "FAIL"]);
|
||||
|
||||
function addCanonicalInputIssues(
|
||||
rows: readonly Readonly<{ path: string }>[],
|
||||
pathPrefix: "policyInputs" | "evidenceInputs",
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
const paths = rows.map(({ path }) => path);
|
||||
const canonical = [...paths].sort((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
if (JSON.stringify(paths) !== JSON.stringify(canonical)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [pathPrefix],
|
||||
message: "must be in canonical ASCII path order",
|
||||
});
|
||||
}
|
||||
if (new Set(paths).size !== paths.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [pathPrefix],
|
||||
message: "must not contain duplicate paths",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localEvidenceAssessmentArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
artifactType: z.literal("local-evidence-assessment"),
|
||||
generatedAt: canonicalTimestamp,
|
||||
status: assessmentStatusSchema,
|
||||
verifier: z
|
||||
.object({
|
||||
id: nonEmptyString,
|
||||
version: nonEmptyString,
|
||||
sourceSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
source: z
|
||||
.object({
|
||||
revision: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u),
|
||||
sourceSetSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
candidate: z
|
||||
.object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 })
|
||||
.strict(),
|
||||
policyInputs: z.array(assessmentInputRowSchema).min(1).max(256),
|
||||
evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096),
|
||||
checks: z
|
||||
.object({
|
||||
release: assessmentStatusSchema,
|
||||
supplyChain: assessmentStatusSchema,
|
||||
dependencyPolicy: assessmentStatusSchema,
|
||||
licensePolicy: assessmentStatusSchema,
|
||||
vulnerabilityPolicy: assessmentStatusSchema,
|
||||
secretScan: assessmentStatusSchema,
|
||||
})
|
||||
.strict(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((assessment, context) => {
|
||||
addCanonicalInputIssues(assessment.policyInputs, "policyInputs", context);
|
||||
addCanonicalInputIssues(assessment.evidenceInputs, "evidenceInputs", context);
|
||||
const failedChecks = Object.values(assessment.checks).filter(
|
||||
(status) => status === "FAIL",
|
||||
);
|
||||
if (
|
||||
assessment.status === "PASS" &&
|
||||
(failedChecks.length > 0 || assessment.failures.length > 0)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "PASS requires all six checks PASS and no failures",
|
||||
});
|
||||
}
|
||||
if (
|
||||
assessment.status === "FAIL" &&
|
||||
(failedChecks.length === 0 || assessment.failures.length === 0)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "FAIL requires a failed check and a failure diagnostic",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type LocalEvidenceAssessment = z.infer<
|
||||
typeof localEvidenceAssessmentArtifactSchema
|
||||
>;
|
||||
|
||||
export const moduleInventoryArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { createLocalEvidenceAssessment } from "./lib/local-release-evidence.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
const assessment = await createLocalEvidenceAssessment();
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
schema: localEvidenceAssessmentArtifactSchema,
|
||||
value: assessment,
|
||||
});
|
||||
const manifest = await createReleaseCandidateManifest();
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
|
||||
@@ -131,6 +131,16 @@ function renderJob(
|
||||
` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`,
|
||||
);
|
||||
}
|
||||
if (job.kind === "provider") {
|
||||
const supervisor = job.steps.find((step) => step.kind === "run-provider");
|
||||
if (!supervisor || supervisor.kind !== "run-provider") {
|
||||
throw new TypeError("provider job lacks supervisor step");
|
||||
}
|
||||
lines.push(
|
||||
" outputs:",
|
||||
` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`,
|
||||
);
|
||||
}
|
||||
if (job.environment.length > 0) {
|
||||
lines.push(" env:");
|
||||
for (const binding of job.environment) {
|
||||
@@ -229,6 +239,7 @@ function renderStep(
|
||||
case "run-provider": {
|
||||
return [
|
||||
` - name: Run and validate external ${step.provider} provider in one trusted supervisor`,
|
||||
` id: ${yamlKey(step.stepId)}`,
|
||||
` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`,
|
||||
];
|
||||
}
|
||||
@@ -240,8 +251,23 @@ function renderStep(
|
||||
case "verify-promotion":
|
||||
return [
|
||||
" - name: Finalize verified promotion from inode-bound captured inputs",
|
||||
` id: ${yamlKey(step.stepId)}`,
|
||||
" run: node scripts/stage-verified-promotion.ts",
|
||||
];
|
||||
case "cleanup-promotion":
|
||||
return [
|
||||
" - name: Always remove private promotion staging",
|
||||
" if: always()",
|
||||
" env:",
|
||||
` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`,
|
||||
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
|
||||
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
|
||||
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
|
||||
" run: |",
|
||||
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then',
|
||||
" node scripts/cleanup-verified-promotion.ts",
|
||||
" fi",
|
||||
];
|
||||
case "upload": {
|
||||
const lines = [
|
||||
` - name: Upload ${humanize(step.transferId)}`,
|
||||
|
||||
@@ -36,6 +36,62 @@ const MAX_MEMBER_PATH_BYTES = 1_024;
|
||||
const TAR_EXECUTABLE = "/usr/bin/tar";
|
||||
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
||||
|
||||
export type CapturedCandidateArchive = Readonly<{
|
||||
bytes: Buffer;
|
||||
archiveSha256: string;
|
||||
}>;
|
||||
|
||||
export async function captureCiCandidateArchive(input: Readonly<{
|
||||
archivePath: string;
|
||||
expectedSha256: string;
|
||||
}>): Promise<CapturedCandidateArchive> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const absolute = path.resolve(input.archivePath);
|
||||
const before = await lstat(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||
}
|
||||
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
bytes = await readCapturedArchive(handle, before.size);
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (archiveSha256 !== input.expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
return Object.freeze({ bytes, archiveSha256 });
|
||||
}
|
||||
|
||||
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
|
||||
captured: CapturedCandidateArchive;
|
||||
verify: (view: Readonly<{
|
||||
extractionRoot: string;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>) => Promise<T>;
|
||||
}>): Promise<T> {
|
||||
let result: T | undefined;
|
||||
await verifyCapturedCiCandidateArchive(
|
||||
input.captured.bytes,
|
||||
input.captured.archiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
result = await input.verify({ extractionRoot, manifest });
|
||||
},
|
||||
},
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export async function verifyCiCandidateArchive(
|
||||
input: Readonly<{
|
||||
archivePath: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
localEvidenceAssessmentArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
releaseManifestArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
@@ -28,8 +29,15 @@ import {
|
||||
verifyBuildManifestOutputs,
|
||||
} from "./build-manifest-outputs.ts";
|
||||
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
||||
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
|
||||
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
||||
import {
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
collectDistOutputs,
|
||||
distSha256,
|
||||
releaseCandidateManifestSchema,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
||||
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
||||
import {
|
||||
@@ -290,7 +298,503 @@ export async function verifyLocalSupplyChainEvidence(
|
||||
});
|
||||
}
|
||||
|
||||
export const LOCAL_EVIDENCE_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/local-evidence-verifier";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
] as const);
|
||||
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
] as const);
|
||||
|
||||
export async function createLocalEvidenceAssessment(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<z.infer<typeof localEvidenceAssessmentArtifactSchema>> {
|
||||
const root = path.resolve(repositoryRoot);
|
||||
const outputs = await collectDistOutputs(root);
|
||||
const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter(
|
||||
(memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
const evidenceInputs = (
|
||||
await Promise.all([
|
||||
...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)),
|
||||
...evidencePaths.map((memberPath) => digestInput(root, memberPath)),
|
||||
])
|
||||
).sort((left, right) => asciiCompare(left.path, right.path));
|
||||
const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml");
|
||||
const sbom = evidenceInputs.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
);
|
||||
if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete");
|
||||
const candidate: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: lockfile.sha256,
|
||||
bundleSha256: supplyChainDigest(evidenceInputs),
|
||||
files: evidenceInputs,
|
||||
};
|
||||
const evaluated = await evaluateProducerLocalChecks(root, candidate);
|
||||
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||
releaseManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/provenance.json").then((value) =>
|
||||
provenanceArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||
supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
|
||||
sbomArtifactSchema.parse(value),
|
||||
),
|
||||
Promise.all(
|
||||
LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) =>
|
||||
digestInput(root, policyPath),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const identityFailures: string[] = [];
|
||||
if (build.commitSha !== release.commitSha) {
|
||||
identityFailures.push("producer build/release source revision mismatch");
|
||||
}
|
||||
if (
|
||||
provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256
|
||||
) {
|
||||
identityFailures.push("producer provenance/supply source-set mismatch");
|
||||
}
|
||||
if (supply.distSha256 !== candidate.distSha256) {
|
||||
identityFailures.push("producer supply/candidate dist digest mismatch");
|
||||
}
|
||||
if (supply.lockfileSha256 !== candidate.lockfileSha256) {
|
||||
identityFailures.push("producer supply/candidate lockfile digest mismatch");
|
||||
}
|
||||
if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) {
|
||||
identityFailures.push("producer supply/candidate SBOM digest mismatch");
|
||||
}
|
||||
const checks = {
|
||||
...evaluated.checks,
|
||||
...(identityFailures.some((failure) => failure.includes("build/release"))
|
||||
? { release: "FAIL" as const }
|
||||
: {}),
|
||||
...(identityFailures.some((failure) => !failure.includes("build/release"))
|
||||
? { supplyChain: "FAIL" as const }
|
||||
: {}),
|
||||
};
|
||||
const failures = [...evaluated.failures, ...identityFailures];
|
||||
const status = failures.length === 0 && Object.values(checks).every(
|
||||
(check) => check === "PASS",
|
||||
)
|
||||
? ("PASS" as const)
|
||||
: ("FAIL" as const);
|
||||
const verifierSources = policyInputs.filter(({ path: policyPath }) =>
|
||||
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||
);
|
||||
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
|
||||
throw new Error("local assessment verifier source set is incomplete");
|
||||
}
|
||||
return localEvidenceAssessmentArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
artifactType: "local-evidence-assessment",
|
||||
generatedAt: build.generatedAt,
|
||||
status,
|
||||
verifier: {
|
||||
id: LOCAL_EVIDENCE_VERIFIER_ID,
|
||||
version: LOCAL_EVIDENCE_VERIFIER_VERSION,
|
||||
sourceSha256: supplyChainDigest(verifierSources),
|
||||
},
|
||||
source: {
|
||||
revision: build.commitSha,
|
||||
sourceSetSha256: supply.sourceSetSha256,
|
||||
},
|
||||
candidate: {
|
||||
distSha256: candidate.distSha256,
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
sbomSha256: sbom.sha256,
|
||||
},
|
||||
policyInputs,
|
||||
evidenceInputs,
|
||||
checks,
|
||||
failures,
|
||||
});
|
||||
}
|
||||
|
||||
type LocalCheckName =
|
||||
| "release"
|
||||
| "supplyChain"
|
||||
| "dependencyPolicy"
|
||||
| "licensePolicy"
|
||||
| "vulnerabilityPolicy"
|
||||
| "secretScan";
|
||||
|
||||
async function evaluateProducerLocalChecks(
|
||||
root: string,
|
||||
candidate: ReleaseCandidateManifest,
|
||||
): Promise<Readonly<{
|
||||
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const checks: Record<LocalCheckName, "PASS" | "FAIL"> = {
|
||||
release: "PASS",
|
||||
supplyChain: "PASS",
|
||||
dependencyPolicy: "PASS",
|
||||
licensePolicy: "PASS",
|
||||
vulnerabilityPolicy: "PASS",
|
||||
secretScan: "PASS",
|
||||
};
|
||||
const failures: string[] = [];
|
||||
const evaluate = async (
|
||||
check: LocalCheckName,
|
||||
operation: () => Promise<readonly string[]>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const diagnostics = await operation();
|
||||
if (diagnostics.length > 0) {
|
||||
checks[check] = "FAIL";
|
||||
failures.push(...diagnostics.map((failure) => `${check}:${failure}`));
|
||||
}
|
||||
} catch (error) {
|
||||
checks[check] = "FAIL";
|
||||
failures.push(
|
||||
`${check}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
await evaluate("release", async () => {
|
||||
const [build, release, stored] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||
releaseManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/verification.json").then((value) =>
|
||||
releaseVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const diagnostics: string[] = [];
|
||||
if (
|
||||
build.commitSha !== release.commitSha ||
|
||||
build.buildId !== release.buildId ||
|
||||
build.releaseId !== release.releaseId ||
|
||||
build.generatedAt !== release.builtAt
|
||||
) {
|
||||
diagnostics.push("build/release identity mismatch");
|
||||
}
|
||||
if (
|
||||
!stored.passed ||
|
||||
!stored.artifact.checked ||
|
||||
!stored.artifact.compatible ||
|
||||
stored.artifact.mismatches.length > 0 ||
|
||||
stored.artifact.releaseId !== release.releaseId ||
|
||||
stored.generatedAt !== release.builtAt ||
|
||||
stored.fixtures.length === 0 ||
|
||||
stored.fixtures.some((fixture) => !fixture.passed)
|
||||
) {
|
||||
diagnostics.push("stored release verification is not a coherent PASS");
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("supplyChain", async () => {
|
||||
const [supply, coherence] = await Promise.all([
|
||||
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||
supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
|
||||
supplyChainCoherenceReportSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const diagnostics: string[] = [];
|
||||
if (
|
||||
supply.localStatus !== "PASS" ||
|
||||
supply.failures.length > 0 ||
|
||||
supply.distSha256 !== candidate.distSha256 ||
|
||||
supply.lockfileSha256 !== candidate.lockfileSha256 ||
|
||||
coherence.status !== "PASS" ||
|
||||
coherence.failures.length > 0 ||
|
||||
coherence.distSha256 !== candidate.distSha256 ||
|
||||
coherence.lockfileSha256 !== candidate.lockfileSha256
|
||||
) {
|
||||
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("dependencyPolicy", async () => {
|
||||
const [inventory, stored] = await Promise.all([
|
||||
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||
dependencyInventoryArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/dependency-diff.json").then((value) =>
|
||||
dependencyDiffArtifactSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const recomputed = recomputeDependencyEvidence({
|
||||
inventory,
|
||||
baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"),
|
||||
baselineApproval: await optionalReadJson(
|
||||
root,
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
),
|
||||
dependencyChangeEvidence: await readJson(
|
||||
root,
|
||||
"config/security/dependency-change-evidence.json",
|
||||
),
|
||||
});
|
||||
return compareStoredDependencyEvidence(recomputed, stored);
|
||||
});
|
||||
await evaluate("licensePolicy", async () => {
|
||||
const [inventory, stored, policy] = await Promise.all([
|
||||
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||
dependencyInventoryArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/license-report.json").then((value) =>
|
||||
licenseReportArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "config/security/dependency-policy.json"),
|
||||
]);
|
||||
return compareStoredLicenseEvidence(
|
||||
recomputeLicenseEvidence({ inventory, policy }),
|
||||
stored,
|
||||
);
|
||||
});
|
||||
await evaluate("vulnerabilityPolicy", async () => {
|
||||
const vulnerability = vulnerabilityReportArtifactSchema.parse(
|
||||
await readJson(root, "artifacts/security/vulnerability-report.json"),
|
||||
);
|
||||
return compareStoredLocalVulnerabilityReport(
|
||||
candidate.lockfileSha256,
|
||||
vulnerability,
|
||||
);
|
||||
});
|
||||
await evaluate("secretScan", async () => {
|
||||
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
|
||||
return verifyStoredSecretScan(
|
||||
evaluation,
|
||||
await readJson(root, "artifacts/security/scan.sarif"),
|
||||
);
|
||||
});
|
||||
return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) });
|
||||
}
|
||||
|
||||
async function digestInput(
|
||||
repositoryRoot: string,
|
||||
memberPath: string,
|
||||
): Promise<Readonly<{ path: string; bytes: number; sha256: string }>> {
|
||||
const absolute = path.resolve(repositoryRoot, memberPath);
|
||||
const relative = path.relative(repositoryRoot, absolute);
|
||||
if (
|
||||
relative === "" ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError(`local assessment input escapes repository: ${memberPath}`);
|
||||
}
|
||||
const bytes = await readFile(absolute);
|
||||
return Object.freeze({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
extractionRoot: string;
|
||||
expectedManifest: ReleaseCandidateManifest;
|
||||
}>): Promise<Readonly<{
|
||||
status: "PASS" | "FAIL";
|
||||
identity: null | Readonly<{
|
||||
sourceRevision: string;
|
||||
sourceSetSha256: string;
|
||||
assessmentSha256: string;
|
||||
}>;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const extractionRoot = path.resolve(input.extractionRoot);
|
||||
const failures: string[] = [];
|
||||
let extractedManifest: ReleaseCandidateManifest | null = null;
|
||||
try {
|
||||
extractedManifest = releaseCandidateManifestSchema.parse(
|
||||
await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
||||
);
|
||||
} catch {
|
||||
failures.push("extracted release candidate manifest is missing or invalid");
|
||||
}
|
||||
if (
|
||||
extractedManifest &&
|
||||
JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest)
|
||||
) {
|
||||
failures.push("caller expectedManifest differs from extracted manifest");
|
||||
}
|
||||
|
||||
let assessment: z.infer<typeof localEvidenceAssessmentArtifactSchema> | null = null;
|
||||
let assessmentSha256 = "";
|
||||
let assessmentBytes: Buffer | null = null;
|
||||
try {
|
||||
assessmentBytes = await readFile(
|
||||
path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH),
|
||||
);
|
||||
assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex");
|
||||
assessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown,
|
||||
);
|
||||
} catch {
|
||||
failures.push("local evidence assessment is missing or invalid");
|
||||
}
|
||||
|
||||
if (assessment && extractedManifest) {
|
||||
const assessmentMember = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
if (
|
||||
!assessmentMember ||
|
||||
assessmentMember.bytes !== assessmentBytes?.byteLength ||
|
||||
assessmentMember.sha256 !== assessmentSha256
|
||||
) {
|
||||
failures.push("local assessment manifest binding mismatch");
|
||||
}
|
||||
if (
|
||||
assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID ||
|
||||
assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION
|
||||
) {
|
||||
failures.push("local assessment verifier identity mismatch");
|
||||
}
|
||||
const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath);
|
||||
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
|
||||
failures.push("local assessment policyInputs exact set mismatch");
|
||||
}
|
||||
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
|
||||
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||
);
|
||||
if (
|
||||
verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length ||
|
||||
supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256
|
||||
) {
|
||||
failures.push("local assessment verifier-source digest mismatch");
|
||||
}
|
||||
|
||||
const expectedEvidenceInputs = extractedManifest.files.filter(
|
||||
({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) {
|
||||
failures.push("local assessment evidenceInputs exact member binding mismatch");
|
||||
}
|
||||
const sbom = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
);
|
||||
if (
|
||||
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
|
||||
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
|
||||
!sbom ||
|
||||
assessment.candidate.sbomSha256 !== sbom.sha256
|
||||
) {
|
||||
failures.push("local assessment candidate digest binding mismatch");
|
||||
}
|
||||
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
|
||||
failures.push("local evidence assessment is not PASS");
|
||||
}
|
||||
|
||||
const identities = await readArchivedIdentities(extractionRoot, failures);
|
||||
if (
|
||||
identities.buildRevision !== assessment.source.revision ||
|
||||
identities.releaseRevision !== assessment.source.revision
|
||||
) {
|
||||
failures.push("local assessment source revision identity mismatch");
|
||||
}
|
||||
if (
|
||||
identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 ||
|
||||
identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256
|
||||
) {
|
||||
failures.push("local assessment source-set identity mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueFailures = Object.freeze([...new Set(failures)]);
|
||||
const passingAssessment = uniqueFailures.length === 0 ? assessment : null;
|
||||
return Object.freeze({
|
||||
status: passingAssessment ? "PASS" : "FAIL",
|
||||
identity: passingAssessment
|
||||
? Object.freeze({
|
||||
sourceRevision: passingAssessment.source.revision,
|
||||
sourceSetSha256: passingAssessment.source.sourceSetSha256,
|
||||
assessmentSha256,
|
||||
})
|
||||
: null,
|
||||
failures: uniqueFailures,
|
||||
});
|
||||
}
|
||||
|
||||
async function readArchivedIdentities(
|
||||
extractionRoot: string,
|
||||
failures: string[],
|
||||
): Promise<Readonly<{
|
||||
buildRevision: unknown;
|
||||
releaseRevision: unknown;
|
||||
provenanceSourceSetSha256: unknown;
|
||||
supplySourceSetSha256: unknown;
|
||||
}>> {
|
||||
try {
|
||||
const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([
|
||||
readJson(extractionRoot, "artifacts/release/build-manifest.json"),
|
||||
readJson(extractionRoot, "dist/release-manifest.json"),
|
||||
readJson(extractionRoot, "artifacts/release/provenance.json"),
|
||||
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"),
|
||||
]);
|
||||
const build = buildManifestArtifactSchema.parse(buildDocument);
|
||||
const release = releaseManifestArtifactSchema.parse(releaseDocument);
|
||||
const provenance = provenanceArtifactSchema.parse(provenanceDocument);
|
||||
const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument);
|
||||
return Object.freeze({
|
||||
buildRevision: build.commitSha,
|
||||
releaseRevision: release.commitSha,
|
||||
provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256,
|
||||
supplySourceSetSha256: supply.sourceSetSha256,
|
||||
});
|
||||
} catch {
|
||||
failures.push("archived source/build/provenance identities are missing or invalid");
|
||||
return Object.freeze({
|
||||
buildRevision: null,
|
||||
releaseRevision: null,
|
||||
provenanceSourceSetSha256: null,
|
||||
supplySourceSetSha256: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function assessLocalEvidenceForProducer(input: Readonly<{
|
||||
repositoryRoot?: string;
|
||||
candidate: ReleaseCandidateManifest;
|
||||
}>): Promise<Readonly<{
|
||||
|
||||
+360
-219
@@ -1,40 +1,54 @@
|
||||
import { createHash, createPublicKey, randomUUID } from "node:crypto";
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
randomBytes as cryptoRandomBytes,
|
||||
} from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
rm,
|
||||
stat,
|
||||
} 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";
|
||||
PROMOTED_FILE_NAMES,
|
||||
type PromotedFileName,
|
||||
} from "../contracts/promotion-artifacts.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;
|
||||
}>;
|
||||
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<{
|
||||
destinationName: string;
|
||||
name: PromotedFileName;
|
||||
bytes: Buffer;
|
||||
digest: string;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export async function stageVerifiedPromotion(input: Readonly<{
|
||||
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;
|
||||
@@ -44,203 +58,330 @@ export async function stageVerifiedPromotion(input: Readonly<{
|
||||
vulnerabilityKeyId: string;
|
||||
provenancePublicKeyPath: string;
|
||||
provenanceKeyId: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
runnerTempRoot: string;
|
||||
}>, dependencies: Readonly<{
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
nowEpochMs?: () => number;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublishRename?: () => Promise<void>;
|
||||
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
|
||||
beforePublish?: () => Promise<void>;
|
||||
afterStagingWrite?: () => Promise<void>;
|
||||
}> = {}): Promise<FinalizedPromotion> {
|
||||
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),
|
||||
]);
|
||||
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?.();
|
||||
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 vulnerabilityTrust = capturedTrust(
|
||||
input.vulnerabilityKeyId,
|
||||
vulnerabilityKeyBytes,
|
||||
);
|
||||
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),
|
||||
),
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
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;
|
||||
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 {
|
||||
for (const source of stagedFiles) {
|
||||
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(temporary, source.destinationName),
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
path.join(descriptorStaging, file.name),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o400,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(source.bytes);
|
||||
await handle.writeFile(file.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
await syncDirectory(temporary);
|
||||
await dependencies.beforePublishRename?.();
|
||||
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
||||
await syncDirectory(descriptorStaging);
|
||||
await syncHandle(parentHandle);
|
||||
await afterStagingWrite?.();
|
||||
const after = await lstat(parentPath);
|
||||
if (
|
||||
releaseIdentity.dev <= 0 ||
|
||||
releaseIdentity.ino <= 0 ||
|
||||
currentReleaseIdentity.dev !== releaseIdentity.dev ||
|
||||
currentReleaseIdentity.ino !== releaseIdentity.ino
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.isSymbolicLink() ||
|
||||
!after.isDirectory()
|
||||
) {
|
||||
throw new Error("promotion staging parent identity changed");
|
||||
throw new Error("runner temporary parent identity changed during staging");
|
||||
}
|
||||
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);
|
||||
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 (ownsTemporary) await rm(temporary, { recursive: true, force: true });
|
||||
if (ownsStaging) {
|
||||
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
await parentHandle.close();
|
||||
}
|
||||
return Object.freeze(
|
||||
stagedFiles.map(({ destinationName, digest }) =>
|
||||
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
|
||||
),
|
||||
}
|
||||
|
||||
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);
|
||||
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, "/"),
|
||||
@@ -248,43 +389,43 @@ async function capture(root: string, configuredPath: string, maxBytes: number):
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
|
||||
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
|
||||
}
|
||||
|
||||
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}`);
|
||||
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");
|
||||
}
|
||||
return relative.replaceAll(path.sep, "/");
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(
|
||||
directory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
await syncHandle(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(directory, constants.O_RDONLY);
|
||||
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ 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";
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
} 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;
|
||||
|
||||
@@ -23,6 +28,7 @@ export type VerifyPromotionInputsOptions = Readonly<{
|
||||
providerEvidenceRoot?: string;
|
||||
trustRoot?: string;
|
||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||
nowEpochMs?: () => number;
|
||||
}>;
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
@@ -75,25 +81,73 @@ export async function verifyPromotionInputs(
|
||||
);
|
||||
const localEvidence = await (
|
||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||
)({ repositoryRoot, candidate: manifest });
|
||||
)({ 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({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
expected,
|
||||
localStatus: localEvidence.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: options.nowEpochMs,
|
||||
});
|
||||
const failures = [
|
||||
...inputFailures,
|
||||
@@ -101,21 +155,93 @@ export async function verifyPromotionInputs(
|
||||
...localEvidence.failures,
|
||||
...result.failures,
|
||||
];
|
||||
return Object.freeze({
|
||||
schemaVersion: 2 as const,
|
||||
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),
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
candidateArchiveSha256: archive.sha256,
|
||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256,
|
||||
provenanceAttestationSha256: provenanceCapture.sha256,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,13 +252,15 @@ export async function readProviderTrust(
|
||||
): 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: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||
),
|
||||
),
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+317
-118
@@ -1,90 +1,145 @@
|
||||
import { verify, type KeyObject } from "node:crypto";
|
||||
import { createHash, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
||||
import {
|
||||
canonicalizeSupplyChainValue,
|
||||
supplyChainDigest,
|
||||
} from "./supply-chain.ts";
|
||||
|
||||
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
||||
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
|
||||
export const PROMOTION_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/promotion-verifier";
|
||||
export const PROMOTION_VERIFIER_VERSION = "3";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
|
||||
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
|
||||
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().min(1);
|
||||
const timestamp = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||
.refine((value) => new Date(value).toISOString() === value);
|
||||
const runSchema = z
|
||||
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
|
||||
.strict();
|
||||
const sourceSchema = z
|
||||
.object({ revision, sourceSetSha256: sha256 })
|
||||
.strict();
|
||||
const candidateSchema = z
|
||||
.object({
|
||||
archiveSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
keyId: nonEmptyString,
|
||||
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
||||
publicKeyFingerprint: fingerprint,
|
||||
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
|
||||
})
|
||||
.strict();
|
||||
const providerCommon = {
|
||||
schemaVersion: z.literal(2),
|
||||
provider: nonEmptyString,
|
||||
issuedAt: timestamp,
|
||||
expiresAt: timestamp,
|
||||
run: providerRunSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
signature: signatureSchema,
|
||||
} as const;
|
||||
|
||||
export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
scannedLockfileSha256: sha256,
|
||||
scannedDistSha256: sha256,
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("vulnerability-report"),
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const provenanceProviderAttestationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("provenance-attestation"),
|
||||
signer: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
subject: z
|
||||
.object({
|
||||
name: z.literal("dist"),
|
||||
digest: z.object({ sha256 }).strict(),
|
||||
})
|
||||
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
|
||||
.strict(),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const verificationCommon = {
|
||||
schemaVersion: z.literal(3),
|
||||
verifiedAt: timestamp,
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
verifier: z
|
||||
.object({ id: nonEmptyString, version: nonEmptyString })
|
||||
.strict(),
|
||||
run: runSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
providerEvidence: z
|
||||
.object({
|
||||
vulnerabilityReportSha256: sha256,
|
||||
provenanceAttestationSha256: sha256,
|
||||
vulnerabilityInvocationNonce: nonce,
|
||||
provenanceInvocationNonce: nonce,
|
||||
vulnerabilityKeyId: nonEmptyString,
|
||||
vulnerabilityKeyFingerprint: fingerprint,
|
||||
provenanceKeyId: nonEmptyString,
|
||||
provenanceKeyFingerprint: fingerprint,
|
||||
})
|
||||
.strict(),
|
||||
trustPolicySha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
} as const;
|
||||
|
||||
const providerVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("provider-verification"),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const promotionVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("promotion-verification"),
|
||||
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
|
||||
localEvidenceAssessmentSha256: sha256,
|
||||
providerVerificationSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
artifactType: z.enum(["provider-verification", "promotion-verification"]),
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
candidateArchiveSha256: sha256.nullable(),
|
||||
vulnerabilityReportSha256: sha256.nullable(),
|
||||
provenanceAttestationSha256: sha256.nullable(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const passing =
|
||||
artifact.status === "PASS" &&
|
||||
artifact.vulnerabilityStatus === "PASS" &&
|
||||
artifact.provenanceAttestationStatus === "PASS" &&
|
||||
artifact.failures.length === 0;
|
||||
if ((artifact.status === "PASS") !== passing) {
|
||||
.discriminatedUnion("artifactType", [
|
||||
providerVerificationV3Schema,
|
||||
promotionVerificationV3Schema,
|
||||
])
|
||||
.superRefine((record, context) => {
|
||||
const subordinatePass =
|
||||
record.artifactType === "provider-verification"
|
||||
? record.vulnerabilityStatus === "PASS" &&
|
||||
record.provenanceAttestationStatus === "PASS"
|
||||
: record.localEvidenceStatus === "PASS";
|
||||
const coherentPass = subordinatePass && record.failures.length === 0;
|
||||
if ((record.status === "PASS") !== coherentPass) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "verification PASS must agree with provider statuses and failures",
|
||||
message: "verification PASS must agree with subordinate statuses and failures",
|
||||
});
|
||||
}
|
||||
if (
|
||||
artifact.status === "PASS" &&
|
||||
[
|
||||
artifact.candidateArchiveSha256,
|
||||
artifact.vulnerabilityReportSha256,
|
||||
artifact.provenanceAttestationSha256,
|
||||
].some((digest) => digest === null)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["candidateArchiveSha256"],
|
||||
message: "passing verification requires every exact input digest",
|
||||
});
|
||||
}
|
||||
if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) {
|
||||
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["failures"],
|
||||
@@ -100,6 +155,20 @@ export type ProviderVerificationArtifactType = z.infer<
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyObject;
|
||||
publicKeyFingerprint: string;
|
||||
}>;
|
||||
|
||||
export type ExpectedPromotionContext = Readonly<{
|
||||
run: Readonly<{ id: string; attempt: number }>;
|
||||
source: Readonly<{ revision: string; sourceSetSha256: string }>;
|
||||
candidate: Readonly<{
|
||||
archiveSha256: string;
|
||||
bundleSha256: string;
|
||||
distSha256: string;
|
||||
lockfileSha256: string;
|
||||
}>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
@@ -109,35 +178,134 @@ export type PromotionEvidenceResult = Readonly<{
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
export function validateProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
value: unknown;
|
||||
expected: ExpectedPromotionContext;
|
||||
trust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Readonly<{
|
||||
evidence: unknown | null;
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}> {
|
||||
const failures: string[] = [];
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external vulnerability provider report is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (parsed.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external signed provenance attestation is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||
const { signature: _signature, ...payload } = value;
|
||||
return Buffer.from(
|
||||
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
||||
"utf8",
|
||||
);
|
||||
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
|
||||
}
|
||||
|
||||
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
|
||||
if (publicKey.asymmetricKeyType !== "ed25519") {
|
||||
throw new TypeError("provider trust key must be Ed25519");
|
||||
}
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createTrustPolicy(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>) {
|
||||
return Object.freeze({
|
||||
algorithm: "Ed25519" as const,
|
||||
vulnerability: Object.freeze({
|
||||
keyId: input.vulnerabilityTrust.keyId,
|
||||
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
|
||||
}),
|
||||
provenance: Object.freeze({
|
||||
keyId: input.provenanceTrust.keyId,
|
||||
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
|
||||
}),
|
||||
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
|
||||
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function trustPolicySha256(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>): string {
|
||||
return supplyChainDigest(createTrustPolicy(input));
|
||||
}
|
||||
|
||||
export function evaluatePromotionEvidence(input: Readonly<{
|
||||
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
||||
currentDistSha256: string;
|
||||
expected: ExpectedPromotionContext;
|
||||
localStatus: unknown;
|
||||
vulnerabilityReport: unknown;
|
||||
provenanceAttestation: unknown;
|
||||
vulnerabilityTrust: ProviderTrust | null;
|
||||
provenanceTrust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): PromotionEvidenceResult {
|
||||
const failures: string[] = [];
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
||||
"FAIL_UNVERIFIED";
|
||||
|
||||
if (input.localStatus !== "PASS") {
|
||||
failures.push("local supply-chain evidence is not PASS");
|
||||
}
|
||||
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
||||
failures.push("candidate dist bytes changed after immutable build");
|
||||
}
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
|
||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||
input.vulnerabilityReport,
|
||||
@@ -145,36 +313,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
if (!vulnerability.success) {
|
||||
failures.push("external vulnerability provider report is missing or invalid");
|
||||
} else {
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 !==
|
||||
input.candidate.lockfileSha256
|
||||
) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
||||
) {
|
||||
failures.push("vulnerability report dist digest mismatch");
|
||||
}
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
vulnerability.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.vulnerabilityTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
vulnerability.data,
|
||||
input.vulnerabilityTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("vulnerability report signature verification failed");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 ===
|
||||
input.candidate.lockfileSha256 &&
|
||||
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
||||
vulnerability.data.findings.length === 0 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
vulnerabilityStatus = "PASS";
|
||||
}
|
||||
}
|
||||
@@ -185,22 +337,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
if (!provenance.success) {
|
||||
failures.push("external signed provenance attestation is missing or invalid");
|
||||
} else {
|
||||
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
||||
failures.push("provenance attestation dist digest mismatch");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
provenance.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.provenanceTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("provenance attestation signature verification failed");
|
||||
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
if (
|
||||
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
provenanceAttestationStatus = "PASS";
|
||||
}
|
||||
}
|
||||
@@ -218,29 +368,78 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
function signatureMatches(
|
||||
function validateCommonContext(
|
||||
label: "vulnerability report" | "provenance attestation",
|
||||
evidence: z.infer<
|
||||
| typeof vulnerabilityProviderReportSchema
|
||||
| typeof provenanceProviderAttestationSchema
|
||||
>,
|
||||
expected: ExpectedPromotionContext,
|
||||
expectedNonce: string,
|
||||
trust: ProviderTrust | null,
|
||||
): boolean {
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
if (
|
||||
evidence.run.id !== expected.run.id ||
|
||||
evidence.run.attempt !== expected.run.attempt
|
||||
) {
|
||||
failures.push(`${label} run identity mismatch`);
|
||||
}
|
||||
if (evidence.run.invocationNonce !== expectedNonce) {
|
||||
failures.push(`${label} invocation nonce mismatch`);
|
||||
}
|
||||
if (
|
||||
evidence.source.revision !== expected.source.revision ||
|
||||
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
|
||||
) {
|
||||
failures.push(`${label} source identity mismatch`);
|
||||
}
|
||||
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
|
||||
failures.push(`${label} candidate identity mismatch`);
|
||||
}
|
||||
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
|
||||
if (
|
||||
!trust ||
|
||||
evidence.signature.keyId !== trust.keyId ||
|
||||
trust.publicKey.asymmetricKeyType !== "ed25519"
|
||||
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
|
||||
) {
|
||||
return false;
|
||||
failures.push(`${label} trust identity mismatch`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
);
|
||||
if (
|
||||
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
|
||||
!verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
)
|
||||
) {
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceTime(
|
||||
label: string,
|
||||
issuedAt: string,
|
||||
expiresAt: string,
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
const issued = Date.parse(issuedAt);
|
||||
const expires = Date.parse(expiresAt);
|
||||
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
|
||||
failures.push(`${label} issuedAt exceeds allowed future skew`);
|
||||
}
|
||||
if (expires <= now) failures.push(`${label} is expired`);
|
||||
if (expires <= issued) failures.push(`${label} validity window is not positive`);
|
||||
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
|
||||
failures.push(`${label} validity window exceeds two hours`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
type CapturedCandidateArchive,
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
|
||||
import { validateProviderUpload } from "./provider-upload-validator.ts";
|
||||
|
||||
export type ProviderInvocation = Readonly<{
|
||||
candidateRoot: string;
|
||||
environment: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
export async function superviseProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
trust: ProviderTrust;
|
||||
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
|
||||
captureReport: () => Promise<Buffer>;
|
||||
}>, dependencies: Readonly<{
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
validateUpload?: typeof validateProviderUpload;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
nowEpochMs?: () => number;
|
||||
}> = {}): Promise<Readonly<{
|
||||
evidence: unknown;
|
||||
invocationNonce: string;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
}>> {
|
||||
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
|
||||
if (nonceBytes.byteLength !== 32) {
|
||||
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
||||
}
|
||||
const invocationNonce = nonceBytes.toString("hex");
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
||||
captured,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||
throw new Error("provider candidate source revision mismatch");
|
||||
}
|
||||
const expectedContext: ExpectedPromotionContext = Object.freeze({
|
||||
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
|
||||
source: Object.freeze({
|
||||
revision: local.identity.sourceRevision,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
}),
|
||||
candidate: Object.freeze({
|
||||
archiveSha256: captured.archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}),
|
||||
vulnerabilityInvocationNonce:
|
||||
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
||||
provenanceInvocationNonce:
|
||||
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
||||
});
|
||||
const issuedAt = new Date(now).toISOString();
|
||||
const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString();
|
||||
await input.executeProvider({
|
||||
candidateRoot: extractionRoot,
|
||||
environment: providerInvocationEnvironment({
|
||||
kind: input.kind,
|
||||
expectedContext,
|
||||
invocationNonce,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
trust: input.trust,
|
||||
}),
|
||||
});
|
||||
const capturedReport = await input.captureReport();
|
||||
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
|
||||
kind: input.kind,
|
||||
verifiedManifest: manifest,
|
||||
archiveSha256: captured.archiveSha256,
|
||||
candidateRoot: extractionRoot,
|
||||
capturedReport,
|
||||
expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function providerInvocationEnvironment(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
invocationNonce: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
trust: ProviderTrust;
|
||||
}>): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||
PROVIDER_EVIDENCE_TYPE:
|
||||
input.kind === "vulnerability"
|
||||
? "vulnerability-report"
|
||||
: "provenance-attestation",
|
||||
PROVIDER_ISSUED_AT: input.issuedAt,
|
||||
PROVIDER_EXPIRES_AT: input.expiresAt,
|
||||
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
|
||||
PROVIDER_KEY_ID: input.trust.keyId,
|
||||
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
|
||||
CI_RUN_ID: input.expectedContext.run.id,
|
||||
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
|
||||
SOURCE_REVISION: input.expectedContext.source.revision,
|
||||
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
|
||||
CANDIDATE_ROOT: "/candidate",
|
||||
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
|
||||
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
|
||||
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
||||
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
||||
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
||||
});
|
||||
}
|
||||
|
||||
export type CaptureArchiveDependency = (
|
||||
input: Readonly<{ archivePath: string; expectedSha256: string }>,
|
||||
) => Promise<CapturedCandidateArchive>;
|
||||
@@ -1,74 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
validateProviderEvidence,
|
||||
type ExpectedPromotionContext,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
verifyReleaseCandidate,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
|
||||
export async function validateProviderUpload(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
verifiedManifest: ReleaseCandidateManifest;
|
||||
archiveSha256: string;
|
||||
candidateRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
reportPath: string;
|
||||
workspaceRoot?: string;
|
||||
expectedDistSha256: string;
|
||||
capturedReport: Buffer;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
trust: ProviderTrust;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Promise<unknown> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) {
|
||||
throw new TypeError("expected candidate dist SHA-256 is invalid");
|
||||
if (
|
||||
input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 ||
|
||||
input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 ||
|
||||
input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 ||
|
||||
input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("provider supervisor expected candidate context mismatch");
|
||||
}
|
||||
const archive = await verifyCiCandidateArchive({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const manifest = archive.manifest;
|
||||
if (manifest.distSha256 !== input.expectedDistSha256) {
|
||||
throw new Error("provider input candidate dist digest mismatch");
|
||||
}
|
||||
const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot);
|
||||
const verifiedCandidate = await verifyReleaseCandidate(
|
||||
input.verifiedManifest,
|
||||
input.candidateRoot,
|
||||
);
|
||||
if (verifiedCandidate.failures.length > 0) {
|
||||
throw new Error(
|
||||
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
const reportAbsolute = path.resolve(input.reportPath);
|
||||
const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd());
|
||||
const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/");
|
||||
const report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await readBoundedRegularFile({
|
||||
root: reportRoot,
|
||||
relativePath: reportRelative,
|
||||
maxBytes: 8_388_608,
|
||||
}),
|
||||
),
|
||||
) as unknown;
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.parse(report);
|
||||
const lockfile = await readBoundedRegularFile({
|
||||
root: input.candidateRoot,
|
||||
relativePath: "pnpm-lock.yaml",
|
||||
maxBytes: 67_108_864,
|
||||
});
|
||||
const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex");
|
||||
if (
|
||||
parsed.scannedDistSha256 !== manifest.distSha256 ||
|
||||
parsed.scannedLockfileSha256 !== manifest.lockfileSha256 ||
|
||||
lockfileSha256 !== manifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("vulnerability provider evidence candidate digest mismatch");
|
||||
}
|
||||
return parsed;
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new TypeError("provider output is not canonical UTF-8 JSON");
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.parse(report);
|
||||
if (parsed.subject.digest.sha256 !== manifest.distSha256) {
|
||||
throw new Error("provenance provider evidence candidate digest mismatch");
|
||||
const evaluated = validateProviderEvidence({
|
||||
kind: input.kind,
|
||||
value: report,
|
||||
expected: input.expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: input.nowEpochMs,
|
||||
});
|
||||
if (evaluated.status !== "PASS" || !evaluated.evidence) {
|
||||
throw new Error(
|
||||
`provider evidence context validation failed: ${evaluated.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
return evaluated.evidence;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export type ReleaseCandidateManifest = z.infer<
|
||||
|
||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
|
||||
"artifacts/security/local-evidence-assessment.json";
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"pnpm-lock.yaml",
|
||||
@@ -45,6 +47,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
"artifacts/security/dependency-diff.json",
|
||||
"artifacts/security/license-report.json",
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
"artifacts/security/scan.sarif",
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { access, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { access, appendFile, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
import { validateProviderUpload } from "./lib/provider-upload-validator.ts";
|
||||
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
|
||||
import { readProviderTrust } from "./lib/promotion-verifier.ts";
|
||||
import { superviseProviderEvidence } from "./lib/provider-supervisor.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
@@ -29,22 +31,37 @@ const reportPath =
|
||||
? process.env.VULNERABILITY_REPORT_PATH
|
||||
: process.env.PROVENANCE_ATTESTATION_PATH;
|
||||
const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH;
|
||||
const candidateLockfile = process.env.CANDIDATE_LOCKFILE_PATH;
|
||||
const archivePath = process.env.CANDIDATE_ARCHIVE_PATH;
|
||||
const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256;
|
||||
const candidateDistSha256 = process.env.CANDIDATE_DIST_SHA256;
|
||||
const publicKeyPath = kind === "vulnerability"
|
||||
? process.env.VULNERABILITY_PUBLIC_KEY_PATH
|
||||
: process.env.PROVENANCE_PUBLIC_KEY_PATH;
|
||||
const keyId = kind === "vulnerability"
|
||||
? process.env.VULNERABILITY_KEY_ID
|
||||
: process.env.PROVENANCE_KEY_ID;
|
||||
const runId = process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? process.env.CI_RUN_ID;
|
||||
const runAttemptSource = process.env.GITEA_RUN_ATTEMPT ??
|
||||
process.env.GITHUB_RUN_ATTEMPT ?? process.env.CI_RUN_ATTEMPT;
|
||||
const sourceRevision = process.env.EXPECTED_SOURCE_REVISION ?? process.env.VITE_COMMIT_SHA;
|
||||
if (
|
||||
!command ||
|
||||
!reportPath ||
|
||||
!sealedPath ||
|
||||
!candidateLockfile ||
|
||||
!archivePath ||
|
||||
!archiveSha256 ||
|
||||
!candidateDistSha256
|
||||
!publicKeyPath ||
|
||||
!keyId ||
|
||||
!runId ||
|
||||
!runAttemptSource ||
|
||||
!sourceRevision
|
||||
) {
|
||||
process.stderr.write("Provider supervisor environment is incomplete\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const runAttempt = Number(runAttemptSource);
|
||||
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
||||
throw new TypeError("provider supervisor run attempt is invalid");
|
||||
}
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const reportAbsolute = path.resolve(reportPath);
|
||||
@@ -62,22 +79,30 @@ await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "s
|
||||
await access("/usr/bin/bwrap", constants.X_OK).catch(() => {
|
||||
throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required");
|
||||
});
|
||||
|
||||
const childEnvironment = createProviderEnvironment(kind, reportPath, {
|
||||
candidateLockfile,
|
||||
archivePath,
|
||||
archiveSha256,
|
||||
candidateDistSha256,
|
||||
});
|
||||
await runProviderInSandbox(command, childEnvironment, rawDirectory, workspaceRoot);
|
||||
const parsed = await validateProviderUpload({
|
||||
const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId);
|
||||
if (!trust) throw new TypeError("provider supervisor trust key is invalid");
|
||||
const supervised = await superviseProviderEvidence({
|
||||
kind,
|
||||
candidateRoot: path.dirname(path.resolve(candidateLockfile)),
|
||||
archivePath,
|
||||
expectedArchiveSha256: archiveSha256,
|
||||
reportPath,
|
||||
workspaceRoot,
|
||||
expectedDistSha256: candidateDistSha256,
|
||||
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
|
||||
trust,
|
||||
executeProvider: async ({ candidateRoot, environment }) => {
|
||||
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
|
||||
await runProviderInSandbox(
|
||||
command,
|
||||
childEnvironment,
|
||||
rawDirectory,
|
||||
workspaceRoot,
|
||||
candidateRoot,
|
||||
);
|
||||
},
|
||||
captureReport: () =>
|
||||
readBoundedRegularFile({
|
||||
root: workspaceRoot,
|
||||
relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"),
|
||||
maxBytes: 8_388_608,
|
||||
}),
|
||||
});
|
||||
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
|
||||
await writeValidatedJsonArtifact({
|
||||
@@ -86,19 +111,21 @@ await writeValidatedJsonArtifact({
|
||||
kind === "vulnerability"
|
||||
? vulnerabilityProviderReportSchema
|
||||
: provenanceProviderAttestationSchema,
|
||||
value: parsed,
|
||||
value: supervised.evidence,
|
||||
});
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await appendFile(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`invocation_nonce=${supervised.invocationNonce}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
process.stdout.write(`${kind} provider supervised validation: PASS\n`);
|
||||
|
||||
function createProviderEnvironment(
|
||||
providerKind: "vulnerability" | "provenance",
|
||||
rawReportPath: string,
|
||||
candidate: Readonly<{
|
||||
candidateLockfile: string;
|
||||
archivePath: string;
|
||||
archiveSha256: string;
|
||||
candidateDistSha256: string;
|
||||
}>,
|
||||
bindings: Readonly<Record<string, string>>,
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
||||
@@ -107,10 +134,7 @@ function createProviderEnvironment(
|
||||
CI: "true",
|
||||
GITHUB_ENV: "/tmp/github-env",
|
||||
GITHUB_PATH: "/tmp/github-path",
|
||||
CANDIDATE_LOCKFILE_PATH: candidate.candidateLockfile,
|
||||
CANDIDATE_ARCHIVE_PATH: candidate.archivePath,
|
||||
CANDIDATE_ARCHIVE_SHA256: candidate.archiveSha256,
|
||||
CANDIDATE_DIST_SHA256: candidate.candidateDistSha256,
|
||||
...bindings,
|
||||
...(providerKind === "vulnerability"
|
||||
? { VULNERABILITY_REPORT_PATH: rawReportPath }
|
||||
: { PROVENANCE_ATTESTATION_PATH: rawReportPath }),
|
||||
@@ -132,6 +156,7 @@ async function runProviderInSandbox(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
rawDirectory: string,
|
||||
workspaceRoot: string,
|
||||
candidateRoot: string,
|
||||
): Promise<void> {
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-"));
|
||||
try {
|
||||
@@ -177,6 +202,7 @@ async function runProviderInSandbox(
|
||||
}
|
||||
arguments_.push(
|
||||
"--bind", rawDirectory, rawDirectory,
|
||||
"--ro-bind", candidateRoot, "/candidate",
|
||||
"--chdir", workspaceRoot,
|
||||
"/bin/sh", "-eu", "-c", command,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { stageVerifiedPromotion } from "./lib/promotion-stager.ts";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
|
||||
import { finalizeVerifiedPromotion } from "./lib/promotion-stager.ts";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
@@ -6,7 +8,7 @@ const required = (name: string): string => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const staged = await stageVerifiedPromotion({
|
||||
const staged = await finalizeVerifiedPromotion({
|
||||
repositoryRoot: process.cwd(),
|
||||
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
||||
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
||||
@@ -16,7 +18,27 @@ const staged = await stageVerifiedPromotion({
|
||||
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
||||
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
||||
expectedRun: {
|
||||
id: process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? required("CI_RUN_ID"),
|
||||
attempt: Number(process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? required("CI_RUN_ATTEMPT")),
|
||||
sourceRevision: process.env.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
|
||||
},
|
||||
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
|
||||
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
|
||||
runnerTempRoot: required("RUNNER_TEMP"),
|
||||
});
|
||||
const output = required("GITHUB_OUTPUT");
|
||||
await appendFile(
|
||||
output,
|
||||
[
|
||||
`staging_root=${staged.stagingRoot}`,
|
||||
`cleanup_token=${staged.cleanupToken}`,
|
||||
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
|
||||
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
process.stdout.write(
|
||||
`Promotion staging: ${staged.map(({ path, sha256 }) => `${path}=${sha256}`).join(", ")} PASS\n`,
|
||||
`Promotion staging: ${staged.files.map(({ name, sha256 }) => `${name}=${sha256}`).join(", ")} PASS\n`,
|
||||
);
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
const candidate = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
|
||||
);
|
||||
const result = await verifyArchivedLocalEvidence({ candidate });
|
||||
const result = await verifyArchivedLocalEvidence({
|
||||
extractionRoot: process.cwd(),
|
||||
expectedManifest: candidate,
|
||||
});
|
||||
if (result.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
||||
|
||||
Reference in New Issue
Block a user