diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 051a66a62..5c1112893 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -41,8 +41,17 @@ jobs: run: | # Audit production dependencies only (omit dev) — devDependencies like eslint # and their transitive deps (brace-expansion, minimatch) never ship to production - # and are not part of the runtime attack surface. - npm audit --omit=dev --audit-level=moderate + # and are not part of the runtime attack surface. npm audit has no ignore + # flag, so a wrapper gates the JSON against a tracked allowlist of reviewed, + # non-applicable advisories (security/npm-audit-known-vulns.json). + # + # `|| true` keeps npm's exit code from failing the step (it exits non-zero + # whenever vulnerabilities exist, which is the case we want to filter, not + # abort on). That is only safe because the gate validates the report shape: + # an npm error payload, an empty file, or a report without a + # `vulnerabilities` object fails the gate instead of parsing as clean. + npm audit --omit=dev --json > npm-audit.json || true + python3 ../scripts/npm_audit_known_vulns.py gate --file npm-audit.json --level moderate continue-on-error: false - name: Generate npm audit report diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9c8f2fa48..520eed920 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4953,9 +4953,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { diff --git a/frontend/package.json b/frontend/package.json index 0b25c4be3..52c65f2dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -104,6 +104,6 @@ ] }, "overrides": { - "dompurify": "3.4.11" + "dompurify": "3.4.12" } } diff --git a/scripts/npm_audit_known_vulns.py b/scripts/npm_audit_known_vulns.py new file mode 100644 index 000000000..460965e1a --- /dev/null +++ b/scripts/npm_audit_known_vulns.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Gate `npm audit --json` against a tracked allowlist of non-applicable findings. + +npm audit has no `--ignore-vuln`, so — unlike pip-audit — the allowlist cannot be +passed to the tool. This mirrors scripts/pip_audit_known_vulns.py in spirit: it +reads the audit JSON, drops advisories whose GHSA id is tracked in +security/npm-audit-known-vulns.json, and fails only if a non-allowlisted advisory +at or above the chosen level remains. Every tracked entry carries a reason, so the +allowlist is an audited record, not a silent mute. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +DEFAULT_KNOWN_VULNS_FILE = ( + Path(__file__).resolve().parents[1] / "security" / "npm-audit-known-vulns.json" +) +SEVERITY_ORDER = ["info", "low", "moderate", "high", "critical"] +GHSA_RE = re.compile(r"GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}", re.IGNORECASE) + + +def load_known_vulns(path: Path) -> list[dict[str, Any]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, list): + raise ValueError(f"{path} must contain a JSON list") + + known_vulns: list[dict[str, Any]] = [] + for index, entry in enumerate(data, start=1): + if not isinstance(entry, dict): + raise ValueError(f"known vuln entry {index} must be a JSON object") + + vuln_id = str(entry.get("id", "")).strip() + if not vuln_id: + raise ValueError(f"known vuln entry {index} must include a non-empty id") + if any(char.isspace() for char in vuln_id): + raise ValueError(f"known vuln entry {index} id must not contain whitespace") + + reason = entry.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError( + f"known vuln entry {index} ({vuln_id}) must include a non-empty " + "reason — the allowlist is an audited record, not a silent mute" + ) + + known_vulns.append({**entry, "id": vuln_id}) + + return known_vulns + + +def validate_report(report: Any) -> dict[str, Any]: + """Reject an npm audit result that did not actually run. + + A successful `npm audit --json` (v2) is an object with a `vulnerabilities` + map. npm emits `{"error": ...}` on failures (network, registry, bad flags), + and the workflow ignores the audit exit code — so without this a failed or + truncated audit would parse into zero advisories and pass as clean. Fail + loudly instead of reporting a green gate on an audit that never happened. + """ + if not isinstance(report, dict): + raise ValueError("npm audit report is not a JSON object") + if "error" in report: + error = report["error"] + summary = error.get("summary", error) if isinstance(error, dict) else error + raise ValueError(f"npm audit reported an error: {summary}") + if not isinstance(report.get("vulnerabilities"), dict): + raise ValueError( + "npm audit report has no 'vulnerabilities' object — a failed, empty, " + "or unexpected report shape; refusing to report clean" + ) + return report + + +def advisories_at_or_above(report: dict[str, Any], level: str) -> list[dict[str, str]]: + """Distinct advisories in a validated `npm audit --json` report at or above + `level`. + + Real advisories are the object-valued entries in each package's `via`; a + string `via` is just a transitive pointer to another package and carries no + advisory of its own. The report is assumed validated (see validate_report): + `vulnerabilities` is indexed directly, never defaulted to empty. + """ + threshold = SEVERITY_ORDER.index(level) + found: dict[str, dict[str, str]] = {} + for package in report["vulnerabilities"].values(): + for via in package.get("via", []): + if not isinstance(via, dict): + continue + severity = via.get("severity", "info") + if SEVERITY_ORDER.index(severity) < threshold: + continue + match = GHSA_RE.search(via.get("url", "") or "") + ghsa = match.group(0) if match else str(via.get("url", "")) + found[ghsa] = { + "id": ghsa, + "severity": severity, + "title": str(via.get("title", "")), + } + return list(found.values()) + + +def non_allowlisted( + report: dict[str, Any], known_vulns: list[dict[str, Any]], level: str +) -> list[dict[str, str]]: + allowed = {entry["id"] for entry in known_vulns} + return [a for a in advisories_at_or_above(report, level) if a["id"] not in allowed] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Gate npm audit against an allowlist.") + subparsers = parser.add_subparsers(dest="command", required=True) + + gate = subparsers.add_parser( + "gate", help="Fail on non-allowlisted advisories at or above --level." + ) + gate.add_argument( + "--file", type=Path, help="npm audit --json output (default: read stdin)." + ) + gate.add_argument("--level", default="moderate", choices=SEVERITY_ORDER) + gate.add_argument("--known-vulns", type=Path, default=DEFAULT_KNOWN_VULNS_FILE) + + args = parser.parse_args(argv) + if args.command != "gate": + parser.error(f"unsupported command: {args.command}") + + raw = args.file.read_text(encoding="utf-8") if args.file else sys.stdin.read() + try: + report = validate_report(json.loads(raw)) + except (json.JSONDecodeError, ValueError) as exc: + print(f"npm audit did not produce a usable report: {exc}", file=sys.stderr) + return 1 + + known_vulns = load_known_vulns(args.known_vulns) + allowed = {entry["id"] for entry in known_vulns} + + remaining = [] + for advisory in advisories_at_or_above(report, args.level): + if advisory["id"] in allowed: + print(f"ignored (allowlisted): {advisory['id']} {advisory['title']}") + else: + remaining.append(advisory) + print( + f"FAIL: {advisory['id']} [{advisory['severity']}] {advisory['title']}", + file=sys.stderr, + ) + + if remaining: + print( + f"{len(remaining)} non-allowlisted advisory(ies) at or above " + f"'{args.level}' — see security/npm-audit-known-vulns.json to track a " + "reviewed, non-applicable one.", + file=sys.stderr, + ) + return 1 + print(f"npm audit clean at or above '{args.level}' (allowlist applied).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/security/npm-audit-known-vulns.json b/security/npm-audit-known-vulns.json new file mode 100644 index 000000000..a0096628f --- /dev/null +++ b/security/npm-audit-known-vulns.json @@ -0,0 +1,9 @@ +[ + { + "id": "GHSA-qwww-vcr4-c8h2", + "package": "react-router", + "reason": "RSC Mode CSRF bypass. This app is a Vite SPA using (see frontend/src/main.tsx), not React Router's RSC mode, so the vector does not apply. The only published fix is react-router 8.3.0, a v7->v8 major upgrade out of scope for a security-hygiene change.", + "reviewed_at": "2026-07-27", + "source": "https://github.com/advisories/GHSA-qwww-vcr4-c8h2" + } +] diff --git a/tests/unit/test_npm_audit_known_vulns.py b/tests/unit/test_npm_audit_known_vulns.py new file mode 100644 index 000000000..d184e25c0 --- /dev/null +++ b/tests/unit/test_npm_audit_known_vulns.py @@ -0,0 +1,124 @@ +"""The npm-audit allowlist gate must drop only tracked, reasoned advisories.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.npm_audit_known_vulns import ( + DEFAULT_KNOWN_VULNS_FILE, + advisories_at_or_above, + load_known_vulns, + main, + non_allowlisted, + validate_report, +) + +REPORT = { + "vulnerabilities": { + "react-router": { + "via": [ + { + "severity": "high", + "url": "https://github.com/advisories/GHSA-qwww-vcr4-c8h2", + "title": "RSC Mode CSRF Bypass", + } + ] + }, + # A transitive pointer, not an advisory of its own. + "react-router-dom": {"via": ["react-router"]}, + "something-low": { + "via": [ + { + "severity": "low", + "url": "https://github.com/advisories/GHSA-aaaa-bbbb-cccc", + "title": "A low one", + } + ] + }, + } +} + + +def test_only_real_advisories_at_or_above_level_are_collected(): + got = advisories_at_or_above(REPORT, "moderate") + # The transitive string `via` and the low-severity advisory are excluded. + assert [a["id"] for a in got] == ["GHSA-qwww-vcr4-c8h2"] + + +def test_a_lower_level_widens_the_net(): + ids = {a["id"] for a in advisories_at_or_above(REPORT, "low")} + assert ids == {"GHSA-qwww-vcr4-c8h2", "GHSA-aaaa-bbbb-cccc"} + + +def test_an_allowlisted_advisory_is_dropped(): + assert non_allowlisted(REPORT, [{"id": "GHSA-qwww-vcr4-c8h2"}], "moderate") == [] + + +def test_a_non_allowlisted_advisory_survives(): + remaining = non_allowlisted(REPORT, [], "moderate") + assert [a["id"] for a in remaining] == ["GHSA-qwww-vcr4-c8h2"] + + +def test_load_rejects_an_entry_without_an_id(tmp_path: Path): + bad = tmp_path / "known.json" + bad.write_text('[{"package": "react-router", "reason": "x"}]', encoding="utf-8") + with pytest.raises(ValueError): + load_known_vulns(bad) + + +def test_load_rejects_an_entry_without_a_reason(tmp_path: Path): + bad = tmp_path / "known.json" + bad.write_text('[{"id": "GHSA-qwww-vcr4-c8h2"}]', encoding="utf-8") + with pytest.raises(ValueError): + load_known_vulns(bad) + + +def test_the_tracked_allowlist_loads_and_every_entry_carries_a_reason(): + known = load_known_vulns(DEFAULT_KNOWN_VULNS_FILE) + assert any(e["id"] == "GHSA-qwww-vcr4-c8h2" for e in known) + for entry in known: + assert entry.get("reason"), f"{entry['id']} must carry a reason" + + +def test_validate_report_rejects_an_error_payload(): + with pytest.raises(ValueError, match="error"): + validate_report({"error": {"summary": "request to registry failed"}}) + + +def test_validate_report_rejects_a_report_without_vulnerabilities(): + # A failed or truncated audit — must not be treated as clean. + with pytest.raises(ValueError): + validate_report({"metadata": {"vulnerabilities": {}}}) + + +def test_validate_report_rejects_a_non_object(): + with pytest.raises(ValueError): + validate_report([]) + + +def test_validate_report_accepts_a_genuinely_clean_report(): + report = {"vulnerabilities": {}, "metadata": {}} + assert validate_report(report) is report + + +def test_gate_fails_on_an_error_report(tmp_path: Path, capsys): + report = tmp_path / "audit.json" + report.write_text( + '{"error": {"summary": "registry unreachable"}}', encoding="utf-8" + ) + assert main(["gate", "--file", str(report), "--level", "moderate"]) == 1 + assert "did not produce a usable report" in capsys.readouterr().err + + +def test_gate_fails_on_empty_output(tmp_path: Path): + report = tmp_path / "audit.json" + report.write_text("", encoding="utf-8") + assert main(["gate", "--file", str(report), "--level", "moderate"]) == 1 + + +def test_gate_is_clean_on_a_valid_empty_report(tmp_path: Path): + report = tmp_path / "audit.json" + report.write_text('{"vulnerabilities": {}, "metadata": {}}', encoding="utf-8") + assert main(["gate", "--file", str(report), "--level", "moderate"]) == 0