mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## 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>
318 lines
9.3 KiB
TypeScript
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()
|