2121from gql import gql , Client
2222from gql .transport .aiohttp import AIOHTTPTransport
2323from nvdlib import searchCVE # type: ignore
24- from packaging .specifiers import SpecifierSet
24+ from packaging .specifiers import InvalidSpecifier , SpecifierSet
25+ from packaging .version import InvalidVersion
2526from typing import Optional , List
2627from pathlib import Path
2728
2829import json
2930import logging
3031import sys
3132import traceback
33+ import urllib .parse
34+ import urllib .request
3235
3336
3437class 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
109339def 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