|
|
|
@@ -0,0 +1,90 @@
|
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
/**
|
|
|
|
|
* npm audit gate with a scoped, self-documenting allowlist.
|
|
|
|
|
*
|
|
|
|
|
* Runs `npm audit --omit=dev --json` in the current directory and exits
|
|
|
|
|
* non-zero on any vulnerability EXCEPT the advisory IDs listed in
|
|
|
|
|
* ALLOWED below. Plain `npm audit` has no way to say "this one advisory
|
|
|
|
|
* has no fix available yet", so without this the CI blocks on findings
|
|
|
|
|
* that cannot be remediated (GHSA-vwc7-r8mq-g2x9 as of 2026-09-10: no
|
|
|
|
|
* patched adm-zip release exists upstream).
|
|
|
|
|
*
|
|
|
|
|
* Usage: node scripts/audit-gate.js (from the directory to audit)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const ALLOWED = {
|
|
|
|
|
'GHSA-vwc7-r8mq-g2x9': {
|
|
|
|
|
package: 'adm-zip',
|
|
|
|
|
reason:
|
|
|
|
|
'adm-zip symlink-following on extraction (CVE-2026-76845). No patched ' +
|
|
|
|
|
'release exists (first_patched_version: null, fix via cthackers/adm-zip#575 ' +
|
|
|
|
|
'still unmerged). Reachable only through @sasjs/cli extract-all (seed-app ' +
|
|
|
|
|
'download into an empty project dir), not through DC build/deploy scripts. ' +
|
|
|
|
|
'Remove this entry once @sasjs/cli ships with a patched adm-zip or a ' +
|
|
|
|
|
'replacement extractor.'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const run = () => {
|
|
|
|
|
const { execFileSync } = require('child_process')
|
|
|
|
|
try {
|
|
|
|
|
const out = execFileSync(
|
|
|
|
|
'npm',
|
|
|
|
|
['audit', '--omit=dev', '--json', '--package-lock-only'],
|
|
|
|
|
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }
|
|
|
|
|
)
|
|
|
|
|
return JSON.parse(out)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// npm audit exits non-zero when it FINDS vulnerabilities; the JSON is
|
|
|
|
|
// still on stdout. Any other failure (no lockfile, registry error) has
|
|
|
|
|
// no parseable report and must fail the gate.
|
|
|
|
|
if (err.stdout) {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(err.stdout)
|
|
|
|
|
} catch {
|
|
|
|
|
console.error('npm audit produced no parseable report.')
|
|
|
|
|
console.error(String(err.message || err))
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.error('npm audit failed to run:')
|
|
|
|
|
console.error(String(err.message || err))
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = run()
|
|
|
|
|
const vulnerabilities = report.vulnerabilities || {}
|
|
|
|
|
const unexpected = []
|
|
|
|
|
const allowedHit = []
|
|
|
|
|
|
|
|
|
|
for (const [name, v] of Object.entries(vulnerabilities)) {
|
|
|
|
|
for (const via of v.via || []) {
|
|
|
|
|
if (typeof via === 'string') continue // chained/transitive ref, counted at its own node
|
|
|
|
|
const id = via.url ? via.url.split('/').pop() : via.name
|
|
|
|
|
if (ALLOWED[id]) {
|
|
|
|
|
allowedHit.push(id)
|
|
|
|
|
console.log(`ALLOWED ${via.severity} ${name} ${id}: ${ALLOWED[id].reason}`)
|
|
|
|
|
} else {
|
|
|
|
|
unexpected.push({ name, id, severity: via.severity, title: via.title })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (unexpected.length) {
|
|
|
|
|
console.error('\naudit-gate: FAILED - unallowlisted vulnerabilities:')
|
|
|
|
|
for (const u of unexpected) {
|
|
|
|
|
console.error(` BLOCKING ${u.severity} ${u.name} ${u.id} - ${u.title}`)
|
|
|
|
|
}
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stale = Object.keys(ALLOWED).filter((id) => !allowedHit.includes(id))
|
|
|
|
|
if (stale.length) {
|
|
|
|
|
console.warn(
|
|
|
|
|
'\naudit-gate: note - allowed advisory no longer present (remove from ALLOWED):'
|
|
|
|
|
)
|
|
|
|
|
for (const s of stale) console.warn(` ${s}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('\naudit-gate: OK')
|