Coverage for .github / scripts / check_npm_audit.py: 100.00%

137 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Gate npm-audit JSON with exact, expiring suppressions. 

3 

4Suppression file format (one entry per non-comment line):: 

5 

6 package-dir|package|advisory|node-path|exp:YYYY-MM-DD 

7 

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 

12When no fixed version of a package exists anywhere in range, npm also reports 

13every dependent whose in-range versions all reach it ("depends on vulnerable 

14versions of X") as a record of its own, with no advisory, naming the packages 

15it reaches through. Such a chain record needs no entry: it clears once every 

16package it reaches through has cleared, and fails closed otherwise. 

17""" 

18 

19from __future__ import annotations 

20 

21import argparse 

22import datetime as dt 

23import json 

24import re 

25import sys 

26from dataclasses import dataclass 

27from pathlib import Path 

28from typing import Any 

29 

30ADVISORY_RE = re.compile(r"^https://github\.com/advisories/(GHSA-[0-9a-z-]+)$") 

31SEVERITY = {"info": 0, "low": 1, "moderate": 2, "high": 3, "critical": 4} 

32# Findings at this severity or above fail the job unless suppressed. Moderate 

33# is where npm's advisories stop being mostly ReDoS-in-a-dev-tool noise and 

34# start describing things a proxy or CLI could actually be hit with; every 

35# owned graph is clean here, so the gate costs nothing until an advisory lands. 

36FAIL_AT = "moderate" 

37 

38 

39@dataclass(frozen=True) 

40class Suppression: 

41 package_dir: str 

42 package: str 

43 advisory: str 

44 node_path: str 

45 expires: dt.date 

46 line: int 

47 

48 @property 

49 def identity(self) -> tuple[str, str, str, str]: 

50 return (self.package_dir, self.package, self.advisory, self.node_path) 

51 

52 

53def _load_suppressions(path: Path, today: dt.date) -> list[Suppression]: 

54 if not path.is_file(): 

55 raise ValueError(f"suppression file not found: {path}") 

56 

57 suppressions: list[Suppression] = [] 

58 identities: set[tuple[str, str, str, str]] = set() 

59 for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): 

60 line = raw_line.strip() 

61 if not line or line.startswith("#"): 

62 continue 

63 

64 fields = line.split("|") 

65 if len(fields) != 5 or not fields[4].startswith("exp:"): 

66 raise ValueError( 

67 f"{path}:{line_number}: expected package-dir|package|advisory|" 

68 "node-path|exp:YYYY-MM-DD" 

69 ) 

70 package_dir, package, advisory, node_path, expiry_field = fields 

71 if not all((package_dir, package, advisory, node_path)): 

72 raise ValueError(f"{path}:{line_number}: suppression fields cannot be empty") 

73 try: 

74 expires = dt.date.fromisoformat(expiry_field.removeprefix("exp:")) 

75 except ValueError as exc: 

76 raise ValueError(f"{path}:{line_number}: invalid expiration date") from exc 

77 if expires <= today: 

78 raise ValueError(f"{path}:{line_number}: {advisory} expired on {expires.isoformat()}") 

79 

80 suppression = Suppression( 

81 package_dir=package_dir, 

82 package=package, 

83 advisory=advisory, 

84 node_path=node_path, 

85 expires=expires, 

86 line=line_number, 

87 ) 

88 if suppression.identity in identities: 

89 raise ValueError(f"{path}:{line_number}: duplicate suppression") 

90 identities.add(suppression.identity) 

91 suppressions.append(suppression) 

92 return suppressions 

93 

94 

95def _advisories(via: Any) -> set[str]: 

96 advisories: set[str] = set() 

97 if not isinstance(via, list): 

98 return advisories 

99 for item in via: 

100 if not isinstance(item, dict): 

101 continue 

102 match = ADVISORY_RE.fullmatch(str(item.get("url", ""))) 

103 if match: 

104 advisories.add(match.group(1)) 

105 return advisories 

106 

107 

108def _reached_through(via: Any) -> set[str]: 

109 """Packages a record is vulnerable through (npm lists them as bare names).""" 

110 if not isinstance(via, list): 

111 return set() 

112 return {item for item in via if isinstance(item, str)} 

113 

114 

115def _load_report(path: Path) -> dict[str, Any]: 

116 try: 

117 report = json.loads(path.read_text(encoding="utf-8")) 

118 except (OSError, json.JSONDecodeError) as exc: 

119 raise ValueError(f"invalid npm-audit JSON in {path}: {exc}") from exc 

120 if not isinstance(report, dict): 

121 raise ValueError("npm-audit report must be a JSON object") 

122 if isinstance(report.get("error"), dict): 

123 raise ValueError(f"npm audit returned an operational error: {report.get('error')!r}") 

124 if not isinstance(report.get("vulnerabilities"), dict): 

125 raise ValueError("npm-audit report is missing the vulnerabilities object") 

126 return report 

127 

128 

129def check_report(report: dict[str, Any], package_dir: str, suppressions: list[Suppression]) -> int: 

130 scoped = {item.identity: item for item in suppressions if item.package_dir == package_dir} 

131 used: set[tuple[str, str, str, str]] = set() 

132 failures: list[str] = [] 

133 # Packages whose own advisories are all suppressed. A chain record clears 

134 # only once every package it reaches through is in here. 

135 cleared: set[str] = set() 

136 # Chain records still waiting on the packages they reach through. 

137 pending: dict[str, tuple[set[str], str, set[str]]] = {} 

138 

139 for package, finding in report["vulnerabilities"].items(): 

140 if not isinstance(finding, dict): 

141 failures.append(f"{package}: malformed vulnerability record") 

142 continue 

143 severity = str(finding.get("severity", "")) 

144 if SEVERITY.get(severity, -1) < SEVERITY[FAIL_AT]: 

145 # Below the gate by policy. It still has to count as a cleared 

146 # link: a dependent that npm marks high because of one suppressed 

147 # chain can also be "vulnerable through" a low-severity package 

148 # (markdownlint-cli2 via markdownlint via katex, 2026-10-05), and 

149 # the gate that ignores the low record must not hold the chain 

150 # record hostage to it. 

151 cleared.add(package) 

152 continue 

153 

154 advisories = _advisories(finding.get("via")) 

155 reached = _reached_through(finding.get("via")) 

156 nodes = set(finding.get("nodes", [])) if isinstance(finding.get("nodes"), list) else set() 

157 matches = { 

158 identity 

159 for identity in scoped 

160 if identity[1] == package and identity[2] in advisories and identity[3] in nodes 

161 } 

162 # Fail closed for compound records: every advisory and node must be 

163 # represented, and no broader package-level suppression is accepted. 

164 expected = { 

165 (package_dir, package, advisory, node) for advisory in advisories for node in nodes 

166 } 

167 if advisories and nodes and matches == expected: 

168 used.update(matches) 

169 for identity in sorted(matches): 

170 suppression = scoped[identity] 

171 print( 

172 "::warning::Temporarily suppressing " 

173 f"{suppression.advisory} for {suppression.node_path} " 

174 f"until {suppression.expires.isoformat()}" 

175 ) 

176 elif advisories or not (reached and nodes): 

177 failures.append( 

178 f"{package}: unsuppressed {severity} finding " 

179 f"(advisories={sorted(advisories)}, nodes={sorted(nodes)})" 

180 ) 

181 continue 

182 # The record's own advisories, if any, are suppressed; whether it clears 

183 # now depends only on the packages it reaches through. 

184 if reached: 

185 pending[package] = (reached, severity, nodes) 

186 else: 

187 cleared.add(package) 

188 

189 # Chain records clear from the bottom up, one link per pass. 

190 while pending: 

191 resolved = sorted( 

192 package for package, (reached, _, _) in pending.items() if reached <= cleared 

193 ) 

194 if not resolved: 

195 break 

196 for package in resolved: 

197 reached, _, nodes = pending.pop(package) 

198 print( 

199 f"::warning::Accepting {package} at {', '.join(sorted(nodes))}: " 

200 "no advisory of its own, vulnerable only through " 

201 f"{', '.join(sorted(reached))}" 

202 ) 

203 cleared.add(package) 

204 

205 for package, (reached, severity, nodes) in sorted(pending.items()): 

206 failures.append( 

207 f"{package}: unsuppressed {severity} finding reached through " 

208 f"{sorted(reached - cleared)} (nodes={sorted(nodes)})" 

209 ) 

210 

211 stale = set(scoped) - used 

212 for identity in sorted(stale): 

213 suppression = scoped[identity] 

214 failures.append( 

215 f"stale suppression at line {suppression.line}: {suppression.advisory} " 

216 f"for {suppression.node_path} no longer matches npm audit" 

217 ) 

218 

219 for failure in failures: 

220 print(f"ERROR: {failure}", file=sys.stderr) 

221 return 1 if failures else 0 

222 

223 

224def main(argv: list[str] | None = None) -> int: 

225 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 

226 parser.add_argument("--report", required=True, type=Path) 

227 parser.add_argument("--package-dir", required=True) 

228 parser.add_argument("--ignore-file", required=True, type=Path) 

229 args = parser.parse_args(argv) 

230 

231 try: 

232 suppressions = _load_suppressions(args.ignore_file, dt.datetime.now(dt.UTC).date()) 

233 report = _load_report(args.report) 

234 except ValueError as exc: 

235 print(f"ERROR: {exc}", file=sys.stderr) 

236 return 1 

237 return check_report(report, args.package_dir, suppressions) 

238 

239 

240if __name__ == "__main__": 

241 raise SystemExit(main())