Skip to content

Commit 8c88b88

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. Update npm audit tests to cover the extra advisory fetch path, alias preservation during enrichment, and the added import stubs for local execution.
1 parent 583a731 commit 8c88b88

3 files changed

Lines changed: 505 additions & 100 deletions

File tree

dep_checker/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def main() -> int:
437437

438438
from npm_audit import NPMAuditChecker
439439
print("Running npm package vulnerability audit...", file=sys.stderr)
440-
npm_checker = NPMAuditChecker(repo_path, npm_timeout, gh_token=gh_token)
440+
npm_checker = NPMAuditChecker(repo_path, npm_timeout, gh_token=gh_token, nvd_key=nvd_key)
441441
npm_vulnerabilities = npm_checker.check_npm_vulnerabilities(Vulnerability)
442442
if npm_checker.failed_packages:
443443
scan_complete = False

dep_checker/npm_audit.py

Lines changed: 224 additions & 47 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,240 @@ 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] = {}
326319
main_dep_name = package_dir.name
327320
main_dep_path = str(package_dir.relative_to(self.repo_path))
328321

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:
343349
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"]},
350353
)
354+
except Exception as exc:
351355
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}"
353357
)
354358
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(
357403
vulnerability_class(
358-
id=preferred_id,
359-
url=vuln["advisory"]["permalink"],
404+
id=vuln["id"],
405+
url=vuln["url"],
360406
dependency=package["name"],
361407
version=package["version"],
362408
source="npm",
363409
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"),
366412
main_dep_name=main_dep_name,
367413
main_dep_path=main_dep_path,
368-
advisory_aliases=self.advisory_aliases(vuln["advisory"], preferred_id),
414+
advisory_aliases=vuln.get("aliases", []),
369415
)
370416
)
371417

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
374551

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

0 commit comments

Comments
 (0)