|
21 | 21 | from gql import gql, Client |
22 | 22 | from gql.transport.aiohttp import AIOHTTPTransport |
23 | 23 | from nvdlib import searchCVE # type: ignore |
24 | | -from packaging.specifiers import InvalidSpecifier, SpecifierSet |
25 | | -from packaging.version import InvalidVersion |
| 24 | +from packaging.specifiers import SpecifierSet |
26 | 25 | from typing import Optional, List |
27 | 26 | from pathlib import Path |
28 | 27 |
|
29 | 28 | import json |
30 | 29 | import logging |
31 | 30 | import sys |
32 | 31 | import traceback |
33 | | -import urllib.parse |
34 | | -import urllib.request |
35 | 32 |
|
36 | 33 |
|
37 | 34 | class Vulnerability: |
@@ -112,9 +109,6 @@ def default(self, obj): |
112 | 109 | """ |
113 | 110 | ) |
114 | 111 |
|
115 | | -CIRCL_PRODUCT_URL = "https://vulnerability.circl.lu/api/vulnerability/" |
116 | | - |
117 | | - |
118 | 112 | def preferred_advisory_id(advisory: dict) -> str: |
119 | 113 | for identifier in advisory.get("identifiers") or []: |
120 | 114 | if identifier.get("type") == "CVE" and identifier.get("value"): |
@@ -148,194 +142,6 @@ def merge_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnerab |
148 | 142 | return list(merged.values()) |
149 | 143 |
|
150 | 144 |
|
151 | | -def circl_lookup_name(name: str, dep: Dependency) -> str: |
152 | | - if dep.cpe is not None: |
153 | | - return dep.cpe.product |
154 | | - if dep.npm_name is not None: |
155 | | - return dep.npm_name |
156 | | - if dep.keyword is not None: |
157 | | - return dep.keyword |
158 | | - return name.lower().replace(" ", "-") |
159 | | - |
160 | | - |
161 | | -def extract_circl_candidates(payload) -> list[dict]: |
162 | | - if isinstance(payload, list): |
163 | | - return [item for item in payload if isinstance(item, dict)] |
164 | | - if not isinstance(payload, dict): |
165 | | - return [] |
166 | | - for key in ("data", "results", "items", "vulnerabilities"): |
167 | | - value = payload.get(key) |
168 | | - if isinstance(value, list): |
169 | | - return [item for item in value if isinstance(item, dict)] |
170 | | - return [] |
171 | | - |
172 | | - |
173 | | -def extract_circl_candidate_id(item: dict) -> Optional[str]: |
174 | | - for key in ("id", "vuln_id", "vulnerability_id", "cve"): |
175 | | - value = item.get(key) |
176 | | - if isinstance(value, str) and value.startswith(("CVE-", "GHSA-")): |
177 | | - return value |
178 | | - metadata = item.get("cveMetadata") |
179 | | - if isinstance(metadata, dict): |
180 | | - value = metadata.get("cveId") |
181 | | - if isinstance(value, str) and value.startswith("CVE-"): |
182 | | - return value |
183 | | - source = (((item.get("containers") or {}).get("cna") or {}).get("source") or {}).get("advisory") |
184 | | - if isinstance(source, str) and source.startswith("GHSA-"): |
185 | | - return source |
186 | | - return None |
187 | | - |
188 | | - |
189 | | -def extract_circl_candidate_url(item: dict, vuln_id: str) -> str: |
190 | | - for key in ("url", "href", "permalink"): |
191 | | - value = item.get(key) |
192 | | - if isinstance(value, str) and value: |
193 | | - return value |
194 | | - references = (((item.get("containers") or {}).get("cna") or {}).get("references") or []) |
195 | | - for reference in references: |
196 | | - if isinstance(reference, dict): |
197 | | - value = reference.get("url") |
198 | | - if isinstance(value, str) and value: |
199 | | - return value |
200 | | - if vuln_id.startswith("CVE-"): |
201 | | - return f"https://www.cve.org/CVERecord?id={vuln_id}" |
202 | | - return f"https://github.com/advisories/{vuln_id}" |
203 | | - |
204 | | - |
205 | | -def extract_circl_candidate_summary(item: dict) -> str: |
206 | | - cna = ((item.get("containers") or {}).get("cna") or {}) |
207 | | - for key in ("title",): |
208 | | - value = cna.get(key) |
209 | | - if isinstance(value, str) and value: |
210 | | - return value |
211 | | - descriptions = cna.get("descriptions") or [] |
212 | | - for description in descriptions: |
213 | | - if isinstance(description, dict): |
214 | | - value = description.get("value") |
215 | | - if isinstance(value, str) and value: |
216 | | - return value |
217 | | - for key in ("summary", "description", "title"): |
218 | | - value = item.get(key) |
219 | | - if isinstance(value, str) and value: |
220 | | - return value |
221 | | - return "" |
222 | | - |
223 | | - |
224 | | -def extract_circl_candidate_severity(item: dict) -> Optional[str]: |
225 | | - severity = item.get("severity") |
226 | | - if isinstance(severity, str) and severity: |
227 | | - return severity.upper() |
228 | | - metrics = ((item.get("containers") or {}).get("cna") or {}).get("metrics") or [] |
229 | | - for metric in metrics: |
230 | | - if not isinstance(metric, dict): |
231 | | - continue |
232 | | - for key in ("cvssV4_0", "cvssV3_1", "cvssV3_0"): |
233 | | - cvss = metric.get(key) |
234 | | - if isinstance(cvss, dict): |
235 | | - value = cvss.get("baseSeverity") |
236 | | - if isinstance(value, str) and value: |
237 | | - return value.upper() |
238 | | - return None |
239 | | - |
240 | | - |
241 | | -def extract_circl_candidate_aliases(item: dict, preferred_id: str) -> list[str]: |
242 | | - aliases: list[str] = [] |
243 | | - for value in item.get("aliases") or []: |
244 | | - if isinstance(value, str) and value and value != preferred_id and value not in aliases: |
245 | | - aliases.append(value) |
246 | | - source = (((item.get("containers") or {}).get("cna") or {}).get("source") or {}).get("advisory") |
247 | | - if isinstance(source, str) and source and source != preferred_id and source not in aliases: |
248 | | - aliases.append(source) |
249 | | - return aliases |
250 | | - |
251 | | - |
252 | | -def circl_version_matches(package_version: str, version_entry: dict) -> bool: |
253 | | - status = version_entry.get("status") |
254 | | - if status != "affected": |
255 | | - return False |
256 | | - version = version_entry.get("version") |
257 | | - less_than = version_entry.get("lessThan") |
258 | | - less_than_or_equal = version_entry.get("lessThanOrEqual") |
259 | | - if not isinstance(version, str): |
260 | | - return False |
261 | | - normalized = version.strip() |
262 | | - if normalized.startswith((">", "<", "=")): |
263 | | - try: |
264 | | - return SpecifierSet(normalized.replace(",", ", ")).contains(package_version, prereleases=True) |
265 | | - except InvalidSpecifier: |
266 | | - return False |
267 | | - clauses = [] |
268 | | - if normalized not in ("", "*"): |
269 | | - clauses.append(f">={normalized}") |
270 | | - if isinstance(less_than, str) and less_than not in ("", "*"): |
271 | | - clauses.append(f"<{less_than}") |
272 | | - if isinstance(less_than_or_equal, str) and less_than_or_equal not in ("", "*"): |
273 | | - clauses.append(f"<={less_than_or_equal}") |
274 | | - if clauses: |
275 | | - try: |
276 | | - return SpecifierSet(",".join(clauses)).contains(package_version, prereleases=True) |
277 | | - except InvalidSpecifier: |
278 | | - return False |
279 | | - try: |
280 | | - return package_version == normalized |
281 | | - except InvalidVersion: |
282 | | - return False |
283 | | - |
284 | | - |
285 | | -def circl_candidate_affects_version(item: dict, lookup_name: str, package_version: str) -> bool: |
286 | | - affected = (((item.get("containers") or {}).get("cna") or {}).get("affected") or []) |
287 | | - lookup_name = lookup_name.lower() |
288 | | - for entry in affected: |
289 | | - if not isinstance(entry, dict): |
290 | | - continue |
291 | | - product = str(entry.get("product") or "").lower() |
292 | | - package_entry = str(entry.get("packageName") or "").lower() |
293 | | - if product != lookup_name and package_entry != lookup_name: |
294 | | - continue |
295 | | - for version in entry.get("versions") or []: |
296 | | - if isinstance(version, dict) and circl_version_matches(package_version, version): |
297 | | - return True |
298 | | - return False |
299 | | - |
300 | | - |
301 | | -def query_circl( |
302 | | - dependencies: dict[str, Dependency], repo_path: Path |
303 | | -) -> tuple[list[Vulnerability], list[str]]: |
304 | | - found_vulnerabilities: list[Vulnerability] = [] |
305 | | - failures: list[str] = [] |
306 | | - for name, dep in dependencies.items(): |
307 | | - version = dep.version_parser(repo_path) |
308 | | - lookup_name = circl_lookup_name(name, dep) |
309 | | - query = urllib.parse.urlencode({"product": lookup_name, "per_page": 100}) |
310 | | - request = urllib.request.Request( |
311 | | - f"{CIRCL_PRODUCT_URL}?{query}", |
312 | | - headers={"Accept": "application/json", "User-Agent": "nsolid-dependency-vuln-assessments"}, |
313 | | - ) |
314 | | - try: |
315 | | - with urllib.request.urlopen(request, timeout=30) as response: |
316 | | - payload = json.load(response) |
317 | | - except Exception as exc: |
318 | | - failures.append(f"{name}: {exc}") |
319 | | - continue |
320 | | - for item in extract_circl_candidates(payload): |
321 | | - vuln_id = extract_circl_candidate_id(item) |
322 | | - if not vuln_id or vuln_id in ignore_list: |
323 | | - continue |
324 | | - if not circl_candidate_affects_version(item, lookup_name, version): |
325 | | - continue |
326 | | - found_vulnerabilities.append( |
327 | | - Vulnerability( |
328 | | - id=vuln_id, |
329 | | - url=extract_circl_candidate_url(item, vuln_id), |
330 | | - dependency=name, |
331 | | - version=version, |
332 | | - severity=extract_circl_candidate_severity(item), |
333 | | - advisory_aliases=extract_circl_candidate_aliases(item, vuln_id), |
334 | | - ) |
335 | | - ) |
336 | | - return found_vulnerabilities, failures |
337 | | - |
338 | | - |
339 | 145 | def resolve_dependencies( |
340 | 146 | repo_path: Path, repo_branch: str |
341 | 147 | ) -> tuple[dict[str, Dependency], list[str]]: |
@@ -618,22 +424,6 @@ def main() -> int: |
618 | 424 | print(f"Warning: NVD query failed: {e}", file=sys.stderr) |
619 | 425 | print(traceback.format_exc(), file=sys.stderr) |
620 | 426 |
|
621 | | - circl_vulnerabilities: list[Vulnerability] = [] |
622 | | - try: |
623 | | - circl_vulnerabilities, circl_failures = query_circl(dependencies, repo_path) |
624 | | - if circl_failures: |
625 | | - scan_complete = False |
626 | | - print( |
627 | | - f"Warning: CIRCL query was incomplete; {len(circl_failures)} dependenc(ies) failed:", |
628 | | - file=sys.stderr, |
629 | | - ) |
630 | | - for failure in circl_failures: |
631 | | - print(f" - {failure}", file=sys.stderr) |
632 | | - except Exception as e: |
633 | | - scan_complete = False |
634 | | - print(f"Warning: CIRCL query failed: {e}", file=sys.stderr) |
635 | | - print(traceback.format_exc(), file=sys.stderr) |
636 | | - |
637 | 427 | # NPM package vulnerability checking |
638 | 428 | npm_vulnerabilities: list[Vulnerability] = [] |
639 | 429 | if include_npm: |
@@ -667,7 +457,7 @@ def main() -> int: |
667 | 457 | print(f"Traceback: {traceback.format_exc()}", file=sys.stderr) |
668 | 458 |
|
669 | 459 | merged_vulnerabilities = merge_vulnerabilities( |
670 | | - ghad_vulnerabilities + nvd_vulnerabilities + circl_vulnerabilities + npm_vulnerabilities |
| 460 | + ghad_vulnerabilities + nvd_vulnerabilities + npm_vulnerabilities |
671 | 461 | ) |
672 | 462 | all_vulnerabilities = { |
673 | 463 | "vulnerabilities": merged_vulnerabilities, |
|
0 commit comments