88#
99
1010
11- import json
11+ import logging
1212from collections import Counter
1313from collections import defaultdict
1414from itertools import chain
15+ from traceback import format_exc as traceback_format_exc
16+ from typing import List
1517
1618from aboutcode .pipeline import LoopProgress
1719from django .db .models import Prefetch
2628from vulnerabilities .models import ToDoRelatedAdvisoryV2
2729from vulnerabilities .pipelines import VulnerableCodePipeline
2830from vulnerabilities .pipes .advisory import advisories_checksum
31+ from vulnerabilities .severity_systems import SCORING_SYSTEMS
2932from vulnerabilities .utils import canonical_value
3033from vulnerabilities .utils import normalize_text
3134from vulnerabilities .utils import sha256_digest
@@ -40,7 +43,8 @@ class ComputeToDo(VulnerableCodePipeline):
4043 def steps (cls ):
4144 return (
4245 cls .compute_individual_advisory_todo ,
43- cls .detect_conflicting_advisories ,
46+ cls .detect_conflicting_package_versions ,
47+ cls .detect_conflicting_cvss_scores ,
4448 )
4549
4650 def compute_individual_advisory_todo (self ):
@@ -107,7 +111,7 @@ def compute_individual_advisory_todo(self):
107111 f"Successfully created { new_todos_count } ToDos for missing summary, affected and fixed packages"
108112 )
109113
110- def detect_conflicting_advisories (self ):
114+ def detect_conflicting_package_versions (self ):
111115 """
112116 Create ToDos for advisories with conflicting opinions on fixed and affected
113117 package versions for a vulnerability.
@@ -276,6 +280,343 @@ def detect_conflicting_advisories(self):
276280 )
277281 self .log (f"Summary of unfurled PURLs: \n { unfurled_purl_summary } " )
278282
283+ def detect_conflicting_cvss_scores (self ):
284+ """
285+ Create ToDos for advisories with conflicting opinions on CVSS for a vulnerability.
286+
287+ Detect advisory that affect the same set of packages
288+ but have different severity scores reported by different advisories.
289+ """
290+ advisory_relation_to_create = {}
291+ todo_to_create = []
292+ new_todos_count = 0
293+ batch_size = 1
294+ total_count_conflicting_advisory = 0
295+ total_cvss_conflict_count = 0
296+ total_successfully_compared_advisory_count = 0
297+ existing_todo_ids = set (
298+ AdvisoryToDoV2 .objects .values_list ("related_advisories_id" , flat = True )
299+ )
300+
301+ advisory_qs = (
302+ AdvisoryV2 .objects .exclude (
303+ advisory_todos__issue_type = "MISSING_AFFECTED_AND_FIXED_BY_PACKAGES"
304+ )
305+ .todo_excluded ()
306+ .latest_per_avid ()
307+ .distinct ()
308+ .prefetch_related ("impacted_packages" )
309+ )
310+
311+ cve_aliases = AdvisoryAlias .objects .filter (alias__istartswith = "cve" ).prefetch_related (
312+ Prefetch ("advisories" , queryset = advisory_qs , to_attr = "filtered_advisories" )
313+ )
314+ non_cve_aliases = AdvisoryAlias .objects .exclude (alias__istartswith = "cve" ).prefetch_related (
315+ Prefetch ("advisories" , queryset = advisory_qs , to_attr = "filtered_advisories" )
316+ )
317+ aliases_count = cve_aliases .count () + non_cve_aliases .count ()
318+ progress = LoopProgress (
319+ total_iterations = aliases_count ,
320+ logger = self .log ,
321+ progress_step = 5 ,
322+ )
323+ self .log (f"Detect conflicting CVSS score for { aliases_count } aliases." )
324+ aliases = chain (
325+ cve_aliases .iterator (chunk_size = 50 ),
326+ non_cve_aliases .iterator (chunk_size = 50 ),
327+ )
328+ for alias in progress .iter (aliases ):
329+
330+ advisory_avid_map = {}
331+ advisory_curation_item_map = defaultdict (dict )
332+ adv_purl_map = defaultdict (
333+ lambda : {
334+ "purls" : set (),
335+ "cvssv4" : set (),
336+ "cvssv3" : set (),
337+ "cvssv3.1" : set (),
338+ }
339+ )
340+
341+ advisories_with_common_alias = alias .filtered_advisories or []
342+ known_advisory_ids = [a .id for a in advisories_with_common_alias ]
343+ adv_with_alias_in_adv_id = advisory_qs .filter (advisory_id = alias .alias ).exclude (
344+ id__in = known_advisory_ids
345+ )
346+ if not advisories_with_common_alias and not adv_with_alias_in_adv_id .exists ():
347+ continue
348+
349+ advisories_with_common_alias .extend (adv_with_alias_in_adv_id )
350+ initial_advisory_group_size = len (advisories_with_common_alias )
351+
352+ if initial_advisory_group_size < 2 :
353+ continue
354+
355+ for advisory in advisories_with_common_alias :
356+ advisory_avid_map [advisory .avid ] = advisory
357+ has_cvss_score = False
358+ advisory_map = adv_purl_map [advisory .avid ]
359+ advisory_curation = advisory_curation_item_map [advisory .avid ]
360+ for sv in advisory .severities .all ():
361+ if sv .scoring_system not in ["cvssv4" , "cvssv3" , "cvssv3.1" ]:
362+ continue
363+
364+ cvss_score = sv .value
365+ cvss_vector = {}
366+ if not cvss_score and not sv .scoring_elements :
367+ continue
368+ system = SCORING_SYSTEMS [sv .scoring_system ]
369+ if sv .scoring_elements :
370+ try :
371+ cvss_score = system .compute (sv .scoring_elements )
372+ cvss_obj = system .get_cvss (sv .scoring_elements )
373+ cvss_vector = cvss_obj .original_metrics
374+ except Exception as e :
375+ self .log (
376+ f"Error while computing score for { advisory .avid !s} , { sv .scoring_system } : { e !r} \n { traceback_format_exc ()} " ,
377+ level = logging .ERROR ,
378+ )
379+
380+ advisory_curation [sv .scoring_system ] = {
381+ "advisory_uid" : advisory .avid ,
382+ "vector" : cvss_vector ,
383+ "score" : cvss_score ,
384+ "vector_string" : sv .scoring_elements or "" ,
385+ }
386+ advisory_map [sv .scoring_system ].add (cvss_score )
387+ has_cvss_score = True
388+
389+ if not has_cvss_score :
390+ continue
391+
392+ for impact in advisory .impacted_packages .all ():
393+ base_purl = impact .base_purl
394+
395+ if not (
396+ impact .fixed_by_packages .exists () or impact .affecting_packages .exists ()
397+ ):
398+ continue
399+ advisory_map ["purls" ].add (base_purl )
400+
401+ comparable_adv_map = {
402+ avid : value for avid , value in adv_purl_map .items () if len (value ["purls" ]) > 0
403+ }
404+
405+ cvss_conflict_count , count_conflicting_advisory = check_conflicting_cvss_for_alias (
406+ alias = alias .alias ,
407+ comparable_adv_map = comparable_adv_map ,
408+ advisories = advisory_avid_map ,
409+ advisory_curation_item_map = advisory_curation_item_map ,
410+ todo_to_create = todo_to_create ,
411+ advisory_relation_to_create = advisory_relation_to_create ,
412+ existing_todo_ids = existing_todo_ids ,
413+ )
414+
415+ total_cvss_conflict_count += cvss_conflict_count
416+ total_count_conflicting_advisory += count_conflicting_advisory
417+ total_successfully_compared_advisory_count += initial_advisory_group_size
418+
419+ if len (todo_to_create ) > batch_size :
420+ new_todos_count += bulk_create_with_m2m (
421+ todos = todo_to_create ,
422+ advisories = advisory_relation_to_create ,
423+ logger = self .log ,
424+ )
425+ advisory_relation_to_create .clear ()
426+ todo_to_create .clear ()
427+
428+ new_todos_count += bulk_create_with_m2m (
429+ todos = todo_to_create ,
430+ advisories = advisory_relation_to_create ,
431+ logger = self .log ,
432+ )
433+
434+ self .log (
435+ f"Successfully compared { total_successfully_compared_advisory_count } advisories, created { new_todos_count } new ToDos for { total_cvss_conflict_count } "
436+ f"conflicting CVSS scores related to { total_count_conflicting_advisory } advisories."
437+ )
438+
439+
440+ def check_conflicting_cvss_for_alias (
441+ alias ,
442+ comparable_adv_map ,
443+ advisories ,
444+ advisory_curation_item_map ,
445+ todo_to_create ,
446+ advisory_relation_to_create ,
447+ existing_todo_ids ,
448+ ):
449+ """Add appropriate AdvisoryToDo for conflicting CVSS scores for an vulnerability."""
450+
451+ cvss_versions = {
452+ "cvssv4" : "4.0" ,
453+ "cvssv3" : "3.0" ,
454+ "cvssv3.1" : "3.1" ,
455+ }
456+ adv_by_cvss = defaultdict (dict )
457+ curation_items = []
458+ conflicting_advisories = []
459+
460+ for avid , value in comparable_adv_map .items ():
461+ for cvss_type in ["cvssv4" , "cvssv3" , "cvssv3.1" ]:
462+ if cvss_type not in value or not value [cvss_type ]:
463+ continue
464+
465+ adv_by_cvss [cvss_type ][avid ] = value
466+
467+ for cvss_type , item in adv_by_cvss .items ():
468+ if len (item ) < 2 :
469+ continue
470+
471+ disagreement = compute_cvss_disagreement (item , cvss_type )
472+ if not disagreement or disagreement ["purl_disagreement" ]:
473+ continue
474+
475+ if not disagreement ["cvssv_disagreement" ]:
476+ continue
477+
478+ consensus_metrics = {}
479+ cvss_version = cvss_versions [cvss_type ]
480+ vectors = []
481+ for avid in item :
482+ metric = advisory_curation_item_map [avid ][cvss_type ]
483+ if metric ["vector" ]:
484+ vectors .append (metric ["vector" ])
485+
486+ if len (vectors ) == len (item ):
487+ consensus_metrics = consensus_cvss_metrics (vectors , cvss_version )
488+
489+ conflicting_advisories .extend ([advisories [avid ] for avid in item ])
490+ packages = disagreement ["purl_union" ]
491+ noun = "package" if len (packages ) == 1 else "packages"
492+
493+ conflict_message = f"Conflicting severity scores for { noun } : " f"{ ', ' .join (packages )} "
494+ conflict_item = {
495+ "cvss" : cvss_version ,
496+ "conflict_reason" : conflict_message ,
497+ "partial_cvss_curation" : consensus_metrics ,
498+ "advisories" : get_grouped_advisory_curation (
499+ advisory_curation_item_map , cvss_type , advisories , item .keys ()
500+ ),
501+ }
502+ curation_items .append (conflict_item )
503+
504+ if not curation_items :
505+ return 0 , 0
506+
507+ issue_detail = {
508+ "alias" : alias ,
509+ "curation_items" : curation_items ,
510+ }
511+
512+ todo_id = advisories_checksum (conflicting_advisories )
513+
514+ if todo_id in existing_todo_ids :
515+ return 0 , 0
516+
517+ existing_todo_ids .add (todo_id )
518+ conflicting_advisories_count = len (conflicting_advisories )
519+
520+ date_published = min (
521+ (a .date_published for a in conflicting_advisories if a .date_published ),
522+ default = None ,
523+ )
524+ date_collected = min (
525+ (a .date_collected for a in conflicting_advisories if a .date_collected ),
526+ default = None ,
527+ )
528+ todo = AdvisoryToDoV2 (
529+ related_advisories_id = todo_id ,
530+ issue_type = "CONFLICTING_SEVERITY_SCORES" ,
531+ issue_detail = issue_detail ,
532+ alias = alias ,
533+ advisories_count = conflicting_advisories_count ,
534+ oldest_advisory_date = date_published or date_collected ,
535+ )
536+ todo_to_create .append (todo )
537+ advisory_relation_to_create [todo_id ] = conflicting_advisories
538+
539+ return len (curation_items ), conflicting_advisories_count
540+
541+
542+ def get_grouped_advisory_curation (advisory_curation_item_map , cvss_type , advisories , avids ):
543+ """Group curation advisory based on CVSS vector similarity."""
544+ curation_items = []
545+ vector_group = defaultdict (list )
546+ for count , avid in enumerate (avids ):
547+ vector = advisory_curation_item_map [avid ][cvss_type ]["vector_string" ] or str (count )
548+ vector_group [vector ].append ((avid , advisories [avid ].precedence ))
549+
550+ for avid_precedence in vector_group .values ():
551+ sorted_avids = [x [0 ] for x in sorted (avid_precedence , key = lambda x : x [1 ], reverse = True )]
552+ primary_avid = sorted_avids [0 ]
553+ curation_items .append (
554+ {
555+ "primary" : advisory_curation_item_map [primary_avid ][cvss_type ],
556+ "secondaries" : [advisory_curation_item_map [a ][cvss_type ] for a in sorted_avids [1 :]],
557+ }
558+ )
559+
560+ return curation_items
561+
562+
563+ def consensus_cvss_metrics (cvss_metrics : List [dict ], cvss_version : str ):
564+ """Return consensus CVSS metrics from metrics reported by different advisories."""
565+
566+ # fmt: off
567+ cvss_v3_keys = [
568+ "AV" ,"AC" ,"PR" ,"UI" ,"S" ,"C" ,"I" ,"A" ,"E" ,"RL" ,"RC" ,"MAV" ,"MAC" ,
569+ "MPR" ,"MUI" ,"MS" ,"MC" ,"MI" ,"MA" ,"CR" ,"IR" ,"AR"
570+ ]
571+
572+ cvss_v4_keys = [
573+ "AV" ,"AC" ,"AR" ,"PR" ,"UI" ,"VC" ,"VI" ,"VA" ,"SC" ,"SI" ,"SA" ,"E" ,"S" ,
574+ "AU" ,"R" ,"V" ,"RE" ,"U" ,"MAV" ,"MAC" ,"MAT" ,"MPR" ,"MUI" ,"MVC" ,"MVI" ,
575+ "MVA" ,"MSC" ,"MSI" ,"MSA" ,"CR" ,"IR" ,"AR" ,
576+ ]
577+ # fmt: on
578+
579+ consensus = {}
580+ cvss_keys = cvss_v4_keys if cvss_version == "4.0" else cvss_v3_keys
581+ if not cvss_metrics :
582+ return consensus
583+
584+ for key in cvss_keys :
585+ unique_values = set (d .get (key ) for d in cvss_metrics if d .get (key ))
586+ if len (unique_values ) == 1 :
587+ consensus [key ] = unique_values .pop ()
588+
589+ return consensus
590+
591+
592+ def compute_cvss_disagreement (adv_map , cvss_type ):
593+ """Compute differences in CVSS score across advisories."""
594+
595+ purl_sets = [v ["purls" ] for v in adv_map .values ()]
596+ cvssv_sets = [v [cvss_type ] for v in adv_map .values ()]
597+
598+ disagreement = {
599+ "purl_union" : [],
600+ "purl_disagreement" : [],
601+ "cvssv_disagreement" : [],
602+ }
603+
604+ if not purl_sets :
605+ return {}
606+
607+ purl_union = set ().union (* purl_sets )
608+ purl_intersection = set .intersection (* purl_sets )
609+
610+ disagreement ["purl_union" ] = list (purl_union )
611+ disagreement ["purl_disagreement" ] = list (purl_union - purl_intersection )
612+
613+ if cvssv_sets :
614+ cvssv_union = set ().union (* cvssv_sets )
615+ cvssv_intersection = set .intersection (* cvssv_sets )
616+ disagreement ["cvssv_disagreement" ] = list (cvssv_union - cvssv_intersection )
617+
618+ return disagreement
619+
279620
280621def check_missing_summary (
281622 advisory : AdvisoryV2 ,
0 commit comments