Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,6 @@
]
},
"overrides": {
"dompurify": "3.4.11"
"dompurify": "3.4.12"
}
}
165 changes: 165 additions & 0 deletions scripts/npm_audit_known_vulns.py
Original file line number Diff line number Diff line change
@@ -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})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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())
9 changes: 9 additions & 0 deletions security/npm-audit-known-vulns.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"id": "GHSA-qwww-vcr4-c8h2",
"package": "react-router",
"reason": "RSC Mode CSRF bypass. This app is a Vite SPA using <BrowserRouter> (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"
}
]
124 changes: 124 additions & 0 deletions tests/unit/test_npm_audit_known_vulns.py
Original file line number Diff line number Diff line change
@@ -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
Loading