454 lines
17 KiB
TypeScript
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})`);
|
|
}
|