Files
supabase/scripts/fix-audit-vulnerability.ts
T
Ivan VasilovandClaude Sonnet 5.5 075c611463 chore: bump vulnerable dependencies (#51381)
## Summary

- Bumps vulnerable transitive dependencies flagged by `pnpm audit`, one
commit per dependency (lockfile only, no permanent overrides):
proxy-addr, shell-quote, @fastify/busboy,
@graphql-tools/executor-legacy-ws, @modelcontextprotocol/sdk,
compression, http-cache-semantics, source-map-js, smol-toml, dompurify.
- Updates `scripts/fix-audit-vulnerability.ts` to be agent-friendly:
accepts a dependency name argument, adds `--json` (single JSON object on
stdout, logs on stderr, never prompts) and `--help`.

## Not fixed

The remaining audit findings could not be resolved by this script. Some
are blocked by `minimumReleaseAge` (braces, node-forge, sprintf-js);
others stay vulnerable even with an override and need a parent
dependency update or scoped override.

## Test plan

- [ ] CI passes (typecheck, lint, prettier)
- [ ] `pnpm audit` shows fewer findings than on master

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:23:43 +02:00

318 lines
9.3 KiB
TypeScript

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<string, Advisory>
metadata: {
vulnerabilities: Record<string, number>
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.
<dependency> 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<string, Advisory>): VulnerableModule[] {
const byModule = new Map<string, Advisory[]>()
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<string, string> = {
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<VulnerableModule> {
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<typeof toJsonModule>[] }
| {
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<Result> {
// 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<Result> {
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<void> {
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()