Add pre-install npm dependency guard
This commit is contained in:
+106
@@ -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
@@ -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>`);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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>"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user