Skip to content

Commit 37a1a09

Browse files
committed
Broaden vuln coverage across dependency sources
Merge vulnerability results across NVD, GHAD, and CIRCL so we do not miss advisories that are absent from a single source. For top-level dependencies, keep the strongest matcher available for each dependency and add CIRCL product lookups as an extra source. NVD remains the primary source for curated CPE-based checks, GHAD is still used for npm-addressable packages, and CIRCL now fills gaps by returning product-scoped CVE records that we filter locally by the resolved dependency version. For vendored npm package trees, keep the installed-package workflow and extend it with the same multi-source model. Query NVD first, merge GHAD matches while preferring CVE identifiers and preserving GHSA aliases, and fall back to CIRCL product search when upstream advisory coverage is incomplete. This catches cases like ip-address@10.2.0 where valid CVEs existed outside GHAD coverage. Also harden reconciliation behavior by carrying advisory aliases through the emitted vulnerability records, and extend the local test harness to cover NVD/GHAD merging, CIRCL fallback behavior, and deterministic network stubbing for GHAD-only tests. Signed-off-by: Santiago Gimeno <santiago.gimeno@gmail.com>
1 parent 4274da6 commit 37a1a09

3 files changed

Lines changed: 707 additions & 75 deletions

File tree

dep_checker/main.py

Lines changed: 258 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@
2121
from gql import gql, Client
2222
from gql.transport.aiohttp import AIOHTTPTransport
2323
from nvdlib import searchCVE # type: ignore
24-
from packaging.specifiers import SpecifierSet
24+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
25+
from packaging.version import InvalidVersion
2526
from typing import Optional, List
2627
from pathlib import Path
2728

2829
import json
2930
import logging
3031
import sys
3132
import traceback
33+
import urllib.parse
34+
import urllib.request
3235

3336

3437
class Vulnerability:
@@ -96,6 +99,10 @@ def default(self, obj):
9699
vulnerableVersionRange
97100
advisory {
98101
ghsaId
102+
identifiers {
103+
type
104+
value
105+
}
99106
permalink
100107
withdrawnAt
101108
}
@@ -105,6 +112,229 @@ def default(self, obj):
105112
"""
106113
)
107114

115+
CIRCL_PRODUCT_URL = "https://vulnerability.circl.lu/api/vulnerability/"
116+
117+
118+
def preferred_advisory_id(advisory: dict) -> str:
119+
for identifier in advisory.get("identifiers") or []:
120+
if identifier.get("type") == "CVE" and identifier.get("value"):
121+
return identifier["value"]
122+
return advisory["ghsaId"]
123+
124+
125+
def advisory_aliases(advisory: dict, preferred_id: str) -> list[str]:
126+
aliases: list[str] = []
127+
for identifier in advisory.get("identifiers") or []:
128+
value = identifier.get("value")
129+
if value and value != preferred_id and value not in aliases:
130+
aliases.append(value)
131+
return aliases
132+
133+
134+
def merge_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnerability]:
135+
merged: dict[str, Vulnerability] = {}
136+
for vuln in vulnerabilities:
137+
candidate_ids = [vuln.id, *(vuln.advisory_aliases or [])]
138+
for candidate_id in candidate_ids:
139+
existing = merged.get(candidate_id)
140+
if existing is None:
141+
continue
142+
for alias in vuln.advisory_aliases or []:
143+
if alias != existing.id and alias not in existing.advisory_aliases:
144+
existing.advisory_aliases.append(alias)
145+
break
146+
else:
147+
merged[vuln.id] = vuln
148+
return list(merged.values())
149+
150+
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+
108338

109339
def resolve_dependencies(
110340
repo_path: Path, repo_branch: str
@@ -202,16 +432,19 @@ def query_ghad(
202432
for v in result["securityVulnerabilities"]["nodes"]
203433
if v["advisory"]["withdrawnAt"] is None
204434
and dep_version in SpecifierSet(v["vulnerableVersionRange"])
205-
and v["advisory"]["ghsaId"] not in ignore_list
435+
and preferred_advisory_id(v["advisory"]) not in ignore_list
206436
]
207437
if matching_vulns:
208438
found_vulnerabilities.extend(
209439
[
210440
Vulnerability(
211-
id=vuln["advisory"]["ghsaId"],
441+
id=preferred_advisory_id(vuln["advisory"]),
212442
url=vuln["advisory"]["permalink"],
213443
dependency=name,
214444
version=dep_version,
445+
advisory_aliases=advisory_aliases(
446+
vuln["advisory"], preferred_advisory_id(vuln["advisory"])
447+
),
215448
)
216449
for vuln in matching_vulns
217450
]
@@ -385,6 +618,22 @@ def main() -> int:
385618
print(f"Warning: NVD query failed: {e}", file=sys.stderr)
386619
print(traceback.format_exc(), file=sys.stderr)
387620

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+
388637
# NPM package vulnerability checking
389638
npm_vulnerabilities: list[Vulnerability] = []
390639
if include_npm:
@@ -398,7 +647,7 @@ def main() -> int:
398647

399648
from npm_audit import NPMAuditChecker
400649
print("Running npm package vulnerability audit...", file=sys.stderr)
401-
npm_checker = NPMAuditChecker(repo_path, npm_timeout, gh_token=gh_token)
650+
npm_checker = NPMAuditChecker(repo_path, npm_timeout, gh_token=gh_token, nvd_key=nvd_key)
402651
npm_vulnerabilities = npm_checker.check_npm_vulnerabilities(Vulnerability)
403652
if npm_checker.failed_packages:
404653
scan_complete = False
@@ -417,11 +666,14 @@ def main() -> int:
417666
print(f"Warning: npm vulnerability checking failed: {e}", file=sys.stderr)
418667
print(f"Traceback: {traceback.format_exc()}", file=sys.stderr)
419668

669+
merged_vulnerabilities = merge_vulnerabilities(
670+
ghad_vulnerabilities + nvd_vulnerabilities + circl_vulnerabilities + npm_vulnerabilities
671+
)
420672
all_vulnerabilities = {
421-
"vulnerabilities": ghad_vulnerabilities + nvd_vulnerabilities + npm_vulnerabilities,
673+
"vulnerabilities": merged_vulnerabilities,
422674
"scan_complete": scan_complete,
423675
}
424-
no_vulnerabilities_found = not ghad_vulnerabilities and not nvd_vulnerabilities and not npm_vulnerabilities
676+
no_vulnerabilities_found = not merged_vulnerabilities
425677

426678
if scan_file is not None:
427679
with open(scan_file, "w") as f:

0 commit comments

Comments
 (0)