chore(ci): scoped allowlist for npm audit gate in sas #318

Closed
hermes wants to merge 1 commits from chore/audit-gate-allowlist into chore/gitleaks-precommit
pull from: chore/audit-gate-allowlist
3 changed files with 96 additions and 2 deletions

No files matched your search

+3 -1
View File
@@ -39,10 +39,12 @@ jobs:
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
# The sas audit runs through scripts/audit-gate.js so that advisories with no upstream fix
# can be explicitly allowlisted (see the ALLOWED map in that script) instead of blocking CI.
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
node ../scripts/audit-gate.js
cd ../client
npm audit --omit=dev
+3 -1
View File
@@ -46,10 +46,12 @@ jobs:
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
# The sas audit runs through scripts/audit-gate.js so that advisories with no upstream fix
# can be explicitly allowlisted (see the ALLOWED map in that script) instead of blocking CI.
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
node ../scripts/audit-gate.js
cd ../client
npm audit --omit=dev
+90
View File
@@ -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')