fix: harden provider and promotion evidence
This commit is contained in:
@@ -36,6 +36,62 @@ const MAX_MEMBER_PATH_BYTES = 1_024;
|
||||
const TAR_EXECUTABLE = "/usr/bin/tar";
|
||||
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
||||
|
||||
export type CapturedCandidateArchive = Readonly<{
|
||||
bytes: Buffer;
|
||||
archiveSha256: string;
|
||||
}>;
|
||||
|
||||
export async function captureCiCandidateArchive(input: Readonly<{
|
||||
archivePath: string;
|
||||
expectedSha256: string;
|
||||
}>): Promise<CapturedCandidateArchive> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const absolute = path.resolve(input.archivePath);
|
||||
const before = await lstat(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||
}
|
||||
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
bytes = await readCapturedArchive(handle, before.size);
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (archiveSha256 !== input.expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
return Object.freeze({ bytes, archiveSha256 });
|
||||
}
|
||||
|
||||
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
|
||||
captured: CapturedCandidateArchive;
|
||||
verify: (view: Readonly<{
|
||||
extractionRoot: string;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>) => Promise<T>;
|
||||
}>): Promise<T> {
|
||||
let result: T | undefined;
|
||||
await verifyCapturedCiCandidateArchive(
|
||||
input.captured.bytes,
|
||||
input.captured.archiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
result = await input.verify({ extractionRoot, manifest });
|
||||
},
|
||||
},
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export async function verifyCiCandidateArchive(
|
||||
input: Readonly<{
|
||||
archivePath: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
localEvidenceAssessmentArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
releaseManifestArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
@@ -28,8 +29,15 @@ import {
|
||||
verifyBuildManifestOutputs,
|
||||
} from "./build-manifest-outputs.ts";
|
||||
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
||||
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
|
||||
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
||||
import {
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
collectDistOutputs,
|
||||
distSha256,
|
||||
releaseCandidateManifestSchema,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
||||
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
||||
import {
|
||||
@@ -290,7 +298,503 @@ export async function verifyLocalSupplyChainEvidence(
|
||||
});
|
||||
}
|
||||
|
||||
export const LOCAL_EVIDENCE_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/local-evidence-verifier";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
] as const);
|
||||
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
] as const);
|
||||
|
||||
export async function createLocalEvidenceAssessment(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<z.infer<typeof localEvidenceAssessmentArtifactSchema>> {
|
||||
const root = path.resolve(repositoryRoot);
|
||||
const outputs = await collectDistOutputs(root);
|
||||
const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter(
|
||||
(memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
const evidenceInputs = (
|
||||
await Promise.all([
|
||||
...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)),
|
||||
...evidencePaths.map((memberPath) => digestInput(root, memberPath)),
|
||||
])
|
||||
).sort((left, right) => asciiCompare(left.path, right.path));
|
||||
const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml");
|
||||
const sbom = evidenceInputs.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
);
|
||||
if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete");
|
||||
const candidate: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: lockfile.sha256,
|
||||
bundleSha256: supplyChainDigest(evidenceInputs),
|
||||
files: evidenceInputs,
|
||||
};
|
||||
const evaluated = await evaluateProducerLocalChecks(root, candidate);
|
||||
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||
releaseManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/provenance.json").then((value) =>
|
||||
provenanceArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||
supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
|
||||
sbomArtifactSchema.parse(value),
|
||||
),
|
||||
Promise.all(
|
||||
LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) =>
|
||||
digestInput(root, policyPath),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const identityFailures: string[] = [];
|
||||
if (build.commitSha !== release.commitSha) {
|
||||
identityFailures.push("producer build/release source revision mismatch");
|
||||
}
|
||||
if (
|
||||
provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256
|
||||
) {
|
||||
identityFailures.push("producer provenance/supply source-set mismatch");
|
||||
}
|
||||
if (supply.distSha256 !== candidate.distSha256) {
|
||||
identityFailures.push("producer supply/candidate dist digest mismatch");
|
||||
}
|
||||
if (supply.lockfileSha256 !== candidate.lockfileSha256) {
|
||||
identityFailures.push("producer supply/candidate lockfile digest mismatch");
|
||||
}
|
||||
if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) {
|
||||
identityFailures.push("producer supply/candidate SBOM digest mismatch");
|
||||
}
|
||||
const checks = {
|
||||
...evaluated.checks,
|
||||
...(identityFailures.some((failure) => failure.includes("build/release"))
|
||||
? { release: "FAIL" as const }
|
||||
: {}),
|
||||
...(identityFailures.some((failure) => !failure.includes("build/release"))
|
||||
? { supplyChain: "FAIL" as const }
|
||||
: {}),
|
||||
};
|
||||
const failures = [...evaluated.failures, ...identityFailures];
|
||||
const status = failures.length === 0 && Object.values(checks).every(
|
||||
(check) => check === "PASS",
|
||||
)
|
||||
? ("PASS" as const)
|
||||
: ("FAIL" as const);
|
||||
const verifierSources = policyInputs.filter(({ path: policyPath }) =>
|
||||
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||
);
|
||||
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
|
||||
throw new Error("local assessment verifier source set is incomplete");
|
||||
}
|
||||
return localEvidenceAssessmentArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
artifactType: "local-evidence-assessment",
|
||||
generatedAt: build.generatedAt,
|
||||
status,
|
||||
verifier: {
|
||||
id: LOCAL_EVIDENCE_VERIFIER_ID,
|
||||
version: LOCAL_EVIDENCE_VERIFIER_VERSION,
|
||||
sourceSha256: supplyChainDigest(verifierSources),
|
||||
},
|
||||
source: {
|
||||
revision: build.commitSha,
|
||||
sourceSetSha256: supply.sourceSetSha256,
|
||||
},
|
||||
candidate: {
|
||||
distSha256: candidate.distSha256,
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
sbomSha256: sbom.sha256,
|
||||
},
|
||||
policyInputs,
|
||||
evidenceInputs,
|
||||
checks,
|
||||
failures,
|
||||
});
|
||||
}
|
||||
|
||||
type LocalCheckName =
|
||||
| "release"
|
||||
| "supplyChain"
|
||||
| "dependencyPolicy"
|
||||
| "licensePolicy"
|
||||
| "vulnerabilityPolicy"
|
||||
| "secretScan";
|
||||
|
||||
async function evaluateProducerLocalChecks(
|
||||
root: string,
|
||||
candidate: ReleaseCandidateManifest,
|
||||
): Promise<Readonly<{
|
||||
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const checks: Record<LocalCheckName, "PASS" | "FAIL"> = {
|
||||
release: "PASS",
|
||||
supplyChain: "PASS",
|
||||
dependencyPolicy: "PASS",
|
||||
licensePolicy: "PASS",
|
||||
vulnerabilityPolicy: "PASS",
|
||||
secretScan: "PASS",
|
||||
};
|
||||
const failures: string[] = [];
|
||||
const evaluate = async (
|
||||
check: LocalCheckName,
|
||||
operation: () => Promise<readonly string[]>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const diagnostics = await operation();
|
||||
if (diagnostics.length > 0) {
|
||||
checks[check] = "FAIL";
|
||||
failures.push(...diagnostics.map((failure) => `${check}:${failure}`));
|
||||
}
|
||||
} catch (error) {
|
||||
checks[check] = "FAIL";
|
||||
failures.push(
|
||||
`${check}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
await evaluate("release", async () => {
|
||||
const [build, release, stored] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||
releaseManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/verification.json").then((value) =>
|
||||
releaseVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const diagnostics: string[] = [];
|
||||
if (
|
||||
build.commitSha !== release.commitSha ||
|
||||
build.buildId !== release.buildId ||
|
||||
build.releaseId !== release.releaseId ||
|
||||
build.generatedAt !== release.builtAt
|
||||
) {
|
||||
diagnostics.push("build/release identity mismatch");
|
||||
}
|
||||
if (
|
||||
!stored.passed ||
|
||||
!stored.artifact.checked ||
|
||||
!stored.artifact.compatible ||
|
||||
stored.artifact.mismatches.length > 0 ||
|
||||
stored.artifact.releaseId !== release.releaseId ||
|
||||
stored.generatedAt !== release.builtAt ||
|
||||
stored.fixtures.length === 0 ||
|
||||
stored.fixtures.some((fixture) => !fixture.passed)
|
||||
) {
|
||||
diagnostics.push("stored release verification is not a coherent PASS");
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("supplyChain", async () => {
|
||||
const [supply, coherence] = await Promise.all([
|
||||
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||
supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
|
||||
supplyChainCoherenceReportSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const diagnostics: string[] = [];
|
||||
if (
|
||||
supply.localStatus !== "PASS" ||
|
||||
supply.failures.length > 0 ||
|
||||
supply.distSha256 !== candidate.distSha256 ||
|
||||
supply.lockfileSha256 !== candidate.lockfileSha256 ||
|
||||
coherence.status !== "PASS" ||
|
||||
coherence.failures.length > 0 ||
|
||||
coherence.distSha256 !== candidate.distSha256 ||
|
||||
coherence.lockfileSha256 !== candidate.lockfileSha256
|
||||
) {
|
||||
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("dependencyPolicy", async () => {
|
||||
const [inventory, stored] = await Promise.all([
|
||||
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||
dependencyInventoryArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/dependency-diff.json").then((value) =>
|
||||
dependencyDiffArtifactSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
const recomputed = recomputeDependencyEvidence({
|
||||
inventory,
|
||||
baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"),
|
||||
baselineApproval: await optionalReadJson(
|
||||
root,
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
),
|
||||
dependencyChangeEvidence: await readJson(
|
||||
root,
|
||||
"config/security/dependency-change-evidence.json",
|
||||
),
|
||||
});
|
||||
return compareStoredDependencyEvidence(recomputed, stored);
|
||||
});
|
||||
await evaluate("licensePolicy", async () => {
|
||||
const [inventory, stored, policy] = await Promise.all([
|
||||
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||
dependencyInventoryArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/license-report.json").then((value) =>
|
||||
licenseReportArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "config/security/dependency-policy.json"),
|
||||
]);
|
||||
return compareStoredLicenseEvidence(
|
||||
recomputeLicenseEvidence({ inventory, policy }),
|
||||
stored,
|
||||
);
|
||||
});
|
||||
await evaluate("vulnerabilityPolicy", async () => {
|
||||
const vulnerability = vulnerabilityReportArtifactSchema.parse(
|
||||
await readJson(root, "artifacts/security/vulnerability-report.json"),
|
||||
);
|
||||
return compareStoredLocalVulnerabilityReport(
|
||||
candidate.lockfileSha256,
|
||||
vulnerability,
|
||||
);
|
||||
});
|
||||
await evaluate("secretScan", async () => {
|
||||
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
|
||||
return verifyStoredSecretScan(
|
||||
evaluation,
|
||||
await readJson(root, "artifacts/security/scan.sarif"),
|
||||
);
|
||||
});
|
||||
return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) });
|
||||
}
|
||||
|
||||
async function digestInput(
|
||||
repositoryRoot: string,
|
||||
memberPath: string,
|
||||
): Promise<Readonly<{ path: string; bytes: number; sha256: string }>> {
|
||||
const absolute = path.resolve(repositoryRoot, memberPath);
|
||||
const relative = path.relative(repositoryRoot, absolute);
|
||||
if (
|
||||
relative === "" ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError(`local assessment input escapes repository: ${memberPath}`);
|
||||
}
|
||||
const bytes = await readFile(absolute);
|
||||
return Object.freeze({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
extractionRoot: string;
|
||||
expectedManifest: ReleaseCandidateManifest;
|
||||
}>): Promise<Readonly<{
|
||||
status: "PASS" | "FAIL";
|
||||
identity: null | Readonly<{
|
||||
sourceRevision: string;
|
||||
sourceSetSha256: string;
|
||||
assessmentSha256: string;
|
||||
}>;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const extractionRoot = path.resolve(input.extractionRoot);
|
||||
const failures: string[] = [];
|
||||
let extractedManifest: ReleaseCandidateManifest | null = null;
|
||||
try {
|
||||
extractedManifest = releaseCandidateManifestSchema.parse(
|
||||
await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
||||
);
|
||||
} catch {
|
||||
failures.push("extracted release candidate manifest is missing or invalid");
|
||||
}
|
||||
if (
|
||||
extractedManifest &&
|
||||
JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest)
|
||||
) {
|
||||
failures.push("caller expectedManifest differs from extracted manifest");
|
||||
}
|
||||
|
||||
let assessment: z.infer<typeof localEvidenceAssessmentArtifactSchema> | null = null;
|
||||
let assessmentSha256 = "";
|
||||
let assessmentBytes: Buffer | null = null;
|
||||
try {
|
||||
assessmentBytes = await readFile(
|
||||
path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH),
|
||||
);
|
||||
assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex");
|
||||
assessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown,
|
||||
);
|
||||
} catch {
|
||||
failures.push("local evidence assessment is missing or invalid");
|
||||
}
|
||||
|
||||
if (assessment && extractedManifest) {
|
||||
const assessmentMember = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
if (
|
||||
!assessmentMember ||
|
||||
assessmentMember.bytes !== assessmentBytes?.byteLength ||
|
||||
assessmentMember.sha256 !== assessmentSha256
|
||||
) {
|
||||
failures.push("local assessment manifest binding mismatch");
|
||||
}
|
||||
if (
|
||||
assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID ||
|
||||
assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION
|
||||
) {
|
||||
failures.push("local assessment verifier identity mismatch");
|
||||
}
|
||||
const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath);
|
||||
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
|
||||
failures.push("local assessment policyInputs exact set mismatch");
|
||||
}
|
||||
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
|
||||
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||
);
|
||||
if (
|
||||
verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length ||
|
||||
supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256
|
||||
) {
|
||||
failures.push("local assessment verifier-source digest mismatch");
|
||||
}
|
||||
|
||||
const expectedEvidenceInputs = extractedManifest.files.filter(
|
||||
({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
);
|
||||
if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) {
|
||||
failures.push("local assessment evidenceInputs exact member binding mismatch");
|
||||
}
|
||||
const sbom = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
);
|
||||
if (
|
||||
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
|
||||
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
|
||||
!sbom ||
|
||||
assessment.candidate.sbomSha256 !== sbom.sha256
|
||||
) {
|
||||
failures.push("local assessment candidate digest binding mismatch");
|
||||
}
|
||||
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
|
||||
failures.push("local evidence assessment is not PASS");
|
||||
}
|
||||
|
||||
const identities = await readArchivedIdentities(extractionRoot, failures);
|
||||
if (
|
||||
identities.buildRevision !== assessment.source.revision ||
|
||||
identities.releaseRevision !== assessment.source.revision
|
||||
) {
|
||||
failures.push("local assessment source revision identity mismatch");
|
||||
}
|
||||
if (
|
||||
identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 ||
|
||||
identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256
|
||||
) {
|
||||
failures.push("local assessment source-set identity mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueFailures = Object.freeze([...new Set(failures)]);
|
||||
const passingAssessment = uniqueFailures.length === 0 ? assessment : null;
|
||||
return Object.freeze({
|
||||
status: passingAssessment ? "PASS" : "FAIL",
|
||||
identity: passingAssessment
|
||||
? Object.freeze({
|
||||
sourceRevision: passingAssessment.source.revision,
|
||||
sourceSetSha256: passingAssessment.source.sourceSetSha256,
|
||||
assessmentSha256,
|
||||
})
|
||||
: null,
|
||||
failures: uniqueFailures,
|
||||
});
|
||||
}
|
||||
|
||||
async function readArchivedIdentities(
|
||||
extractionRoot: string,
|
||||
failures: string[],
|
||||
): Promise<Readonly<{
|
||||
buildRevision: unknown;
|
||||
releaseRevision: unknown;
|
||||
provenanceSourceSetSha256: unknown;
|
||||
supplySourceSetSha256: unknown;
|
||||
}>> {
|
||||
try {
|
||||
const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([
|
||||
readJson(extractionRoot, "artifacts/release/build-manifest.json"),
|
||||
readJson(extractionRoot, "dist/release-manifest.json"),
|
||||
readJson(extractionRoot, "artifacts/release/provenance.json"),
|
||||
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"),
|
||||
]);
|
||||
const build = buildManifestArtifactSchema.parse(buildDocument);
|
||||
const release = releaseManifestArtifactSchema.parse(releaseDocument);
|
||||
const provenance = provenanceArtifactSchema.parse(provenanceDocument);
|
||||
const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument);
|
||||
return Object.freeze({
|
||||
buildRevision: build.commitSha,
|
||||
releaseRevision: release.commitSha,
|
||||
provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256,
|
||||
supplySourceSetSha256: supply.sourceSetSha256,
|
||||
});
|
||||
} catch {
|
||||
failures.push("archived source/build/provenance identities are missing or invalid");
|
||||
return Object.freeze({
|
||||
buildRevision: null,
|
||||
releaseRevision: null,
|
||||
provenanceSourceSetSha256: null,
|
||||
supplySourceSetSha256: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function assessLocalEvidenceForProducer(input: Readonly<{
|
||||
repositoryRoot?: string;
|
||||
candidate: ReleaseCandidateManifest;
|
||||
}>): Promise<Readonly<{
|
||||
|
||||
+360
-219
@@ -1,40 +1,54 @@
|
||||
import { createHash, createPublicKey, randomUUID } from "node:crypto";
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
randomBytes as cryptoRandomBytes,
|
||||
} from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
rm,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerVerificationArtifactSchema,
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { verifyReleaseCandidate } from "./release-candidate.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
PROMOTED_FILE_NAMES,
|
||||
type PromotedFileName,
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
|
||||
|
||||
export { PROMOTED_STAGING_PATHS };
|
||||
|
||||
type PromotionSource = Readonly<{
|
||||
sourcePath: string;
|
||||
destinationName: string;
|
||||
maxBytes: number;
|
||||
validate: (bytes: Buffer) => void;
|
||||
}>;
|
||||
evaluatePromotionEvidence,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
provenanceProviderAttestationSchema,
|
||||
trustPolicySha256,
|
||||
vulnerabilityProviderReportSchema,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
|
||||
type StagedFile = Readonly<{
|
||||
destinationName: string;
|
||||
name: PromotedFileName;
|
||||
bytes: Buffer;
|
||||
digest: string;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export async function stageVerifiedPromotion(input: Readonly<{
|
||||
export type FinalizedPromotion = Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
||||
}>;
|
||||
|
||||
export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
@@ -44,203 +58,330 @@ export async function stageVerifiedPromotion(input: Readonly<{
|
||||
vulnerabilityKeyId: string;
|
||||
provenancePublicKeyPath: string;
|
||||
provenanceKeyId: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
runnerTempRoot: string;
|
||||
}>, dependencies: Readonly<{
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
nowEpochMs?: () => number;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublishRename?: () => Promise<void>;
|
||||
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
|
||||
beforePublish?: () => Promise<void>;
|
||||
afterStagingWrite?: () => Promise<void>;
|
||||
}> = {}): Promise<FinalizedPromotion> {
|
||||
const root = path.resolve(input.repositoryRoot);
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) {
|
||||
throw new TypeError("promotion archive SHA-256 is invalid");
|
||||
}
|
||||
const sources: PromotionSource[] = [
|
||||
{
|
||||
sourcePath: input.archivePath,
|
||||
destinationName: "release-candidate.tar.gz",
|
||||
maxBytes: 268_435_456,
|
||||
validate: (bytes) => {
|
||||
if (sha256(bytes) !== input.expectedArchiveSha256) {
|
||||
throw new Error("promotion archive SHA-256 changed before staging");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
sourcePath: input.vulnerabilityReportPath,
|
||||
destinationName: "vulnerability-report.json",
|
||||
maxBytes: 16_777_216,
|
||||
validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: input.provenanceAttestationPath,
|
||||
destinationName: "provenance-attestation.json",
|
||||
maxBytes: 16_777_216,
|
||||
validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: "artifacts/security/provider-verification.json",
|
||||
destinationName: "provider-verification.json",
|
||||
maxBytes: 4_194_304,
|
||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: "artifacts/security/promotion-verification.json",
|
||||
destinationName: "promotion-verification.json",
|
||||
maxBytes: 4_194_304,
|
||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
];
|
||||
const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([
|
||||
Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const relativePath = repositoryRelative(root, source.sourcePath);
|
||||
const bytes = await readBoundedRegularFile({
|
||||
root,
|
||||
relativePath,
|
||||
maxBytes: source.maxBytes,
|
||||
});
|
||||
source.validate(bytes);
|
||||
return Object.freeze({ ...source, bytes, digest: sha256(bytes) });
|
||||
}),
|
||||
),
|
||||
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
||||
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
||||
]);
|
||||
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
|
||||
await Promise.all([
|
||||
capture(root, input.vulnerabilityReportPath, 16_777_216),
|
||||
capture(root, input.provenanceAttestationPath, 16_777_216),
|
||||
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
||||
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
||||
]);
|
||||
await dependencies.afterCapture?.();
|
||||
let capturedLocalStatus: "PASS" | "FAIL" = "FAIL";
|
||||
const archive = await verifyCapturedCiCandidateArchive(
|
||||
captured[0]!.bytes,
|
||||
input.expectedArchiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
const candidate = await verifyReleaseCandidate(manifest, extractionRoot);
|
||||
if (candidate.failures.length > 0) {
|
||||
throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`);
|
||||
}
|
||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||
repositoryRoot: extractionRoot,
|
||||
candidate: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || local.failures.length > 0) {
|
||||
throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`);
|
||||
}
|
||||
capturedLocalStatus = local.status;
|
||||
},
|
||||
},
|
||||
|
||||
const vulnerabilityTrust = capturedTrust(
|
||||
input.vulnerabilityKeyId,
|
||||
vulnerabilityKeyBytes,
|
||||
);
|
||||
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes));
|
||||
const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes));
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
candidate: archive.manifest,
|
||||
currentDistSha256: archive.manifest.distSha256,
|
||||
localStatus: capturedLocalStatus,
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: {
|
||||
keyId: input.vulnerabilityKeyId,
|
||||
publicKey: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey),
|
||||
),
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: input.provenanceKeyId,
|
||||
publicKey: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey),
|
||||
),
|
||||
const provenanceTrust = capturedTrust(
|
||||
input.provenanceKeyId,
|
||||
provenanceKeyBytes,
|
||||
);
|
||||
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
||||
parseJson(vulnerabilityBytes),
|
||||
);
|
||||
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
||||
parseJson(provenanceBytes),
|
||||
);
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const verifiedAt = new Date(now).toISOString();
|
||||
|
||||
const generated = await withVerifiedCapturedCandidate({
|
||||
captured: capturedArchive,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
const local = await verifyArchivedLocalEvidence({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||
throw new Error("captured source revision differs from expected promotion revision");
|
||||
}
|
||||
const expected = {
|
||||
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
|
||||
source: {
|
||||
revision: local.identity.sourceRevision,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
},
|
||||
candidate: {
|
||||
archiveSha256: capturedArchive.archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
} as const;
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
expected,
|
||||
localStatus: local.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
if (reevaluated.status !== "PASS") {
|
||||
throw new Error(
|
||||
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const providerEvidence = {
|
||||
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
|
||||
provenanceAttestationSha256: sha256(provenanceBytes),
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
||||
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||
provenanceKeyId: provenanceTrust.keyId,
|
||||
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||
} as const;
|
||||
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
verifiedAt,
|
||||
status: "PASS" as const,
|
||||
verifier: {
|
||||
id: PROMOTION_VERIFIER_ID,
|
||||
version: PROMOTION_VERIFIER_VERSION,
|
||||
},
|
||||
run: expected.run,
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
providerEvidence,
|
||||
trustPolicySha256: trustDigest,
|
||||
failures: [] as const,
|
||||
};
|
||||
const providerRecord = providerVerificationArtifactSchema.parse({
|
||||
...common,
|
||||
artifactType: "provider-verification",
|
||||
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
|
||||
});
|
||||
const providerRecordBytes = canonicalJsonBytes(providerRecord);
|
||||
const promotionRecord = providerVerificationArtifactSchema.parse({
|
||||
...common,
|
||||
artifactType: "promotion-verification",
|
||||
localEvidenceStatus: local.status,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
providerVerificationSha256: sha256(providerRecordBytes),
|
||||
});
|
||||
return Object.freeze({
|
||||
providerRecordBytes,
|
||||
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
||||
});
|
||||
},
|
||||
});
|
||||
if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) {
|
||||
throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`);
|
||||
}
|
||||
const expectedBindings = {
|
||||
candidateArchiveSha256: captured[0]!.digest,
|
||||
vulnerabilityReportSha256: captured[1]!.digest,
|
||||
provenanceAttestationSha256: captured[2]!.digest,
|
||||
};
|
||||
for (const [index, expectedArtifactType] of [
|
||||
[3, "provider-verification"],
|
||||
[4, "promotion-verification"],
|
||||
] as const) {
|
||||
const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes));
|
||||
if (verification.artifactType !== expectedArtifactType) {
|
||||
throw new Error(
|
||||
`${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
verification.status !== reevaluated.status ||
|
||||
verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus ||
|
||||
verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus ||
|
||||
verification.failures.length > 0
|
||||
) {
|
||||
throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`);
|
||||
}
|
||||
if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) {
|
||||
throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`);
|
||||
}
|
||||
if (verification.distSha256 !== archive.manifest.distSha256) {
|
||||
throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`);
|
||||
}
|
||||
for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray<
|
||||
readonly [keyof typeof expectedBindings, string]
|
||||
>) {
|
||||
if (verification[binding] !== expectedDigest) {
|
||||
throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const stagedFiles: readonly StagedFile[] = captured;
|
||||
|
||||
const releaseRoot = path.join(root, ".release");
|
||||
const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
||||
const stagingRoot = path.join(releaseRoot, "promoted-staging");
|
||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
||||
const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`));
|
||||
let ownsTemporary = true;
|
||||
const stagedFiles: readonly StagedFile[] = Object.freeze([
|
||||
staged("release-candidate.tar.gz", capturedArchive.bytes),
|
||||
staged("vulnerability-report.json", vulnerabilityBytes),
|
||||
staged("provenance-attestation.json", provenanceBytes),
|
||||
staged("provider-verification.json", generated.providerRecordBytes),
|
||||
staged("promotion-verification.json", generated.promotionRecordBytes),
|
||||
]);
|
||||
if (
|
||||
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
|
||||
JSON.stringify(PROMOTED_FILE_NAMES)
|
||||
) {
|
||||
throw new Error("promotion exact-five canonical file order drift");
|
||||
}
|
||||
await dependencies.beforePublish?.();
|
||||
return publishPrivateStaging(
|
||||
input.runnerTempRoot,
|
||||
input.expectedRun,
|
||||
stagedFiles,
|
||||
dependencies.randomBytes ?? cryptoRandomBytes,
|
||||
dependencies.afterStagingWrite,
|
||||
);
|
||||
}
|
||||
|
||||
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
|
||||
|
||||
export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
runnerTempRoot: string;
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
}>, dependencies: Readonly<{
|
||||
beforeRemove?: () => Promise<void>;
|
||||
}> = {}): Promise<void> {
|
||||
const parent = path.resolve(input.runnerTempRoot);
|
||||
const expected = path.join(parent, input.cleanupToken);
|
||||
if (
|
||||
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
|
||||
path.resolve(input.stagingRoot) !== expected ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
|
||||
input.runnerTempIdentity.dev <= 0 ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
||||
input.runnerTempIdentity.ino <= 0
|
||||
) {
|
||||
throw new TypeError("promotion cleanup root/token mismatch");
|
||||
}
|
||||
const parentHandle = await open(
|
||||
parent,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
for (const source of stagedFiles) {
|
||||
const openedParent = await parentHandle.stat();
|
||||
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
|
||||
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||
const descriptorMetadata = await stat(descriptorRoot);
|
||||
if (!descriptorMetadata.isDirectory()) {
|
||||
throw new Error("descriptor-relative cleanup is unavailable");
|
||||
}
|
||||
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(descriptorExpected);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError("promotion cleanup leaf is unsafe");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
await rm(descriptorExpected, { recursive: true, force: true });
|
||||
const afterParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
||||
} finally {
|
||||
await parentHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPrivateStaging(
|
||||
runnerTempRoot: string,
|
||||
run: Readonly<{ id: string; attempt: number }>,
|
||||
files: readonly StagedFile[],
|
||||
randomBytes: (bytes: number) => Buffer,
|
||||
afterStagingWrite?: () => Promise<void>,
|
||||
): Promise<FinalizedPromotion> {
|
||||
const parentPath = path.resolve(runnerTempRoot);
|
||||
const before = await lstat(parentPath);
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) {
|
||||
throw new TypeError("runner temporary root must be a real directory");
|
||||
}
|
||||
const parentHandle = await open(
|
||||
parentPath,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const tokenBytes = randomBytes(16);
|
||||
if (tokenBytes.byteLength !== 16) {
|
||||
await parentHandle.close();
|
||||
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
|
||||
}
|
||||
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
|
||||
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
|
||||
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
||||
const visibleStaging = path.join(parentPath, cleanupToken);
|
||||
let ownsStaging = false;
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
ownsStaging = true;
|
||||
for (const file of files) {
|
||||
const handle = await open(
|
||||
path.join(temporary, source.destinationName),
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
path.join(descriptorStaging, file.name),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o400,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(source.bytes);
|
||||
await handle.writeFile(file.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
await syncDirectory(temporary);
|
||||
await dependencies.beforePublishRename?.();
|
||||
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
||||
await syncDirectory(descriptorStaging);
|
||||
await syncHandle(parentHandle);
|
||||
await afterStagingWrite?.();
|
||||
const after = await lstat(parentPath);
|
||||
if (
|
||||
releaseIdentity.dev <= 0 ||
|
||||
releaseIdentity.ino <= 0 ||
|
||||
currentReleaseIdentity.dev !== releaseIdentity.dev ||
|
||||
currentReleaseIdentity.ino !== releaseIdentity.ino
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.isSymbolicLink() ||
|
||||
!after.isDirectory()
|
||||
) {
|
||||
throw new Error("promotion staging parent identity changed");
|
||||
throw new Error("runner temporary parent identity changed during staging");
|
||||
}
|
||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
||||
await rename(temporary, stagingRoot);
|
||||
ownsTemporary = false;
|
||||
await syncDirectory(releaseRoot);
|
||||
const visible = await lstat(visibleStaging);
|
||||
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
||||
throw new Error("promotion staging visibility identity mismatch");
|
||||
}
|
||||
ownsStaging = false;
|
||||
return Object.freeze({
|
||||
stagingRoot: visibleStaging,
|
||||
cleanupToken,
|
||||
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
||||
files: Object.freeze(
|
||||
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
if (ownsTemporary) await rm(temporary, { recursive: true, force: true });
|
||||
if (ownsStaging) {
|
||||
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
await parentHandle.close();
|
||||
}
|
||||
return Object.freeze(
|
||||
stagedFiles.map(({ destinationName, digest }) =>
|
||||
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
|
||||
),
|
||||
}
|
||||
|
||||
function assertRunnerTempIdentity(
|
||||
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
|
||||
expected: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
if (
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino ||
|
||||
!metadata.isDirectory() ||
|
||||
metadata.isSymbolicLink?.()
|
||||
) {
|
||||
throw new Error("runner temporary parent identity changed during cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
const outside =
|
||||
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
return readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||
@@ -248,43 +389,43 @@ async function capture(root: string, configuredPath: string, maxBytes: number):
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
|
||||
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
|
||||
}
|
||||
|
||||
function repositoryRelative(root: string, configuredPath: string): string {
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new TypeError(`promotion source escapes repository: ${configuredPath}`);
|
||||
function canonicalJsonBytes(value: unknown): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
|
||||
}
|
||||
return relative.replaceAll(path.sep, "/");
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(
|
||||
directory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
await syncHandle(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(directory, constants.O_RDONLY);
|
||||
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { createHash, createPublicKey } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
evaluatePromotionEvidence,
|
||||
providerPublicKeyFingerprint,
|
||||
trustPolicySha256,
|
||||
type ProviderVerificationArtifactType,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
} from "./release-candidate.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
||||
|
||||
@@ -23,6 +28,7 @@ export type VerifyPromotionInputsOptions = Readonly<{
|
||||
providerEvidenceRoot?: string;
|
||||
trustRoot?: string;
|
||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||
nowEpochMs?: () => number;
|
||||
}>;
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
@@ -75,25 +81,73 @@ export async function verifyPromotionInputs(
|
||||
);
|
||||
const localEvidence = await (
|
||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||
)({ repositoryRoot, candidate: manifest });
|
||||
)({ extractionRoot: repositoryRoot, expectedManifest: manifest });
|
||||
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
||||
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
||||
const vulnerabilityTrust = await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
);
|
||||
const provenanceTrust = await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
);
|
||||
const runId = environment.CI_RUN_ID ?? "missing-run";
|
||||
const runAttempt = Number(environment.CI_RUN_ATTEMPT);
|
||||
if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing");
|
||||
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
||||
inputFailures.push("provider expected run attempt is missing or invalid");
|
||||
}
|
||||
if (!localEvidence.identity) {
|
||||
inputFailures.push("archived local evidence identity is unavailable");
|
||||
}
|
||||
if (
|
||||
environment.EXPECTED_SOURCE_REVISION &&
|
||||
localEvidence.identity &&
|
||||
environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision
|
||||
) {
|
||||
inputFailures.push(
|
||||
`provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`,
|
||||
);
|
||||
}
|
||||
const vulnerabilityInvocationNonce = requiredExpectedNonce(
|
||||
environment.VULNERABILITY_INVOCATION_NONCE,
|
||||
"vulnerability",
|
||||
inputFailures,
|
||||
);
|
||||
const provenanceInvocationNonce = requiredExpectedNonce(
|
||||
environment.PROVENANCE_INVOCATION_NONCE,
|
||||
"provenance",
|
||||
inputFailures,
|
||||
);
|
||||
const expected = {
|
||||
run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 },
|
||||
source: {
|
||||
revision:
|
||||
localEvidence.identity?.sourceRevision ??
|
||||
environment.EXPECTED_SOURCE_REVISION ??
|
||||
"0".repeat(40),
|
||||
sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64),
|
||||
},
|
||||
candidate: {
|
||||
archiveSha256: archive.sha256 ?? "0".repeat(64),
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce,
|
||||
} as const;
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
expected,
|
||||
localStatus: localEvidence.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: options.nowEpochMs,
|
||||
});
|
||||
const failures = [
|
||||
...inputFailures,
|
||||
@@ -101,21 +155,93 @@ export async function verifyPromotionInputs(
|
||||
...localEvidence.failures,
|
||||
...result.failures,
|
||||
];
|
||||
return Object.freeze({
|
||||
schemaVersion: 2 as const,
|
||||
const now = (options.nowEpochMs ?? Date.now)();
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
artifactType: options.artifactType,
|
||||
verifiedAt: new Date(now).toISOString(),
|
||||
status:
|
||||
failures.length === 0 && result.status === "PASS"
|
||||
? ("PASS" as const)
|
||||
: ("FAIL_UNVERIFIED" as const),
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
candidateArchiveSha256: archive.sha256,
|
||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256,
|
||||
provenanceAttestationSha256: provenanceCapture.sha256,
|
||||
verifier: Object.freeze({
|
||||
id: PROMOTION_VERIFIER_ID,
|
||||
version: PROMOTION_VERIFIER_VERSION,
|
||||
}),
|
||||
run: expected.run,
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
providerEvidence: Object.freeze({
|
||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64),
|
||||
provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64),
|
||||
vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: expected.provenanceInvocationNonce,
|
||||
vulnerabilityKeyId:
|
||||
vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||
vulnerabilityKeyFingerprint:
|
||||
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
provenanceKeyId:
|
||||
provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||
provenanceKeyFingerprint:
|
||||
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
}),
|
||||
trustPolicySha256: verificationTrustPolicySha256(
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
environment,
|
||||
),
|
||||
failures: Object.freeze(failures),
|
||||
};
|
||||
return options.artifactType === "provider-verification"
|
||||
? Object.freeze({
|
||||
...common,
|
||||
artifactType: "provider-verification" as const,
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
})
|
||||
: Object.freeze({
|
||||
...common,
|
||||
artifactType: "promotion-verification" as const,
|
||||
localEvidenceStatus: localEvidence.status,
|
||||
localEvidenceAssessmentSha256:
|
||||
localEvidence.identity?.assessmentSha256 ?? "0".repeat(64),
|
||||
providerVerificationSha256:
|
||||
environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
function requiredExpectedNonce(
|
||||
value: string | undefined,
|
||||
label: "vulnerability" | "provenance",
|
||||
failures: string[],
|
||||
): string {
|
||||
if (value && /^[a-f0-9]{64}$/u.test(value)) return value;
|
||||
failures.push(`${label} expected invocation nonce is missing or invalid`);
|
||||
return "0".repeat(64);
|
||||
}
|
||||
|
||||
function verificationTrustPolicySha256(
|
||||
vulnerabilityTrust: ProviderTrust | null,
|
||||
provenanceTrust: ProviderTrust | null,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
if (vulnerabilityTrust && provenanceTrust) {
|
||||
return trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
}
|
||||
return supplyChainDigest({
|
||||
algorithm: "Ed25519",
|
||||
vulnerability: {
|
||||
keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||
publicKeyFingerprint:
|
||||
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
},
|
||||
provenance: {
|
||||
keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||
publicKeyFingerprint:
|
||||
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
},
|
||||
issuedAtFutureSkewMs: 5 * 60 * 1_000,
|
||||
maximumLifetimeMs: 2 * 60 * 60 * 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,13 +252,15 @@ export async function readProviderTrust(
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||
),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||
),
|
||||
),
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+317
-118
@@ -1,90 +1,145 @@
|
||||
import { verify, type KeyObject } from "node:crypto";
|
||||
import { createHash, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
||||
import {
|
||||
canonicalizeSupplyChainValue,
|
||||
supplyChainDigest,
|
||||
} from "./supply-chain.ts";
|
||||
|
||||
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
||||
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
|
||||
export const PROMOTION_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/promotion-verifier";
|
||||
export const PROMOTION_VERIFIER_VERSION = "3";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
|
||||
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
|
||||
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().min(1);
|
||||
const timestamp = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||
.refine((value) => new Date(value).toISOString() === value);
|
||||
const runSchema = z
|
||||
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
|
||||
.strict();
|
||||
const sourceSchema = z
|
||||
.object({ revision, sourceSetSha256: sha256 })
|
||||
.strict();
|
||||
const candidateSchema = z
|
||||
.object({
|
||||
archiveSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
keyId: nonEmptyString,
|
||||
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
||||
publicKeyFingerprint: fingerprint,
|
||||
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
|
||||
})
|
||||
.strict();
|
||||
const providerCommon = {
|
||||
schemaVersion: z.literal(2),
|
||||
provider: nonEmptyString,
|
||||
issuedAt: timestamp,
|
||||
expiresAt: timestamp,
|
||||
run: providerRunSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
signature: signatureSchema,
|
||||
} as const;
|
||||
|
||||
export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
scannedLockfileSha256: sha256,
|
||||
scannedDistSha256: sha256,
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("vulnerability-report"),
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const provenanceProviderAttestationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("provenance-attestation"),
|
||||
signer: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
subject: z
|
||||
.object({
|
||||
name: z.literal("dist"),
|
||||
digest: z.object({ sha256 }).strict(),
|
||||
})
|
||||
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
|
||||
.strict(),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const verificationCommon = {
|
||||
schemaVersion: z.literal(3),
|
||||
verifiedAt: timestamp,
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
verifier: z
|
||||
.object({ id: nonEmptyString, version: nonEmptyString })
|
||||
.strict(),
|
||||
run: runSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
providerEvidence: z
|
||||
.object({
|
||||
vulnerabilityReportSha256: sha256,
|
||||
provenanceAttestationSha256: sha256,
|
||||
vulnerabilityInvocationNonce: nonce,
|
||||
provenanceInvocationNonce: nonce,
|
||||
vulnerabilityKeyId: nonEmptyString,
|
||||
vulnerabilityKeyFingerprint: fingerprint,
|
||||
provenanceKeyId: nonEmptyString,
|
||||
provenanceKeyFingerprint: fingerprint,
|
||||
})
|
||||
.strict(),
|
||||
trustPolicySha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
} as const;
|
||||
|
||||
const providerVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("provider-verification"),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const promotionVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("promotion-verification"),
|
||||
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
|
||||
localEvidenceAssessmentSha256: sha256,
|
||||
providerVerificationSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
artifactType: z.enum(["provider-verification", "promotion-verification"]),
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
candidateArchiveSha256: sha256.nullable(),
|
||||
vulnerabilityReportSha256: sha256.nullable(),
|
||||
provenanceAttestationSha256: sha256.nullable(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const passing =
|
||||
artifact.status === "PASS" &&
|
||||
artifact.vulnerabilityStatus === "PASS" &&
|
||||
artifact.provenanceAttestationStatus === "PASS" &&
|
||||
artifact.failures.length === 0;
|
||||
if ((artifact.status === "PASS") !== passing) {
|
||||
.discriminatedUnion("artifactType", [
|
||||
providerVerificationV3Schema,
|
||||
promotionVerificationV3Schema,
|
||||
])
|
||||
.superRefine((record, context) => {
|
||||
const subordinatePass =
|
||||
record.artifactType === "provider-verification"
|
||||
? record.vulnerabilityStatus === "PASS" &&
|
||||
record.provenanceAttestationStatus === "PASS"
|
||||
: record.localEvidenceStatus === "PASS";
|
||||
const coherentPass = subordinatePass && record.failures.length === 0;
|
||||
if ((record.status === "PASS") !== coherentPass) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "verification PASS must agree with provider statuses and failures",
|
||||
message: "verification PASS must agree with subordinate statuses and failures",
|
||||
});
|
||||
}
|
||||
if (
|
||||
artifact.status === "PASS" &&
|
||||
[
|
||||
artifact.candidateArchiveSha256,
|
||||
artifact.vulnerabilityReportSha256,
|
||||
artifact.provenanceAttestationSha256,
|
||||
].some((digest) => digest === null)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["candidateArchiveSha256"],
|
||||
message: "passing verification requires every exact input digest",
|
||||
});
|
||||
}
|
||||
if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) {
|
||||
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["failures"],
|
||||
@@ -100,6 +155,20 @@ export type ProviderVerificationArtifactType = z.infer<
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyObject;
|
||||
publicKeyFingerprint: string;
|
||||
}>;
|
||||
|
||||
export type ExpectedPromotionContext = Readonly<{
|
||||
run: Readonly<{ id: string; attempt: number }>;
|
||||
source: Readonly<{ revision: string; sourceSetSha256: string }>;
|
||||
candidate: Readonly<{
|
||||
archiveSha256: string;
|
||||
bundleSha256: string;
|
||||
distSha256: string;
|
||||
lockfileSha256: string;
|
||||
}>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
@@ -109,35 +178,134 @@ export type PromotionEvidenceResult = Readonly<{
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
export function validateProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
value: unknown;
|
||||
expected: ExpectedPromotionContext;
|
||||
trust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Readonly<{
|
||||
evidence: unknown | null;
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}> {
|
||||
const failures: string[] = [];
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external vulnerability provider report is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (parsed.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external signed provenance attestation is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||
const { signature: _signature, ...payload } = value;
|
||||
return Buffer.from(
|
||||
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
||||
"utf8",
|
||||
);
|
||||
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
|
||||
}
|
||||
|
||||
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
|
||||
if (publicKey.asymmetricKeyType !== "ed25519") {
|
||||
throw new TypeError("provider trust key must be Ed25519");
|
||||
}
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createTrustPolicy(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>) {
|
||||
return Object.freeze({
|
||||
algorithm: "Ed25519" as const,
|
||||
vulnerability: Object.freeze({
|
||||
keyId: input.vulnerabilityTrust.keyId,
|
||||
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
|
||||
}),
|
||||
provenance: Object.freeze({
|
||||
keyId: input.provenanceTrust.keyId,
|
||||
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
|
||||
}),
|
||||
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
|
||||
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function trustPolicySha256(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>): string {
|
||||
return supplyChainDigest(createTrustPolicy(input));
|
||||
}
|
||||
|
||||
export function evaluatePromotionEvidence(input: Readonly<{
|
||||
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
||||
currentDistSha256: string;
|
||||
expected: ExpectedPromotionContext;
|
||||
localStatus: unknown;
|
||||
vulnerabilityReport: unknown;
|
||||
provenanceAttestation: unknown;
|
||||
vulnerabilityTrust: ProviderTrust | null;
|
||||
provenanceTrust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): PromotionEvidenceResult {
|
||||
const failures: string[] = [];
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
||||
"FAIL_UNVERIFIED";
|
||||
|
||||
if (input.localStatus !== "PASS") {
|
||||
failures.push("local supply-chain evidence is not PASS");
|
||||
}
|
||||
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
||||
failures.push("candidate dist bytes changed after immutable build");
|
||||
}
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
|
||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||
input.vulnerabilityReport,
|
||||
@@ -145,36 +313,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
if (!vulnerability.success) {
|
||||
failures.push("external vulnerability provider report is missing or invalid");
|
||||
} else {
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 !==
|
||||
input.candidate.lockfileSha256
|
||||
) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
||||
) {
|
||||
failures.push("vulnerability report dist digest mismatch");
|
||||
}
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
vulnerability.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.vulnerabilityTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
vulnerability.data,
|
||||
input.vulnerabilityTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("vulnerability report signature verification failed");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 ===
|
||||
input.candidate.lockfileSha256 &&
|
||||
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
||||
vulnerability.data.findings.length === 0 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
vulnerabilityStatus = "PASS";
|
||||
}
|
||||
}
|
||||
@@ -185,22 +337,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
if (!provenance.success) {
|
||||
failures.push("external signed provenance attestation is missing or invalid");
|
||||
} else {
|
||||
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
||||
failures.push("provenance attestation dist digest mismatch");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
provenance.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.provenanceTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("provenance attestation signature verification failed");
|
||||
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
if (
|
||||
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
provenanceAttestationStatus = "PASS";
|
||||
}
|
||||
}
|
||||
@@ -218,29 +368,78 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
function signatureMatches(
|
||||
function validateCommonContext(
|
||||
label: "vulnerability report" | "provenance attestation",
|
||||
evidence: z.infer<
|
||||
| typeof vulnerabilityProviderReportSchema
|
||||
| typeof provenanceProviderAttestationSchema
|
||||
>,
|
||||
expected: ExpectedPromotionContext,
|
||||
expectedNonce: string,
|
||||
trust: ProviderTrust | null,
|
||||
): boolean {
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
if (
|
||||
evidence.run.id !== expected.run.id ||
|
||||
evidence.run.attempt !== expected.run.attempt
|
||||
) {
|
||||
failures.push(`${label} run identity mismatch`);
|
||||
}
|
||||
if (evidence.run.invocationNonce !== expectedNonce) {
|
||||
failures.push(`${label} invocation nonce mismatch`);
|
||||
}
|
||||
if (
|
||||
evidence.source.revision !== expected.source.revision ||
|
||||
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
|
||||
) {
|
||||
failures.push(`${label} source identity mismatch`);
|
||||
}
|
||||
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
|
||||
failures.push(`${label} candidate identity mismatch`);
|
||||
}
|
||||
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
|
||||
if (
|
||||
!trust ||
|
||||
evidence.signature.keyId !== trust.keyId ||
|
||||
trust.publicKey.asymmetricKeyType !== "ed25519"
|
||||
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
|
||||
) {
|
||||
return false;
|
||||
failures.push(`${label} trust identity mismatch`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
);
|
||||
if (
|
||||
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
|
||||
!verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
)
|
||||
) {
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceTime(
|
||||
label: string,
|
||||
issuedAt: string,
|
||||
expiresAt: string,
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
const issued = Date.parse(issuedAt);
|
||||
const expires = Date.parse(expiresAt);
|
||||
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
|
||||
failures.push(`${label} issuedAt exceeds allowed future skew`);
|
||||
}
|
||||
if (expires <= now) failures.push(`${label} is expired`);
|
||||
if (expires <= issued) failures.push(`${label} validity window is not positive`);
|
||||
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
|
||||
failures.push(`${label} validity window exceeds two hours`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
type CapturedCandidateArchive,
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
|
||||
import { validateProviderUpload } from "./provider-upload-validator.ts";
|
||||
|
||||
export type ProviderInvocation = Readonly<{
|
||||
candidateRoot: string;
|
||||
environment: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
export async function superviseProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
trust: ProviderTrust;
|
||||
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
|
||||
captureReport: () => Promise<Buffer>;
|
||||
}>, dependencies: Readonly<{
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
validateUpload?: typeof validateProviderUpload;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
nowEpochMs?: () => number;
|
||||
}> = {}): Promise<Readonly<{
|
||||
evidence: unknown;
|
||||
invocationNonce: string;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
}>> {
|
||||
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
|
||||
if (nonceBytes.byteLength !== 32) {
|
||||
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
||||
}
|
||||
const invocationNonce = nonceBytes.toString("hex");
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
||||
captured,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||
throw new Error("provider candidate source revision mismatch");
|
||||
}
|
||||
const expectedContext: ExpectedPromotionContext = Object.freeze({
|
||||
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
|
||||
source: Object.freeze({
|
||||
revision: local.identity.sourceRevision,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
}),
|
||||
candidate: Object.freeze({
|
||||
archiveSha256: captured.archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}),
|
||||
vulnerabilityInvocationNonce:
|
||||
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
||||
provenanceInvocationNonce:
|
||||
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
||||
});
|
||||
const issuedAt = new Date(now).toISOString();
|
||||
const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString();
|
||||
await input.executeProvider({
|
||||
candidateRoot: extractionRoot,
|
||||
environment: providerInvocationEnvironment({
|
||||
kind: input.kind,
|
||||
expectedContext,
|
||||
invocationNonce,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
trust: input.trust,
|
||||
}),
|
||||
});
|
||||
const capturedReport = await input.captureReport();
|
||||
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
|
||||
kind: input.kind,
|
||||
verifiedManifest: manifest,
|
||||
archiveSha256: captured.archiveSha256,
|
||||
candidateRoot: extractionRoot,
|
||||
capturedReport,
|
||||
expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function providerInvocationEnvironment(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
invocationNonce: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
trust: ProviderTrust;
|
||||
}>): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||
PROVIDER_EVIDENCE_TYPE:
|
||||
input.kind === "vulnerability"
|
||||
? "vulnerability-report"
|
||||
: "provenance-attestation",
|
||||
PROVIDER_ISSUED_AT: input.issuedAt,
|
||||
PROVIDER_EXPIRES_AT: input.expiresAt,
|
||||
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
|
||||
PROVIDER_KEY_ID: input.trust.keyId,
|
||||
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
|
||||
CI_RUN_ID: input.expectedContext.run.id,
|
||||
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
|
||||
SOURCE_REVISION: input.expectedContext.source.revision,
|
||||
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
|
||||
CANDIDATE_ROOT: "/candidate",
|
||||
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
|
||||
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
|
||||
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
||||
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
||||
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
||||
});
|
||||
}
|
||||
|
||||
export type CaptureArchiveDependency = (
|
||||
input: Readonly<{ archivePath: string; expectedSha256: string }>,
|
||||
) => Promise<CapturedCandidateArchive>;
|
||||
@@ -1,74 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
validateProviderEvidence,
|
||||
type ExpectedPromotionContext,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
verifyReleaseCandidate,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
|
||||
export async function validateProviderUpload(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
verifiedManifest: ReleaseCandidateManifest;
|
||||
archiveSha256: string;
|
||||
candidateRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
reportPath: string;
|
||||
workspaceRoot?: string;
|
||||
expectedDistSha256: string;
|
||||
capturedReport: Buffer;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
trust: ProviderTrust;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Promise<unknown> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) {
|
||||
throw new TypeError("expected candidate dist SHA-256 is invalid");
|
||||
if (
|
||||
input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 ||
|
||||
input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 ||
|
||||
input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 ||
|
||||
input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("provider supervisor expected candidate context mismatch");
|
||||
}
|
||||
const archive = await verifyCiCandidateArchive({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const manifest = archive.manifest;
|
||||
if (manifest.distSha256 !== input.expectedDistSha256) {
|
||||
throw new Error("provider input candidate dist digest mismatch");
|
||||
}
|
||||
const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot);
|
||||
const verifiedCandidate = await verifyReleaseCandidate(
|
||||
input.verifiedManifest,
|
||||
input.candidateRoot,
|
||||
);
|
||||
if (verifiedCandidate.failures.length > 0) {
|
||||
throw new Error(
|
||||
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
const reportAbsolute = path.resolve(input.reportPath);
|
||||
const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd());
|
||||
const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/");
|
||||
const report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await readBoundedRegularFile({
|
||||
root: reportRoot,
|
||||
relativePath: reportRelative,
|
||||
maxBytes: 8_388_608,
|
||||
}),
|
||||
),
|
||||
) as unknown;
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.parse(report);
|
||||
const lockfile = await readBoundedRegularFile({
|
||||
root: input.candidateRoot,
|
||||
relativePath: "pnpm-lock.yaml",
|
||||
maxBytes: 67_108_864,
|
||||
});
|
||||
const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex");
|
||||
if (
|
||||
parsed.scannedDistSha256 !== manifest.distSha256 ||
|
||||
parsed.scannedLockfileSha256 !== manifest.lockfileSha256 ||
|
||||
lockfileSha256 !== manifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("vulnerability provider evidence candidate digest mismatch");
|
||||
}
|
||||
return parsed;
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new TypeError("provider output is not canonical UTF-8 JSON");
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.parse(report);
|
||||
if (parsed.subject.digest.sha256 !== manifest.distSha256) {
|
||||
throw new Error("provenance provider evidence candidate digest mismatch");
|
||||
const evaluated = validateProviderEvidence({
|
||||
kind: input.kind,
|
||||
value: report,
|
||||
expected: input.expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: input.nowEpochMs,
|
||||
});
|
||||
if (evaluated.status !== "PASS" || !evaluated.evidence) {
|
||||
throw new Error(
|
||||
`provider evidence context validation failed: ${evaluated.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
return evaluated.evidence;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export type ReleaseCandidateManifest = z.infer<
|
||||
|
||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
|
||||
"artifacts/security/local-evidence-assessment.json";
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"pnpm-lock.yaml",
|
||||
@@ -45,6 +47,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
"artifacts/security/dependency-diff.json",
|
||||
"artifacts/security/license-report.json",
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
"artifacts/security/scan.sarif",
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
|
||||
Reference in New Issue
Block a user