1919
2020# TODO: use saneyaml
2121import yaml
22+ from fetchcode import package_versions
2223from packageurl import PackageURL
2324from texttable import Texttable
24- from univers .normalized_range import NormalizedVersionRanges
25+ from univers .version_range import RANGE_CLASS_BY_SCHEMES
26+ from univers .version_range import VersionRange
27+ from univers .version_range import build_range_from_github_advisory_constraint
28+ from univers .version_range import build_range_from_snyk_advisory_string
29+ from univers .version_range import from_gitlab_native
2530
26- from vulnerabilities .package_managers import VERSION_API_CLASSES_BY_PACKAGE_TYPE
2731from vulntotal .datasources import DATASOURCE_REGISTRY
2832from vulntotal .validator import VendorData
2933from vulntotal .vulntotal_utils import get_item
@@ -189,7 +193,9 @@ def handler(
189193 prettyprint (purl , active_datasource , pagination , no_threading )
190194
191195 elif purl :
192- prettyprint_group_by_cve (purl , active_datasource , pagination , no_threading , vers , no_compare )
196+ prettyprint_group_by_cve (
197+ purl , active_datasource , pagination , no_threading , vers , no_compare
198+ )
193199
194200
195201def get_valid_datasources (datasources ):
@@ -281,10 +287,12 @@ def run_datasources(purl, datasources, no_threading=False):
281287 return vulnerabilities
282288
283289
284- class VulntotalEncoder (json .JSONEncoder ):
290+ class VendorDataEncoder (json .JSONEncoder ):
285291 def default (self , obj ):
286- if isinstance (obj , VendorData ) or isinstance ( obj , NormalizedVersionRanges ) :
292+ if isinstance (obj , VendorData ):
287293 return obj .to_dict ()
294+ if isinstance (obj , VersionRange ):
295+ return str (obj )
288296 return json .JSONEncoder .default (self , obj )
289297
290298
@@ -298,7 +306,7 @@ def write_json_output(purl, datasources, json_output, no_threading, no_group, no
298306 grouped_by_cve = group_by_cve (vulnerabilities , PackageURL .from_string (purl ), no_compare )
299307 results .update (grouped_by_cve )
300308
301- return json .dump (results , json_output , cls = VulntotalEncoder , indent = 2 )
309+ return json .dump (results , json_output , cls = VendorDataEncoder , indent = 2 )
302310
303311
304312def noop (self , * args , ** kw ):
@@ -316,30 +324,26 @@ def write_yaml_output(purl, datasources, yaml_output, no_threading, no_group, no
316324 results .update (vulnerabilities )
317325 else :
318326 grouped_by_cve = group_by_cve (vulnerabilities , PackageURL .from_string (purl ), no_compare )
319- serialize_normalized_range (grouped_by_cve , no_compare )
327+ serialize_version_range (grouped_by_cve , no_compare )
320328 results .update (grouped_by_cve )
321329
322330 return yaml .dump (results , yaml_output , default_flow_style = False , indent = 2 , sort_keys = False )
323331
324332
325- def serialize_normalized_range (grouped_by_cve , no_compare ):
333+ def serialize_version_range (grouped_by_cve , no_compare ):
326334 if no_compare :
327335 return
328336 for cve , value in grouped_by_cve .items ():
329337 if cve in ("NOCVE" , "NOADVISORY" ):
330338 continue
331- for datasource , resources in value .items ():
339+ for _ , resources in value .items ():
332340 for resource in resources :
333341 affected_versions = resource .get ("normalized_affected_versions" )
334342 fixed_versions = resource .get ("normalized_fixed_versions" )
335- if isinstance (affected_versions , NormalizedVersionRanges ):
336- resource ["normalized_affected_versions" ] = [
337- str (vers ) for vers in affected_versions .version_ranges
338- ]
339- if isinstance (fixed_versions , NormalizedVersionRanges ):
340- resource ["normalized_fixed_versions" ] = [
341- str (vers ) for vers in fixed_versions .version_ranges
342- ]
343+ if isinstance (affected_versions , VersionRange ):
344+ resource ["normalized_affected_versions" ] = str (affected_versions )
345+ if isinstance (fixed_versions , VersionRange ):
346+ resource ["normalized_fixed_versions" ] = str (fixed_versions )
343347
344348
345349def prettyprint (purl , datasources , pagination , no_threading ):
@@ -363,17 +367,6 @@ def prettyprint(purl, datasources, pagination, no_threading):
363367 pydoc .pager (metadata + table .draw ()) if pagination else click .echo (metadata + table .draw ())
364368
365369
366- NORMALIZED_VERSION_RANGE_BY_DATASOURCE = {
367- "deps" : NormalizedVersionRanges .from_discrete ,
368- "github" : NormalizedVersionRanges .from_github ,
369- "gitlab" : NormalizedVersionRanges .from_gitlab ,
370- "oss_index" : None ,
371- "osv" : NormalizedVersionRanges .from_discrete ,
372- "snyk" : NormalizedVersionRanges .from_snyk ,
373- "vulnerablecode" : NormalizedVersionRanges .from_discrete ,
374- }
375-
376-
377370def group_by_cve (vulnerabilities , purl , no_compare ):
378371 grouped_by_cve = {}
379372 nocve = {}
@@ -382,32 +375,20 @@ def group_by_cve(vulnerabilities, purl, no_compare):
382375 if not advisories :
383376 if datasource not in noadvisory :
384377 noadvisory [datasource ] = []
385- noadvisory [datasource ].append (
386- {
387- "advisory" : None ,
388- }
389- )
378+ noadvisory [datasource ].append ({"advisory" : None })
390379 for advisory in advisories :
391380 cve = next ((x for x in advisory .aliases if x .startswith ("CVE" )), None )
392381 if not cve :
393382 if datasource not in nocve :
394383 nocve [datasource ] = []
395- nocve [datasource ].append (
396- {
397- "advisory" : advisory ,
398- }
399- )
384+ nocve [datasource ].append ({"advisory" : advisory })
400385 continue
401386 if cve not in grouped_by_cve :
402387 grouped_by_cve [cve ] = {}
403388
404389 if datasource not in grouped_by_cve [cve ]:
405390 grouped_by_cve [cve ][datasource ] = []
406- grouped_by_cve [cve ][datasource ].append (
407- {
408- "advisory" : advisory ,
409- }
410- )
391+ grouped_by_cve [cve ][datasource ].append ({"advisory" : advisory })
411392 grouped_by_cve ["NOCVE" ] = nocve
412393 grouped_by_cve ["NOADVISORY" ] = noadvisory
413394 if not no_compare :
@@ -418,7 +399,6 @@ def group_by_cve(vulnerabilities, purl, no_compare):
418399
419400def normalize_version_ranges (grouped_by_cve , purl ):
420401 package_versions = get_all_versions (purl )
421-
422402 for cve , value in grouped_by_cve .items ():
423403 if cve in ("NOCVE" , "NOADVISORY" ):
424404 continue
@@ -427,20 +407,24 @@ def normalize_version_ranges(grouped_by_cve, purl):
427407 advisory = resource ["advisory" ]
428408 normalized_affected_versions = []
429409 normalized_fixed_versions = []
430- datasource_normalizer = NORMALIZED_VERSION_RANGE_BY_DATASOURCE .get (datasource )
431- if datasource_normalizer and advisory .affected_versions :
410+ version_range_func = VERSION_RANGE_BY_DATASOURCE .get (datasource )
411+ if version_range_func and advisory .affected_versions :
412+ affected = advisory .affected_versions
413+ if len (affected ) == 1 :
414+ affected = affected [0 ]
415+
432416 try :
433- normalized_affected_versions = datasource_normalizer (
434- advisory .affected_versions , purl .type , package_versions
435- )
417+ vra = version_range_func (purl .type , affected )
418+ normalized_affected_versions = vra .normalize (package_versions )
436419 except Exception as err :
437420 normalized_affected_versions = [err ]
438421
439422 if advisory .fixed_versions :
440423 try :
441- normalized_fixed_versions = NormalizedVersionRanges . from_discrete (
442- advisory . fixed_versions , purl .type , package_versions
424+ vrf = get_range_from_discrete_version_string (
425+ purl .type , advisory . fixed_versions
443426 )
427+ normalized_fixed_versions = vrf .normalize (package_versions )
444428 except Exception as err :
445429 normalized_fixed_versions = [err ]
446430
@@ -449,35 +433,37 @@ def normalize_version_ranges(grouped_by_cve, purl):
449433
450434
451435def compare (grouped_by_cve ):
452- for cve , value in grouped_by_cve .items ():
436+ for cve , advisories in grouped_by_cve .items ():
453437 if cve in ("NOCVE" , "NOADVISORY" ):
454438 continue
455- sources = list (value .keys ())
439+ sources = list (advisories .keys ())
456440 board = {source : {} for source in sources }
457- """
458- A typical board after comparison may look like this.
459-
460- board = {
461- "github":{
462- "snyk": 0,
463- "gitlab": 1,
464- "deps": 0,
465- "vulnerablecode": 1,
466- "osv": 1,
467- "oss_index": 1,
468- },
469- "snyk":{
470- "github": 0,
471- "gitlab": 1,
472- "deps": 0,
473- "vulnerablecode": 1,
474- "osv": 1,
475- "oss_index": 1,
476- },
477- ...
478- }
479- """
480- for datasource , resources in value .items ():
441+
442+ # For each unique CVE create the scoring board to score
443+ # advisory from different datasources.
444+ # A typical board after comparison may look like this.
445+
446+ # board = {
447+ # "github":{
448+ # "snyk": 0,
449+ # "gitlab": 1,
450+ # "deps": 0,
451+ # "vulnerablecode": 1,
452+ # "osv": 1,
453+ # "oss_index": 1,
454+ # },
455+ # "snyk":{
456+ # "github": 0,
457+ # "gitlab": 1,
458+ # "deps": 0,
459+ # "vulnerablecode": 1,
460+ # "osv": 1,
461+ # "oss_index": 1,
462+ # },
463+ # ...
464+ # }
465+
466+ for datasource , resources in advisories .items ():
481467 normalized_affected_versions_a = get_item (resources , 0 , "normalized_affected_versions" )
482468 normalized_fixed_versions_a = get_item (resources , 0 , "normalized_fixed_versions" )
483469 if normalized_fixed_versions_a and normalized_affected_versions_a :
@@ -489,28 +475,31 @@ def compare(grouped_by_cve):
489475 ):
490476 continue
491477 normalized_affected_versions_b = get_item (
492- value , source , 0 , "normalized_affected_versions"
478+ advisories , source , 0 , "normalized_affected_versions"
493479 )
494480 normalized_fixed_versions_b = get_item (
495- value , source , 0 , "normalized_fixed_versions"
481+ advisories , source , 0 , "normalized_fixed_versions"
496482 )
497483 board [datasource ][source ] = 0
498484 board [source ][datasource ] = 0
499- if (
500- normalized_fixed_versions_a == normalized_fixed_versions_b
501- and normalized_affected_versions_a == normalized_affected_versions_b
502- ):
503- board [datasource ][source ] = 1
504- board [source ][datasource ] = 1
505-
506- maximum = max ([sum (list (table .values ())) for table in board .values ()])
485+ if normalized_fixed_versions_a == normalized_fixed_versions_b :
486+ board [datasource ][source ] += 0.5
487+ board [source ][datasource ] += 0.5
488+ elif normalized_affected_versions_a == normalized_affected_versions_b :
489+ board [datasource ][source ] += 0.5
490+ board [source ][datasource ] += 0.5
491+
492+ # Compute the relative score from the score board for each advisory.
493+ maximum = max ([sum (table .values ()) for table in board .values ()])
507494 datasource_count = len (sources )
508495 for datasource , table in board .items ():
509496 if maximum == 0 :
510- # NA if only one advisory else TC aka `Total Collision`.
511- value [datasource ][0 ]["score" ] = "TC" if datasource_count > 1 else "NA"
497+ # NA if only one advisory and nothing to compare with.
498+ # TC (Total Collision) i.e no two advisory agree on common fixed or affected version.
499+ advisories [datasource ][0 ]["score" ] = "TC" if datasource_count > 1 else "NA"
512500 continue
513- value [datasource ][0 ]["score" ] = (sum (list (table .values ())) / maximum ) * 100
501+ datasource_score = (sum (table .values ()) / maximum ) * 100
502+ advisories [datasource ][0 ]["score" ] = datasource_score
514503
515504
516505def prettyprint_group_by_cve (purl , datasources , pagination , no_threading , vers , no_compare ):
@@ -535,37 +524,21 @@ def prettyprint_group_by_cve(purl, datasources, pagination, no_threading, vers,
535524 if not no_compare and vers and "score" in resources [0 ]:
536525 na_affected = get_item (resources , 0 , "normalized_affected_versions" )
537526 na_fixed = get_item (resources , 0 , "normalized_fixed_versions" )
538- na_affected = (
539- na_affected .version_ranges
540- if isinstance (na_affected , NormalizedVersionRanges )
541- else na_affected
542- )
543- na_fixed = (
544- na_fixed .version_ranges
545- if isinstance (na_fixed , NormalizedVersionRanges )
546- else na_fixed
547- )
548- na_affected = "\n " .join ([str (i ) for i in na_affected ])
549- na_fixed = "\n " .join ([str (i ) for i in na_fixed ])
550527 table .add_row (["" , "" , "" , na_affected , na_fixed , "" ])
551528
552529 pydoc .pager (metadata + table .draw ()) if pagination else click .echo (metadata + table .draw ())
553530
554531
555- def strip_leading_v (version ):
556- if version .startswith ("v" ):
557- return version [1 :]
558- return version
559-
560-
561532def get_texttable (no_group = False , no_compare = False ):
562533 quantum = 100 / 125
563534 terminal_width = os .get_terminal_size ().columns
564535 line_factor = terminal_width / 100
565536
566- column_5x = math .floor (5 * quantum * line_factor )
567- column_15x = math .floor (15 * quantum * line_factor )
568- column_20x = math .floor (20 * quantum * line_factor )
537+ column_size = lambda f : math .floor (f * quantum * line_factor )
538+ column_7x = column_size (5 )
539+ column_17x = column_size (10 )
540+ column_15x = column_size (15 )
541+ column_20x = column_size (20 )
569542
570543 table = Texttable ()
571544
@@ -581,37 +554,44 @@ def get_texttable(no_group=False, no_compare=False):
581554 table .set_cols_dtype (["a" , "a" , "a" , "a" , "a" ])
582555 table .set_cols_align (["l" , "l" , "l" , "l" , "l" ])
583556 table .set_cols_valign (["t" , "t" , "t" , "a" , "t" ])
584- table .set_cols_width ([column_20x , column_15x , column_20x , column_20x , column_20x ])
557+ table .set_cols_width ([column_15x , column_15x , column_20x , column_20x , column_20x ])
585558 table .header (["CVE" , "DATASOURCE" , "ALIASES" , "AFFECTED" , "FIXED" ])
586559 return table
587560
588561 table .set_cols_dtype (["a" , "a" , "a" , "a" , "a" , "a" ])
589562 table .set_cols_align (["l" , "l" , "l" , "l" , "l" , "l" ])
590563 table .set_cols_valign (["t" , "t" , "t" , "a" , "t" , "t" ])
591- table .set_cols_width ([column_20x , column_15x , column_20x , column_20x , column_20x , column_5x ])
564+ table .set_cols_width ([column_17x , column_15x , column_15x , column_20x , column_20x , column_7x ])
592565 table .header (["CVE" , "DATASOURCE" , "ALIASES" , "AFFECTED" , "FIXED" , "SCORE" ])
593566
594567 return table
595568
596569
597- def get_all_versions (purl : PackageURL ):
598- if purl .type not in VERSION_API_CLASSES_BY_PACKAGE_TYPE :
599- return
570+ def get_range_from_discrete_version_string (schema , versions ):
571+ range_cls = RANGE_CLASS_BY_SCHEMES .get (schema )
572+ if isinstance (versions , str ):
573+ versions = [versions ]
574+ return range_cls .from_versions (versions )
600575
601- versionAPI = None
602- package_name = None
603576
604- if purl .type == "maven" :
605- package_name = f"{ purl .namespace } :{ purl .name } "
606- if purl .type in ("composer" , "golang" , "github" ):
607- package_name = f"{ purl .namespace } /{ purl .name } "
608- if purl .type in ("nuget" , "pypi" , "gem" , "npm" , "hex" , "deb" , "cargo" ):
609- package_name = purl .name
577+ VERSION_RANGE_BY_DATASOURCE = {
578+ "deps" : get_range_from_discrete_version_string ,
579+ "github" : build_range_from_github_advisory_constraint ,
580+ "gitlab" : from_gitlab_native ,
581+ "oss_index" : None ,
582+ "osv" : get_range_from_discrete_version_string ,
583+ "snyk" : build_range_from_snyk_advisory_string ,
584+ "safetydb" : build_range_from_snyk_advisory_string ,
585+ "vulnerablecode" : get_range_from_discrete_version_string ,
586+ }
587+
610588
611- versionAPI = VERSION_API_CLASSES_BY_PACKAGE_TYPE .get (purl .type )()
612- all_versions = versionAPI .fetch (package_name )
589+ def get_all_versions (purl : PackageURL ):
590+ if purl .type not in package_versions .SUPPORTED_ECOSYSTEMS :
591+ return
613592
614- return [strip_leading_v (package_version .value ) for package_version in all_versions ]
593+ all_versions = package_versions .versions (str (purl ))
594+ return [package_version .value for package_version in all_versions ]
615595
616596
617597if __name__ == "__main__" :
0 commit comments