From: Israel Madueme Date: Tue, 23 Oct 2018 14:50:33 +0000 (-0400) Subject: Bug 1496057 - Update Security Bugs Report X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=2720770bec52e736db14992917ea476b6f918c4f;p=thirdparty%2Fbugzilla.git Bug 1496057 - Update Security Bugs Report --- diff --git a/Bugzilla/Config/Reports.pm b/Bugzilla/Config/Reports.pm index 26c5aad57..0435f6fc5 100644 --- a/Bugzilla/Config/Reports.pm +++ b/Bugzilla/Config/Reports.pm @@ -29,9 +29,9 @@ sub get_param_list { default => 'bugzilla-admin@mozilla.org' }, { - name => 'report_secbugs_products', + name => 'report_secbugs_teams', type => 'l', - default => '[]' + default => '{}' }, ); } diff --git a/Bugzilla/Report/SecurityRisk.pm b/Bugzilla/Report/SecurityRisk.pm index 5eb98fd7f..0c4c1ef2c 100644 --- a/Bugzilla/Report/SecurityRisk.pm +++ b/Bugzilla/Report/SecurityRisk.pm @@ -13,109 +13,149 @@ use MooX::StrictConstructor; use Bugzilla::Error; use Bugzilla::Status qw(is_open_state); -use Bugzilla::Util qw(datetime_from); +use Bugzilla::Util qw(datetime_from diff_arrays); use Bugzilla; + +use Chart::Clicker; +use Chart::Clicker::Axis::DateTime; +use Chart::Clicker::Data::Series; +use Chart::Clicker::Data::DataSet; +use Chart::Clicker::Renderer::Point; +use Chart::Clicker::Renderer::StackedArea; + use DateTime; -use List::Util qw(any first sum); +use JSON::PP::Boolean; +use List::Util qw(any first sum uniq); +use Mojo::File qw(tempfile); use POSIX qw(ceil); use Type::Utils; -use Types::Standard qw(Num Int Bool Str HashRef ArrayRef CodeRef Map Dict Enum); +use Types::Standard qw(Num Int Bool Str HashRef ArrayRef CodeRef Maybe Map Dict Enum Optional Object); -my $DateTime = class_type { class => 'DateTime' }; +my $DateTime = class_type {class => 'DateTime'}; +my $JSONBool = class_type {class => 'JSON::PP::Boolean'}; -has 'start_date' => ( - is => 'ro', - required => 1, - isa => $DateTime, -); +has 'start_date' => (is => 'ro', required => 1, isa => $DateTime,); -has 'end_date' => ( - is => 'ro', - required => 1, - isa => $DateTime, -); +has 'end_date' => (is => 'ro', required => 1, isa => $DateTime,); -has 'products' => ( - is => 'ro', - required => 1, - isa => ArrayRef [Str], +# The teams are loaded from an admin parameter containing JSON, e.g.: +# { +# "Plugins": { +# "Core": { +# "all_components": false, +# "prefixed_components": ["Plugin"], +# "named_components": [ +# "Plug-ins" +# ] +# }, +# "Plugins": { "all_components": true }, +# "External Software Affecting Firefox": { "all_components": true } +# }, +# ... +# } +# This will create a new team ("Plugins") which groups bugs in the following components: +# - All components in the Core product that start with "Plugin" (e.g. Plugin:FlashPlayer) +# - The single "Plug-ins" component in the Core product. +# - All components in the Plugins _product_. +# - All components in the External Software Affecting Firefox product. +has 'teams' => ( + is => 'ro', + required => 1, + isa => HashRef [ + HashRef [ + Dict [ + all_components => $JSONBool, + prefixed_components => Optional [ArrayRef [Str]], + named_components => Optional [ArrayRef [Str]], + ], + ], + ], ); -has 'sec_keywords' => ( - is => 'ro', - required => 1, - isa => ArrayRef [Str], -); +has 'sec_keywords' => (is => 'ro', required => 1, isa => ArrayRef [Str],); -has 'initial_bug_ids' => ( - is => 'lazy', - isa => ArrayRef [Int], -); +has 'products' => (is => 'lazy', isa => ArrayRef [Str],); + +has 'initial_bug_ids' => (is => 'lazy', isa => ArrayRef [Int],); has 'initial_bugs' => ( - is => 'lazy', - isa => HashRef [ - Dict [ - id => Int, - product => Str, - sec_level => Str, - is_open => Bool, - created_at => $DateTime, - ], + is => 'lazy', + isa => HashRef [ + Dict [ + id => Int, + product => Str, + component => Str, + team => Maybe [Str], + sec_level => Str, + status => Str, + is_stalled => Bool, + is_open => Bool, + created_at => $DateTime, ], + ], ); -has 'check_open_state' => ( - is => 'ro', - isa => CodeRef, - default => sub { return \&is_open_state; }, -); +has 'check_open_state' => (is => 'ro', isa => CodeRef, default => sub { return \&is_open_state; },); has 'events' => ( - is => 'lazy', - isa => ArrayRef [ - Dict [ - bug_id => Int, - bug_when => $DateTime, - field_name => Enum [qw(bug_status keywords)], - removed => Str, - added => Str, - ], + is => 'lazy', + isa => ArrayRef [ + Dict [ + bug_id => Int, + bug_when => $DateTime, + field_name => Enum [qw(bug_status keywords)], + removed => Str, + added => Str, ], + ], ); has 'results' => ( - is => 'lazy', - isa => ArrayRef [ - Dict [ - date => $DateTime, - bugs_by_product => HashRef [ - Dict [ - open => ArrayRef [Int], - closed => ArrayRef [Int], - median_age_open => Num - ] - ], - bugs_by_sec_keyword => HashRef [ - Dict [ - open => ArrayRef [Int], - closed => ArrayRef [Int], - median_age_open => Num - ] - ], - ], + is => 'lazy', + isa => ArrayRef [ + Dict [ + date => $DateTime, + bugs_by_team => HashRef [Dict [open => ArrayRef [Int], closed => ArrayRef [Int], median_age_open => Num]], + bugs_by_sec_keyword => HashRef [Dict [open => ArrayRef [Int], closed => ArrayRef [Int], median_age_open => Num]], ], + ], +); + +has 'deltas' => ( + is => 'lazy', + isa => Dict [ + by_sec_keyword => HashRef [Dict [added => ArrayRef [Int], closed => ArrayRef [Int],],], + by_team => HashRef [Dict [added => ArrayRef [Int], closed => ArrayRef [Int],],], + ], ); +has 'graphs' => ( + is => 'lazy', + isa => Dict [bugs_by_sec_keyword_count => Object, bugs_by_sec_keyword_age => Object, bugs_by_team_age => Object,], +); + +sub _build_products { + my ($self) = @_; + my @products = (); + foreach my $team (values %{$self->teams}) { + foreach my $product (keys %$team) { + push @products, $product; + } + } + @products = uniq @products; + return \@products; +} + sub _build_initial_bug_ids { - # TODO: Handle changes in product (e.g. gravyarding) by searching the events table - # for changes to the 'product' field where one of $self->products is found in - # the 'removed' field, add the related bug id to the list of initial bugs. - my ($self) = @_; - my $dbh = Bugzilla->dbh; - my $products = join ', ', map { $dbh->quote($_) } @{ $self->products }; - my $sec_keywords = join ', ', map { $dbh->quote($_) } @{ $self->sec_keywords }; - my $query = qq{ + +# TODO: Handle changes in product (e.g. gravyarding) by searching the events table +# for changes to the 'product' field where one of $self->products is found in +# the 'removed' field, add the related bug id to the list of initial bugs. + my ($self) = @_; + my $dbh = Bugzilla->dbh; + my $products = join ', ', map { $dbh->quote($_) } @{$self->products}; + my $sec_keywords = join ', ', map { $dbh->quote($_) } @{$self->sec_keywords}; + my $query = qq{ SELECT bug_id FROM @@ -128,39 +168,45 @@ sub _build_initial_bug_ids { keyword.name IN ($sec_keywords) AND product.name IN ($products) }; - return Bugzilla->dbh->selectcol_arrayref($query); + return Bugzilla->dbh->selectcol_arrayref($query); } sub _build_initial_bugs { - my ($self) = @_; - my $bugs = {}; - my $bugs_list = Bugzilla::Bug->new_from_list( $self->initial_bug_ids ); - for my $bug (@$bugs_list) { - $bugs->{ $bug->id } = { - id => $bug->id, - product => $bug->product, - sec_level => ( - # Select the first keyword matching one of the target keywords - # (of which there _should_ only be one found anyway). - first { - my $x = $_; - grep { lc($_) eq lc( $x->name ) } @{ $self->sec_keywords } - } - @{ $bug->keyword_objects } - )->name, - is_open => $self->check_open_state->( $bug->status->name ), - created_at => datetime_from( $bug->creation_ts ), - }; - } - return $bugs; + my ($self) = @_; + my $bugs = {}; + my $bugs_list = Bugzilla::Bug->new_from_list($self->initial_bug_ids); + for my $bug (@$bugs_list) { + my $is_stalled = grep { lc($_->name) eq 'stalled' } @{$bug->keyword_objects}; + $bugs->{$bug->id} = { + id => $bug->id, + product => $bug->product, + component => $bug->component, + team => $self->_find_team($bug->product, $bug->component), + sec_level => ( + + # Select the first keyword matching one of the target keywords + # (of which there _should_ only be one found anyway). + first { + my $x = $_; + grep { lc($_) eq lc($x->name) } @{$self->sec_keywords} + } + @{$bug->keyword_objects} + )->name, + status => $bug->status->name, + is_stalled => scalar $is_stalled, + is_open => $self->_is_bug_open($bug->status->name, scalar $is_stalled), + created_at => datetime_from($bug->creation_ts), + }; + } + return $bugs; } sub _build_events { - my ($self) = @_; - return [] if !(@{$self->initial_bug_ids}); - my $bug_ids = join ', ', @{ $self->initial_bug_ids }; - my $start_date = $self->start_date->ymd('-'); - my $query = qq{ + my ($self) = @_; + return [] if !(@{$self->initial_bug_ids}); + my $bug_ids = join ', ', @{$self->initial_bug_ids}; + my $start_date = $self->start_date->ymd('-'); + my $query = qq{ SELECT bug_id, bug_when, @@ -177,138 +223,287 @@ sub _build_events { AND bug_when >= '$start_date 00:00:00' GROUP BY bug_id , bug_when , field.name }; - my $result = Bugzilla->dbh->selectall_hashref( $query, 'bug_id' ); - my @events = values %$result; - foreach my $event (@events) { - $event->{bug_when} = datetime_from( $event->{bug_when} ); - } + my $result = Bugzilla->dbh->selectall_hashref($query, 'bug_id'); + my @events = values %$result; + foreach my $event (@events) { + $event->{bug_when} = datetime_from($event->{bug_when}); + } - # We sort by reverse chronological order instead of ORDER BY - # since values %hash doesn't guareentee any order. - @events = sort { $b->{bug_when} cmp $a->{bug_when} } @events; - return \@events; + # We sort by reverse chronological order instead of ORDER BY + # since values %hash doesn't guareentee any order. + @events = sort { $b->{bug_when} cmp $a->{bug_when} } @events; + return \@events; } sub _build_results { - my ($self) = @_; - my $e = 0; - my $bugs = $self->initial_bugs; - my @results = (); - - # We must generate a report for each week in the target time interval, regardless of - # whether anything changed. The for loop here ensures that we do so. - for ( my $report_date = $self->end_date; $report_date >= $self->start_date; $report_date->subtract( weeks => 1 ) ) { - # We rewind events while there are still events existing which occured after the start - # of the report week. The bugs will reflect a snapshot of how they were at the start of the week. - # $self->events is ordered reverse chronologically, so the end of the array is the earliest event. - while ( $e < @{ $self->events } - && ( @{ $self->events }[$e] )->{bug_when} > $report_date ) - { - my $event = @{ $self->events }[$e]; - my $bug = $bugs->{ $event->{bug_id} }; - - # Undo bug status changes - if ( $event->{field_name} eq 'bug_status' ) { - $bug->{is_open} = $self->check_open_state->( $event->{removed} ); - } - - # Undo keyword changes - if ( $event->{field_name} eq 'keywords' ) { - my $bug_sec_level = $bug->{sec_level}; - if ( $event->{added} =~ /\b\Q$bug_sec_level\E\b/ ) { - # If the currently set sec level was added in this event, remove it. - $bug->{sec_level} = undef; - } - if ( $event->{removed} ) { - # If a target sec keyword was removed, add the first one back. - my $removed_sec = first { $event->{removed} =~ /\b\Q$_\E\b/ } @{ $self->sec_keywords }; - $bug->{sec_level} = $removed_sec if ($removed_sec); - } - } - - $e++; + my ($self) = @_; + my $e = 0; + my $bugs = $self->initial_bugs; + my @results = (); + +# We must generate a report for each week in the target time interval, regardless of +# whether anything changed. The for loop here ensures that we do so. + for (my $report_date = $self->end_date->clone(); + $report_date >= $self->start_date; $report_date->subtract(weeks => 1)) + { + +# We rewind events while there are still events existing which occured after the start +# of the report week. The bugs will reflect a snapshot of how they were at the start of the week. +# $self->events is ordered reverse chronologically, so the end of the array is the earliest event. + while ($e < @{$self->events} && (@{$self->events}[$e])->{bug_when} > $report_date) { + my $event = @{$self->events}[$e]; + my $bug = $bugs->{$event->{bug_id}}; + + + # Undo bug status changes + if ($event->{field_name} eq 'bug_status') { + $bug->{status} = $event->{removed}; + $bug->{is_open} = $self->_is_bug_open($bug->{status}, $bug->{is_stalled}); + } + + # Undo sec keyword changes + if ($event->{field_name} eq 'keywords') { + my $bug_sec_level = $bug->{sec_level}; + if ($event->{added} =~ /\b\Q$bug_sec_level\E\b/) { + + # If the currently set sec level was added in this event, remove it. + $bug->{sec_level} = undef; + } + if ($event->{removed}) { + + # If a target sec keyword was removed, add the first one back. + my $removed_sec = first { $event->{removed} =~ /\b\Q$_\E\b/ } + @{$self->sec_keywords}; + $bug->{sec_level} = $removed_sec if ($removed_sec); } + } - # Remove uncreated bugs - foreach my $bug_key ( keys %$bugs ) { - if ( $bugs->{$bug_key}->{created_at} > $report_date ) { - delete $bugs->{$bug_key}; - } + # Undo stalled keyword changes + if ($event->{field_name} eq 'keywords') { + if ($event->{added} =~ /\b\stalled\b/) { + + # If the stalled keyword was added in this event, remove it: + $bug->{is_stalled} = 0; + } + if ($event->{removed} =~ /\b\stalled\b/) { + + # If the stalled keyword was removed in this event, add it: + $bug->{is_stalled} = 1; } + $bug->{is_open} = $self->_is_bug_open($bug->{status}, $bug->{is_stalled}); + } + + $e++; + } - # Report! - my $date_snapshot = $report_date->clone(); - my @bugs_snapshot = values %$bugs; - my $result = { - date => $date_snapshot, - bugs_by_product => $self->_bugs_by_product( $date_snapshot, @bugs_snapshot ), - bugs_by_sec_keyword => $self->_bugs_by_sec_keyword( $date_snapshot, @bugs_snapshot ), - }; - push @results, $result; + # Remove uncreated bugs + foreach my $bug_key (keys %$bugs) { + if ($bugs->{$bug_key}->{created_at} > $report_date) { + delete $bugs->{$bug_key}; + } } - return [reverse @results]; + # Report! + my $date_snapshot = $report_date->clone(); + my @bugs_snapshot = values %$bugs; + my $result = { + date => $date_snapshot, + bugs_by_team => $self->_bugs_by_team($date_snapshot, @bugs_snapshot), + bugs_by_sec_keyword => $self->_bugs_by_sec_keyword($date_snapshot, @bugs_snapshot), + }; + push @results, $result; + } + + return [reverse @results]; } -sub _bugs_by_product { - my ( $self, $report_date, @bugs ) = @_; - my $result = {}; - my $groups = {}; - foreach my $product ( @{ $self->products } ) { - $groups->{$product} = []; +sub _build_graphs { + my ($self) = @_; + my $graphs = {}; + my $data = [ + { + id => 'bugs_by_sec_keyword_count', + title => sprintf('Open security bugs by severity (%s to %s)', $self->start_date->ymd, $self->end_date->ymd), + range_label => 'Open Bugs Count', + datasets => [ + map { + my $keyword = $_; + { + name => $_, + keys => [map { $_->{date}->epoch } @{$self->results}], + values => [map { scalar @{$_->{bugs_by_sec_keyword}->{$keyword}->{open}} } @{$self->results}], + } + } @{$self->sec_keywords} + ], + renderer => Chart::Clicker::Renderer::StackedArea->new(opacity => .6), + image_file => tempfile(SUFFIX => '.png'), + }, + { + id => 'bugs_by_sec_keyword_age', + title => sprintf( + 'Median age of open security bugs by severity (%s to %s)', + $self->start_date->ymd, + $self->end_date->ymd + ), + range_label => 'Median Age (days)', + datasets => [ + map { + my $keyword = $_; + { + name => $_, + keys => [map { $_->{date}->epoch } @{$self->results}], + values => [map { $_->{bugs_by_sec_keyword}->{$keyword}->{median_age_open} } @{$self->results}], + } + } @{$self->sec_keywords} + ], + image_file => tempfile(SUFFIX => '.png'), + }, + { + id => 'bugs_by_team_age', + title => + sprintf('Median age of open security bugs by team (%s to %s)', $self->start_date->ymd, $self->end_date->ymd), + range_label => 'Median Age (days)', + datasets => [ + map { + my $team = $_; + { + name => $_, + keys => [map { $_->{date}->epoch } @{$self->results}], + values => [map { $_->{bugs_by_team}->{$team}->{median_age_open} } @{$self->results}], + } + } keys %{$self->teams} + ], + image_file => tempfile(SUFFIX => '.png'), + }, + ]; + + foreach my $datum (@$data) { + my $cc = Chart::Clicker->new; + $cc->title->text($datum->{title}); + $cc->title->font->size(14); + $cc->title->padding->bottom(5); + $cc->title->padding->top(5); + foreach my $dataset (@{$datum->{datasets}}) { + my $series = Chart::Clicker::Data::Series->new( + name => $dataset->{name}, + values => $dataset->{values}, + keys => $dataset->{keys}, + ); + my $ds = Chart::Clicker::Data::DataSet->new(series => [$series]); + $cc->add_to_datasets($ds); } - foreach my $bug (@bugs) { - # We skip over bugs with no sec level which can happen during event rewinding. - if ( $bug->{sec_level} ) { - push @{ $groups->{ $bug->{product} } }, $bug; - } + my $ctx = $cc->get_context('default'); + $ctx->renderer($datum->{renderer}) if exists $datum->{renderer}; + $ctx->range_axis->label($datum->{range_label}); + $ctx->domain_axis( + Chart::Clicker::Axis::DateTime->new(position => 'bottom', orientation => 'horizontal', format => "%m/%d",)); + $cc->write_output($datum->{image_file}); + $graphs->{$datum->{id}} = $datum->{image_file}; + } + + return $graphs; +} + +sub _build_deltas { + my ($self) = @_; + my @teams = keys %{$self->teams}; + my $deltas = {by_team => {}, by_sec_keyword => {}}; + my $data = [ + {domain => \@teams, results_key => 'bugs_by_team', deltas_key => 'by_team',}, + {domain => $self->sec_keywords, results_key => 'bugs_by_sec_keyword', deltas_key => 'by_sec_keyword',} + ]; + + foreach my $datum (@$data) { + foreach my $item (@{$datum->{domain}}) { + my $current_result = $self->results->[-1]->{$datum->{results_key}}->{$item}; + my $last_result = $self->results->[-2]->{$datum->{results_key}}->{$item}; + + my @all_bugs_this_week = (@{$current_result->{open}}, @{$current_result->{closed}}); + my @all_bugs_last_week = (@{$last_result->{open}}, @{$last_result->{closed}}); + + my $added_delta = (diff_arrays(\@all_bugs_this_week, \@all_bugs_last_week))[0]; + my $closed_delta = (diff_arrays($current_result->{closed}, $last_result->{closed}))[0]; + + $deltas->{$datum->{deltas_key}}->{$item} = {added => $added_delta, closed => $closed_delta}; } - foreach my $product ( @{ $self->products } ) { - my @open = map { $_->{id} } grep { ( $_->{is_open} ) } @{ $groups->{$product} }; - my @closed = map { $_->{id} } grep { !( $_->{is_open} ) } @{ $groups->{$product} }; - my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400; } - grep { ( $_->{is_open} ) } @{ $groups->{$product} }; - $result->{$product} = { - open => \@open, - closed => \@closed, - median_age_open => @ages ? _median(@ages) : 0, - }; + } + return $deltas; +} + +sub _bugs_by_team { + my ($self, $report_date, @bugs) = @_; + my $result = {}; + my $groups = {}; + foreach my $team (keys %{$self->teams}) { + $groups->{$team} = []; + } + foreach my $bug (@bugs) { + + # We skip over bugs with no sec level which can happen during event rewinding. + # We also skip over bugs that don't fall into one of the specified teams. + if (defined $bug->{sec_level} && defined $bug->{team}) { + push @{$groups->{$bug->{team}}}, $bug; } + } + foreach my $team (keys %{$self->teams}) { + my @open = map { $_->{id} } grep { ($_->{is_open}) } @{$groups->{$team}}; + my @closed = map { $_->{id} } grep { !($_->{is_open}) } @{$groups->{$team}}; + my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400; } + grep { ($_->{is_open}) } @{$groups->{$team}}; + $result->{$team} = {open => \@open, closed => \@closed, median_age_open => @ages ? _median(@ages) : 0,}; + } - return $result; + return $result; } sub _bugs_by_sec_keyword { - my ( $self, $report_date, @bugs ) = @_; - my $result = {}; - my $groups = {}; - foreach my $sec_keyword ( @{ $self->sec_keywords } ) { - $groups->{$sec_keyword} = []; - } - foreach my $bug (@bugs) { - # We skip over bugs with no sec level which can happen during event rewinding. - if ( $bug->{sec_level} ) { - push @{ $groups->{ $bug->{sec_level} } }, $bug; - } - } - foreach my $sec_keyword ( @{ $self->sec_keywords } ) { - my @open = map { $_->{id} } grep { ( $_->{is_open} ) } @{ $groups->{$sec_keyword} }; - my @closed = map { $_->{id} } grep { !( $_->{is_open} ) } @{ $groups->{$sec_keyword} }; - my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400 } - grep { ( $_->{is_open} ) } @{ $groups->{$sec_keyword} }; - $result->{$sec_keyword} = { - open => \@open, - closed => \@closed, - median_age_open => @ages ? _median(@ages) : 0, - }; + my ($self, $report_date, @bugs) = @_; + my $result = {}; + my $groups = {}; + foreach my $sec_keyword (@{$self->sec_keywords}) { + $groups->{$sec_keyword} = []; + } + foreach my $bug (@bugs) { + + # We skip over bugs with no sec level which can happen during event rewinding. + if (defined $bug->{sec_level}) { + push @{$groups->{$bug->{sec_level}}}, $bug; } + } + foreach my $sec_keyword (@{$self->sec_keywords}) { + my @open = map { $_->{id} } grep { ($_->{is_open}) } @{$groups->{$sec_keyword}}; + my @closed = map { $_->{id} } grep { !($_->{is_open}) } @{$groups->{$sec_keyword}}; + my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400 } + grep { ($_->{is_open}) } @{$groups->{$sec_keyword}}; + $result->{$sec_keyword} = {open => \@open, closed => \@closed, median_age_open => @ages ? _median(@ages) : 0,}; + } - return $result; + return $result; +} + +sub _is_bug_open { + my ($self, $status, $is_stalled) = @_; + return ($self->check_open_state->($status)) && !($is_stalled); +} + +sub _find_team { + my ($self, $product, $component) = @_; + foreach my $team_key (keys %{$self->teams}) { + my $team = $self->teams->{$team_key}; + if (exists $team->{$product}) { + return $team_key if $team->{$product}->{all_components}; + return $team_key if any { lc $component eq lc $_ } @{$team->{$product}->{named_components}}; + return $team_key if any { $component =~ /^\Q$_\E/i } @{$team->{$product}->{prefixed_components}}; + } + } + return undef; } sub _median { - # From tlm @ https://www.perlmonks.org/?node_id=474564. Jul 14, 2005 - return sum( ( sort { $a <=> $b } @_ )[ int( $#_ / 2 ), ceil( $#_ / 2 ) ] ) / 2; + + # From tlm @ https://www.perlmonks.org/?node_id=474564. Jul 14, 2005 + return sum((sort { $a <=> $b } @_)[int($#_ / 2), ceil($#_ / 2)]) / 2; } + 1; diff --git a/scripts/secbugsreport.pl b/scripts/secbugsreport.pl index 81041b222..e57989857 100644 --- a/scripts/secbugsreport.pl +++ b/scripts/secbugsreport.pl @@ -25,6 +25,7 @@ use Bugzilla::Report::SecurityRisk; use DateTime; use URI; use JSON::MaybeXS; +use Mojo::File; BEGIN { Bugzilla->extensions } Bugzilla->usage_mode(USAGE_MODE_CMDLINE); @@ -34,51 +35,74 @@ exit 0 unless defined $ARGV[0] && defined $ARGV[1] && defined $ARGV[2]; my $html; my $template = Bugzilla->template(); -my $end_date = DateTime->new( year => $ARGV[0], month => $ARGV[1], day => $ARGV[2] ); -my $start_date = $end_date->clone()->subtract( months => 6 ); +my $end_date = DateTime->new(year => $ARGV[0], month => $ARGV[1], day => $ARGV[2]); +my $start_date = $end_date->clone()->subtract(months => 12); my $report_week = $end_date->ymd('-'); -my $products = decode_json( Bugzilla->params->{report_secbugs_products} ); -my $sec_keywords = [ 'sec-critical', 'sec-high' ]; +my $teams = decode_json(Bugzilla->params->{report_secbugs_teams}); +my $sec_keywords = ['sec-critical', 'sec-high']; my $report = Bugzilla::Report::SecurityRisk->new( - start_date => $start_date, - end_date => $end_date, - products => $products, - sec_keywords => $sec_keywords + start_date => $start_date, + end_date => $end_date, + teams => $teams, + sec_keywords => $sec_keywords ); + +my $bugs_by_team = $report->results->[-1]->{bugs_by_team}; +my @sorted_team_names = sort { ## no critic qw(BuiltinFunctions::ProhibitReverseSortBlock + @{$bugs_by_team->{$b}->{open}} <=> @{$bugs_by_team->{$a}->{open}} ## no critic qw(Freenode::DollarAB) + || $a cmp $b +} keys %$teams; + my $vars = { - urlbase => Bugzilla->localconfig->{urlbase}, - report_week => $report_week, - products => $products, - sec_keywords => $sec_keywords, - results => $report->results, - build_bugs_link => \&build_bugs_link, + urlbase => Bugzilla->localconfig->{urlbase}, + report_week => $report_week, + teams => \@sorted_team_names, + sec_keywords => $sec_keywords, + results => $report->results, + deltas => $report->deltas, + build_bugs_link => \&build_bugs_link, }; -$template->process( 'reports/email/security-risk.html.tmpl', $vars, \$html ) - or ThrowTemplateError( $template->error() ); +$template->process('reports/email/security-risk.html.tmpl', $vars, \$html) or ThrowTemplateError($template->error()); # For now, only send HTML email. -my $email = Email::MIME->create( - header_str => [ - From => Bugzilla->params->{'mailfrom'}, - To => Bugzilla->params->{report_secbugs_emails}, - Subject => "Security Bugs Report for $report_week", - 'X-Bugzilla-Type' => 'admin' - ], - attributes => { - content_type => 'text/html', +my @parts = ( + Email::MIME->create( + attributes => {content_type => 'text/html', charset => 'UTF-8', encoding => 'quoted-printable',}, + body_str => $html, + ), + map { + Email::MIME->create( + header_str => ['Content-ID' => "<$_.png>",], + attributes => { + filename => "$_.png", charset => 'UTF-8', - encoding => 'quoted-printable', - }, - body_str => $html, + content_type => 'image/png', + disposition => 'inline', + name => "$_.png", + encoding => 'base64', + }, + body => $report->graphs->{$_}->slurp, + ) + } sort { $a cmp $b } keys %{$report->graphs} +); + +my $email = Email::MIME->create( + header_str => [ + From => Bugzilla->params->{'mailfrom'}, + To => Bugzilla->params->{report_secbugs_emails}, + Subject => "Security Bugs Report for $report_week", + 'X-Bugzilla-Type' => 'admin', + ], + parts => [@parts], ); MessageToMTA($email); sub build_bugs_link { - my ( $arr, $product ) = @_; - my $uri = URI->new( Bugzilla->localconfig->{urlbase} . 'buglist.cgi' ); - $uri->query_param( bug_id => ( join ',', @$arr ) ); - $uri->query_param( product => $product ) if $product; - return $uri->as_string; + my ($arr, $product) = @_; + my $uri = URI->new(Bugzilla->localconfig->{urlbase} . 'buglist.cgi'); + $uri->query_param(bug_id => (join ',', @$arr)); + $uri->query_param(product => $product) if $product; + return $uri->as_string; } diff --git a/t/security-risk.t b/t/security-risk.t index 520953bc0..926f032af 100644 --- a/t/security-risk.t +++ b/t/security-risk.t @@ -11,8 +11,9 @@ use 5.10.1; use lib qw( . lib local/lib/perl5 ); use Bugzilla; -BEGIN { Bugzilla->extensions }; +BEGIN { Bugzilla->extensions } +use JSON::MaybeXS; use Test::More; use Test2::Tools::Mock; use Try::Tiny; @@ -21,136 +22,144 @@ use ok 'Bugzilla::Report::SecurityRisk'; can_ok('Bugzilla::Report::SecurityRisk', qw(new results)); sub check_open_state_mock { - my ($state) = @_; - return grep { /^$state$/ } qw(UNCOMFIRMED NEW ASSIGNED REOPENED); + my ($state) = @_; + return grep {/^$state$/} qw(UNCOMFIRMED NEW ASSIGNED REOPENED); } +my $teams_json + = '{ "Frontend": { "Firefox": { "all_components": true } }, "Backend": { "Core": { "all_components": true } } }'; + try { - use Bugzilla::Report::SecurityRisk; - my $report = Bugzilla::Report::SecurityRisk->new( - start_date => DateTime->new( year => 2000, month => 1, day => 9 ), - end_date => DateTime->new( year => 2000, month => 1, day => 16 ), - products => [ 'Firefox', 'Core' ], - sec_keywords => [ 'sec-critical', 'sec-high' ], - check_open_state => \&check_open_state_mock, - initial_bug_ids => [ 1, 2, 3, 4 ], - initial_bugs => { - 1 => { - id => 1, - product => 'Firefox', - sec_level => 'sec-high', - is_open => 0, - created_at => DateTime->new( year => 2000, month => 1, day => 1 ), - }, - 2 => { - id => 2, - product => 'Core', - sec_level => 'sec-critical', - is_open => 0, - created_at => DateTime->new( year => 2000, month => 1, day => 1 ), - }, - 3 => { - id => 3, - product => 'Core', - sec_level => 'sec-high', - is_open => 1, - created_at => DateTime->new( year => 2000, month => 1, day => 5 ), - }, - 4 => { - id => 4, - product => 'Firefox', - sec_level => 'sec-critical', - is_open => 1, - created_at => DateTime->new( year => 2000, month => 1, day => 10 ), - }, - }, - events => [ - # Canned event's should be in reverse chronological order. - { - bug_id => 2, - bug_when => DateTime->new( year => 2000, month => 1, day => 14 ), - field_name => 'keywords', - removed => '', - added => 'sec-critical', + use Bugzilla::Report::SecurityRisk; + my $report = Bugzilla::Report::SecurityRisk->new( + start_date => DateTime->new(year => 2000, month => 1, day => 9), + end_date => DateTime->new(year => 2000, month => 1, day => 16), + teams => decode_json($teams_json), + sec_keywords => ['sec-critical', 'sec-high'], + check_open_state => \&check_open_state_mock, + initial_bug_ids => [1, 2, 3, 4], + initial_bugs => { + 1 => { + id => 1, + product => 'Firefox', + component => 'ComponentA', + team => 'Frontend', + sec_level => 'sec-high', + status => 'RESOLVED', + is_open => 0, + is_stalled => 0, + created_at => DateTime->new(year => 2000, month => 1, day => 1), + }, + 2 => { + id => 2, + product => 'Core', + component => 'ComponentB', + team => 'Backend', + sec_level => 'sec-critical', + status => 'RESOLVED', + is_open => 0, + is_stalled => 0, + created_at => DateTime->new(year => 2000, month => 1, day => 1), + }, + 3 => { + id => 3, + product => 'Core', + component => 'ComponentB', + team => 'Backend', + sec_level => 'sec-high', + status => 'ASSIGNED', + is_open => 1, + is_stalled => 0, + created_at => DateTime->new(year => 2000, month => 1, day => 5), + }, + 4 => { + id => 4, + product => 'Firefox', + component => 'ComponentA', + team => 'Frontend', + sec_level => 'sec-critical', + status => 'ASSIGNED', + is_open => 1, + is_stalled => 0, + created_at => DateTime->new(year => 2000, month => 1, day => 10), + }, + }, + events => [ + + # Canned event's should be in reverse chronological order. + { + bug_id => 2, + bug_when => DateTime->new(year => 2000, month => 1, day => 14), + field_name => 'keywords', + removed => '', + added => 'sec-critical', + + }, + { + bug_id => 1, + bug_when => DateTime->new(year => 2000, month => 1, day => 12), + field_name => 'bug_status', + removed => 'ASSIGNED', + added => 'RESOLVED', + }, + ], + ); + my $actual_results = $report->results; + my $expected_results = [ + { + date => DateTime->new(year => 2000, month => 1, day => 9), + bugs_by_team => { + 'Frontend' => { - }, - { - bug_id => 1, - bug_when => DateTime->new( year => 2000, month => 1, day => 12 ), - field_name => 'bug_status', - removed => 'ASSIGNED', - added => 'RESOLVED', - }, - ], - ); - my $actual_results = $report->results; - my $expected_results = [ - { - date => DateTime->new( year => 2000, month => 1, day => 9 ), - bugs_by_product => { - 'Firefox' => { - # Rewind the event that caused 1 to close. - open => [1], - closed => [], - median_age_open => 8 - }, - 'Core' => { - # 2 wasn't a sec-critical bug on the report date. - open => [3], - closed => [], - median_age_open => 4 - } - }, - bugs_by_sec_keyword => { - 'sec-critical' => { - # 2 wasn't a sec-crtical bug and 4 wasn't created yet on the report date. - open => [], - closed => [], - median_age_open => 0 - }, - 'sec-high' => { - # Rewind the event that caused 1 to close. - open => [ 1, 3 ], - closed => [], - median_age_open => 6 - } - }, + # Rewind the event that caused 1 to close. + open => [1], + closed => [], + median_age_open => 8 }, - { # The report on 2000-01-16 matches the state of initial_bugs. - date => DateTime->new( year => 2000, month => 1, day => 16 ), - bugs_by_product => { - 'Firefox' => { - open => [4], - closed => [1], - median_age_open => 6 - }, - 'Core' => { - open => [3], - closed => [2], - median_age_open => 11 - } - }, - bugs_by_sec_keyword => { - 'sec-critical' => { - open => [4], - closed => [2], - median_age_open => 6 - }, - 'sec-high' => { - open => [3], - closed => [1], - median_age_open => 11 - } - }, + 'Backend' => { + + # 2 wasn't a sec-critical bug on the report date. + open => [3], + closed => [], + median_age_open => 4 + } + }, + bugs_by_sec_keyword => { + 'sec-critical' => { + + # 2 wasn't a sec-crtical bug and 4 wasn't created yet on the report date. + open => [], + closed => [], + median_age_open => 0 }, - ]; + 'sec-high' => { + + # Rewind the event that caused 1 to close. + open => [1, 3], + closed => [], + median_age_open => 6 + } + }, + }, + { # The report on 2000-01-16 matches the state of initial_bugs. + date => DateTime->new(year => 2000, month => 1, day => 16), + bugs_by_team => { + 'Frontend' => {open => [4], closed => [1], median_age_open => 6}, + 'Backend' => {open => [3], closed => [2], median_age_open => 11} + }, + bugs_by_sec_keyword => { + 'sec-critical' => {open => [4], closed => [2], median_age_open => 6}, + 'sec-high' => {open => [3], closed => [1], median_age_open => 11} + }, + }, + ]; - is_deeply($actual_results, $expected_results, 'Report results are accurate'); + is_deeply($actual_results, $expected_results, 'Report results are accurate'); } catch { - fail('got an exception during main part of test'); - diag($_); + fail('got an exception during main part of test'); + diag($_); }; done_testing; diff --git a/template/en/default/admin/params/reports.html.tmpl b/template/en/default/admin/params/reports.html.tmpl index 79b6af35d..0c343b4a7 100644 --- a/template/en/default/admin/params/reports.html.tmpl +++ b/template/en/default/admin/params/reports.html.tmpl @@ -11,10 +11,10 @@ %] [% param_descs = { - report_secbugs_active => "Enable or disable the security $terms.bugs report feature." + report_secbugs_active => "Enable or disable the security $terms.bugs report feature.", report_secbugs_emails => "Comma delimited list of the email addresses that the security $terms.bugs report will be sent to.", - report_secbugs_products => - "JSON array of the products the security $terms.bugs report will report on. e.g [\"Prod1\", \"Prod2\"]", + report_secbugs_teams => + "JSON dictionary of the teams the security $terms.bugs report will report on. e.g {\"Team1\": { \"Product\": { \"all_components\": false, \"prefixed_components\": [\"Prefix\"], \"named_components\": [\"Component1\"]}, \"Team2\": { ... } }", } %] diff --git a/template/en/default/reports/email/security-risk.html.tmpl b/template/en/default/reports/email/security-risk.html.tmpl index 0fca42e05..e284a7190 100644 --- a/template/en/default/reports/email/security-risk.html.tmpl +++ b/template/en/default/reports/email/security-risk.html.tmpl @@ -15,81 +15,156 @@ -

Security [% terms.Bugs %] Report for the week of [% report_week FILTER html %]

-

To narrow down open [% terms.bugs %] click on the link and at the bottom of the search results use the 'Edit Search' functionality to filter by component and so on. -This will filter only the open [% terms.bugs %] counted in the report (as long as you do not modify the '[% terms.Bugs %] numbered' section of the search). -

-

[% terms.Bugs %] By Severity

- +

Sec-Critical + Sec-High [% terms.Bugs %] by Team

+
- - [% FOREACH keyword IN sec_keywords %] - + + + + + [% FOREACH result IN results.reverse %] + [% NEXT IF loop.count < 2 %] + [% LAST IF loop.count > 4 %] + [% END %] - - - [% FOREACH keyword IN sec_keywords %] - - - [% END %] - - [% FOREACH result IN results.reverse %] - - - [% FOREACH keyword IN sec_keywords %] - + + + - - [% END %] - + [% FOREACH result IN results.reverse %] + [% NEXT IF loop.count < 2 %] + [% LAST IF loop.count > 4 %] + + [% END %] + [% END %]
[% keyword FILTER html %]Team + Open
[% results.reverse.0.date FILTER time('%m/%d') %] +
Closed
Last Week
Added
Last Week
+ Open
[% result.date FILTER time('%m/%d') %] +
Open CountMedian Days Open
[% result.date.ymd('-') FILTER html %] - [% IF result.bugs_by_sec_keyword.$keyword.open.size %] - - [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %] + [% FOREACH team IN teams %] +
[% team FILTER html %] + [% IF results.reverse.0.bugs_by_team.$team.open.size %] + + [% results.reverse.0.bugs_by_team.$team.open.size FILTER html %] + + [% ELSE %] + 0 + [% END %] + + [% IF deltas.by_team.$team.closed.size %] + + -[% deltas.by_team.$team.closed.size FILTER html %] [% ELSE %] - [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %] + 0 [% END %] - [% result.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.2f") FILTER html %] + + [% IF deltas.by_team.$team.added.size %] + + +[% deltas.by_team.$team.added.size FILTER html %] + + [% ELSE %] + 0 + [% END %]
+ [% IF result.bugs_by_team.$team.open.size %] + + [% result.bugs_by_team.$team.open.size FILTER html %] + + [% ELSE %] + 0 + [% END %] +
+
-

Sec-Critical + Sec-High [% terms.Bugs %] by Product

- - - - [% FOREACH product IN products %] - - [% END %] - +

[% terms.Bugs %] By Severity

+
[% product FILTER html %]
- - [% FOREACH product IN products %] - - + + + + + + [% FOREACH result IN results.reverse %] + [% NEXT IF loop.count < 2 %] + [% LAST IF loop.count > 4 %] + [% END %] - [% FOREACH result IN results.reverse %] - - - [% FOREACH product IN products %] - + + + - - [% END %] - + + [% FOREACH result IN results.reverse %] + [% NEXT IF loop.count < 2 %] + [% LAST IF loop.count > 4 %] + + [% END %] + [% END %]
Open CountMedian Days OpenCategory + Open
[% results.reverse.0.date FILTER time('%m/%d') %] +
Closed
Last Week
Added
Last Week
+ Median Age
of Open [% terms.Bugs %]
+
+ Open
[% result.date FILTER time('%m/%d') %] +
[% result.date.ymd('-') FILTER html %] - [% IF result.bugs_by_product.$product.open.size %] - - [% result.bugs_by_product.$product.open.size FILTER html %] + [% FOREACH keyword IN sec_keywords %] +
[% keyword FILTER html %] + [% IF results.reverse.0.bugs_by_sec_keyword.$keyword.open %] + + [% results.reverse.0.bugs_by_sec_keyword.$keyword.open.size FILTER html %] + + [% ELSE %] + 0 + [% END %] + + [% IF deltas.by_sec_keyword.$keyword.closed.size %] + + -[% deltas.by_sec_keyword.$keyword.closed.size FILTER html %] [% ELSE %] - [% result.bugs_by_product.$product.open.size FILTER html %] + 0 [% END %] - [% result.bugs_by_product.$product.median_age_open FILTER format("%.2f") FILTER html %] + + [% IF deltas.by_sec_keyword.$keyword.added.size %] + + +[% deltas.by_sec_keyword.$keyword.added.size FILTER html %] + + [% ELSE %] + 0 + [% END %]
+ [% results.-1.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.0f") FILTER html %] days + + [% IF result.bugs_by_sec_keyword.$keyword.open %] + + [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %] + + [% ELSE %] + 0 + [% END %] +
+ +

+To narrow down open [% terms.bugs %] click on the link and at the bottom of the search results use the +'Edit Search' functionality to filter by component and so on. This will filter only the open [% terms.bugs %] +counted in the report (as long as you do not modify the '[% terms.Bugs %] numbered' section of the search). +Keep in mind that you will only be able to see [% terms.bugs %] that you are allowed to see. Also keep in mind that +this report treats marking a [% terms.bug %] as 'stalled' the same as closing it. +

+ +

Attached to this email are some graphs with stats for the past 12 months.

+