#!/usr/bin/env python3 """Filter ``npm audit --json`` output against an allowlist of advisories. Reads ``npm audit --json`` on stdin. Exits non-zero (1) if any moderate-or-higher vulnerability is NOT fully explained by allowlisted advisories, otherwise 0. npm reports a parent package as vulnerable when it (transitively) depends on a vulnerable child; the parent's ``via`` then contains a *string* reference to the child rather than an advisory object. We resolve those ``via`` chains so a package that is vulnerable *only* because of an allowlisted advisory is itself treated as handled — otherwise allowlisting one leaf (e.g. js-yaml) would still leave its dozen parents (lhci/*, jest/*) failing the gate. The allowlist is supplied via the ``AUDIT_ALLOWLIST`` env var as a space-separated list of GHSA IDs. Keep it tight and justified in the workflow that sets it — only advisories with **no available fix** in **dev/test-only** tooling belong here. """ import json import os import sys ALLOW = set(os.environ.get("AUDIT_ALLOWLIST", "").split()) SEVERITIES = {"moderate", "high", "critical"} def main() -> int: raw = sys.stdin.read().strip() if not raw: print( "::error::empty npm audit output (audit did not run?)", file=sys.stderr, ) return 1 try: data = json.loads(raw) except json.JSONDecodeError as exc: print( f"::error::could not parse npm audit JSON: {exc}", file=sys.stderr ) return 1 # A genuine npm audit report always carries these keys (an empty/clean # audit still has "vulnerabilities": {}). Their absence means the audit # did NOT actually run — e.g. a registry/network error returns valid JSON # like {"message": ..., "error": ...} and npm exits non-zero, which the # caller's `|| true` swallows. Fail safe (gate red) rather than pass a # green gate on an audit that never happened. if "vulnerabilities" not in data or "auditReportVersion" not in data: print( "::error::npm audit did not return a valid report (audit failed " f"to run?): {data.get('message', 'unknown error')}", file=sys.stderr, ) return 1 vulns = data.get("vulnerabilities") or {} if not isinstance(vulns, dict): print( "::error::npm audit 'vulnerabilities' is not an object — " "treating as a failed audit", file=sys.stderr, ) return 1 def reachable_ghsas(name, seen): """All advisory GHSAs reachable from a package's via-chain.""" if name in seen: return set() seen.add(name) found = set() for via in (vulns.get(name) or {}).get("via", []): if isinstance(via, dict) and "GHSA-" in (via.get("url") or ""): # Take only the GHSA token, not any trailing path/query the # URL might carry (e.g. .../GHSA-xxxx/foo?bar). tail = via["url"].split("GHSA-", 1)[1] found.add("GHSA-" + tail.split("/")[0].split("?")[0]) elif isinstance(via, str): found |= reachable_ghsas(via, seen) return found unhandled = 0 for name, info in vulns.items(): if info.get("severity") not in SEVERITIES: continue ghsas = reachable_ghsas(name, set()) if ghsas and ghsas <= ALLOW: print(f" allowlisted: {name} ({info['severity']}) {sorted(ghsas)}") else: unhandled += 1 detail = sorted(ghsas - ALLOW) if ghsas else "no-GHSA" print(f" UNHANDLED: {name} ({info['severity']}) {detail}") suffix = "y" if unhandled == 1 else "ies" print(f" -> {unhandled} non-allowlisted moderate+ vulnerabilit{suffix}") return 1 if unhandled else 0 if __name__ == "__main__": sys.exit(main())