Coverage for .github / scripts / check_npm_audit.py: 100.00%
112 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""Gate npm-audit JSON with exact, expiring suppressions.
4Suppression file format (one entry per non-comment line)::
6 package-dir|package|advisory|node-path|exp:YYYY-MM-DD
8A finding is suppressed only when all four identity fields match. Suppressions
9expire inclusively and stale entries fail, forcing their removal after the
10upstream dependency is fixed.
11"""
13from __future__ import annotations
15import argparse
16import datetime as dt
17import json
18import re
19import sys
20from dataclasses import dataclass
21from pathlib import Path
22from typing import Any
24ADVISORY_RE = re.compile(r"^https://github\.com/advisories/(GHSA-[0-9a-z-]+)$")
25SEVERITY = {"info": 0, "low": 1, "moderate": 2, "high": 3, "critical": 4}
28@dataclass(frozen=True)
29class Suppression:
30 package_dir: str
31 package: str
32 advisory: str
33 node_path: str
34 expires: dt.date
35 line: int
37 @property
38 def identity(self) -> tuple[str, str, str, str]:
39 return (self.package_dir, self.package, self.advisory, self.node_path)
42def _load_suppressions(path: Path, today: dt.date) -> list[Suppression]:
43 if not path.is_file():
44 raise ValueError(f"suppression file not found: {path}")
46 suppressions: list[Suppression] = []
47 identities: set[tuple[str, str, str, str]] = set()
48 for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
49 line = raw_line.strip()
50 if not line or line.startswith("#"):
51 continue
53 fields = line.split("|")
54 if len(fields) != 5 or not fields[4].startswith("exp:"):
55 raise ValueError(
56 f"{path}:{line_number}: expected package-dir|package|advisory|"
57 "node-path|exp:YYYY-MM-DD"
58 )
59 package_dir, package, advisory, node_path, expiry_field = fields
60 if not all((package_dir, package, advisory, node_path)):
61 raise ValueError(f"{path}:{line_number}: suppression fields cannot be empty")
62 try:
63 expires = dt.date.fromisoformat(expiry_field.removeprefix("exp:"))
64 except ValueError as exc:
65 raise ValueError(f"{path}:{line_number}: invalid expiration date") from exc
66 if expires <= today:
67 raise ValueError(f"{path}:{line_number}: {advisory} expired on {expires.isoformat()}")
69 suppression = Suppression(
70 package_dir=package_dir,
71 package=package,
72 advisory=advisory,
73 node_path=node_path,
74 expires=expires,
75 line=line_number,
76 )
77 if suppression.identity in identities:
78 raise ValueError(f"{path}:{line_number}: duplicate suppression")
79 identities.add(suppression.identity)
80 suppressions.append(suppression)
81 return suppressions
84def _advisories(via: Any) -> set[str]:
85 advisories: set[str] = set()
86 if not isinstance(via, list):
87 return advisories
88 for item in via:
89 if not isinstance(item, dict):
90 continue
91 match = ADVISORY_RE.fullmatch(str(item.get("url", "")))
92 if match:
93 advisories.add(match.group(1))
94 return advisories
97def _load_report(path: Path) -> dict[str, Any]:
98 try:
99 report = json.loads(path.read_text(encoding="utf-8"))
100 except (OSError, json.JSONDecodeError) as exc:
101 raise ValueError(f"invalid npm-audit JSON in {path}: {exc}") from exc
102 if not isinstance(report, dict):
103 raise ValueError("npm-audit report must be a JSON object")
104 if isinstance(report.get("error"), dict):
105 raise ValueError(f"npm audit returned an operational error: {report.get('error')!r}")
106 if not isinstance(report.get("vulnerabilities"), dict):
107 raise ValueError("npm-audit report is missing the vulnerabilities object")
108 return report
111def check_report(report: dict[str, Any], package_dir: str, suppressions: list[Suppression]) -> int:
112 scoped = {item.identity: item for item in suppressions if item.package_dir == package_dir}
113 used: set[tuple[str, str, str, str]] = set()
114 failures: list[str] = []
116 for package, finding in report["vulnerabilities"].items():
117 if not isinstance(finding, dict):
118 failures.append(f"{package}: malformed vulnerability record")
119 continue
120 if SEVERITY.get(str(finding.get("severity", "")), -1) < SEVERITY["high"]:
121 continue
123 advisories = _advisories(finding.get("via"))
124 nodes = set(finding.get("nodes", [])) if isinstance(finding.get("nodes"), list) else set()
125 matches = {
126 identity
127 for identity in scoped
128 if identity[1] == package and identity[2] in advisories and identity[3] in nodes
129 }
130 # Fail closed for compound records: every advisory and node must be
131 # represented, and no broader package-level suppression is accepted.
132 expected = {
133 (package_dir, package, advisory, node) for advisory in advisories for node in nodes
134 }
135 if advisories and nodes and matches == expected:
136 used.update(matches)
137 for identity in sorted(matches):
138 suppression = scoped[identity]
139 print(
140 "::warning::Temporarily suppressing "
141 f"{suppression.advisory} for {suppression.node_path} "
142 f"until {suppression.expires.isoformat()}"
143 )
144 else:
145 failures.append(
146 f"{package}: unsuppressed {finding.get('severity', 'unknown')} finding "
147 f"(advisories={sorted(advisories)}, nodes={sorted(nodes)})"
148 )
150 stale = set(scoped) - used
151 for identity in sorted(stale):
152 suppression = scoped[identity]
153 failures.append(
154 f"stale suppression at line {suppression.line}: {suppression.advisory} "
155 f"for {suppression.node_path} no longer matches npm audit"
156 )
158 for failure in failures:
159 print(f"ERROR: {failure}", file=sys.stderr)
160 return 1 if failures else 0
163def main(argv: list[str] | None = None) -> int:
164 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
165 parser.add_argument("--report", required=True, type=Path)
166 parser.add_argument("--package-dir", required=True)
167 parser.add_argument("--ignore-file", required=True, type=Path)
168 args = parser.parse_args(argv)
170 try:
171 suppressions = _load_suppressions(args.ignore_file, dt.date.today())
172 report = _load_report(args.report)
173 except ValueError as exc:
174 print(f"ERROR: {exc}", file=sys.stderr)
175 return 1
176 return check_report(report, args.package_dir, suppressions)
179if __name__ == "__main__":
180 raise SystemExit(main())