import { execSync } from 'node:child_process' import * as fs from 'node:fs' import * as readline from 'node:readline' import { bumpPackage, compareSemver, LOCKFILE_PATH } from './bump-package' interface Advisory { id: number module_name: string severity: string title: string vulnerable_versions: string patched_versions: string findings: Array<{ version: string paths: string[] }> } interface AuditOutput { advisories: Record metadata: { vulnerabilities: Record dependencies: number totalDependencies: number } } interface VulnerableModule { module_name: string advisories: Advisory[] highestSeverity: string overrideVersion: string allPaths: string[] } const USAGE = `Usage: pnpm tsx scripts/fix-audit-vulnerability.ts [dependency] [--json] [--help] (no args) List patchable vulnerabilities and pick one interactively. Fix that dependency non-interactively (e.g. braces, @scope/pkg). --json Machine-readable mode. Prints a single JSON object to stdout (progress logs go to stderr). Never prompts: without a dependency it only lists vulnerabilities. JSON "status" values: listed | fixed | not_vulnerable | still_vulnerable | error Exit code is 0 for listed/fixed/not_vulnerable and 1 otherwise. Fixing modifies pnpm-lock.yaml only (reverted on failure); commit the result yourself.` const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'low'] function runAudit(): AuditOutput { try { const stdout = execSync('pnpm audit --json', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, }) return JSON.parse(stdout) } catch (error: any) { // pnpm audit exits with code 1 when vulnerabilities exist if (error.stdout) { return JSON.parse(error.stdout) } throw error } } function parseMinVersion(patchedVersions: string): string | null { const match = patchedVersions.match(/>=(\d+\.\d+\.\d+)/) return match ? match[1] : null } function groupAdvisories(advisories: Record): VulnerableModule[] { const byModule = new Map() for (const adv of Object.values(advisories)) { if (adv.patched_versions === '<0.0.0') continue const existing = byModule.get(adv.module_name) ?? [] existing.push(adv) byModule.set(adv.module_name, existing) } const result: VulnerableModule[] = [] for (const [module_name, advs] of byModule) { const allPaths = [...new Set(advs.flatMap((a) => a.findings.flatMap((f) => f.paths)))] const versions = advs .map((a) => parseMinVersion(a.patched_versions)) .filter(Boolean) as string[] const highestVersion = versions.sort(compareSemver).pop()! const overrideVersion = `^${highestVersion}` const highestSeverity = advs .map((a) => a.severity) .sort((a, b) => SEVERITY_ORDER.indexOf(a) - SEVERITY_ORDER.indexOf(b)) .at(0)! result.push({ module_name, advisories: advs, highestSeverity, overrideVersion, allPaths, }) } result.sort((a, b) => { const sevDiff = SEVERITY_ORDER.indexOf(a.highestSeverity) - SEVERITY_ORDER.indexOf(b.highestSeverity) if (sevDiff !== 0) return sevDiff return a.module_name.localeCompare(b.module_name) }) return result } function displayVulnerabilities(modules: VulnerableModule[]): void { console.log('\nVulnerable dependencies (patchable):\n') const severityColors: Record = { critical: '\x1b[31m', high: '\x1b[33m', moderate: '\x1b[36m', low: '\x1b[37m', } const reset = '\x1b[0m' for (let i = 0; i < modules.length; i++) { const m = modules[i] const color = severityColors[m.highestSeverity] ?? reset console.log( ` ${String(i + 1).padStart(2)}. ${color}[${m.highestSeverity.toUpperCase()}]${reset} ` + `${m.module_name} -> ${m.overrideVersion}` ) const maxPaths = 3 const paths = m.allPaths.slice(0, maxPaths) for (const p of paths) { console.log(` via ${p.replace(/__/g, '/')}`) } if (m.allPaths.length > maxPaths) { console.log(` ... and ${m.allPaths.length - maxPaths} more`) } } console.log('') } function promptSelection(modules: VulnerableModule[]): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }) return new Promise((resolve, reject) => { rl.question(`Select vulnerability to fix (1-${modules.length}): `, (answer) => { rl.close() const num = parseInt(answer, 10) if (isNaN(num) || num < 1 || num > modules.length) { reject(new Error(`Invalid selection: ${answer}`)) return } resolve(modules[num - 1]) }) }) } function toJsonModule(m: VulnerableModule) { return { name: m.module_name, severity: m.highestSeverity, targetVersion: m.overrideVersion, advisories: m.advisories.map((a) => ({ id: a.id, title: a.title, severity: a.severity, vulnerableVersions: a.vulnerable_versions, patchedVersions: a.patched_versions, })), paths: m.allPaths.map((p) => p.replace(/__/g, '/')), } } type Result = | { status: 'listed'; vulnerabilities: ReturnType[] } | { status: 'fixed' dependency: string targetVersion: string previousVersion: string | null finalVersion: string | null } | { status: 'not_vulnerable'; dependency: string; message: string } | { status: 'still_vulnerable'; dependency: string; targetVersion: string; message: string } | { status: 'error'; dependency?: string; message: string } async function fixModule(selected: VulnerableModule): Promise { // Snapshot lockfile to revert if the audit verify step fails const originalLockfile = fs.readFileSync(LOCKFILE_PATH, 'utf-8') let bump try { bump = await bumpPackage(selected.module_name, selected.overrideVersion) } catch (error: any) { fs.writeFileSync(LOCKFILE_PATH, originalLockfile, 'utf-8') return { status: 'error', dependency: selected.module_name, message: String(error.message ?? error), } } console.log('\nRunning pnpm audit to verify fix without override...') let verifyResult: AuditOutput try { verifyResult = runAudit() } catch (error) { fs.writeFileSync(LOCKFILE_PATH, originalLockfile, 'utf-8') throw error } const stillVulnerable = Object.values(verifyResult.advisories).some( (adv) => adv.module_name === selected.module_name ) if (stillVulnerable) { console.log('\nReverting pnpm-lock.yaml...') fs.writeFileSync(LOCKFILE_PATH, originalLockfile, 'utf-8') console.log('Reverted to original state.') return { status: 'still_vulnerable', dependency: selected.module_name, targetVersion: selected.overrideVersion, message: 'Vulnerability still present even with override. Consider using scoped overrides or updating the parent dependency.', } } console.log( `\nSUCCESS: Vulnerability for "${selected.module_name}" resolved without needing a permanent override.` ) return { status: 'fixed', dependency: selected.module_name, targetVersion: selected.overrideVersion, previousVersion: bump.previousVersion, finalVersion: bump.finalVersion, } } async function run(dependency: string | undefined, json: boolean): Promise { console.log('Running pnpm audit...') const modules = groupAdvisories(runAudit().advisories) if (dependency) { const selected = modules.find((m) => m.module_name === dependency) if (!selected) { return { status: 'not_vulnerable', dependency, message: `No patchable vulnerability found for "${dependency}".`, } } return fixModule(selected) } if (json || modules.length === 0 || !process.stdin.isTTY) { if (!json) displayVulnerabilities(modules) if (modules.length === 0) console.log('No patchable vulnerabilities found.') return { status: 'listed', vulnerabilities: modules.map(toJsonModule) } } displayVulnerabilities(modules) return fixModule(await promptSelection(modules)) } async function main(): Promise { const args = process.argv.slice(2) if (args.includes('--help') || args.includes('-h')) { console.log(USAGE) return } const json = args.includes('--json') const positional = args.filter((a) => !a.startsWith('-')) const unknown = args.filter((a) => a.startsWith('-') && a !== '--json') // In JSON mode stdout is reserved for the final result; route progress logs to stderr. if (json) console.log = (...a: unknown[]) => console.error(...a) let result: Result if (unknown.length > 0 || positional.length > 1) { result = { status: 'error', message: `Invalid arguments: ${args.join(' ')}\n${USAGE}`, } } else { try { result = await run(positional[0], json) } catch (error: any) { result = { status: 'error', message: String(error.message ?? error) } } } if (json) { process.stdout.write(JSON.stringify(result, null, 2) + '\n') } else if (result.status !== 'listed' && result.status !== 'fixed') { console.error(`\nERROR: ${'message' in result ? result.message : result.status}`) } process.exit(['listed', 'fixed', 'not_vulnerable'].includes(result.status) ? 0 : 1) } main()