Files
supabase/scripts/fix-audit-vulnerability.ts
T
Ivan Vasilov e671676696 chore: Bump vulnerable dependencies (#44180)
Each dependency was bumped in its commit.
2026-03-25 14:02:11 +01:00

326 lines
9.8 KiB
TypeScript

import { execSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as readline from 'node:readline'
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 WORKSPACE_YAML_PATH = path.join(process.cwd(), 'pnpm-workspace.yaml')
const LOCKFILE_PATH = path.join(process.cwd(), 'pnpm-lock.yaml')
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 compareSemver(a: string, b: string): number {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i]
}
return 0
}
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 escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function formatOverrideLine(moduleName: string, version: string): string {
const key = moduleName.includes('@') ? `'${moduleName}'` : moduleName
return ` ${key}: ${version}`
}
function addOverride(yamlContent: string, moduleName: string, version: string): string {
const lines = yamlContent.split('\n')
const overridesIdx = lines.findIndex((line) => /^overrides:\s*$/.test(line))
if (overridesIdx === -1) {
throw new Error('Could not find "overrides:" section in pnpm-workspace.yaml')
}
// Find the end of the overrides block (next non-indented, non-empty line)
let blockEnd = overridesIdx + 1
while (blockEnd < lines.length) {
const line = lines[blockEnd]
if (line === '' || /^\s+/.test(line)) {
blockEnd++
} else {
break
}
}
// Check if this module already has an override
const existingPattern = new RegExp(`^\\s+['"]?${escapeRegex(moduleName)}['"]?\\s*:`)
const existingIdx = lines.findIndex(
(line, idx) => idx > overridesIdx && idx < blockEnd && existingPattern.test(line)
)
if (existingIdx !== -1) {
console.log(`\nWARNING: Override for "${moduleName}" already exists:`)
console.log(` ${lines[existingIdx].trim()}`)
console.log(` Replacing with: ${moduleName}: ${version}`)
lines[existingIdx] = formatOverrideLine(moduleName, version)
return lines.join('\n')
}
// Insert new override at the end of the overrides block
const newLine = formatOverrideLine(moduleName, version)
lines.splice(blockEnd, 0, newLine)
return lines.join('\n')
}
async function main(): Promise<void> {
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 original files for revert on failure
const originalYaml = fs.readFileSync(WORKSPACE_YAML_PATH, 'utf-8')
const originalLockfile = fs.readFileSync(LOCKFILE_PATH, 'utf-8')
function revert(): void {
console.log('\nReverting pnpm-workspace.yaml and pnpm-lock.yaml...')
fs.writeFileSync(WORKSPACE_YAML_PATH, originalYaml, 'utf-8')
fs.writeFileSync(LOCKFILE_PATH, originalLockfile, 'utf-8')
console.log('Reverted to original state.')
}
console.log(`\nAdding override: ${selected.module_name}: ${selected.overrideVersion}`)
const updatedYaml = addOverride(originalYaml, selected.module_name, selected.overrideVersion)
fs.writeFileSync(WORKSPACE_YAML_PATH, updatedYaml, 'utf-8')
console.log('Updated pnpm-workspace.yaml')
console.log('\nRunning pnpm install (with override)...')
try {
execSync('pnpm install', {
stdio: 'pipe',
encoding: 'utf-8',
})
} catch (error: any) {
const output = (error.stdout ?? '') + (error.stderr ?? '')
if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
console.error(
`\nNo matching version found for "${selected.module_name}@${selected.overrideVersion}", the minimumReleaseAge option forbids it from installing.`
)
// Extract and display dependency chains that failed due to minimumReleaseAge
const blocks = output.split('ERR_PNPM_NO_MATCHING_VERSION')
for (const block of blocks.slice(1)) {
const versionMatch = block.match(
/No matching version found for (\S+) published by .+?\. Version (\S+) satisfies the specs but was released at (.+)/
)
const chainLines = block
.split('\n')
.filter((line: string) => /^\s+at /.test(line))
.map((line: string) => line.trim().replace(/^at /, ''))
if (versionMatch) {
const [, spec, version, releaseDate] = versionMatch
console.error(`\n Blocked package: ${spec} (v${version} released ${releaseDate.trim()})`)
if (chainLines.length > 0) {
console.error(` Dependency chain: ${chainLines.join(' -> ')}`)
}
// Handle scoped (@org/pkg) and unscoped packages: strip the version range suffix
const pkgName = spec.replace(/@[^/]*$/, '')
console.error(
` To unblock, add "${pkgName}" to the minimumReleaseAgeExclude setting in pnpm-workspace.yaml`
)
}
}
revert()
process.exit(1)
}
throw error
}
// Remove the override and re-install to see if the lockfile update alone fixes it
console.log('\nRemoving override and running pnpm install again...')
fs.writeFileSync(WORKSPACE_YAML_PATH, originalYaml, 'utf-8')
execSync('pnpm install --silent', {
stdio: 'pipe',
encoding: 'utf-8',
})
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) {
revert()
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)
})