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

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""" 

12 

13from __future__ import annotations 

14 

15import argparse 

16import datetime as dt 

17import json 

18import re 

19import sys 

20from dataclasses import dataclass 

21from pathlib import Path 

22from typing import Any 

23 

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

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

26 

27 

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 

36 

37 @property 

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

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

40 

41 

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}") 

45 

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 

52 

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()}") 

68 

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 

82 

83 

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 

95 

96 

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 

109 

110 

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] = [] 

115 

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 

122 

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 ) 

149 

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 ) 

157 

158 for failure in failures: 

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

160 return 1 if failures else 0 

161 

162 

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) 

169 

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) 

177 

178 

179if __name__ == "__main__": 

180 raise SystemExit(main())