Skip to content

Commit 999824e

Browse files
committed
Augment npm scans with GH advisories and NVD data
Pass the NVD key into npm package scanning, query GitHub's global advisory API for exact installed npm package versions, and enrich CVE matches with NVD metadata when available. Harden advisory merging, error accounting, pagination, GHSA fallback, severity normalization, and deterministic test coverage for the extra advisory and NVD paths.
1 parent 92b9a82 commit 999824e

2 files changed

Lines changed: 624 additions & 97 deletions

File tree

dep_checker/npm_audit.py

Lines changed: 257 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@
1212
import subprocess
1313
import logging
1414
import re
15+
import urllib.error
16+
import urllib.parse
17+
import urllib.request
1518
from pathlib import Path
1619
from typing import List, Dict, Optional
1720

1821
from gql import gql, Client
1922
from gql.transport.aiohttp import AIOHTTPTransport
23+
from nvdlib import searchCVE # type: ignore
2024
from packaging.specifiers import InvalidSpecifier, SpecifierSet
2125
from packaging.version import InvalidVersion
2226

2327
logger = logging.getLogger(__name__)
2428

29+
GITHUB_API_VERSION = "2026-03-10"
30+
2531

2632
class AuditParseError(Exception):
2733
"""Raised when npm audit returns output that cannot be parsed reliably."""
@@ -80,10 +86,11 @@ class AuditParseError(Exception):
8086
class NPMAuditChecker:
8187
"""Handles npm audit vulnerability checking for package.json files."""
8288

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):
8490
self.repo_path = repo_path
8591
self.timeout = timeout
8692
self.gh_token = gh_token
93+
self.nvd_key = nvd_key
8794
self.exclude_paths = EXCLUDE_PATHS
8895
# A failed package-level audit means the aggregate npm result is partial.
8996
# The caller uses this to prevent reconciliation from closing valid issues.
@@ -307,70 +314,275 @@ def walk(name: str, node: Dict, path_parts: Optional[list[str]] = None) -> None:
307314
def query_installed_package_vulnerabilities(
308315
self, package_dir: Path, packages: List[Dict[str, str]], vulnerability_class
309316
) -> 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] = {}
319+
ordered_vulnerabilities: List[object] = []
326320
main_dep_name = package_dir.name
327321
main_dep_path = str(package_dir.relative_to(self.repo_path))
328322

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:
323+
def index_vulnerability(vuln) -> None:
324+
aliases = list(dict.fromkeys(
325+
alias for alias in (getattr(vuln, "advisory_aliases", []) or []) if alias != vuln.id
326+
))
327+
setattr(vuln, "advisory_aliases", aliases)
328+
for candidate_id in [vuln.id, *aliases]:
329+
vulnerabilities_by_id[candidate_id] = vuln
330+
331+
def merge_vulnerability(vuln) -> None:
332+
candidate_ids = [vuln.id, *(getattr(vuln, "advisory_aliases", []) or [])]
333+
for candidate_id in candidate_ids:
334+
existing = vulnerabilities_by_id.get(candidate_id)
335+
if existing is None:
342336
continue
337+
aliases = list(getattr(existing, "advisory_aliases", []) or [])
338+
for alias in [vuln.id, *(getattr(vuln, "advisory_aliases", []) or [])]:
339+
if alias != existing.id and alias not in aliases:
340+
aliases.append(alias)
341+
setattr(existing, "advisory_aliases", aliases)
342+
index_vulnerability(existing)
343+
return
344+
index_vulnerability(vuln)
345+
ordered_vulnerabilities.append(vuln)
346+
347+
packages_for_global_advisories = list(packages)
348+
if self.gh_token is not None:
349+
transport = AIOHTTPTransport(
350+
url="https://api.github.com/graphql",
351+
headers={"Authorization": f"bearer {self.gh_token}"},
352+
)
353+
client = Client(
354+
transport=transport,
355+
fetch_schema_from_transport=True,
356+
serialize_variables=True,
357+
parse_results=True,
358+
)
359+
360+
for package in packages:
343361
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:
362+
result = client.execute(
363+
github_vulnerabilities_query,
364+
variable_values={"package_name": package["name"]},
365+
)
366+
except Exception as exc:
348367
self.failed_packages.append(
349-
f"{package_dir}: invalid advisory match for {package['name']}@{package['version']}: {exc}"
368+
f"{package_dir}: GitHub advisory query failed for {package['name']}@{package['version']}: {exc}"
350369
)
351370
logger.warning(
352-
f"Skipping advisory match for {package['name']}@{package['version']}: {exc}"
371+
f"Skipping GitHub advisory query for {package['name']}@{package['version']}: {exc}"
353372
)
354373
continue
355-
preferred_id = self.preferred_advisory_id(vuln["advisory"])
356-
vulnerabilities.append(
374+
for vuln in result["securityVulnerabilities"]["nodes"]:
375+
if vuln["advisory"]["withdrawnAt"] is not None:
376+
continue
377+
try:
378+
vulnerable_range = self.normalize_version_range(vuln["vulnerableVersionRange"])
379+
if not SpecifierSet(vulnerable_range).contains(package["version"], prereleases=True):
380+
continue
381+
except (InvalidSpecifier, InvalidVersion) as exc:
382+
self.failed_packages.append(
383+
f"{package_dir}: invalid advisory match for {package['name']}@{package['version']}: {exc}"
384+
)
385+
logger.warning(
386+
f"Skipping advisory match for {package['name']}@{package['version']}: {exc}"
387+
)
388+
continue
389+
preferred_id = self.preferred_advisory_id(vuln["advisory"])
390+
merge_vulnerability(
391+
vulnerability_class(
392+
id=preferred_id,
393+
url=vuln["advisory"]["permalink"],
394+
dependency=package["name"],
395+
version=package["version"],
396+
source="npm",
397+
severity=vuln.get("severity"),
398+
via=[vuln["advisory"]["summary"]] if vuln["advisory"].get("summary") else [],
399+
fix_available=vuln.get("firstPatchedVersion") is not None,
400+
main_dep_name=main_dep_name,
401+
main_dep_path=main_dep_path,
402+
advisory_aliases=self.advisory_aliases(vuln["advisory"], preferred_id),
403+
)
404+
)
405+
try:
406+
global_advisories = self.fetch_global_advisories(packages_for_global_advisories)
407+
except Exception as exc:
408+
self.failed_packages.append(
409+
f"{package_dir}: global advisory query failed: {exc}"
410+
)
411+
logger.warning(f"Skipping global advisory query for {package_dir}: {exc}")
412+
global_advisories = []
413+
414+
for package in packages_for_global_advisories:
415+
matched_global = self.match_global_advisories(package, global_advisories)
416+
for vuln in matched_global:
417+
merge_vulnerability(
357418
vulnerability_class(
358-
id=preferred_id,
359-
url=vuln["advisory"]["permalink"],
419+
id=vuln["id"],
420+
url=vuln["url"],
360421
dependency=package["name"],
361422
version=package["version"],
362423
source="npm",
363424
severity=vuln.get("severity"),
364-
via=[vuln["advisory"]["summary"]] if vuln["advisory"].get("summary") else [],
365-
fix_available=vuln.get("firstPatchedVersion") is not None,
425+
via=[vuln["summary"]] if vuln.get("summary") else [],
426+
fix_available=vuln.get("fix_available"),
366427
main_dep_name=main_dep_name,
367428
main_dep_path=main_dep_path,
368-
advisory_aliases=self.advisory_aliases(vuln["advisory"], preferred_id),
429+
advisory_aliases=vuln.get("aliases", []),
369430
)
370431
)
371432

372-
logger.info(f"Parsed {len(vulnerabilities)} GitHub advisory matches from {package_dir}")
373-
return vulnerabilities
433+
processed_cve_ids: set[str] = set()
434+
for package in packages:
435+
for vuln in list(vulnerabilities_by_id.values()):
436+
if vuln.dependency != package["name"] or not str(vuln.id).startswith("CVE-"):
437+
continue
438+
if vuln.id in processed_cve_ids:
439+
continue
440+
try:
441+
query_kwargs = {
442+
"cveId": vuln.id,
443+
"key": self.nvd_key,
444+
}
445+
if self.nvd_key:
446+
query_kwargs["delay"] = 6
447+
matches = searchCVE(**query_kwargs)
448+
processed_cve_ids.add(vuln.id)
449+
except Exception as exc:
450+
self.failed_packages.append(
451+
f"{package_dir}: NVD enrichment failed for {package['name']}@{package['version']} {vuln.id}: {exc}"
452+
)
453+
logger.warning(
454+
f"Skipping NVD enrichment for {package['name']}@{package['version']} {vuln.id}: {exc}"
455+
)
456+
continue
457+
if not matches:
458+
continue
459+
cve = matches[0]
460+
try:
461+
severity = None
462+
if hasattr(cve, "metrics") and cve.metrics:
463+
if hasattr(cve.metrics, 'cvssMetricV31') and cve.metrics.cvssMetricV31:
464+
severity = cve.metrics.cvssMetricV31[0].cvssData.baseSeverity
465+
elif hasattr(cve.metrics, 'cvssMetricV30') and cve.metrics.cvssMetricV30:
466+
severity = cve.metrics.cvssMetricV30[0].cvssData.baseSeverity
467+
elif hasattr(cve.metrics, 'cvssMetricV2') and cve.metrics.cvssMetricV2:
468+
base_score = cve.metrics.cvssMetricV2[0].cvssData.baseScore
469+
severity = "HIGH" if base_score >= 7.0 else "MEDIUM" if base_score >= 4.0 else "LOW"
470+
except (AttributeError, IndexError, TypeError):
471+
severity = None
472+
if severity is not None:
473+
vuln.severity = str(severity).upper()
474+
if getattr(cve, 'url', None):
475+
vuln.url = cve.url
476+
477+
return ordered_vulnerabilities
478+
479+
def match_global_advisories(self, package: Dict[str, str], advisories: List[Dict]) -> List[Dict[str, object]]:
480+
results: List[Dict[str, object]] = []
481+
seen_ids: set[str] = set()
482+
for advisory in advisories:
483+
if advisory.get("withdrawn_at") is not None:
484+
continue
485+
preferred_id = advisory.get("cve_id") or self.preferred_global_advisory_cve(advisory) or advisory.get("ghsa_id")
486+
if not preferred_id or preferred_id in seen_ids:
487+
continue
488+
aliases = self.global_advisory_aliases(advisory, preferred_id)
489+
for vuln in advisory.get("vulnerabilities") or []:
490+
package_info = vuln.get("package") or {}
491+
if package_info.get("ecosystem") != "npm" or package_info.get("name") != package["name"]:
492+
continue
493+
try:
494+
vulnerable_range = self.normalize_version_range(vuln.get("vulnerable_version_range") or "")
495+
matched = bool(vulnerable_range) and SpecifierSet(vulnerable_range).contains(package["version"], prereleases=True)
496+
if not matched:
497+
continue
498+
except (InvalidSpecifier, InvalidVersion):
499+
continue
500+
seen_ids.add(preferred_id)
501+
results.append({
502+
"id": preferred_id,
503+
"url": advisory.get("html_url") or advisory.get("url") or f"https://github.com/advisories/{advisory.get('ghsa_id', preferred_id)}",
504+
"severity": str(advisory.get("severity") or "").upper() or None,
505+
"summary": advisory.get("summary") or "",
506+
"aliases": aliases,
507+
"fix_available": bool(vuln.get("first_patched_version") or vuln.get("patched_versions")),
508+
})
509+
return results
510+
511+
def fetch_global_advisories(self, packages: List[Dict[str, str]]) -> List[Dict]:
512+
if not packages:
513+
return []
514+
cache = getattr(self, "_global_advisory_cache", None)
515+
if cache is None:
516+
cache = self._global_advisory_cache = {}
517+
requested = tuple(sorted({f"{package['name']}@{package['version']}" for package in packages}))
518+
if requested in cache:
519+
return cache[requested]
520+
521+
headers = {
522+
"Accept": "application/vnd.github+json",
523+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
524+
"User-Agent": "nsolid-dependency-vuln-assessments",
525+
}
526+
if self.gh_token is not None:
527+
headers["Authorization"] = f"bearer {self.gh_token}"
528+
advisories: List[Dict] = []
529+
seen_ids: set[str] = set()
530+
batch_size = 25
531+
532+
for index in range(0, len(requested), batch_size):
533+
batch = requested[index:index + batch_size]
534+
query = urllib.parse.urlencode(
535+
[("ecosystem", "npm"), *(("affects[]", item) for item in batch), ("per_page", "100")]
536+
)
537+
next_url = f"https://api.github.com/advisories?{query}"
538+
batch_payloads: List[Dict] | None = []
539+
while next_url is not None:
540+
request = urllib.request.Request(next_url, headers=headers)
541+
with urllib.request.urlopen(request, timeout=min(self.timeout, 30)) as response:
542+
payload = json.load(response)
543+
link_header = getattr(response, "headers", {}).get("Link")
544+
if not isinstance(payload, list):
545+
logger.warning(
546+
f"Global advisory query returned non-list payload for batch {index // batch_size + 1} of {((len(requested) - 1) // batch_size) + 1}: {payload}"
547+
)
548+
batch_payloads = None
549+
break
550+
batch_payloads.extend(item for item in payload if isinstance(item, dict))
551+
next_url = None
552+
if link_header:
553+
for part in link_header.split(","):
554+
match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', part)
555+
if match and match.group(2) == "next":
556+
next_url = match.group(1)
557+
break
558+
if batch_payloads is None:
559+
continue
560+
for item in batch_payloads:
561+
advisory_id = item.get("ghsa_id") or item.get("cve_id") or id(item)
562+
if advisory_id in seen_ids:
563+
continue
564+
seen_ids.add(advisory_id)
565+
advisories.append(item)
566+
567+
cache[requested] = advisories
568+
return advisories
569+
570+
def preferred_global_advisory_cve(self, advisory: Dict) -> Optional[str]:
571+
for identifier in advisory.get("identifiers") or []:
572+
if identifier.get("type") == "CVE" and identifier.get("value"):
573+
return identifier["value"]
574+
return None
575+
576+
def global_advisory_aliases(self, advisory: Dict, preferred_id: str) -> list[str]:
577+
aliases: list[str] = []
578+
ghsa_id = advisory.get("ghsa_id")
579+
if isinstance(ghsa_id, str) and ghsa_id and ghsa_id != preferred_id:
580+
aliases.append(ghsa_id)
581+
for identifier in advisory.get("identifiers") or []:
582+
value = identifier.get("value")
583+
if value and value != preferred_id and value not in aliases:
584+
aliases.append(value)
585+
return aliases
374586

375587
def normalize_version_range(self, version_range: str) -> str:
376588
"""Normalize GitHub advisory version syntax to packaging-compatible specifiers."""

0 commit comments

Comments
 (0)