|
12 | 12 | import subprocess |
13 | 13 | import logging |
14 | 14 | import re |
| 15 | +import urllib.error |
| 16 | +import urllib.parse |
| 17 | +import urllib.request |
15 | 18 | from pathlib import Path |
16 | 19 | from typing import List, Dict, Optional |
17 | 20 |
|
18 | 21 | from gql import gql, Client |
19 | 22 | from gql.transport.aiohttp import AIOHTTPTransport |
| 23 | +from nvdlib import searchCVE # type: ignore |
20 | 24 | from packaging.specifiers import InvalidSpecifier, SpecifierSet |
21 | 25 | from packaging.version import InvalidVersion |
22 | 26 |
|
23 | 27 | logger = logging.getLogger(__name__) |
24 | 28 |
|
| 29 | +GITHUB_API_VERSION = "2026-03-10" |
| 30 | + |
25 | 31 |
|
26 | 32 | class AuditParseError(Exception): |
27 | 33 | """Raised when npm audit returns output that cannot be parsed reliably.""" |
@@ -80,10 +86,11 @@ class AuditParseError(Exception): |
80 | 86 | class NPMAuditChecker: |
81 | 87 | """Handles npm audit vulnerability checking for package.json files.""" |
82 | 88 |
|
83 | | - def __init__(self, repo_path: Path, timeout: int = 300, gh_token: Optional[str] = None): |
| 89 | + def __init__(self, repo_path: Path, timeout: int = 300, gh_token: Optional[str] = None, nvd_key: Optional[str] = None): |
84 | 90 | self.repo_path = repo_path |
85 | 91 | self.timeout = timeout |
86 | 92 | self.gh_token = gh_token |
| 93 | + self.nvd_key = nvd_key |
87 | 94 | self.exclude_paths = EXCLUDE_PATHS |
88 | 95 | # A failed package-level audit means the aggregate npm result is partial. |
89 | 96 | # The caller uses this to prevent reconciliation from closing valid issues. |
@@ -307,70 +314,240 @@ def walk(name: str, node: Dict, path_parts: Optional[list[str]] = None) -> None: |
307 | 314 | def query_installed_package_vulnerabilities( |
308 | 315 | self, package_dir: Path, packages: List[Dict[str, str]], vulnerability_class |
309 | 316 | ) -> List: |
310 | | - """Query GitHub advisories for exact installed package versions.""" |
311 | | - if self.gh_token is None: |
312 | | - raise RuntimeError("GitHub token is required to scan installed npm package trees") |
313 | | - |
314 | | - transport = AIOHTTPTransport( |
315 | | - url="https://api.github.com/graphql", |
316 | | - headers={"Authorization": f"bearer {self.gh_token}"}, |
317 | | - ) |
318 | | - client = Client( |
319 | | - transport=transport, |
320 | | - fetch_schema_from_transport=True, |
321 | | - serialize_variables=True, |
322 | | - parse_results=True, |
323 | | - ) |
324 | | - |
325 | | - vulnerabilities = [] |
| 317 | + """Query NVD and GitHub advisories for exact installed package versions.""" |
| 318 | + vulnerabilities_by_id: Dict[str, object] = {} |
326 | 319 | main_dep_name = package_dir.name |
327 | 320 | main_dep_path = str(package_dir.relative_to(self.repo_path)) |
328 | 321 |
|
329 | | - for package in packages: |
330 | | - try: |
331 | | - result = client.execute( |
332 | | - github_vulnerabilities_query, |
333 | | - variable_values={"package_name": package["name"]}, |
334 | | - ) |
335 | | - except Exception as exc: |
336 | | - logger.warning( |
337 | | - f"Skipping GitHub advisory query for {package['name']}@{package['version']}: {exc}" |
338 | | - ) |
339 | | - continue |
340 | | - for vuln in result["securityVulnerabilities"]["nodes"]: |
341 | | - if vuln["advisory"]["withdrawnAt"] is not None: |
342 | | - continue |
| 322 | + def merge_vulnerability(vuln) -> None: |
| 323 | + candidate_ids = [vuln.id, *(getattr(vuln, "advisory_aliases", []) or [])] |
| 324 | + for candidate_id in candidate_ids: |
| 325 | + existing = vulnerabilities_by_id.get(candidate_id) |
| 326 | + if existing is not None: |
| 327 | + aliases = list(getattr(existing, "advisory_aliases", []) or []) |
| 328 | + for alias in getattr(vuln, "advisory_aliases", []) or []: |
| 329 | + if alias != existing.id and alias not in aliases: |
| 330 | + aliases.append(alias) |
| 331 | + setattr(existing, "advisory_aliases", aliases) |
| 332 | + return |
| 333 | + vulnerabilities_by_id[vuln.id] = vuln |
| 334 | + |
| 335 | + packages_for_global_advisories = list(packages) |
| 336 | + if self.gh_token is not None: |
| 337 | + transport = AIOHTTPTransport( |
| 338 | + url="https://api.github.com/graphql", |
| 339 | + headers={"Authorization": f"bearer {self.gh_token}"}, |
| 340 | + ) |
| 341 | + client = Client( |
| 342 | + transport=transport, |
| 343 | + fetch_schema_from_transport=True, |
| 344 | + serialize_variables=True, |
| 345 | + parse_results=True, |
| 346 | + ) |
| 347 | + |
| 348 | + for package in packages: |
343 | 349 | try: |
344 | | - vulnerable_range = self.normalize_version_range(vuln["vulnerableVersionRange"]) |
345 | | - if not SpecifierSet(vulnerable_range).contains(package["version"], prereleases=True): |
346 | | - continue |
347 | | - except (InvalidSpecifier, InvalidVersion) as exc: |
348 | | - self.failed_packages.append( |
349 | | - f"{package_dir}: invalid advisory match for {package['name']}@{package['version']}: {exc}" |
| 350 | + result = client.execute( |
| 351 | + github_vulnerabilities_query, |
| 352 | + variable_values={"package_name": package["name"]}, |
350 | 353 | ) |
| 354 | + except Exception as exc: |
351 | 355 | logger.warning( |
352 | | - f"Skipping advisory match for {package['name']}@{package['version']}: {exc}" |
| 356 | + f"Skipping GitHub advisory query for {package['name']}@{package['version']}: {exc}" |
353 | 357 | ) |
354 | 358 | continue |
355 | | - preferred_id = self.preferred_advisory_id(vuln["advisory"]) |
356 | | - vulnerabilities.append( |
| 359 | + for vuln in result["securityVulnerabilities"]["nodes"]: |
| 360 | + if vuln["advisory"]["withdrawnAt"] is not None: |
| 361 | + continue |
| 362 | + try: |
| 363 | + vulnerable_range = self.normalize_version_range(vuln["vulnerableVersionRange"]) |
| 364 | + if not SpecifierSet(vulnerable_range).contains(package["version"], prereleases=True): |
| 365 | + continue |
| 366 | + except (InvalidSpecifier, InvalidVersion) as exc: |
| 367 | + self.failed_packages.append( |
| 368 | + f"{package_dir}: invalid advisory match for {package['name']}@{package['version']}: {exc}" |
| 369 | + ) |
| 370 | + logger.warning( |
| 371 | + f"Skipping advisory match for {package['name']}@{package['version']}: {exc}" |
| 372 | + ) |
| 373 | + continue |
| 374 | + preferred_id = self.preferred_advisory_id(vuln["advisory"]) |
| 375 | + merge_vulnerability( |
| 376 | + vulnerability_class( |
| 377 | + id=preferred_id, |
| 378 | + url=vuln["advisory"]["permalink"], |
| 379 | + dependency=package["name"], |
| 380 | + version=package["version"], |
| 381 | + source="npm", |
| 382 | + severity=vuln.get("severity"), |
| 383 | + via=[vuln["advisory"]["summary"]] if vuln["advisory"].get("summary") else [], |
| 384 | + fix_available=vuln.get("firstPatchedVersion") is not None, |
| 385 | + main_dep_name=main_dep_name, |
| 386 | + main_dep_path=main_dep_path, |
| 387 | + advisory_aliases=self.advisory_aliases(vuln["advisory"], preferred_id), |
| 388 | + ) |
| 389 | + ) |
| 390 | + try: |
| 391 | + global_advisories = self.fetch_global_advisories(packages_for_global_advisories) |
| 392 | + except Exception as exc: |
| 393 | + self.failed_packages.append( |
| 394 | + f"{package_dir}: global advisory query failed: {exc}" |
| 395 | + ) |
| 396 | + logger.warning(f"Skipping global advisory query for {package_dir}: {exc}") |
| 397 | + global_advisories = [] |
| 398 | + |
| 399 | + for package in packages_for_global_advisories: |
| 400 | + matched_global = self.match_global_advisories(package, global_advisories) |
| 401 | + for vuln in matched_global: |
| 402 | + merge_vulnerability( |
357 | 403 | vulnerability_class( |
358 | | - id=preferred_id, |
359 | | - url=vuln["advisory"]["permalink"], |
| 404 | + id=vuln["id"], |
| 405 | + url=vuln["url"], |
360 | 406 | dependency=package["name"], |
361 | 407 | version=package["version"], |
362 | 408 | source="npm", |
363 | 409 | severity=vuln.get("severity"), |
364 | | - via=[vuln["advisory"]["summary"]] if vuln["advisory"].get("summary") else [], |
365 | | - fix_available=vuln.get("firstPatchedVersion") is not None, |
| 410 | + via=[vuln["summary"]] if vuln.get("summary") else [], |
| 411 | + fix_available=vuln.get("fix_available"), |
366 | 412 | main_dep_name=main_dep_name, |
367 | 413 | main_dep_path=main_dep_path, |
368 | | - advisory_aliases=self.advisory_aliases(vuln["advisory"], preferred_id), |
| 414 | + advisory_aliases=vuln.get("aliases", []), |
369 | 415 | ) |
370 | 416 | ) |
371 | 417 |
|
372 | | - logger.info(f"Parsed {len(vulnerabilities)} GitHub advisory matches from {package_dir}") |
373 | | - return vulnerabilities |
| 418 | + for package in packages: |
| 419 | + for vuln in list(vulnerabilities_by_id.values()): |
| 420 | + if vuln.dependency != package["name"] or not str(vuln.id).startswith("CVE-"): |
| 421 | + continue |
| 422 | + try: |
| 423 | + matches = searchCVE( |
| 424 | + cveId=vuln.id, |
| 425 | + key=self.nvd_key, |
| 426 | + delay=6 if self.nvd_key else False, |
| 427 | + ) |
| 428 | + except Exception as exc: |
| 429 | + self.failed_packages.append( |
| 430 | + f"{package_dir}: NVD enrichment failed for {package['name']}@{package['version']} {vuln.id}: {exc}" |
| 431 | + ) |
| 432 | + logger.warning( |
| 433 | + f"Skipping NVD enrichment for {package['name']}@{package['version']} {vuln.id}: {exc}" |
| 434 | + ) |
| 435 | + continue |
| 436 | + if not matches: |
| 437 | + continue |
| 438 | + cve = matches[0] |
| 439 | + try: |
| 440 | + severity = None |
| 441 | + if hasattr(cve, "metrics") and cve.metrics: |
| 442 | + if hasattr(cve.metrics, 'cvssMetricV31') and cve.metrics.cvssMetricV31: |
| 443 | + severity = cve.metrics.cvssMetricV31[0].cvssData.baseSeverity |
| 444 | + elif hasattr(cve.metrics, 'cvssMetricV30') and cve.metrics.cvssMetricV30: |
| 445 | + severity = cve.metrics.cvssMetricV30[0].cvssData.baseSeverity |
| 446 | + elif hasattr(cve.metrics, 'cvssMetricV2') and cve.metrics.cvssMetricV2: |
| 447 | + base_score = cve.metrics.cvssMetricV2[0].cvssData.baseScore |
| 448 | + severity = "HIGH" if base_score >= 7.0 else "MEDIUM" if base_score >= 4.0 else "LOW" |
| 449 | + except (AttributeError, IndexError, TypeError): |
| 450 | + severity = None |
| 451 | + if severity is not None: |
| 452 | + vuln.severity = severity |
| 453 | + if getattr(cve, 'url', None): |
| 454 | + vuln.url = cve.url |
| 455 | + |
| 456 | + return list(vulnerabilities_by_id.values()) |
| 457 | + |
| 458 | + def match_global_advisories(self, package: Dict[str, str], advisories: List[Dict]) -> List[Dict[str, object]]: |
| 459 | + results: List[Dict[str, object]] = [] |
| 460 | + seen_ids: set[str] = set() |
| 461 | + for advisory in advisories: |
| 462 | + if advisory.get("withdrawn_at") is not None: |
| 463 | + continue |
| 464 | + preferred_id = advisory.get("cve_id") or self.preferred_global_advisory_cve(advisory) |
| 465 | + if not preferred_id or preferred_id in seen_ids: |
| 466 | + continue |
| 467 | + aliases = self.global_advisory_aliases(advisory, preferred_id) |
| 468 | + for vuln in advisory.get("vulnerabilities") or []: |
| 469 | + package_info = vuln.get("package") or {} |
| 470 | + if package_info.get("ecosystem") != "npm" or package_info.get("name") != package["name"]: |
| 471 | + continue |
| 472 | + try: |
| 473 | + vulnerable_range = self.normalize_version_range(vuln.get("vulnerable_version_range") or "") |
| 474 | + matched = bool(vulnerable_range) and SpecifierSet(vulnerable_range).contains(package["version"], prereleases=True) |
| 475 | + if not matched: |
| 476 | + continue |
| 477 | + except (InvalidSpecifier, InvalidVersion): |
| 478 | + continue |
| 479 | + seen_ids.add(preferred_id) |
| 480 | + results.append({ |
| 481 | + "id": preferred_id, |
| 482 | + "url": advisory.get("html_url") or advisory.get("url") or f"https://github.com/advisories/{advisory.get('ghsa_id', preferred_id)}", |
| 483 | + "severity": str(advisory.get("severity") or "").upper() or None, |
| 484 | + "summary": advisory.get("summary") or "", |
| 485 | + "aliases": aliases, |
| 486 | + "fix_available": bool(vuln.get("first_patched_version") or vuln.get("patched_versions")), |
| 487 | + }) |
| 488 | + return results |
| 489 | + |
| 490 | + def fetch_global_advisories(self, packages: List[Dict[str, str]]) -> List[Dict]: |
| 491 | + if not packages: |
| 492 | + return [] |
| 493 | + cache = getattr(self, "_global_advisory_cache", None) |
| 494 | + if cache is None: |
| 495 | + cache = self._global_advisory_cache = {} |
| 496 | + requested = tuple(sorted({f"{package['name']}@{package['version']}" for package in packages})) |
| 497 | + if requested in cache: |
| 498 | + return cache[requested] |
| 499 | + |
| 500 | + headers = { |
| 501 | + "Accept": "application/vnd.github+json", |
| 502 | + "X-GitHub-Api-Version": GITHUB_API_VERSION, |
| 503 | + "User-Agent": "nsolid-dependency-vuln-assessments", |
| 504 | + } |
| 505 | + advisories: List[Dict] = [] |
| 506 | + seen_ids: set[str] = set() |
| 507 | + batch_size = 25 |
| 508 | + |
| 509 | + for index in range(0, len(requested), batch_size): |
| 510 | + batch = requested[index:index + batch_size] |
| 511 | + query = urllib.parse.urlencode( |
| 512 | + [("ecosystem", "npm"), *( ("affects[]", item) for item in batch ), ("per_page", "100")] |
| 513 | + ) |
| 514 | + url = f"https://api.github.com/advisories?{query}" |
| 515 | + request = urllib.request.Request(url, headers=headers) |
| 516 | + with urllib.request.urlopen(request, timeout=min(self.timeout, 30)) as response: |
| 517 | + payload = json.load(response) |
| 518 | + if not isinstance(payload, list): |
| 519 | + logger.warning( |
| 520 | + f"Global advisory query returned non-list payload for batch {index // batch_size + 1} of {((len(requested) - 1) // batch_size) + 1}: {payload}" |
| 521 | + ) |
| 522 | + continue |
| 523 | + for item in payload: |
| 524 | + if not isinstance(item, dict): |
| 525 | + continue |
| 526 | + advisory_id = item.get("ghsa_id") or item.get("cve_id") or id(item) |
| 527 | + if advisory_id in seen_ids: |
| 528 | + continue |
| 529 | + seen_ids.add(advisory_id) |
| 530 | + advisories.append(item) |
| 531 | + |
| 532 | + cache[requested] = advisories |
| 533 | + return advisories |
| 534 | + |
| 535 | + def preferred_global_advisory_cve(self, advisory: Dict) -> Optional[str]: |
| 536 | + for identifier in advisory.get("identifiers") or []: |
| 537 | + if identifier.get("type") == "CVE" and identifier.get("value"): |
| 538 | + return identifier["value"] |
| 539 | + return None |
| 540 | + |
| 541 | + def global_advisory_aliases(self, advisory: Dict, preferred_id: str) -> list[str]: |
| 542 | + aliases: list[str] = [] |
| 543 | + ghsa_id = advisory.get("ghsa_id") |
| 544 | + if isinstance(ghsa_id, str) and ghsa_id and ghsa_id != preferred_id: |
| 545 | + aliases.append(ghsa_id) |
| 546 | + for identifier in advisory.get("identifiers") or []: |
| 547 | + value = identifier.get("value") |
| 548 | + if value and value != preferred_id and value not in aliases: |
| 549 | + aliases.append(value) |
| 550 | + return aliases |
374 | 551 |
|
375 | 552 | def normalize_version_range(self, version_range: str) -> str: |
376 | 553 | """Normalize GitHub advisory version syntax to packaging-compatible specifiers.""" |
|
0 commit comments