Add pre-install npm dependency guard

This commit is contained in:
2026-08-27 12:46:55 +01:00
parent 7574b31b8c
commit f28b9ee87d
22 changed files with 1321 additions and 137 deletions
+106
View File
@@ -0,0 +1,106 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { COMMAND_TIMEOUT_MS, HTTP_TIMEOUT_MS, REPORT_VERSION } from "./constants.js";
import { inspectLock, readProject } from "./project.js";
import { fetchPackageDocument, verifyDirectPackage } from "./verify.js";
import { redact, runCommand } from "./util.js";
export async function checkProject(options, injected = {}) {
const projectPath = resolve(options.path);
const run = injected.run ?? runCommand;
const dependencies = {
fetchImpl: injected.fetch ?? globalThis.fetch,
remoteRefs: injected.remoteRefs,
run,
now: injected.now ?? Date.now,
timeoutMs: options.httpTimeoutMs ?? HTTP_TIMEOUT_MS,
};
const report = {
reportVersion: REPORT_VERSION,
generatedAt: new Date(dependencies.now()).toISOString(),
projectPath,
status: "fail",
summary: {},
lockfile: {},
packages: [],
commands: [],
failures: [],
warnings: [],
};
try {
const project = await readProject(projectPath);
const lockInspection = inspectLock(project.lock, options.policy);
report.lockfile = { lockfileVersion: project.lock.lockfileVersion, registryEntries: lockInspection.registryEntries };
report.failures.push(...lockInspection.failures.map((message) => `lockfile: ${message}`));
for (const item of project.direct) {
try {
const document = await fetchPackageDocument(item.name, dependencies);
const packageResult = await verifyDirectPackage(item, document, options.policy, dependencies);
const pack = await execute(run, "npm", ["pack", `${item.name}@${item.version}`, "--dry-run", "--json", "--ignore-scripts"], projectPath, options.commandTimeoutMs, 25_000_000);
report.commands.push(pack.evidence);
if (pack.result.code !== 0) {
packageResult.failures.push(`npm pack failed${pack.result.timedOut ? " (timed out)" : ""}`);
} else {
try {
const packed = JSON.parse(pack.result.stdout)[0];
packageResult.tarballSizeBytes = packed.size;
packageResult.unpackedSizeBytes = packed.unpackedSize;
if (!Number.isFinite(packed.size) || packed.size > options.policy.maxTarballSizeBytes) packageResult.failures.push(`tarball size ${packed.size ?? "unknown"} exceeds ${options.policy.maxTarballSizeBytes}`);
if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > options.policy.maxUnpackedSizeBytes) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${options.policy.maxUnpackedSizeBytes}`);
} catch {
packageResult.failures.push("npm pack returned invalid JSON evidence");
}
}
report.packages.push(packageResult);
} catch (error) {
report.packages.push({ name: item.name, version: item.version, dev: item.dev, failures: [error.message], warnings: [] });
}
}
if (!options.skipInstall) {
for (const args of [
["ci", "--ignore-scripts"],
["audit", "signatures"],
["audit", "--omit=dev"],
]) {
const command = await execute(run, "npm", args, projectPath, options.commandTimeoutMs);
report.commands.push(command.evidence);
if (command.result.code !== 0) report.failures.push(`npm ${args.join(" ")} failed${command.result.timedOut ? " (timed out)" : ""}`);
if (command.result.code !== 0 && args[0] === "ci") break;
}
}
} catch (error) {
report.failures.push(error.message);
}
for (const item of report.packages) {
report.failures.push(...item.failures.map((message) => `${item.name}@${item.version}: ${message}`));
report.warnings.push(...item.warnings.map((message) => `${item.name}@${item.version}: ${message}`));
}
report.status = report.failures.length ? "fail" : "pass";
report.summary = {
directPackages: report.packages.length,
failures: report.failures.length,
warnings: report.warnings.length,
commands: report.commands.length,
};
await mkdir(dirname(resolve(options.report)), { recursive: true });
await writeFile(resolve(options.report), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
return report;
}
async function execute(run, command, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, maxOutputBytes = 20_000) {
const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes });
return {
result,
evidence: {
command: [command, ...args].join(" "),
exitCode: result.code,
timedOut: Boolean(result.timedOut),
stdout: redact(result.stdout ?? ""),
stderr: redact(result.stderr ?? ""),
},
};
}
+56
View File
@@ -0,0 +1,56 @@
import { resolve } from "node:path";
import { checkProject } from "./check.js";
import { initPolicy } from "./init.js";
import { loadPolicy } from "./policy.js";
export async function main(argv = process.argv.slice(2)) {
if (argv.includes("--help") || argv.includes("-h")) {
printHelp();
return;
}
const command = argv.shift();
if (!["check", "init"].includes(command)) throw new Error("expected command check or init; use --help for usage");
const args = parseArgs(argv, command === "check" ? new Set(["path", "policy", "report", "skip-install"]) : new Set(["path", "output"]));
if (!args.path) throw new Error("--path is required");
if (command === "check") {
if (!args.report) throw new Error("--report is required");
const policy = await loadPolicy(args.policy ? resolve(args.policy) : null);
const report = await checkProject({ path: args.path, report: args.report, policy, skipInstall: args["skip-install"] === true });
console.log(`dependency-guard: ${report.status.toUpperCase()} - ${report.summary.directPackages} direct package(s), ${report.summary.failures} failure(s), ${report.summary.warnings} warning(s)`);
console.log(`evidence: ${resolve(args.report)}`);
for (const failure of report.failures.slice(0, 10)) console.error(`FAIL: ${failure}`);
for (const warning of report.warnings.slice(0, 10)) console.warn(`WARN: ${warning}`);
if (report.status !== "pass") process.exitCode = 1;
} else {
if (!args.output) throw new Error("--output is required");
const result = await initPolicy({ path: args.path, output: args.output });
console.log(`wrote policy: ${result.output}`);
for (const warning of result.skipped) console.warn(`WARN: ${warning}`);
}
}
function parseArgs(argv, allowed) {
const result = {};
while (argv.length) {
const token = argv.shift();
if (!token.startsWith("--")) throw new Error(`unexpected positional argument: ${token}`);
const key = token.slice(2);
if (!allowed.has(key)) throw new Error(`unknown option: ${token}`);
if (key === "skip-install") {
if (result[key]) throw new Error(`${token} specified more than once`);
result[key] = true;
continue;
}
const value = argv.shift();
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
if (result[key]) throw new Error(`${token} specified more than once`);
result[key] = value;
}
return result;
}
function printHelp() {
console.log(`Usage:
dependency-guard check --path <project> [--policy <policy.json>] --report <report.json> [--skip-install]
dependency-guard init --path <project> --output <policy.json>`);
}
+15
View File
@@ -0,0 +1,15 @@
export const DEFAULT_POLICY = Object.freeze({
allowInstallScripts: ["fsevents"],
allowDirectLifecycleScripts: [],
sourceEvidenceExceptions: {},
trustedRepositories: {},
trustedMaintainers: {},
minimumReleaseAgeHours: 72,
freshReleaseAction: "warn",
maxTarballSizeBytes: 20 * 1024 * 1024,
maxUnpackedSizeBytes: 100 * 1024 * 1024,
});
export const HTTP_TIMEOUT_MS = 15_000;
export const COMMAND_TIMEOUT_MS = 120_000;
export const REPORT_VERSION = 1;
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { DEFAULT_POLICY, HTTP_TIMEOUT_MS } from "./constants.js";
import { readProject } from "./project.js";
import { fetchPackageDocument } from "./verify.js";
import { normalizeRepository } from "./util.js";
export async function initPolicy(options, injected = {}) {
const project = await readProject(resolve(options.path));
const policy = structuredClone(DEFAULT_POLICY);
const skipped = [];
const dependencies = { fetchImpl: injected.fetch ?? globalThis.fetch, timeoutMs: options.httpTimeoutMs ?? HTTP_TIMEOUT_MS };
for (const item of project.direct) {
const document = await fetchPackageDocument(item.name, dependencies);
const versionData = document.versions?.[item.version];
if (!versionData) throw new Error(`${item.name}@${item.version} is absent from npm registry metadata`);
const repository = normalizeRepository(versionData.repository ?? document.repository);
if (!repository) {
skipped.push(`${item.name}@${item.version}: no GitHub repository; not trusted`);
continue;
}
policy.trustedRepositories[item.name] = repository;
const maintainers = (versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name).filter(Boolean).sort();
if (maintainers.length) policy.trustedMaintainers[item.name] = [...new Set(maintainers)];
}
const output = resolve(options.output);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx", mode: 0o600 });
return { policy, skipped, output };
}
+77
View File
@@ -0,0 +1,77 @@
import { readFile } from "node:fs/promises";
import { DEFAULT_POLICY } from "./constants.js";
import { normalizeRepository } from "./util.js";
const KEYS = new Set(Object.keys(DEFAULT_POLICY));
export async function loadPolicy(file) {
if (!file) return structuredClone(DEFAULT_POLICY);
let parsed;
try {
parsed = JSON.parse(await readFile(file, "utf8"));
} catch (error) {
throw new Error(`cannot read policy ${file}: ${error.message}`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("policy must be a JSON object");
}
for (const key of Object.keys(parsed)) {
if (!KEYS.has(key)) throw new Error(`unknown policy key: ${key}`);
}
const policy = { ...structuredClone(DEFAULT_POLICY), ...parsed };
validateStringArray(policy, "allowInstallScripts");
validateStringArray(policy, "allowDirectLifecycleScripts");
validateSourceExceptions(policy.sourceEvidenceExceptions);
validateMap(policy, "trustedRepositories", false);
validateMap(policy, "trustedMaintainers", true);
if (!["fail", "warn"].includes(policy.freshReleaseAction)) {
throw new Error("freshReleaseAction must be fail or warn");
}
for (const key of ["minimumReleaseAgeHours", "maxTarballSizeBytes", "maxUnpackedSizeBytes"]) {
if (!Number.isFinite(policy[key]) || policy[key] < 0) throw new Error(`${key} must be a non-negative number`);
}
for (const [name, repository] of Object.entries(policy.trustedRepositories)) {
if (!normalizeRepository(repository)) throw new Error(`trustedRepositories.${name} must be a GitHub repository URL`);
}
return policy;
}
function validateSourceExceptions(value) {
if (!value || Array.isArray(value) || typeof value !== "object") {
throw new Error("sourceEvidenceExceptions must be an object");
}
for (const [name, exception] of Object.entries(value)) {
if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") {
throw new Error(`invalid sourceEvidenceExceptions entry for ${name || "<empty>"}`);
}
const keys = Object.keys(exception);
if (keys.some((key) => !["version", "reason", "expiresAt"].includes(key))) {
throw new Error(`unknown sourceEvidenceExceptions field for ${name}`);
}
if (typeof exception.version !== "string" || !exception.version) {
throw new Error(`sourceEvidenceExceptions.${name}.version must be a non-empty string`);
}
if (typeof exception.reason !== "string" || exception.reason.trim().length < 20) {
throw new Error(`sourceEvidenceExceptions.${name}.reason must contain at least 20 characters`);
}
if (typeof exception.expiresAt !== "string" || !Number.isFinite(Date.parse(exception.expiresAt))) {
throw new Error(`sourceEvidenceExceptions.${name}.expiresAt must be an ISO date`);
}
}
}
function validateStringArray(policy, key) {
if (!Array.isArray(policy[key]) || policy[key].some((value) => typeof value !== "string" || !value)) {
throw new Error(`${key} must be an array of non-empty strings`);
}
}
function validateMap(policy, key, arrays) {
const value = policy[key];
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${key} must be an object`);
for (const [name, entry] of Object.entries(value)) {
if (!name || (arrays ? !Array.isArray(entry) || entry.some((item) => typeof item !== "string") : typeof entry !== "string")) {
throw new Error(`invalid ${key} entry for ${name || "<empty>"}`);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { packageNameFromLockPath } from "./util.js";
export async function readProject(projectPath) {
const manifest = await readJson(join(projectPath, "package.json"), "package.json");
const lock = await readJson(join(projectPath, "package-lock.json"), "package-lock.json");
if (![2, 3].includes(lock.lockfileVersion)) throw new Error("package-lock.json must use lockfileVersion 2 or 3");
if (!lock.packages || typeof lock.packages !== "object") throw new Error("package-lock.json has no packages map");
const requested = { ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) };
const direct = [];
for (const name of Object.keys(requested).sort()) {
const entry = lock.packages[`node_modules/${name}`];
if (!entry || typeof entry.version !== "string" || entry.link) {
throw new Error(`cannot resolve exact direct version for ${name} from package-lock.json`);
}
direct.push({ name, version: entry.version, dev: name in (manifest.devDependencies ?? {}) });
}
return { manifest, lock, direct };
}
export function inspectLock(lock, policy) {
const failures = [];
let registryEntries = 0;
for (const [lockPath, entry] of Object.entries(lock.packages)) {
if (!lockPath || entry.link) continue;
const name = packageNameFromLockPath(lockPath, entry) ?? lockPath;
if (entry.hasInstallScript && !policy.allowInstallScripts.includes(name)) {
failures.push(`${name}: lock entry declares hasInstallScript; add a reviewed allowInstallScripts exception if intentional`);
}
if (!lockPath.includes("node_modules/")) continue;
registryEntries += 1;
if (typeof entry.integrity !== "string" || !/^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(entry.integrity)) {
failures.push(`${name}: lock entry has no valid sha256/sha384/sha512 integrity`);
}
try {
const resolved = new URL(entry.resolved);
if (resolved.protocol !== "https:" || resolved.hostname !== "registry.npmjs.org") {
failures.push(`${name}: lock entry is not resolved from https://registry.npmjs.org`);
}
} catch {
failures.push(`${name}: lock entry has no valid registry resolved URL`);
}
}
return { failures, registryEntries };
}
async function readJson(file, label) {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch (error) {
throw new Error(`cannot parse ${label}: ${error.message}`);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { spawn } from "node:child_process";
export function normalizeRepository(value) {
if (!value) return null;
let raw = typeof value === "string" ? value : value.url;
if (typeof raw !== "string") return null;
raw = raw.trim()
.replace(/^github:/i, "https://github.com/")
.replace(/^github\.com\//i, "https://github.com/")
.replace(/^git\+/, "")
.replace(/^git:\/\/github\.com\//i, "https://github.com/")
.replace(/^ssh:\/\/git@github\.com\//i, "https://github.com/")
.replace(/^git@github\.com:/i, "https://github.com/");
try {
const url = new URL(raw);
if (url.hostname.toLowerCase() !== "github.com") return null;
const parts = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/");
if (parts.length !== 2 || !parts.every(Boolean)) return null;
return `https://github.com/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`;
} catch {
return null;
}
}
export function repositorySlug(repository) {
return normalizeRepository(repository)?.slice("https://github.com/".length) ?? null;
}
export function packageNameFromLockPath(lockPath, entry) {
if (typeof entry.name === "string") return entry.name;
const match = lockPath.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/);
return match?.[1] ?? null;
}
export function redact(value) {
return String(value)
.replace(/(authorization|_?authToken|token|password|secret|npm_[a-z0-9_]*token)\s*[:=]\s*[^\s,]+/gi, "$1=[REDACTED]")
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
.replace(/https:\/\/[^\s/@]+@/gi, "https://[REDACTED]@")
.slice(0, 20_000);
}
export async function fetchJson(url, options = {}) {
const { fetchImpl = globalThis.fetch, timeoutMs = 15_000, headers = {} } = options;
const response = await fetchImpl(url, {
headers: { accept: "application/json", ...headers },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new Error(`HTTP ${response.status} from ${safeUrl(url)}`);
return response.json();
}
export function safeUrl(value) {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
return url.toString();
}
export function runCommand(command, args, options = {}) {
const { cwd, timeoutMs = 120_000, maxOutputBytes = 20_000 } = options;
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
}, timeoutMs);
child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk, maxOutputBytes); });
child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk, maxOutputBytes); });
child.on("error", (error) => {
clearTimeout(timer);
resolve({ code: null, stdout, stderr: error.message, timedOut });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr, timedOut });
});
});
}
function appendBounded(current, chunk, limit) {
if (current.length >= limit) return current;
return current + String(chunk).slice(0, limit - current.length);
}
export function decodeBase64Json(value) {
if (typeof value !== "string") throw new Error("attestation payload is missing");
return JSON.parse(Buffer.from(value, "base64").toString("utf8"));
}
+169
View File
@@ -0,0 +1,169 @@
import { decodeBase64Json, fetchJson, normalizeRepository, runCommand } from "./util.js";
export async function fetchPackageDocument(name, dependencies) {
return fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}`, dependencies);
}
export async function verifyDirectPackage(item, document, policy, dependencies) {
const failures = [];
const warnings = [];
const versionData = document.versions?.[item.version];
if (!versionData) return result(item, failures.concat("exact version is absent from npm registry metadata"), warnings);
const signatures = versionData.dist?.signatures;
if (!Array.isArray(signatures) || signatures.length === 0 || signatures.some((entry) => !entry.keyid || !entry.sig)) {
failures.push("npm registry metadata has no valid registry signature");
}
const actualRepository = normalizeRepository(versionData.repository ?? document.repository);
const configuredRepository = policy.trustedRepositories[item.name]
? normalizeRepository(policy.trustedRepositories[item.name])
: null;
if (configuredRepository && actualRepository !== configuredRepository) {
failures.push(`repository mismatch: expected ${configuredRepository}, registry reports ${actualRepository ?? "no GitHub repository"}`);
}
const repository = configuredRepository ?? actualRepository;
if (!repository) failures.push("no verifiable GitHub upstream repository is available");
const trustedMaintainers = policy.trustedMaintainers[item.name];
if (trustedMaintainers) {
const actual = [...new Set((versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name?.toLowerCase()).filter(Boolean))].sort();
const trusted = [...new Set(trustedMaintainers.map((entry) => entry.toLowerCase()))].sort();
if (actual.length === 0) failures.push("registry metadata has no maintainers to compare with policy");
if (actual.join("\n") !== trusted.join("\n")) {
failures.push(`registry maintainer set changed: expected ${trusted.join(", ")}; found ${actual.join(", ") || "none"}`);
}
}
const lifecycle = ["preinstall", "install", "postinstall"].filter((name) => versionData.scripts?.[name]);
if (lifecycle.length && !policy.allowDirectLifecycleScripts.includes(item.name)) {
failures.push(`direct package declares lifecycle hook(s): ${lifecycle.join(", ")}`);
}
const publishedAt = document.time?.[item.version];
let releaseAgeHours = null;
if (!publishedAt || !Number.isFinite(Date.parse(publishedAt))) {
warnings.push("registry metadata has no valid release timestamp");
} else {
releaseAgeHours = (dependencies.now() - Date.parse(publishedAt)) / 3_600_000;
if (releaseAgeHours < policy.minimumReleaseAgeHours) {
const message = `release is ${Math.max(0, releaseAgeHours).toFixed(1)} hours old; policy requires ${policy.minimumReleaseAgeHours}`;
(policy.freshReleaseAction === "fail" ? failures : warnings).push(message);
}
}
let provenance = "none";
if (repository) {
const attestations = versionData.dist?.attestations;
const provenanceUrl = attestations?.provenance?.predicateType === "https://slsa.dev/provenance/v1"
? attestations.url
: attestations?.provenance?.url;
if (provenanceUrl) {
provenance = "slsa";
try {
if (new URL(provenanceUrl).protocol !== "https:") throw new Error("attestation URL is not HTTPS");
await verifyProvenance(provenanceUrl, item, repository, dependencies);
} catch (error) {
const message = `SLSA provenance: ${error.message}`;
if (hasValidSourceException(item, policy, dependencies)) {
provenance = "policy-exception";
warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`);
} else {
failures.push(message);
}
}
} else {
provenance = "gitHead-tag";
try {
await verifyGitFallback(versionData.gitHead, item, repository, dependencies);
} catch (error) {
const message = `git fallback: ${error.message}`;
if (hasValidSourceException(item, policy, dependencies)) {
provenance = "policy-exception";
warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`);
} else {
failures.push(message);
}
}
}
}
return { ...result(item, failures, warnings), repository, releaseAgeHours, provenance, maintainers: (versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name).filter(Boolean) };
}
async function verifyProvenance(url, item, repository, dependencies) {
const data = await fetchJson(url, dependencies);
const attestations = data.attestations ?? (data.bundle ? [data] : []);
const attestation = attestations.find((entry) => String(entry.predicateType ?? "").includes("slsa")) ?? attestations[0];
const envelope = attestation?.bundle?.dsseEnvelope ?? attestation?.dsseEnvelope;
const statement = decodeBase64Json(envelope?.payload);
const predicate = statement.predicate ?? {};
const workflow = predicate.buildDefinition?.externalParameters?.workflow ?? predicate.invocation?.configSource ?? {};
const sourceRepository = normalizeRepository(workflow.repository ?? workflow.uri);
const sourceRef = workflow.ref ?? workflow.digest?.ref;
const dependenciesList = predicate.buildDefinition?.resolvedDependencies ?? predicate.materials ?? [];
const source = dependenciesList.find((entry) => normalizeProvenanceRepository(entry.uri) === normalizeRepository(repository));
const commit = source?.digest?.gitCommit ?? source?.digest?.sha1;
const resolvedRef = provenanceRef(source?.uri) ?? sourceRef;
if (sourceRepository !== normalizeRepository(repository)) throw new Error(`source repository mismatch (${sourceRepository ?? "missing"})`);
if (!/^[0-9a-f]{7,64}$/i.test(commit ?? "")) throw new Error("source commit is missing");
if (typeof resolvedRef !== "string" || !resolvedRef.startsWith("refs/")) throw new Error("resolved source ref is missing");
if (resolvedRef.startsWith("refs/tags/")) {
await requireRemoteTag(repository, resolvedRef, commit, dependencies);
} else {
await requireVersionTag(repository, item.version, commit, dependencies);
}
}
function normalizeProvenanceRepository(value) {
if (typeof value !== "string") return normalizeRepository(value);
return normalizeRepository(value.replace(/@refs\/(?:tags|heads)\/.*$/, ""));
}
async function verifyGitFallback(gitHead, item, repository, dependencies) {
if (!/^[0-9a-f]{7,64}$/i.test(gitHead ?? "")) throw new Error("npm gitHead is missing or invalid");
const refs = await listRemoteTags(repository, `refs/tags/*${item.version}*`, dependencies);
const found = refs.some((ref) => ref.sha.toLowerCase() === gitHead.toLowerCase());
if (!found) throw new Error(`no Git tag containing ${item.version} points to npm gitHead`);
}
async function requireRemoteTag(repository, ref, commit, dependencies) {
const refs = await listRemoteTags(repository, ref, dependencies);
if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) {
throw new Error(`${ref} does not point to provenance commit`);
}
}
async function requireVersionTag(repository, version, commit, dependencies) {
const refs = await listRemoteTags(repository, `refs/tags/*${version}*`, dependencies);
if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) {
throw new Error(`no Git tag containing ${version} points to provenance commit`);
}
}
async function listRemoteTags(repository, pattern, dependencies) {
if (dependencies.remoteRefs) return dependencies.remoteRefs(repository, pattern);
const run = dependencies.run ?? runCommand;
const result = await run("git", ["ls-remote", "--tags", `${repository}.git`, pattern, `${pattern}^{}`], {
timeoutMs: dependencies.timeoutMs,
maxOutputBytes: 2_000_000,
});
if (result.code !== 0) throw new Error(`git ls-remote failed: ${result.stderr || `exit ${result.code}`}`);
return String(result.stdout).split("\n").filter(Boolean).map((line) => {
const [sha, ref] = line.split(/\s+/, 2);
return { sha, ref };
});
}
function provenanceRef(value) {
if (typeof value !== "string") return null;
return value.match(/@(refs\/(?:tags|heads)\/.*)$/)?.[1] ?? null;
}
function hasValidSourceException(item, policy, dependencies) {
const exception = policy.sourceEvidenceExceptions[item.name];
return exception?.version === item.version && Date.parse(exception.expiresAt) > dependencies.now();
}
function result(item, failures, warnings) {
return { name: item.name, version: item.version, dev: item.dev, failures, warnings };
}