import { constants, type Stats } from "node:fs"; import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; import { z, type ZodType } from "zod"; import type { CiGateArtifact, CiGateArtifactSchema, } from "../contracts/ci-gates.ts"; import { ciContractReportSchema } from "./ci-contract-report.ts"; import { buildManifestArtifactSchema, bundlePerformanceArtifactSchema, dependencyDiffArtifactSchema, dependencyInventoryArtifactSchema, fieldWebVitalsArtifactSchema, jsonSchemaDocumentArtifactSchema, labPerformanceArtifactSchema, licenseReportArtifactSchema, moduleInventoryArtifactSchema, provenanceArtifactSchema, registryGovernanceRunArtifactSchema, registrySnapshotArtifactSchema, releaseVerificationArtifactSchema, runbookRecordArtifactSchema, sbomArtifactSchema, supplyChainVerificationArtifactSchema, vulnerabilityReportArtifactSchema, } from "../contracts/release-artifacts.ts"; import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts"; import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts"; import { providerVerificationArtifactSchema, provenanceProviderAttestationSchema, vulnerabilityProviderReportSchema, } from "./provider-evidence.ts"; import { releaseCandidateManifestSchema } from "./release-candidate.ts"; import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; import { secretScanSarifSchema } from "./secret-scan-evaluator.ts"; import { testEvidenceReportSchema } from "./test-evidence-artifact.ts"; const jsonObjectSchema = z.record(z.string(), z.json()).refine( (value) => Object.keys(value).length > 0, "generic JSON artifact must be a non-empty object", ); const coverageCounterSchema = z .object({ total: z.number().int().nonnegative(), covered: z.number().int().nonnegative(), skipped: z.number().int().nonnegative(), pct: z.number().min(0).max(100), }) .strict() .superRefine((counter, context) => { if (counter.covered + counter.skipped > counter.total) { context.addIssue({ code: "custom", message: "coverage counter exceeds total" }); } const expected = counter.total === 0 ? 100 : Math.floor((counter.covered / counter.total) * 10_000) / 100; if (counter.pct !== expected) { context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" }); } }); const coverageSummarySchema = z .record( z.string(), z .object({ lines: coverageCounterSchema, statements: coverageCounterSchema, functions: coverageCounterSchema, branches: coverageCounterSchema, }) .strict(), ) .refine((value) => "total" in value, "coverage summary lacks total"); const riskCoverageArtifactSchema = z .object({ schemaVersion: z.literal(3), policy: z.string().min(1), summary: z.string().min(1), status: z.enum(["PASS", "FAIL"]), selectedTotal: z.number().int().nonnegative(), repositoryTotal: z.number().int().positive(), counterBearingTotal: z.number().int().nonnegative(), instrumentedCounterBearingTotal: z.number().int().nonnegative(), counterlessTotal: z.number().int().nonnegative(), counterlessModules: z.array(z.string()), preExclusionTotal: z.number().int().positive(), generatedExclusionCount: z.number().int().nonnegative(), generatedExclusions: z.array(z.string()), ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"), ownedHighRiskPaths: z.array(z.string()), waivedHighRiskPaths: z.array(z.string()), uncoveredModules: z.array(z.string()), results: z .array( z .object({ scope: z.string().min(1), metric: z.enum(["lines", "statements", "functions", "branches"]), threshold: z.number().min(0).max(100), received: z.number().min(0).max(100), passed: z.boolean(), }) .strict(), ) .min(4), failures: z.array(z.string()), }) .strict() .superRefine((artifact, context) => { const fail = (path: PropertyKey[], message: string) => context.addIssue({ code: "custom", path, message }); if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) { fail(["counterBearingTotal"], "counter partition must equal repositoryTotal"); } if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) { fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total"); } if (artifact.counterlessModules.length !== artifact.counterlessTotal) { fail(["counterlessModules"], "counterless list length drift"); } if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) { fail(["generatedExclusions"], "generated exclusion list length drift"); } if ( artifact.preExclusionTotal !== artifact.repositoryTotal + artifact.generatedExclusionCount ) { fail(["preExclusionTotal"], "pre-exclusion inventory total drift"); } if ( artifact.selectedTotal > artifact.repositoryTotal || artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal ) { fail(["selectedTotal"], "selected/uncovered repository totals drift"); } if ( (artifact.status === "PASS") !== (artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed)) ) { fail(["status"], "status must agree with failures and threshold results"); } artifact.results.forEach((result, index) => { if (result.passed !== (result.received >= result.threshold)) { fail(["results", index, "passed"], "threshold result is inconsistent"); } }); for (const [field, values] of [ ["counterlessModules", artifact.counterlessModules], ["generatedExclusions", artifact.generatedExclusions], ["ownedHighRiskPaths", artifact.ownedHighRiskPaths], ["waivedHighRiskPaths", artifact.waivedHighRiskPaths], ["uncoveredModules", artifact.uncoveredModules], ] as const) { if (new Set(values).size !== values.length) fail([field], "path list contains duplicates"); } const owned = new Set(artifact.ownedHighRiskPaths); if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) { fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap"); } const resultsByScope = new Map>(); artifact.results.forEach(({ scope, metric }, index) => { const metrics = resultsByScope.get(scope) ?? new Set(); if (metrics.has(metric)) { fail(["results", index, "metric"], "threshold metric is duplicated within scope"); } metrics.add(metric); resultsByScope.set(scope, metrics); }); for (const [scope, metrics] of resultsByScope) { if (metrics.size !== 4) { fail(["results"], `threshold scope must contain all four metrics: ${scope}`); } } }); type ExecutableJsonSchemaId = Extract< CiGateArtifactSchema, Readonly<{ kind: "json" }> >["executableSchemaId"]; const executableJsonSchemas: Readonly> = Object.freeze({ "generic-json-object": jsonObjectSchema, "coverage-summary-v8": coverageSummarySchema, "risk-coverage-v3": riskCoverageArtifactSchema, "build-manifest": buildManifestArtifactSchema, "module-inventory": moduleInventoryArtifactSchema, "dependency-inventory": dependencyInventoryArtifactSchema, "registry-snapshot": registrySnapshotArtifactSchema, "registry-governance-run": registryGovernanceRunArtifactSchema, "bundle-performance": bundlePerformanceArtifactSchema, sbom: sbomArtifactSchema, provenance: provenanceArtifactSchema, "dependency-diff": dependencyDiffArtifactSchema, "license-report": licenseReportArtifactSchema, "vulnerability-report": vulnerabilityReportArtifactSchema, "field-web-vitals": fieldWebVitalsArtifactSchema, "lab-performance": labPerformanceArtifactSchema, "release-verification": releaseVerificationArtifactSchema, "runbook-record": runbookRecordArtifactSchema, "supply-chain-verification": supplyChainVerificationArtifactSchema, "release-candidate": releaseCandidateManifestSchema, "supply-chain-coherence": supplyChainCoherenceReportSchema, "http-scenario-receipt": httpScenarioReceiptSchema, "test-evidence-report": testEvidenceReportSchema, "provider-vulnerability": vulnerabilityProviderReportSchema, "provider-provenance": provenanceProviderAttestationSchema, "provider-verification": providerVerificationArtifactSchema, "ci-contract-report": ciContractReportSchema, }); type ReadHandle = Readonly<{ stat(): Promise; read( buffer: Buffer, offset: number, length: number, position: number, ): Promise>; close(): Promise; }>; type ValidatorDependencies = Readonly<{ lstatPath?: typeof lstat; realpathPath?: typeof realpath; openFile?: (target: string, flags: number) => Promise; }>; export async function validateCiArtifact( input: Readonly<{ root: string; artifact: CiGateArtifact; schema: CiGateArtifactSchema; }>, dependencies: ValidatorDependencies = {}, ): Promise { const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path"); assertExtensionCoherence(relative, input.schema.kind); const bytes = await readBoundedRegularFile( { root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes }, dependencies, ); if (input.schema.kind === "candidate-archive") return; const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`); switch (input.schema.kind) { case "text": return; case "markdown": if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`); return; case "html": if (!/^\s*(?:\s*)?]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) { throw new TypeError(`invalid HTML artifact: ${relative}`); } return; case "junit": assertWellFormedJUnitXml(text, relative); return; case "sarif": secretScanSarifSchema.parse(JSON.parse(text) as unknown); return; case "json-schema": jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown); return; case "json": { const schema = executableJsonSchemas[input.schema.executableSchemaId]; if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`); schema.parse(JSON.parse(text) as unknown); return; } } } export async function readBoundedRegularFile( input: Readonly<{ root: string; relativePath: string; maxBytes: number }>, dependencies: ValidatorDependencies = {}, ): Promise { const root = path.resolve(input.root); const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path"); const maxBytes = input.maxBytes; if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) { throw new RangeError("bounded file maximum must be within 1..268435456"); } const lstatPath = dependencies.lstatPath ?? lstat; const realpathPath = dependencies.realpathPath ?? realpath; const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags)); const rootMetadata = await lstatPath(root); if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { throw new TypeError("bounded file root is unsafe"); } const rootRealpath = await realpathPath(root); let ancestor = root; const segments = relative.split("/"); for (const segment of segments.slice(0, -1)) { ancestor = path.join(ancestor, segment); const metadata = await lstatPath(ancestor); if (metadata.isSymbolicLink() || !metadata.isDirectory()) { throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`); } } const absolute = path.join(root, relative); const before = await lstatPath(absolute); if (before.isSymbolicLink() || !before.isFile()) { throw new TypeError(`CI artifact is not a regular file: ${relative}`); } if (before.size <= 0 || before.size > maxBytes) { throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`); } const resolved = await realpathPath(absolute); const outside = path.relative(rootRealpath, resolved); if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { throw new TypeError(`CI artifact escapes repository: ${relative}`); } const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); try { const opened = await handle.stat(); assertSameIdentity(before, opened, relative); const bytes = await readHandleBounded(handle, before.size, maxBytes, relative); const after = await handle.stat(); assertSameIdentity(opened, after, relative); if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) { throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); } return bytes; } finally { await handle.close(); } } async function readHandleBounded( handle: ReadHandle, expectedSize: number, maxBytes: number, relative: string, ): Promise { const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1)); let offset = 0; while (offset < captured.byteLength) { const { bytesRead } = await handle.read( captured, offset, captured.byteLength - offset, offset, ); if (bytesRead === 0) break; offset += bytesRead; } if (offset !== expectedSize) { throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); } return captured.subarray(0, offset); } function assertWellFormedJUnitXml(source: string, relative: string): void { const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`); if (/", open + 4); if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); cursor = close + 3; continue; } if (source.startsWith("", open + 9); if (stack.length === 0 || close < 0) throw invalid(); cursor = close + 3; continue; } if (source.startsWith("", open + 2); if (root || close < 0) throw invalid(); cursor = close + 2; continue; } const close = source.indexOf(">", open + 1); if (close < 0) throw invalid(); const tag = source.slice(open, close + 1); const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag); if (closing) { if (stack.pop() !== closing[1]) throw invalid(); if (stack.length === 0) rootClosed = true; cursor = close + 1; continue; } const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag); if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid(); root ??= opening[1]; if (opening[3] !== "/") stack.push(opening[1]!); else if (stack.length === 0) rootClosed = true; cursor = close + 1; } if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) { throw invalid(); } } function hasValidXmlAttributes(source: string): boolean { let remaining = source; const names = new Set(); while (remaining.length > 0) { if (!remaining.trim()) return true; const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"[^"<]*"|'[^'<]*')/u.exec(remaining); if (!match || names.has(match[1]!)) return false; names.add(match[1]!); remaining = remaining.slice(match[0].length); } return true; } function assertSameIdentity(before: Stats, after: Stats, relative: string): void { if ( !Number.isSafeInteger(before.dev) || !Number.isSafeInteger(before.ino) || before.dev <= 0 || before.ino <= 0 || before.dev !== after.dev || before.ino !== after.ino || !after.isFile() ) { throw new TypeError(`CI artifact file identity changed: ${relative}`); } } function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void { const valid = kind === "json" || kind === "json-schema" ? relative.endsWith(".json") : kind === "sarif" ? relative.endsWith(".sarif") : kind === "junit" ? relative.endsWith(".xml") : kind === "html" ? relative.endsWith(".html") : kind === "markdown" ? relative.endsWith(".md") : kind === "candidate-archive" ? relative.endsWith(".tar.gz") : !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative); if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`); }