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 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]) }) }) } async function main(): Promise { console.log('Running pnpm audit...') const auditResult = runAudit() const modules = groupAdvisories(auditResult.advisories) if (modules.length === 0) { console.log('No patchable vulnerabilities found.') process.exit(0) } displayVulnerabilities(modules) const selected = await promptSelection(modules) // Snapshot lockfile to revert if the audit verify step fails const originalLockfile = fs.readFileSync(LOCKFILE_PATH, 'utf-8') try { await bumpPackage(selected.module_name, selected.overrideVersion) } catch (error: any) { console.error(error.message ?? error) process.exit(1) } console.log('\nRunning pnpm audit to verify fix without override...') const verifyResult = runAudit() 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.') console.error( `\nERROR: Vulnerability for "${selected.module_name}" still present even with override.` ) console.error('Consider using scoped overrides or updating the parent dependency.') process.exit(1) } console.log( `\nSUCCESS: Vulnerability for "${selected.module_name}" resolved without needing a permanent override.` ) } main().catch((error) => { console.error('Fatal error:', error) process.exit(1) })