160 lines
7.2 KiB
JavaScript
160 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Distribution provenance gate.
|
|
*
|
|
* Every tracked media/model/font artifact must be listed exactly once in
|
|
* PROVENANCE.json and match its recorded SHA-256. Original generated artifacts
|
|
* must name a tracked generator and lineage note. A copied item additionally
|
|
* needs an immutable upstream revision, exact upstream path, and intake record.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, extname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const manifestPath = resolve(root, "PROVENANCE.json");
|
|
const ARTIFACT_EXTENSIONS = new Set([
|
|
".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico",
|
|
".glb", ".gltf", ".fbx", ".obj", ".hdr", ".exr",
|
|
".ttf", ".otf", ".woff", ".woff2",
|
|
".mp3", ".wav", ".ogg", ".mp4", ".webm",
|
|
]);
|
|
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".woff", ".woff2"]);
|
|
const ALLOWED_ARTIFACT_LICENSES = new Set([
|
|
"Apache-2.0", "BSD-3-Clause", "CC0-1.0", "CC-BY-4.0", "ISC", "MIT",
|
|
]);
|
|
const problems = [];
|
|
|
|
function trackedFiles() {
|
|
try {
|
|
return execFileSync("git", ["ls-files", "-z"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
}).split("\0").filter(Boolean);
|
|
} catch {
|
|
console.error("check-provenance: FAIL — run this gate inside a git checkout.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function nonEmpty(value) {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function validDate(value) {
|
|
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
}
|
|
|
|
function hash(path) {
|
|
return createHash("sha256").update(readFileSync(resolve(root, path))).digest("hex");
|
|
}
|
|
|
|
function pathIsCovered(path, tracked) {
|
|
if (path.endsWith("/")) return [...tracked].some((candidate) => candidate.startsWith(path));
|
|
return tracked.has(path);
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
} catch (error) {
|
|
console.error(`check-provenance: FAIL — cannot parse PROVENANCE.json (${String(error)}).`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const tracked = new Set(trackedFiles());
|
|
const artifacts = [...tracked].filter((path) => ARTIFACT_EXTENSIONS.has(extname(path).toLowerCase()));
|
|
const records = Array.isArray(manifest.distributedArtifacts) ? manifest.distributedArtifacts : [];
|
|
const recordCounts = new Map();
|
|
|
|
if (manifest.schemaVersion !== 1) problems.push("schemaVersion must be 1");
|
|
if (!nonEmpty(manifest.projectLicense)) problems.push("projectLicense is required");
|
|
|
|
for (const [index, record] of records.entries()) {
|
|
const at = `distributedArtifacts[${index}]`;
|
|
if (!nonEmpty(record?.path)) {
|
|
problems.push(`${at}: path is required`);
|
|
continue;
|
|
}
|
|
recordCounts.set(record.path, (recordCounts.get(record.path) ?? 0) + 1);
|
|
if (!tracked.has(record.path)) problems.push(`${at}: ${record.path} is not a tracked file`);
|
|
if (!ARTIFACT_EXTENSIONS.has(extname(record.path).toLowerCase())) {
|
|
problems.push(`${at}: ${record.path} is not a recognized distribution artifact`);
|
|
}
|
|
if (!nonEmpty(record.kind)) problems.push(`${at}: kind is required`);
|
|
if (!ALLOWED_ARTIFACT_LICENSES.has(record.license)) {
|
|
problems.push(`${at}: license ${String(record.license)} is not in the reviewed allowlist`);
|
|
}
|
|
if (!/^[a-f0-9]{64}$/.test(record.sha256 ?? "")) problems.push(`${at}: sha256 must be 64 lowercase hex characters`);
|
|
else if (tracked.has(record.path) && hash(record.path) !== record.sha256) problems.push(`${at}: SHA-256 mismatch for ${record.path}`);
|
|
if (!validDate(record.intakeDate)) problems.push(`${at}: intakeDate must be YYYY-MM-DD`);
|
|
if (!nonEmpty(record.intakeNote)) problems.push(`${at}: intakeNote is required`);
|
|
|
|
if (record.origin === "repository-generated") {
|
|
if (!nonEmpty(record.generator) || !tracked.has(record.generator)) {
|
|
problems.push(`${at}: repository-generated artifact requires a tracked generator`);
|
|
}
|
|
if (!Array.isArray(record.inputs) || record.inputs.length === 0 ||
|
|
record.inputs.some((input) => !nonEmpty(input) || !tracked.has(input))) {
|
|
problems.push(`${at}: repository-generated artifact requires non-empty tracked inputs`);
|
|
}
|
|
if (record.source !== undefined) problems.push(`${at}: repository-generated artifact must not claim an upstream source`);
|
|
} else if (record.origin === "copied") {
|
|
const source = record.source;
|
|
if (!nonEmpty(source?.url) || !/^https:\/\//.test(source.url)) problems.push(`${at}: copied item requires an HTTPS source URL`);
|
|
if (!nonEmpty(source?.upstreamPath)) problems.push(`${at}: copied item requires its exact upstreamPath`);
|
|
if (!nonEmpty(source?.revision) || !/^[a-f0-9]{40,64}$/i.test(source.revision)) {
|
|
problems.push(`${at}: copied item requires an immutable 40-64 hex upstream revision`);
|
|
} else if (nonEmpty(source?.url) && !source.url.toLowerCase().includes(source.revision.toLowerCase())) {
|
|
problems.push(`${at}: copied item source URL must be pinned to its recorded revision`);
|
|
}
|
|
} else {
|
|
problems.push(`${at}: origin must be repository-generated or copied`);
|
|
}
|
|
}
|
|
|
|
for (const artifact of artifacts) {
|
|
const count = recordCounts.get(artifact) ?? 0;
|
|
if (count !== 1) problems.push(`${artifact}: expected exactly one manifest record, found ${count}`);
|
|
}
|
|
for (const [path, count] of recordCounts) {
|
|
if (count !== 1) problems.push(`${path}: duplicate manifest records (${count})`);
|
|
}
|
|
|
|
const lineage = Array.isArray(manifest.proceduralLineage) ? manifest.proceduralLineage : [];
|
|
if (lineage.length === 0) problems.push("proceduralLineage must contain at least one original-source record");
|
|
for (const [index, record] of lineage.entries()) {
|
|
const at = `proceduralLineage[${index}]`;
|
|
if (!nonEmpty(record?.path) || !pathIsCovered(record.path, tracked)) problems.push(`${at}: path must identify tracked source`);
|
|
if (record?.origin !== "original") problems.push(`${at}: origin must be original`);
|
|
if (!nonEmpty(record?.kind)) problems.push(`${at}: kind is required`);
|
|
if (record?.sourceLicense !== "Apache-2.0") problems.push(`${at}: sourceLicense must be Apache-2.0`);
|
|
if (!nonEmpty(record?.lineage)) problems.push(`${at}: lineage is required`);
|
|
}
|
|
|
|
const trackedFonts = artifacts.filter((path) => FONT_EXTENSIONS.has(extname(path).toLowerCase()));
|
|
const codeFiles = [...tracked].filter((path) =>
|
|
path !== "scripts/check-provenance.mjs" && /\.(?:css|html|js|mjs|ts|tsx)$/.test(path)
|
|
);
|
|
for (const path of codeFiles) {
|
|
const text = readFileSync(resolve(root, path), "utf8");
|
|
if (/@font-face\b/i.test(text) || /fonts\.(?:googleapis|gstatic)\.com/i.test(text)) {
|
|
problems.push(`${path}: bundled/remote web fonts are forbidden by the system-font policy`);
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
console.error("\ncheck-provenance: FAIL\n");
|
|
for (const problem of problems) console.error(` ${problem}`);
|
|
console.error("");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`check-provenance: ok — ${artifacts.length} tracked distribution artifacts are hash-manifested.`);
|
|
console.log(` ${records.filter((record) => record.origin === "copied").length} copied items; ${lineage.length} original procedural/data lineage records.`);
|
|
console.log(` ${trackedFonts.length} bundled fonts; system-font/no-remote-font policy verified.`);
|