#!/usr/bin/env perl # # daysofrisk.pl # # Generate statistics on "days of risk" for vulnerabilities in Red Hat # products. This is based on the definition of "days of risk" as starting # from when a vulnerability is first made public and ending when Red Hat # release an update to fix that vulnerability. # # Copyright Red Hat # July 2002-2008 mjc@redhat.com # https://www.redhat.com/security/data/metrics/ # # Inputs: # cve_dates.txt -> A mapping of CVE name to severity and date, this # is generated mostly by hand from our vulnerability work # release_dates.txt -> dates we first published a RHSA, generated # partially from RHN partially from archives and partially from hand # rhsamapcpe.txt -> A mapping of RHSA to CVE names, generated automatically # from our errata database. Also maps RHSA to product CPE use strict; use Getopt::Long; use Time::Local; my %options; my $success = GetOptions(\%options, 'distrib=s', 'cpe=s', 'cpelist=s', 'datestart=s', 'dateend=s', 'days=s', 'severity=s', 'xmlsummary=s', 'quiet', 'verbose', 'help'); if (!$success || exists $options{'help'}) { print < [examples: --cpe enterprise_linux --cpe enterprise_linux:3 --cpe enterprise_linux:5::client/firefox --cpe /httpd ] --severity [filter severity, 'C'ritical 'I'mportant 'M'oderate 'L'ow] --datestart [starting date, default is 'all'] --dateend [ending date, default is 'all'] --xmlsummary [output the XML summary to this file, default summary.xml] EOF exit(1); } my $xml_filename = $options{'xmlsummary'} || "summary.xml"; my $filter_distrib = $options{'distrib'}; my $filter_cpe = $options{'cpe'} || "enterprise_linux:5::server"; my $filter_cpelist = $options{'cpelist'}; if ($filter_distrib) { $filter_cpe = ""; # Compatibility with older daysofrisk.pl $filter_cpe = "rhel_application_server".($1?":$1":"") if $filter_distrib =~ m/^appserver(\d*)/; $filter_cpe = "directory_server".($1?":$1":"") if $filter_distrib =~ m/^directory(\d*)/; $filter_cpe = "rhel_extras".($1?":$1":"") if $filter_distrib =~ m/^extras(\d*)/; $filter_cpe = "rhel_application_stack".($1?":$1":"") if $filter_distrib =~ m/^stack(\d*)/; $filter_cpe = "enterprise_linux".($1?":$1":"") if $filter_distrib =~ m/^rhel(\d*)/; $filter_cpe = "all" if $filter_distrib eq "all"; print "** NOTE --distrib $filter_distrib is deprecated, use '--cpe $filter_cpe' instead\n\n"; sleep(1); } $filter_cpe = "" if ($filter_cpe eq "all"); $filter_cpe =~ s|^cpe://||; $filter_cpe =~ s|^cpe:/\S?:||; $filter_cpe = "redhat:".$filter_cpe unless ($filter_cpe =~ m/^redhat:/); # Someone might specify part of a CPE name but add a package, for example # redhat:enterprise_linux/firefox -- find all firefox in RHEL # so we need to make up a cpe regexp my ($cpe_product,$cpe_package) = split(/\//,$filter_cpe); my $filter_cpe_fixed = $filter_cpe; if ($cpe_package) { my ($cpe_os,$cpe_prod,$cpe_ver,$cpe_upd,$cpe_var) = split(/:/,$cpe_product); $cpe_product = join(":",$cpe_os,($cpe_prod?$cpe_prod:".*"),($cpe_upd?$cpe_upd:".*"),($cpe_ver?$cpe_ver:".*"),($cpe_var?$cpe_var:".*")); $filter_cpe_fixed = join("/",$cpe_product,$cpe_package); } my $filter_severity = $options{'severity'} || "all"; my $filter_datestart = $options{'datestart'}; my $filter_dateend = $options{'dateend'}; my $filter_days = $options{'days'}; my $verbose = $options{'verbose'} ? 1:0; #$options{'quiet'}? 0:1; # A user can't be vulnerable to an issue for more days than # the distribution was public in the first place, so this is a # table of release dates # my %releasedate = ("redhat:enterprise_linux:2.1" => "20020517", "redhat:enterprise_linux:3" => "20031022", "redhat:rhel_extras:3" => "20031022", "redhat:enterprise_linux:4" => "20050215", "redhat:rhel_extras:4" => "20050215", "redhat:enterprise_linux:5" => "20070314", "redhat:rhel_extras:5" => "20070314", "redhat:rhel_application_stack:1" => "20060918", "redhat:rhel_application_stack:2" => "20070918", "redhat:certificate_system:7.1" => "20050607", "redhat:certificate_system:7.2" => "20061030", "redhat:certificate_system:7.3" => "20070511", "redhat:directory_server:7.1" => "20050607", "redhat:rhel_application_server:1" => "20040802", "redhat:rhel_application_server:2" => "20050916" ); my %CVE2DATA = load_cve_metadata("cve_dates.txt"); my %RHSA2DATE = load_release_dates("release_dates.txt"); my %CVE2RHSA = load_rhsa_map("rhsamapcpe.txt"); my %RHSA2PRODUCT = load_rhsa_products("rhsamapcpe.txt"); my %CPELIST = load_cpelist($filter_cpelist); open(SUMMARY,">$xml_filename"); print SUMMARY "\n"; my ($number, $maxdays, $total, $advisories) = 0; my @values; my %RHSAseverity; my %filteredSEV; my %filteredRHSA; my $filteredRHSAcount; my %SEVCOUNT; my %seen; if ($filter_days) { if ($filter_datestart) { $filter_dateend = add_days($filter_datestart, $filter_days); } elsif ($filter_dateend) { $filter_datestart = add_days($filter_dateend, -$filter_days); } } if (!$filter_dateend) { my ($x,$x,$x,$d,$m,$y) =localtime(time); $filter_dateend = sprintf("%04d%02d%02d",$y+1900,$m+1,$d); } my $out_datestart = $filter_datestart; my $out_dateend = $filter_dateend; foreach my $can (sort keys %CVE2RHSA) { my ($earliest, $days_difference, $affectedadvisories) = undef; my $days_count = 0; my $count2 = 0; my $cvedate = $CVE2DATA{"public=".$can}; if (!$cvedate) { print "Warning: Missing earliest date for $can ($CVE2RHSA{$can})\n" if $verbose; next; } foreach my $advisory (split(/ /,$CVE2RHSA{$can})) { my $match = 0; foreach my $cpe (split(',',$RHSA2PRODUCT{$advisory})) { if (($filter_cpe_fixed && $cpe =~ m/$filter_cpe_fixed/) || ($CPELIST{$cpe})) { if (!$RHSA2DATE{$advisory}) { print "Warning: Missing release date for $advisory\n" if ($cpe !~ m/:linux|:powertools|:secure_/ && $verbose); last; } my $fromdate = $cvedate; $cpe =~ m|^(redhat:[^:]+:[^(:/)]+)|; my $reldate = $releasedate{$1}; $fromdate = $reldate if ($reldate && ($reldate > $fromdate)); my $days = diff_days($RHSA2DATE{$advisory},$fromdate); $match = $cpe; if (!$days_count || $days_difference > $days) { $days_difference = $days; $earliest = substr($RHSA2DATE{$advisory},0,8); $cvedate = $fromdate; $days_count++; } } } next unless $match; next unless ((!$filter_datestart || ($RHSA2DATE{$advisory} >= $filter_datestart)) && (!$filter_dateend || ($RHSA2DATE{$advisory} <= $filter_dateend))); $count2++; # Work out the worst severity rating for this RHSA if (!$seen{$advisory}) { $advisories++; $seen{$advisory}=1; } if ($affectedadvisories !~ m/$advisory/) { $affectedadvisories.=" " unless !$affectedadvisories; $affectedadvisories.=$advisory; } my $cve2impact = $CVE2DATA{"impact=".$can."-".$filter_distrib} || $CVE2DATA{"impact=".$can."-".$advisory} || $CVE2DATA{"impact=".$can}; if (!$RHSAseverity{$advisory}) { $RHSAseverity{$advisory} = $cve2impact; } else { my $s1 = index("LMIC",$RHSAseverity{$advisory}); my $s2 = index("LMIC",$cve2impact); $RHSAseverity{$advisory} = $cve2impact if ($s2>$s1); } } next unless $earliest; if (!$CVE2DATA{"impact=".$can} && $verbose) { print "Warning: No severity for $can\n"; } if ($count2) { my $flawtype; $CVE2RHSA{$can} =~ s/([\s\r\n])$//; # This foreach below is needed for the case where an issue has # a different severity for one distribution than another, for # example if something is caught by FORTIFY_SOURCE. We need # to work out what the impact of the CVE is, but only looking # at those advisories for which we care about my $cveseverity = -1; foreach my $tempadv (split(/ /,$affectedadvisories)) { my $cve2impact = $CVE2DATA{"impact=".$can."-".$filter_distrib} || $CVE2DATA{"impact=".$can."-".$tempadv} || $CVE2DATA{"impact=".$can}; my $s1 = index("LMIC",$cveseverity); my $s2 = index("LMIC",$cve2impact); $cveseverity = $cve2impact if ($s2>$s1); } if ($filter_severity eq "all" || ($cveseverity && $filter_severity =~ m/[$cveseverity]/)) { # Severity is within our filter if ((!$filter_datestart || ($earliest >= $filter_datestart)) && (!$filter_dateend || ($earliest <= $filter_dateend)) || 1) { $out_datestart = $earliest if (!$out_datestart || $out_datestart > $earliest); $out_dateend = $earliest if (!$out_dateend || $out_dateend < $earliest); $number++; $days_difference = 0 if ($days_difference <0) ; push(@values,$days_difference); $total += $days_difference; $maxdays = $days_difference if ($maxdays < $days_difference); $SEVCOUNT{$cveseverity}++; print SUMMARY "\n"; print SUMMARY " $can\n"; print SUMMARY " $cveseverity\n"; foreach my $tempadv (split(/ /,$affectedadvisories)) { print SUMMARY " $tempadv\n"; next if $filteredRHSA{$tempadv}; $filteredRHSA{$tempadv}=1; $filteredRHSAcount++; } print SUMMARY " $earliest\n"; print SUMMARY " $cvedate\n"; print SUMMARY " $days_difference\n"; print SUMMARY "\n\n"; } } } } my $prod = $CPELIST{'description'}; $prod = cpe_to_text($filter_cpe) unless $prod; my $sev = severity_to_text($filter_severity); $filter_cpe = "cpe:/:".$filter_cpe unless ($filter_cpe =~ m/^from/); print SUMMARY "$prod$filter_cpe$sev$out_datestart$filter_dateend\n"; print "Matched $advisories advisories before filtering\n" if $verbose; print "After filtering, all days: ".join(" ",sort({$a<=>$b}@values))."\n\n" if $verbose; my @stat; print "** Product: $prod\n"; print "** CPE: $filter_cpe\n"; print "** Severity: $sev\n"; $filter_datestart = $releasedate{$filter_distrib} unless ($filter_datestart); $filter_datestart = $out_datestart unless ($filter_datestart); my $summary_days = diff_days($filter_dateend,$filter_datestart); print "** Dates: $filter_datestart - $filter_dateend ($summary_days days)\n\n"; printf "** $filteredRHSAcount advisories ("; foreach my $tempadv (keys %filteredRHSA) { $filteredSEV{$RHSAseverity{$tempadv}}++; } foreach my $sev (sort keys %filteredSEV) { print $sev?$sev:"unknown"; print "=".$filteredSEV{$sev}." "; } print ")\n"; print "** $number vulnerabilities ("; foreach my $sev (sort keys %SEVCOUNT) { print $sev?$sev:"unknown"; print"=".$SEVCOUNT{$sev}." "; } print ")\n"; if ($summary_days != 0) { my $workload = ($SEVCOUNT{"C"}+$SEVCOUNT{"I"}+$SEVCOUNT{"M"}/5+$SEVCOUNT{"L"}/20)/$summary_days; my $advworkload = ($filteredSEV{"C"}+$filteredSEV{"I"}+$filteredSEV{"M"}/5+$filteredSEV{"L"}/20)/$summary_days; printf "** Advisory Workload index is %.2f\n", $advworkload; printf "** Vulnerability Workload index is %.2f\n", $workload; } print "** Average is ".int($total/$number+.5)." days\n" unless ($number==0); print "** Median is ".median(@values)." days\n"; print SUMMARY "\n"; print SUMMARY "$number".int($total/$number+.5)."".median(@values)."" unless ($number ==0); for my $date (@values) { for (my $i=90; $i >= $date; $i--) { $stat[$i]++; } } if ($number != 0) { print "** ".int($stat[0]/$number*100)."% were 0day\n"; print "** ".int($stat[1]/$number*100)."% were within 1 day\n"; print SUMMARY "".int($stat[1]/$number*100)."\n"; print "** ".int($stat[7]/$number*100)."% were within 7 days\n"; print "** ".int($stat[14]/$number*100)."% were within 14 days\n"; print "** ".int($stat[31]/$number*100)."% were within 31 days\n"; print "** ".int($stat[90]/$number*100)."% were within 90 days\n"; } print SUMMARY "\n"; exit; ############################################################ # # Quick hacks to load the various files we want to parse. # In some cases we load the same file twice and parse it # different ways, this is historical since sometimes the # data came from different files and got merged in 2004 # sub load_release_dates { my ($filename) = (@_); my %DATE; my $x = 0; open(RD,"<$filename") || die "$filename missing. $!"; while() { chop; my ($rhsa,$date)=split(/ /); next if ($DATE{$rhsa} && ($DATE{$rhsa} < $date)); $DATE{$rhsa} = $date; $x++; } close(RD); print "Loaded ".($x+1)." Red Hat release dates from $filename\n" if $verbose; return %DATE; } sub load_rhsa_map { my ($filename) = (@_); open(RD,"<$filename") || die "$filename missing. $!"; my %MAP; my $x = 0; while() { my ($rhsa,$cvenames,$cpenames)=split(' '); $cvenames =~ s/CAN-/CVE-/g; foreach my $cve (split(',',$cvenames)) { $MAP{$cve} .= $rhsa." "; } $x++; } close(RD); print "Loaded ".($x+1)." RHSA with CVE mappings from $filename\n" if $verbose; return %MAP; } sub load_rhsa_products { my ($filename) = (@_); open(RD,"<$filename") || die "$filename missing. $!"; my %MAP; my $x = 0; while() { chop; my ($rhsa,$cvenames,$cpenames)=split(' '); $cpenames =~ s|cpe:/\S?:||g; # CPE v2 style names $cpenames =~ s|cpe://||g; # old CPE style names $MAP{$rhsa} = $cpenames; $x++; } close(RD); print "Loaded ".($x+1)." RHSA to product mappings from $filename\n" if $verbose; return %MAP; } sub load_cpelist { my ($filename) = (@_); return unless $filename; open(RD,"<$filename") || die "$filename missing. $!"; my %MAP; my $x = 0; $MAP{'description'} = ; while() { chop; s|cpe:/\S?:||g; # CPE v2 style names s|cpe://||g; # old CPE style names $MAP{$_} = 1; $x++; } close(RD); $filter_cpe = "from file $filename"; $filter_cpe_fixed = ""; print "Loaded ".($x+1)." CPE names to filter from $filename\n" if $verbose; return %MAP; } sub load_cve_metadata { my ($filename) = (@_); open(CVE,"<$filename") || die "$filename missing. $!"; my %CVE; my $x = 0; while() { s/CAN/CVE/; next unless (my ($rhsa,$data) = m/^(CVE-\d{4}-\d+\S*)\s*(.*)/); foreach my $segment (split(/,/,$data)) { my ($name,$value) = split(/=/,$segment); $value =~ tr/a-z/A-Z/; $value = substr($value, 0, 1) if ($name =~ m/impact/i); $value = substr($value, 0, 8) if ($name =~ m/public/i); $CVE{$name."=".$rhsa}=$value; } $x++; } close(CVE); print "Loaded ".($x+1)." CVE metadata from $filename\n" if $verbose; return %CVE; } # Other useful routines to save requiring more modules for trivial things sub median { my(@values) = sort({$a <=>$b} @_); my($mid) = scalar(@values) >> 1; return ($values[$mid]) if $mid & 1; return ($values[$mid-.5] + $values[$mid+.5]) / 2; } # Dates will be YYYYMMDD or YYYYMMDD:hhmm sub diff_days { my ($date1, $date2) = (@_); return unless $date1 and $date2; my $time1 = timegm(0,0,0,substr($date1,6,2),substr($date1,4,2)-1, substr($date1,0,4)); my $time2 = timegm(0,0,0,substr($date2,6,2),substr($date2,4,2)-1, substr($date2,0,4)); return int(($time1 - $time2) / 86400); } # Add days... YYYYMMDD + days = YYYYMMDD sub add_days { my ($date1, $days) = @_; my $time1 = timegm(0,0,0,substr($date1,6,2), substr($date1,4,2)-1, substr($date1,0,4)); $time1 += $days * 86400; my ($x,$x,$x,$d,$m,$y) = gmtime($time1); return sprintf("%04d%02d%02d",$y+1900,$m+1,$d); } # Convert the CPE to something more human readable (even if not 100% # correct full-text product name sub cpe_to_text { my ($cpe_product,$cpe_package) = split(/\//,@_[0]); my ($cpe_os,$cpe_prod,$cpe_ver,$cpe_upd,$cpe_var) = split(/:/,$cpe_product); $cpe_os =~ s/redhat/Red Hat/; $cpe_prod =~ s/rhel_//; $cpe_prod =~ s/_/ /g; $cpe_prod =~ s/\b(\w)/\U$1/g; $cpe_ver = "(all versions)" unless $cpe_ver; $cpe_prod = "(all products)" unless $cpe_prod; if ($cpe_package) { $cpe_package = "(package ".$cpe_package.")"; } else { $cpe_package = "(all packages)"; } my $prod = join(' ',$cpe_os,$cpe_prod,$cpe_ver,$cpe_upd,$cpe_var,$cpe_package); $prod =~ s/\s+/\ /g; return $prod; } # Severity short to human readable sub severity_to_text { my ($sev) = @_; $sev =~ s/all/CIML/; $sev =~ s/C/Critical /; $sev =~ s/I/Important /; $sev =~ s/M/Moderate /; $sev =~ s/L/Low /; return $sev; }