mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 13:11:05 +00:00
d30e4daadc
The braces advisory GHSA-vfj7-8cjw-p6xm (CVE-2026-93687) marks every released version (<= 3.0.3) HIGH for stack exhaustion while parsing a deeply nested glob pattern, and no patched release exists. braces reaches the production image through http-proxy-middleware -> micromatch, and dev tooling through chokidar, so both the Trivy image scan and the npm audit gate fail on every branch. Record the triage as an OpenVEX not_affected statement: the only patterns braces compiles come from static proxy and watcher configuration at startup, and request URLs are matched against the compiled matcher, never compiled as patterns, so an adversary cannot control the vulnerable code path. Verified against a real scan with `trivy fs --scanners vuln --vex`. Replace the two `npm audit --audit-level=high` CI steps with scripts/audit-with-vex.mjs, which fails only on advisories the VEX document does not cover and propagates suppression through npm's `via` dependency chains. The statement carries the CVE in `name` for Trivy and the GHSA URL in `@id` for the script, so one record suppresses both gates.
293 lines
10 KiB
JavaScript
293 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Dependency audit gate that honors the OpenVEX triage document.
|
|
*
|
|
* `npm audit --audit-level=high` fails on every high or critical advisory,
|
|
* including ones with no patched release that triage already accepted in
|
|
* security/vex/sencho.openvex.json. This script runs the same audit and fails
|
|
* only on advisories the VEX document does not cover, so one not_affected
|
|
* statement suppresses both the Trivy image scan and this gate.
|
|
*
|
|
* Matching rules:
|
|
* - npm reports advisories by GHSA URL, never by CVE. A statement covers an
|
|
* advisory when its vulnerability name or @id equals the URL or the GHSA id
|
|
* from it. Trivy matches the same statement by the CVE in `name`, so a
|
|
* statement carries the CVE in `name` and the GHSA URL in `@id`.
|
|
* - A statement covers a package when one of its product or subcomponent
|
|
* purls is pkg:npm/<name> with no version or with the installed version.
|
|
* - npm marks a package that is vulnerable only through a dependency with a
|
|
* string entry in `via`; it is suppressed when every dependency it points
|
|
* at is suppressed.
|
|
*
|
|
* Run: node scripts/audit-with-vex.mjs [--dir backend] [--vex <path>] [--audit-level high]
|
|
*
|
|
* Tests: node --test scripts/audit-with-vex.test.mjs
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const DEFAULT_VEX = join(HERE, '..', 'security', 'vex', 'sencho.openvex.json')
|
|
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical']
|
|
|
|
/** Severities at or above `threshold`; npm audit --audit-level semantics. */
|
|
export function gateLevelsFromThreshold(threshold) {
|
|
const index = SEVERITY_ORDER.indexOf(threshold)
|
|
if (index === -1) {
|
|
throw new Error(`unknown audit level "${threshold}" (expected one of ${SEVERITY_ORDER.join(', ')})`)
|
|
}
|
|
return SEVERITY_ORDER.slice(index)
|
|
}
|
|
|
|
/** Split a purl into type, decoded name, and version (null when absent). */
|
|
export function parsePurl(purl) {
|
|
if (typeof purl !== 'string' || !purl.startsWith('pkg:')) return null
|
|
const rest = purl.slice(4).split('#')[0].split('?')[0]
|
|
const slash = rest.indexOf('/')
|
|
if (slash <= 0) return null
|
|
const type = rest.slice(0, slash)
|
|
let name = rest.slice(slash + 1)
|
|
let version = null
|
|
const at = name.lastIndexOf('@')
|
|
if (at > 0) {
|
|
const rawVersion = name.slice(at + 1)
|
|
version = rawVersion === '' ? null : rawVersion
|
|
name = name.slice(0, at)
|
|
}
|
|
try {
|
|
name = decodeURIComponent(name)
|
|
} catch {
|
|
// Keep the raw segment when it is not valid percent-encoding.
|
|
}
|
|
return { type, name, version }
|
|
}
|
|
|
|
/** Every product and subcomponent purl in a VEX statement. */
|
|
export function statementPurls(statement) {
|
|
const purls = []
|
|
for (const product of statement?.products ?? []) {
|
|
if (product?.['@id']) purls.push(product['@id'])
|
|
for (const subcomponent of product?.subcomponents ?? []) {
|
|
if (subcomponent?.['@id']) purls.push(subcomponent['@id'])
|
|
}
|
|
}
|
|
return purls
|
|
}
|
|
|
|
/** Identifiers a statement answers to: name, @id, and the GHSA id inside @id. */
|
|
export function statementAdvisoryIds(statement) {
|
|
const ids = new Set()
|
|
const vulnerability = statement?.vulnerability ?? {}
|
|
for (const value of [vulnerability.name, vulnerability['@id']]) {
|
|
if (typeof value !== 'string') continue
|
|
ids.add(value)
|
|
const ghsa = /(GHSA-[a-z0-9-]+)/i.exec(value)?.[1]
|
|
if (ghsa) ids.add(ghsa)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
/** Identifiers npm gives for one `via` advisory object. */
|
|
export function advisoryIdsFromVia(viaEntry) {
|
|
const ids = new Set()
|
|
if (typeof viaEntry === 'string' || !viaEntry) return ids
|
|
if (typeof viaEntry.url === 'string') {
|
|
ids.add(viaEntry.url)
|
|
const ghsa = /(GHSA-[a-z0-9-]+)/i.exec(viaEntry.url)?.[1]
|
|
if (ghsa) ids.add(ghsa)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
/** True when the statement is not_affected and names one of the advisory ids. */
|
|
export function statementCoversAdvisory(statement, advisoryIds) {
|
|
if (statement?.status !== 'not_affected') return false
|
|
const statementIds = statementAdvisoryIds(statement)
|
|
for (const id of advisoryIds) {
|
|
if (statementIds.has(id)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** True when a statement product purl is pkg:npm/<name> at a matching version. */
|
|
export function statementCoversPackage(statement, packageName, versions) {
|
|
for (const purl of statementPurls(statement)) {
|
|
const parsed = parsePurl(purl)
|
|
if (!parsed || parsed.type !== 'npm' || parsed.name !== packageName) continue
|
|
if (parsed.version === null || versions.has(parsed.version)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Installed versions for the lockfile node paths npm reports. */
|
|
export function installedVersions(lock, nodePaths) {
|
|
const versions = new Set()
|
|
for (const nodePath of nodePaths ?? []) {
|
|
const version = lock?.packages?.[nodePath]?.version
|
|
if (version) versions.add(version)
|
|
}
|
|
return versions
|
|
}
|
|
|
|
/**
|
|
* Compare an `npm audit --json` report against the VEX document. Returns the
|
|
* packages at or above the gate that no statement covers, plus the suppressed
|
|
* names for the success line. Suppression propagates through npm's `via`
|
|
* chains, so covering the root advisory clears every dependent package.
|
|
*/
|
|
export function evaluateAudit(report, vex, lock, gateLevels) {
|
|
const levels = new Set(gateLevels)
|
|
const statements = vex?.statements ?? []
|
|
const vulnerabilities = report?.vulnerabilities ?? {}
|
|
|
|
const packages = new Map()
|
|
for (const [name, entry] of Object.entries(vulnerabilities)) {
|
|
const advisories = []
|
|
const dependencies = []
|
|
for (const via of entry.via ?? []) {
|
|
if (typeof via === 'string') {
|
|
dependencies.push(via)
|
|
continue
|
|
}
|
|
if (!levels.has(via?.severity)) continue
|
|
advisories.push({ severity: via.severity, url: via?.url ?? '', ids: advisoryIdsFromVia(via) })
|
|
}
|
|
packages.set(name, {
|
|
severity: entry.severity,
|
|
versions: installedVersions(lock, entry.nodes),
|
|
advisories,
|
|
dependencies,
|
|
})
|
|
}
|
|
|
|
const suppressed = new Set()
|
|
const belowGate = new Set()
|
|
let changed = true
|
|
while (changed) {
|
|
changed = false
|
|
for (const [name, info] of packages) {
|
|
if (suppressed.has(name) || belowGate.has(name)) continue
|
|
if (!levels.has(info.severity)) {
|
|
belowGate.add(name)
|
|
changed = true
|
|
continue
|
|
}
|
|
// A gated package with no advisory or dependency evidence cannot be
|
|
// cleared by any statement; leave it for the final loop to fail on.
|
|
if (info.advisories.length === 0 && info.dependencies.length === 0) continue
|
|
const advisoriesCovered = info.advisories.every(advisory =>
|
|
statements.some(statement =>
|
|
statementCoversAdvisory(statement, advisory.ids)
|
|
&& statementCoversPackage(statement, name, info.versions)))
|
|
const dependenciesCovered = info.dependencies.every(dependency =>
|
|
suppressed.has(dependency) || belowGate.has(dependency))
|
|
if (advisoriesCovered && dependenciesCovered) {
|
|
suppressed.add(name)
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
const failures = []
|
|
for (const [name, info] of packages) {
|
|
if (!levels.has(info.severity) || suppressed.has(name)) continue
|
|
failures.push({
|
|
name,
|
|
severity: info.severity,
|
|
versions: [...info.versions],
|
|
advisories: info.advisories,
|
|
dependencies: info.dependencies,
|
|
})
|
|
}
|
|
return { failures, suppressed: [...suppressed], belowGate: [...belowGate] }
|
|
}
|
|
|
|
function runNpmAudit(dir) {
|
|
const result = spawnSync('npm', ['audit', '--json'], {
|
|
cwd: dir,
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
})
|
|
if (result.error) {
|
|
throw new Error(`failed to run npm audit: ${result.error.message}`)
|
|
}
|
|
const stdout = (result.stdout ?? '').trim()
|
|
if (!stdout) {
|
|
throw new Error(`npm audit produced no output (exit ${result.status}): ${(result.stderr ?? '').trim()}`)
|
|
}
|
|
try {
|
|
return JSON.parse(stdout)
|
|
} catch {
|
|
throw new Error(`npm audit did not return JSON (exit ${result.status}): ${stdout.slice(0, 300)}`)
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = { dir: process.cwd(), vex: DEFAULT_VEX, level: 'high' }
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const flag = argv[i]
|
|
const value = argv[i + 1]
|
|
if (flag === '--dir' && value) {
|
|
args.dir = value
|
|
i += 1
|
|
} else if (flag === '--vex' && value) {
|
|
args.vex = value
|
|
i += 1
|
|
} else if (flag === '--audit-level' && value) {
|
|
args.level = value
|
|
i += 1
|
|
} else {
|
|
throw new Error(`unknown argument "${flag}"`)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const args = parseArgs(argv)
|
|
const dir = resolve(args.dir)
|
|
const vexPath = resolve(args.vex)
|
|
const levels = gateLevelsFromThreshold(args.level)
|
|
|
|
const report = runNpmAudit(dir)
|
|
const vex = JSON.parse(readFileSync(vexPath, 'utf8'))
|
|
const lock = JSON.parse(readFileSync(join(dir, 'package-lock.json'), 'utf8'))
|
|
const { failures, suppressed, belowGate } = evaluateAudit(report, vex, lock, levels)
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`Dependency audit failed: ${failures.length} package(s) at or above "${args.level}" without a VEX not_affected statement.`)
|
|
for (const failure of failures) {
|
|
const versions = failure.versions.length > 0 ? failure.versions.join(', ') : 'unknown version'
|
|
console.error(` - ${failure.name}@${versions} (${failure.severity})`)
|
|
for (const advisory of failure.advisories) {
|
|
console.error(` ${advisory.url || [...advisory.ids].join(', ')}`)
|
|
}
|
|
if (failure.dependencies.length > 0) {
|
|
console.error(` via ${failure.dependencies.join(', ')}`)
|
|
}
|
|
}
|
|
console.error(`Triage the advisory and add a not_affected statement to ${vexPath} (see docs/internal/runbooks/supply-chain-vex.md).`)
|
|
return 1
|
|
}
|
|
|
|
const notes = []
|
|
if (suppressed.length > 0) notes.push(`VEX suppressed: ${suppressed.join(', ')}`)
|
|
if (belowGate.length > 0) notes.push(`below gate: ${belowGate.join(', ')}`)
|
|
const notesNote = notes.length > 0 ? ` (${notes.join('; ')})` : ''
|
|
console.log(`Dependency audit passed: no unsuppressed ${args.level} or higher advisories${notesNote}.`)
|
|
return 0
|
|
}
|
|
|
|
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
if (isCli) {
|
|
try {
|
|
process.exitCode = main()
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
process.exitCode = 1
|
|
}
|
|
}
|