Files
clean-architecture-frontend…/scripts/lib/ci-artifact-validator.ts
T

454 lines
17 KiB
TypeScript

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<string, Set<string>>();
artifact.results.forEach(({ scope, metric }, index) => {
const metrics = resultsByScope.get(scope) ?? new Set<string>();
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<Record<ExecutableJsonSchemaId, ZodType>> = 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<Stats>;
read(
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<Readonly<{ bytesRead: number }>>;
close(): Promise<unknown>;
}>;
type ValidatorDependencies = Readonly<{
lstatPath?: typeof lstat;
realpathPath?: typeof realpath;
openFile?: (target: string, flags: number) => Promise<ReadHandle>;
}>;
export async function validateCiArtifact(
input: Readonly<{
root: string;
artifact: CiGateArtifact;
schema: CiGateArtifactSchema;
}>,
dependencies: ValidatorDependencies = {},
): Promise<void> {
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*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\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<Buffer> {
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<Buffer> {
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 (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", 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<string>();
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})`);
}