fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+506 -2
View File
@@ -15,6 +15,7 @@ import {
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
localEvidenceAssessmentArtifactSchema,
provenanceArtifactSchema,
releaseManifestArtifactSchema,
releaseVerificationArtifactSchema,
@@ -28,8 +29,15 @@ import {
verifyBuildManifestOutputs,
} from "./build-manifest-outputs.ts";
import { assertMatchesJsonSchema } from "./json-schema.ts";
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
import {
LOCAL_EVIDENCE_ASSESSMENT_PATH,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
collectDistOutputs,
distSha256,
releaseCandidateManifestSchema,
type ReleaseCandidateManifest,
} from "./release-candidate.ts";
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
import {
@@ -290,7 +298,503 @@ export async function verifyLocalSupplyChainEvidence(
});
}
export const LOCAL_EVIDENCE_VERIFIER_ID =
"clean-architecture-frontend-template/local-evidence-verifier";
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
] as const);
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
] as const);
export async function createLocalEvidenceAssessment(
repositoryRoot = process.cwd(),
): Promise<z.infer<typeof localEvidenceAssessmentArtifactSchema>> {
const root = path.resolve(repositoryRoot);
const outputs = await collectDistOutputs(root);
const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter(
(memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
);
const evidenceInputs = (
await Promise.all([
...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)),
...evidencePaths.map((memberPath) => digestInput(root, memberPath)),
])
).sort((left, right) => asciiCompare(left.path, right.path));
const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml");
const sbom = evidenceInputs.find(
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
);
if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete");
const candidate: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: distSha256(outputs),
lockfileSha256: lockfile.sha256,
bundleSha256: supplyChainDigest(evidenceInputs),
files: evidenceInputs,
};
const evaluated = await evaluateProducerLocalChecks(root, candidate);
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
buildManifestArtifactSchema.parse(value),
),
readJson(root, "dist/release-manifest.json").then((value) =>
releaseManifestArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/provenance.json").then((value) =>
provenanceArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
supplyChainVerificationArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
sbomArtifactSchema.parse(value),
),
Promise.all(
LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) =>
digestInput(root, policyPath),
),
),
]);
const identityFailures: string[] = [];
if (build.commitSha !== release.commitSha) {
identityFailures.push("producer build/release source revision mismatch");
}
if (
provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256
) {
identityFailures.push("producer provenance/supply source-set mismatch");
}
if (supply.distSha256 !== candidate.distSha256) {
identityFailures.push("producer supply/candidate dist digest mismatch");
}
if (supply.lockfileSha256 !== candidate.lockfileSha256) {
identityFailures.push("producer supply/candidate lockfile digest mismatch");
}
if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) {
identityFailures.push("producer supply/candidate SBOM digest mismatch");
}
const checks = {
...evaluated.checks,
...(identityFailures.some((failure) => failure.includes("build/release"))
? { release: "FAIL" as const }
: {}),
...(identityFailures.some((failure) => !failure.includes("build/release"))
? { supplyChain: "FAIL" as const }
: {}),
};
const failures = [...evaluated.failures, ...identityFailures];
const status = failures.length === 0 && Object.values(checks).every(
(check) => check === "PASS",
)
? ("PASS" as const)
: ("FAIL" as const);
const verifierSources = policyInputs.filter(({ path: policyPath }) =>
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
);
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
throw new Error("local assessment verifier source set is incomplete");
}
return localEvidenceAssessmentArtifactSchema.parse({
schemaVersion: 1,
artifactType: "local-evidence-assessment",
generatedAt: build.generatedAt,
status,
verifier: {
id: LOCAL_EVIDENCE_VERIFIER_ID,
version: LOCAL_EVIDENCE_VERIFIER_VERSION,
sourceSha256: supplyChainDigest(verifierSources),
},
source: {
revision: build.commitSha,
sourceSetSha256: supply.sourceSetSha256,
},
candidate: {
distSha256: candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
sbomSha256: sbom.sha256,
},
policyInputs,
evidenceInputs,
checks,
failures,
});
}
type LocalCheckName =
| "release"
| "supplyChain"
| "dependencyPolicy"
| "licensePolicy"
| "vulnerabilityPolicy"
| "secretScan";
async function evaluateProducerLocalChecks(
root: string,
candidate: ReleaseCandidateManifest,
): Promise<Readonly<{
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
failures: readonly string[];
}>> {
const checks: Record<LocalCheckName, "PASS" | "FAIL"> = {
release: "PASS",
supplyChain: "PASS",
dependencyPolicy: "PASS",
licensePolicy: "PASS",
vulnerabilityPolicy: "PASS",
secretScan: "PASS",
};
const failures: string[] = [];
const evaluate = async (
check: LocalCheckName,
operation: () => Promise<readonly string[]>,
): Promise<void> => {
try {
const diagnostics = await operation();
if (diagnostics.length > 0) {
checks[check] = "FAIL";
failures.push(...diagnostics.map((failure) => `${check}:${failure}`));
}
} catch (error) {
checks[check] = "FAIL";
failures.push(
`${check}:${error instanceof Error ? error.message : String(error)}`,
);
}
};
await evaluate("release", async () => {
const [build, release, stored] = await Promise.all([
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
buildManifestArtifactSchema.parse(value),
),
readJson(root, "dist/release-manifest.json").then((value) =>
releaseManifestArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/verification.json").then((value) =>
releaseVerificationArtifactSchema.parse(value),
),
]);
const diagnostics: string[] = [];
if (
build.commitSha !== release.commitSha ||
build.buildId !== release.buildId ||
build.releaseId !== release.releaseId ||
build.generatedAt !== release.builtAt
) {
diagnostics.push("build/release identity mismatch");
}
if (
!stored.passed ||
!stored.artifact.checked ||
!stored.artifact.compatible ||
stored.artifact.mismatches.length > 0 ||
stored.artifact.releaseId !== release.releaseId ||
stored.generatedAt !== release.builtAt ||
stored.fixtures.length === 0 ||
stored.fixtures.some((fixture) => !fixture.passed)
) {
diagnostics.push("stored release verification is not a coherent PASS");
}
return diagnostics;
});
await evaluate("supplyChain", async () => {
const [supply, coherence] = await Promise.all([
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
supplyChainVerificationArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
supplyChainCoherenceReportSchema.parse(value),
),
]);
const diagnostics: string[] = [];
if (
supply.localStatus !== "PASS" ||
supply.failures.length > 0 ||
supply.distSha256 !== candidate.distSha256 ||
supply.lockfileSha256 !== candidate.lockfileSha256 ||
coherence.status !== "PASS" ||
coherence.failures.length > 0 ||
coherence.distSha256 !== candidate.distSha256 ||
coherence.lockfileSha256 !== candidate.lockfileSha256
) {
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
}
return diagnostics;
});
await evaluate("dependencyPolicy", async () => {
const [inventory, stored] = await Promise.all([
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
dependencyInventoryArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/dependency-diff.json").then((value) =>
dependencyDiffArtifactSchema.parse(value),
),
]);
const recomputed = recomputeDependencyEvidence({
inventory,
baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"),
baselineApproval: await optionalReadJson(
root,
"config/security/dependency-baseline.approval.json",
),
dependencyChangeEvidence: await readJson(
root,
"config/security/dependency-change-evidence.json",
),
});
return compareStoredDependencyEvidence(recomputed, stored);
});
await evaluate("licensePolicy", async () => {
const [inventory, stored, policy] = await Promise.all([
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
dependencyInventoryArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/license-report.json").then((value) =>
licenseReportArtifactSchema.parse(value),
),
readJson(root, "config/security/dependency-policy.json"),
]);
return compareStoredLicenseEvidence(
recomputeLicenseEvidence({ inventory, policy }),
stored,
);
});
await evaluate("vulnerabilityPolicy", async () => {
const vulnerability = vulnerabilityReportArtifactSchema.parse(
await readJson(root, "artifacts/security/vulnerability-report.json"),
);
return compareStoredLocalVulnerabilityReport(
candidate.lockfileSha256,
vulnerability,
);
});
await evaluate("secretScan", async () => {
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
return verifyStoredSecretScan(
evaluation,
await readJson(root, "artifacts/security/scan.sarif"),
);
});
return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) });
}
async function digestInput(
repositoryRoot: string,
memberPath: string,
): Promise<Readonly<{ path: string; bytes: number; sha256: string }>> {
const absolute = path.resolve(repositoryRoot, memberPath);
const relative = path.relative(repositoryRoot, absolute);
if (
relative === "" ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new TypeError(`local assessment input escapes repository: ${memberPath}`);
}
const bytes = await readFile(absolute);
return Object.freeze({
path: memberPath,
bytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
export async function verifyArchivedLocalEvidence(input: Readonly<{
extractionRoot: string;
expectedManifest: ReleaseCandidateManifest;
}>): Promise<Readonly<{
status: "PASS" | "FAIL";
identity: null | Readonly<{
sourceRevision: string;
sourceSetSha256: string;
assessmentSha256: string;
}>;
failures: readonly string[];
}>> {
const extractionRoot = path.resolve(input.extractionRoot);
const failures: string[] = [];
let extractedManifest: ReleaseCandidateManifest | null = null;
try {
extractedManifest = releaseCandidateManifestSchema.parse(
await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
);
} catch {
failures.push("extracted release candidate manifest is missing or invalid");
}
if (
extractedManifest &&
JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest)
) {
failures.push("caller expectedManifest differs from extracted manifest");
}
let assessment: z.infer<typeof localEvidenceAssessmentArtifactSchema> | null = null;
let assessmentSha256 = "";
let assessmentBytes: Buffer | null = null;
try {
assessmentBytes = await readFile(
path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH),
);
assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex");
assessment = localEvidenceAssessmentArtifactSchema.parse(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown,
);
} catch {
failures.push("local evidence assessment is missing or invalid");
}
if (assessment && extractedManifest) {
const assessmentMember = extractedManifest.files.find(
({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH,
);
if (
!assessmentMember ||
assessmentMember.bytes !== assessmentBytes?.byteLength ||
assessmentMember.sha256 !== assessmentSha256
) {
failures.push("local assessment manifest binding mismatch");
}
if (
assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID ||
assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION
) {
failures.push("local assessment verifier identity mismatch");
}
const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath);
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
failures.push("local assessment policyInputs exact set mismatch");
}
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
);
if (
verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length ||
supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256
) {
failures.push("local assessment verifier-source digest mismatch");
}
const expectedEvidenceInputs = extractedManifest.files.filter(
({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
);
if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) {
failures.push("local assessment evidenceInputs exact member binding mismatch");
}
const sbom = extractedManifest.files.find(
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
);
if (
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
!sbom ||
assessment.candidate.sbomSha256 !== sbom.sha256
) {
failures.push("local assessment candidate digest binding mismatch");
}
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
failures.push("local evidence assessment is not PASS");
}
const identities = await readArchivedIdentities(extractionRoot, failures);
if (
identities.buildRevision !== assessment.source.revision ||
identities.releaseRevision !== assessment.source.revision
) {
failures.push("local assessment source revision identity mismatch");
}
if (
identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 ||
identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256
) {
failures.push("local assessment source-set identity mismatch");
}
}
const uniqueFailures = Object.freeze([...new Set(failures)]);
const passingAssessment = uniqueFailures.length === 0 ? assessment : null;
return Object.freeze({
status: passingAssessment ? "PASS" : "FAIL",
identity: passingAssessment
? Object.freeze({
sourceRevision: passingAssessment.source.revision,
sourceSetSha256: passingAssessment.source.sourceSetSha256,
assessmentSha256,
})
: null,
failures: uniqueFailures,
});
}
async function readArchivedIdentities(
extractionRoot: string,
failures: string[],
): Promise<Readonly<{
buildRevision: unknown;
releaseRevision: unknown;
provenanceSourceSetSha256: unknown;
supplySourceSetSha256: unknown;
}>> {
try {
const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([
readJson(extractionRoot, "artifacts/release/build-manifest.json"),
readJson(extractionRoot, "dist/release-manifest.json"),
readJson(extractionRoot, "artifacts/release/provenance.json"),
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"),
]);
const build = buildManifestArtifactSchema.parse(buildDocument);
const release = releaseManifestArtifactSchema.parse(releaseDocument);
const provenance = provenanceArtifactSchema.parse(provenanceDocument);
const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument);
return Object.freeze({
buildRevision: build.commitSha,
releaseRevision: release.commitSha,
provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256,
supplySourceSetSha256: supply.sourceSetSha256,
});
} catch {
failures.push("archived source/build/provenance identities are missing or invalid");
return Object.freeze({
buildRevision: null,
releaseRevision: null,
provenanceSourceSetSha256: null,
supplySourceSetSha256: null,
});
}
}
export async function assessLocalEvidenceForProducer(input: Readonly<{
repositoryRoot?: string;
candidate: ReleaseCandidateManifest;
}>): Promise<Readonly<{