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
+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),