Files
openclaw/scripts/pre-commit/pnpm-audit-prod.mjs
2026-04-14 21:12:26 -04:00

704 lines
19 KiB
JavaScript

#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
const BULK_ADVISORY_PATH = "/-/npm/v1/security/advisories/bulk";
const MIN_SEVERITY = "high";
const SEVERITY_RANK = {
info: 0,
low: 1,
moderate: 2,
high: 3,
critical: 4,
};
const TOP_LEVEL_INDENT = 0;
const SECTION_ENTRY_INDENT = 2;
const NESTED_SECTION_INDENT = 4;
const MAPPING_ENTRY_INDENT = 6;
const NESTED_MAPPING_ENTRY_INDENT = 8;
const SNAPSHOT_SECTIONS = ["dependencies", "optionalDependencies"];
const IMPORTER_SECTIONS = ["dependencies", "optionalDependencies"];
const LOCAL_REFERENCE_PREFIXES = ["file:", "link:", "portal:", "workspace:"];
export function normalizeAuditLevel(level) {
const normalized = String(level ?? "").toLowerCase();
if (normalized in SEVERITY_RANK) {
return normalized;
}
throw new Error(
`Unsupported audit level "${String(level)}". Expected one of: ${Object.keys(SEVERITY_RANK).join(", ")}`,
);
}
export function stripVersionDecorators(reference) {
const openParenIndex = reference.indexOf("(");
if (openParenIndex === -1) {
return reference;
}
return reference.slice(0, openParenIndex);
}
export function parseSnapshotKey(snapshotKey) {
let separatorIndex = -1;
let parenDepth = 0;
for (let index = 1; index < snapshotKey.length; index += 1) {
const character = snapshotKey[index];
if (character === "(") {
parenDepth += 1;
continue;
}
if (character === ")") {
parenDepth = Math.max(0, parenDepth - 1);
continue;
}
if (character === "@" && parenDepth === 0) {
separatorIndex = index;
}
}
if (separatorIndex <= 0) {
throw new Error(`Unable to parse pnpm snapshot key "${snapshotKey}".`);
}
const packageName = snapshotKey.slice(0, separatorIndex);
const reference = snapshotKey.slice(separatorIndex + 1);
return {
packageName,
reference,
version: stripVersionDecorators(reference),
};
}
function isLocalReference(reference) {
return LOCAL_REFERENCE_PREFIXES.some((prefix) => reference.startsWith(prefix));
}
function countIndentation(line) {
let indentation = 0;
while (indentation < line.length && line[indentation] === " ") {
indentation += 1;
}
return indentation;
}
function isIgnorableYamlLine(trimmed) {
return !trimmed || trimmed.startsWith("#");
}
function unquoteYamlString(value) {
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replaceAll("''", "'");
}
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replaceAll('\\"', '"');
}
return value;
}
function parseYamlScalar(value) {
return unquoteYamlString(value.trim());
}
function splitInlineYamlMapEntries(text) {
const entries = [];
let current = "";
let quote = null;
let depth = 0;
for (const character of text) {
if (quote) {
current += character;
if (character === quote) {
quote = null;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
current += character;
continue;
}
if (character === "{" || character === "[" || character === "(") {
depth += 1;
current += character;
continue;
}
if (character === "}" || character === "]" || character === ")") {
depth = Math.max(0, depth - 1);
current += character;
continue;
}
if (character === "," && depth === 0) {
const entry = current.trim();
if (entry) {
entries.push(entry);
}
current = "";
continue;
}
current += character;
}
const entry = current.trim();
if (entry) {
entries.push(entry);
}
return entries;
}
function parseInlineYamlMap(rawValue) {
const trimmed = rawValue.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
const body = trimmed.slice(1, -1).trim();
if (!body) {
return {};
}
const result = {};
for (const entry of splitInlineYamlMapEntries(body)) {
const mapping = parseYamlMappingLine(entry);
if (!mapping?.value) {
continue;
}
result[mapping.key] = parseYamlScalar(mapping.value);
}
return result;
}
function parseYamlMappingLine(line) {
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) {
return null;
}
return {
key: parseYamlScalar(line.slice(0, separatorIndex)),
value: line.slice(separatorIndex + 1).trim(),
};
}
function isNamedYamlSection(trimmed, sectionNames) {
return sectionNames.some((sectionName) => trimmed === `${sectionName}:`);
}
function readNestedVersionValue(lines, startIndex, parentIndent) {
let index = startIndex;
let version = null;
while (index < lines.length) {
const nestedLine = lines[index];
const nestedTrimmed = nestedLine.trim();
const nestedIndentation = countIndentation(nestedLine);
if (isIgnorableYamlLine(nestedTrimmed)) {
index += 1;
continue;
}
if (nestedIndentation <= parentIndent) {
break;
}
if (nestedIndentation === NESTED_MAPPING_ENTRY_INDENT) {
const nestedEntry = parseYamlMappingLine(nestedTrimmed);
if (nestedEntry?.key === "version") {
version = parseYamlScalar(nestedEntry.value);
}
}
index += 1;
}
return { nextIndex: index, version };
}
function collectIndentedStringMap(lines, startIndex, entryIndent) {
const entries = {};
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
const indentation = countIndentation(line);
if (isIgnorableYamlLine(trimmed)) {
index += 1;
continue;
}
if (indentation < entryIndent) {
break;
}
if (indentation !== entryIndent) {
index += 1;
continue;
}
const entry = parseYamlMappingLine(trimmed);
if (entry?.value) {
entries[entry.key] = parseYamlScalar(entry.value);
}
index += 1;
}
return { entries, nextIndex: index };
}
function collectImporterDependencyReferences(lines, startIndex) {
const references = [];
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
const indentation = countIndentation(line);
if (isIgnorableYamlLine(trimmed)) {
index += 1;
continue;
}
if (indentation < MAPPING_ENTRY_INDENT) {
break;
}
if (indentation > MAPPING_ENTRY_INDENT) {
index += 1;
continue;
}
const entry = parseYamlMappingLine(trimmed);
index += 1;
if (!entry) {
continue;
}
if (entry.value) {
const inlineMap = parseInlineYamlMap(entry.value);
if (inlineMap && typeof inlineMap.version === "string") {
references.push({ dependencyName: entry.key, reference: inlineMap.version });
continue;
}
references.push({ dependencyName: entry.key, reference: parseYamlScalar(entry.value) });
continue;
}
const nestedVersion = readNestedVersionValue(lines, index, MAPPING_ENTRY_INDENT);
index = nestedVersion.nextIndex;
if (nestedVersion.version) {
references.push({ dependencyName: entry.key, reference: nestedVersion.version });
}
}
return {
nextIndex: index,
references,
};
}
function collectSnapshotDependencies(lines, startIndex) {
const result = collectIndentedStringMap(lines, startIndex, MAPPING_ENTRY_INDENT);
return { dependencies: result.entries, nextIndex: result.nextIndex };
}
function parsePnpmLockfileSections(lockfileText) {
// Keep this parser dependency-free: security-fast runs this hook without pnpm install.
// It only needs the small pnpm-lock subset used to collect production snapshots.
const importers = [];
const snapshots = {};
const lines = lockfileText.split(/\r?\n/u);
let currentTopLevelSection = null;
let hasImportersSection = false;
let hasSnapshotsSection = false;
for (let index = 0; index < lines.length; ) {
const line = lines[index];
const trimmed = line.trim();
const indentation = countIndentation(line);
if (isIgnorableYamlLine(trimmed)) {
index += 1;
continue;
}
if (indentation === TOP_LEVEL_INDENT && trimmed.endsWith(":")) {
currentTopLevelSection = parseYamlScalar(trimmed.slice(0, -1));
if (currentTopLevelSection === "importers") {
hasImportersSection = true;
}
if (currentTopLevelSection === "snapshots") {
hasSnapshotsSection = true;
}
index += 1;
continue;
}
if (
currentTopLevelSection === "importers" &&
indentation === SECTION_ENTRY_INDENT &&
trimmed.endsWith(":")
) {
index += 1;
while (index < lines.length) {
const nestedLine = lines[index];
const nestedTrimmed = nestedLine.trim();
const nestedIndentation = countIndentation(nestedLine);
if (isIgnorableYamlLine(nestedTrimmed)) {
index += 1;
continue;
}
if (nestedIndentation <= SECTION_ENTRY_INDENT) {
break;
}
if (
nestedIndentation === NESTED_SECTION_INDENT &&
isNamedYamlSection(nestedTrimmed, IMPORTER_SECTIONS)
) {
const result = collectImporterDependencyReferences(lines, index + 1);
importers.push(...result.references);
index = result.nextIndex;
continue;
}
index += 1;
}
continue;
}
if (currentTopLevelSection === "snapshots" && indentation === SECTION_ENTRY_INDENT) {
const snapshotEntry = parseYamlMappingLine(trimmed);
if (!snapshotEntry) {
index += 1;
continue;
}
if (snapshotEntry.value) {
snapshots[snapshotEntry.key] = {};
index += 1;
continue;
}
const snapshotKey = snapshotEntry.key;
const snapshot = {};
index += 1;
while (index < lines.length) {
const nestedLine = lines[index];
const nestedTrimmed = nestedLine.trim();
const nestedIndentation = countIndentation(nestedLine);
if (isIgnorableYamlLine(nestedTrimmed)) {
index += 1;
continue;
}
if (nestedIndentation <= SECTION_ENTRY_INDENT) {
break;
}
if (
nestedIndentation === NESTED_SECTION_INDENT &&
isNamedYamlSection(nestedTrimmed, SNAPSHOT_SECTIONS)
) {
const result = collectSnapshotDependencies(lines, index + 1);
snapshot[nestedTrimmed.slice(0, -1)] = result.dependencies;
index = result.nextIndex;
continue;
}
index += 1;
}
snapshots[snapshotKey] = snapshot;
continue;
}
index += 1;
}
return { hasImportersSection, hasSnapshotsSection, importers, snapshots };
}
function resolveSnapshot({ dependencyName, reference, snapshots }) {
if (isLocalReference(reference)) {
return null;
}
const directKey = `${dependencyName}@${reference}`;
if (directKey in snapshots) {
return {
snapshotKey: directKey,
...parseSnapshotKey(directKey),
};
}
if (reference in snapshots) {
return {
snapshotKey: reference,
...parseSnapshotKey(reference),
};
}
if (reference.startsWith("npm:")) {
const aliasKey = reference.slice(4);
if (aliasKey in snapshots) {
return {
snapshotKey: aliasKey,
...parseSnapshotKey(aliasKey),
};
}
}
throw new Error(
`Unable to resolve pnpm snapshot for dependency "${dependencyName}" with reference "${reference}".`,
);
}
export function collectProdResolvedPackagesFromLockfile(lockfileText) {
const lockfile = parsePnpmLockfileSections(lockfileText);
if (!lockfile.hasImportersSection) {
throw new Error("pnpm-lock.yaml is missing the importers section.");
}
if (!lockfile.hasSnapshotsSection) {
throw new Error("pnpm-lock.yaml is missing the snapshots section.");
}
const versionsByPackage = new Map();
const seenSnapshots = new Set();
const queue = [...lockfile.importers];
while (queue.length > 0) {
const next = queue.pop();
if (!next) {
continue;
}
const resolved = resolveSnapshot({
dependencyName: next.dependencyName,
reference: next.reference,
snapshots: lockfile.snapshots,
});
if (!resolved) {
continue;
}
let versions = versionsByPackage.get(resolved.packageName);
if (!versions) {
versions = new Set();
versionsByPackage.set(resolved.packageName, versions);
}
versions.add(resolved.version);
if (seenSnapshots.has(resolved.snapshotKey)) {
continue;
}
seenSnapshots.add(resolved.snapshotKey);
const snapshot = lockfile.snapshots[resolved.snapshotKey];
if (!snapshot || typeof snapshot !== "object") {
continue;
}
for (const sectionName of SNAPSHOT_SECTIONS) {
const dependencies = snapshot[sectionName];
if (!dependencies || typeof dependencies !== "object") {
continue;
}
for (const [dependencyName, reference] of Object.entries(dependencies)) {
if (typeof reference !== "string") {
continue;
}
queue.push({ dependencyName, reference });
}
}
}
return versionsByPackage;
}
export function createBulkAdvisoryPayload(versionsByPackage) {
return Object.fromEntries(
[...versionsByPackage.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([packageName, versions]) => [
packageName,
[...versions].toSorted((left, right) => left.localeCompare(right)),
]),
);
}
function normalizeSeverity(severity) {
if (typeof severity !== "string") {
return "info";
}
return severity.toLowerCase();
}
export function filterFindingsBySeverity(advisoriesByPackage, minSeverity) {
const threshold = normalizeAuditLevel(minSeverity);
const findings = [];
for (const [packageName, advisories] of Object.entries(advisoriesByPackage ?? {})) {
if (!Array.isArray(advisories)) {
continue;
}
for (const advisory of advisories) {
if (!advisory || typeof advisory !== "object") {
continue;
}
const severity = normalizeSeverity(advisory.severity);
if ((SEVERITY_RANK[severity] ?? -1) < SEVERITY_RANK[threshold]) {
continue;
}
findings.push({
packageName,
id: advisory.id ?? "unknown",
severity,
title: advisory.title ?? "Untitled advisory",
url: advisory.url ?? null,
vulnerableVersions: advisory.vulnerable_versions ?? null,
});
}
}
findings.sort((left, right) => {
const severityDelta =
(SEVERITY_RANK[right.severity] ?? -1) - (SEVERITY_RANK[left.severity] ?? -1);
if (severityDelta !== 0) {
return severityDelta;
}
return left.packageName.localeCompare(right.packageName);
});
return findings;
}
function chunkEntries(entries, size) {
const chunks = [];
for (let index = 0; index < entries.length; index += size) {
chunks.push(entries.slice(index, index + size));
}
return chunks;
}
function resolveRegistryBaseUrl() {
const configured =
process.env.npm_config_registry ??
process.env.NPM_CONFIG_REGISTRY ??
process.env.npm_config_userconfig_registry ??
DEFAULT_REGISTRY;
return configured.replace(/\/+$/u, "");
}
export async function fetchBulkAdvisories({
payload,
fetchImpl = fetch,
registryBaseUrl = resolveRegistryBaseUrl(),
}) {
const url = `${registryBaseUrl}${BULK_ADVISORY_PATH}`;
const response = await fetchImpl(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(
`Bulk advisory request failed (${response.status} ${response.statusText}): ${bodyText}`,
);
}
return response.json();
}
export async function runPnpmAuditProd({
rootDir = process.cwd(),
fetchImpl = fetch,
stdout = process.stdout,
stderr = process.stderr,
minSeverity = MIN_SEVERITY,
} = {}) {
const normalizedMinSeverity = normalizeAuditLevel(minSeverity);
const lockfilePath = path.join(rootDir, "pnpm-lock.yaml");
const lockfileText = await readFile(lockfilePath, "utf8");
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfileText));
const payloadEntries = Object.entries(payload);
if (payloadEntries.length === 0) {
stdout.write("No production dependencies found in pnpm-lock.yaml.\n");
return 0;
}
const advisoryResults = {};
for (const payloadChunk of chunkEntries(payloadEntries, 400)) {
const chunkPayload = Object.fromEntries(payloadChunk);
const chunkResults = await fetchBulkAdvisories({
payload: chunkPayload,
fetchImpl,
});
Object.assign(advisoryResults, chunkResults);
}
const findings = filterFindingsBySeverity(advisoryResults, normalizedMinSeverity);
if (findings.length === 0) {
stdout.write(
`No ${normalizedMinSeverity} or higher advisories found for production dependencies.\n`,
);
return 0;
}
stderr.write(
`Found ${findings.length} ${normalizedMinSeverity} or higher advisories in production dependencies:\n`,
);
for (const finding of findings.slice(0, 25)) {
const details = [
`${finding.severity.toUpperCase()} ${finding.packageName}`,
`id=${finding.id}`,
`title=${finding.title}`,
];
if (finding.vulnerableVersions) {
details.push(`range=${finding.vulnerableVersions}`);
}
if (finding.url) {
details.push(`url=${finding.url}`);
}
stderr.write(`- ${details.join(" · ")}\n`);
}
if (findings.length > 25) {
stderr.write(`...and ${findings.length - 25} more advisories.\n`);
}
return 1;
}
function parseArgs(argv) {
let minSeverity = MIN_SEVERITY;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--audit-level" || argument === "--min-severity") {
minSeverity = argv[index + 1] ?? "";
index += 1;
continue;
}
if (argument.startsWith("--audit-level=")) {
minSeverity = argument.slice("--audit-level=".length);
continue;
}
if (argument.startsWith("--min-severity=")) {
minSeverity = argument.slice("--min-severity=".length);
continue;
}
throw new Error(`Unknown argument "${argument}".`);
}
return { minSeverity };
}
async function main() {
try {
const { minSeverity } = parseArgs(process.argv.slice(2));
process.exitCode = await runPnpmAuditProd({ minSeverity });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}