-
-
Notifications
You must be signed in to change notification settings - Fork 51
fix(frontend): resolve the npm-audit security gate #782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ioanalytica
wants to merge
1
commit into
karanhudia:main
Choose a base branch
from
ioanalytica:fix/frontend-npm-audit
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,6 +104,6 @@ | |
| ] | ||
| }, | ||
| "overrides": { | ||
| "dompurify": "3.4.11" | ||
| "dompurify": "3.4.12" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}) | ||
|
|
||
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.