fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+79 -20
View File
@@ -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),
+25
View File
@@ -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");
+51 -36
View File
@@ -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
) {
+12 -6
View File
@@ -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}`),
);
+125
View File
@@ -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),
+10
View File
@@ -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({
+26
View File
@@ -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)}`,
+56
View File
@@ -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;
+506 -2
View File
@@ -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
View File
@@ -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;
}
}
+155 -27
View File
@@ -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
View File
@@ -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`);
}
}
+143
View File
@@ -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>;
+40 -55
View File
@@ -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;
}
+3
View File
@@ -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",
+56 -30
View File
@@ -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,
);
+25 -3
View File
@@ -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`,
);
+4 -1
View File
@@ -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`,