diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f9d299a5b3..87772e9d8be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -117,10 +117,10 @@ repos: # Project checks (same commands as CI) - repo: local hooks: - # pnpm audit --prod --audit-level=high + # node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high - id: pnpm-audit-prod name: pnpm-audit-prod - entry: pnpm audit --prod --audit-level=high + entry: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high language: system pass_filenames: false diff --git a/scripts/pre-commit/pnpm-audit-prod.mjs b/scripts/pre-commit/pnpm-audit-prod.mjs new file mode 100644 index 00000000000..ea798cc1001 --- /dev/null +++ b/scripts/pre-commit/pnpm-audit-prod.mjs @@ -0,0 +1,395 @@ +#!/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"; +import YAML from "yaml"; + +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 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 readResolvedReference(entry) { + if (typeof entry === "string") { + return entry; + } + if (entry && typeof entry === "object" && typeof entry.version === "string") { + return entry.version; + } + return null; +} + +function isLocalReference(reference) { + return LOCAL_REFERENCE_PREFIXES.some((prefix) => reference.startsWith(prefix)); +} + +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 = YAML.parse(lockfileText); + const importers = lockfile?.importers; + const snapshots = lockfile?.snapshots; + if (!importers || typeof importers !== "object") { + throw new Error("pnpm-lock.yaml is missing the importers section."); + } + if (!snapshots || typeof snapshots !== "object") { + throw new Error("pnpm-lock.yaml is missing the snapshots section."); + } + + const versionsByPackage = new Map(); + const seenSnapshots = new Set(); + const queue = []; + + for (const importer of Object.values(importers)) { + if (!importer || typeof importer !== "object") { + continue; + } + for (const sectionName of IMPORTER_SECTIONS) { + const dependencies = importer[sectionName]; + if (!dependencies || typeof dependencies !== "object") { + continue; + } + for (const [dependencyName, entry] of Object.entries(dependencies)) { + const reference = readResolvedReference(entry); + if (!reference) { + continue; + } + queue.push({ dependencyName, reference }); + } + } + } + + while (queue.length > 0) { + const next = queue.pop(); + if (!next) { + continue; + } + const resolved = resolveSnapshot({ + dependencyName: next.dependencyName, + reference: next.reference, + 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 = 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(); +} diff --git a/test/scripts/pnpm-audit-prod.test.ts b/test/scripts/pnpm-audit-prod.test.ts new file mode 100644 index 00000000000..5eac500a0b0 --- /dev/null +++ b/test/scripts/pnpm-audit-prod.test.ts @@ -0,0 +1,183 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + collectProdResolvedPackagesFromLockfile, + createBulkAdvisoryPayload, + filterFindingsBySeverity, + parseSnapshotKey, + runPnpmAuditProd, + stripVersionDecorators, +} from "../../scripts/pre-commit/pnpm-audit-prod.mjs"; + +describe("pnpm-audit-prod", () => { + it("parses scoped snapshot keys with peer suffixes", () => { + expect(parseSnapshotKey("@scope/pkg@1.2.3(peer@4.5.6)")).toEqual({ + packageName: "@scope/pkg", + reference: "1.2.3(peer@4.5.6)", + version: "1.2.3", + }); + }); + + it("strips peer and patch decorators from resolved versions", () => { + expect(stripVersionDecorators("7.0.0-rc.9(patch_hash=abc123)(sharp@0.34.5)")).toBe( + "7.0.0-rc.9", + ); + expect(stripVersionDecorators("1.2.3")).toBe("1.2.3"); + }); + + it("collects the production graph from pnpm lockfile snapshots", () => { + const lockfile = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + pkg-a: + version: 1.0.0 + devDependencies: + dev-only: + version: 9.9.9 + extensions/demo: + dependencies: + '@scope/pkg': + version: 2.0.0(peer@4.0.0) + workspace-lib: + version: link:../../packages/workspace-lib + +snapshots: + pkg-a@1.0.0: + dependencies: + transitive: 3.0.0(patch_hash=abc123) + transitive@3.0.0(patch_hash=abc123): {} + '@scope/pkg@2.0.0(peer@4.0.0)': + optionalDependencies: + opt-dep: 4.0.0 + opt-dep@4.0.0: {} +`; + + const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile)); + expect(payload).toEqual({ + "@scope/pkg": ["2.0.0"], + "opt-dep": ["4.0.0"], + "pkg-a": ["1.0.0"], + transitive: ["3.0.0"], + }); + }); + + it("resolves npm alias snapshots to the real package name", () => { + const lockfile = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + request: + version: npm:@cypress/request@3.0.10 + +snapshots: + '@cypress/request@3.0.10': {} +`; + + const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfile)); + expect(payload).toEqual({ + "@cypress/request": ["3.0.10"], + }); + }); + + it("filters advisory findings by minimum severity", () => { + const findings = filterFindingsBySeverity( + { + axios: [ + { + id: "GHSA-low", + severity: "moderate", + title: "moderate issue", + }, + { + id: "GHSA-high", + severity: "high", + title: "high issue", + url: "https://github.com/advisories/GHSA-high", + }, + ], + }, + "high", + ); + + expect(findings).toEqual([ + { + id: "GHSA-high", + packageName: "axios", + severity: "high", + title: "high issue", + url: "https://github.com/advisories/GHSA-high", + vulnerableVersions: null, + }, + ]); + }); + + it("returns a failing exit code when bulk advisories include high severity findings", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-audit-prod-")); + await writeFile( + path.join(tempDir, "pnpm-lock.yaml"), + `lockfileVersion: '9.0' + +importers: + .: + dependencies: + axios: + version: 1.0.0 + +snapshots: + axios@1.0.0: {} +`, + "utf8", + ); + + try { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const exitCode = await runPnpmAuditProd({ + rootDir: tempDir, + fetchImpl: async () => + new Response( + JSON.stringify({ + axios: [ + { + id: "GHSA-test", + severity: "high", + title: "test issue", + vulnerable_versions: "<=1.0.0", + url: "https://github.com/advisories/GHSA-test", + }, + ], + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ), + stdout: { + write(chunk: string) { + stdoutChunks.push(chunk); + return true; + }, + } as NodeJS.WriteStream, + stderr: { + write(chunk: string) { + stderrChunks.push(chunk); + return true; + }, + } as NodeJS.WriteStream, + }); + + expect(exitCode).toBe(1); + expect(stdoutChunks).toEqual([]); + expect(stderrChunks.join("")).toContain("Found 1 high or higher advisories"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +});