]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1496057 - Update Security Bugs Report
authorIsrael Madueme <purelogiq@gmail.com>
Tue, 23 Oct 2018 14:50:33 +0000 (10:50 -0400)
committerGitHub <noreply@github.com>
Tue, 23 Oct 2018 14:50:33 +0000 (10:50 -0400)
Bugzilla/Config/Reports.pm
Bugzilla/Report/SecurityRisk.pm
scripts/secbugsreport.pl
t/security-risk.t
template/en/default/admin/params/reports.html.tmpl
template/en/default/reports/email/security-risk.html.tmpl

index 26c5aad5728a9761bc4b9745a340dd2c4aca01dd..0435f6fc582fbf87f02ec7205d5bbc5c846b12ef 100644 (file)
@@ -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 => '{}'
         },
      );
 }
index 5eb98fd7f3d2651c7feaf31a243562c29e1273de..0c4c1ef2cfc306eb0cd7783c81080ef61f801eed 100644 (file)
@@ -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;
index 81041b222410eadcee29a096ff1d9cd110c6e7e9..e57989857a3d5886aff86f03e61af28c3c7f0683 100644 (file)
@@ -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;
 }
index 520953bc0651d2cf8ebec6c58ff725e14189075f..926f032affc8a44b4b36ee48534b225ad4250c60 100644 (file)
@@ -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;
index 79b6af35db03b3576b06eb9e2b59268ec53afe5f..0c343b4a72abce1bb06547f80c39d47d61d6e1f9 100644 (file)
 %]
 
 [% 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\": { ... } }",
   }
 %]
index 0fca42e054f6dd02d75a32360e236dd84dc2d50d..e284a71904a6b2c66553076b3b2a45d671ffe44d 100644 (file)
   <base href="[% urlbase FILTER txt %]">
 </head>
 <body>
-<p>Security [% terms.Bugs %] Report for the week of [% report_week FILTER html %]</p>
-<p>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).
-</p>
 
-<h3>[% terms.Bugs %] By Severity</h3>
-<table style="border: 1px solid grey">
+<h3>Sec-Critical + Sec-High [% terms.Bugs %] by Team</h3>
+<table style="border-spacing: 0">
   <tr>
-    <th style="border: 1px solid grey"></th>
-    [% FOREACH keyword IN sec_keywords %]
-      <th style="border: 1px solid grey; text-align: center" colspan="2"><b>[% keyword FILTER html %]</b></th>
+    <th style="padding: 0px 15px 10px 0px;">Team</th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">
+        Open<br>[% results.reverse.0.date FILTER time('%m/%d') %]
+    </th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">Closed<br />Last Week</th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">Added<br />Last Week</th>
+    [% FOREACH result IN results.reverse %]
+      [% NEXT IF loop.count < 2 %]
+      [% LAST IF loop.count > 4 %]
+      <th style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+        Open<br>[% result.date FILTER time('%m/%d') %]
+      </th>
     [% END %]
   </tr>
-  <tr>
-    <td style="border: 1px solid grey"></td>
-    [% FOREACH keyword IN sec_keywords %]
-      <td style="border: 1px solid grey; text-align: right">Open Count</td>
-      <td style="border: 1px solid grey; text-align: right">Median Days Open</td>
-    [% END %]
-  </tr>
-  [% FOREACH result IN results.reverse %]
-  <tr>
-    <td style="border: 1px solid grey">[% result.date.ymd('-') FILTER html %]</td>
-    [% FOREACH keyword IN sec_keywords %]
-      <td style="border: 1px solid grey; text-align: right">
-        [% IF result.bugs_by_sec_keyword.$keyword.open.size %]
-          <a href="[% build_bugs_link(result.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
-            [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+  [% FOREACH team IN teams %]
+    <tr>
+      <td style="padding: 0px 15px 10px 0px;">[% team FILTER html %]</td>
+      <td style="padding: 0px 15px 10px 0px; text-align: right;">
+          [% IF results.reverse.0.bugs_by_team.$team.open.size %]
+            <a style="text-decoration: none;" href="[% build_bugs_link(results.reverse.0.bugs_by_team.$team.open) FILTER html %]">
+              [% results.reverse.0.bugs_by_team.$team.open.size FILTER html %]
+            </a>
+          [% ELSE %]
+            0
+          [% END %]
+        </td>
+      <td style="padding: 0px 15px 10px 0px; text-align: right;
+                [% IF deltas.by_team.$team.closed.size %] background-color: #e6ffe6 [% END %]">
+        [% IF deltas.by_team.$team.closed.size %]
+          <a style="text-decoration: none;"
+             href="[% build_bugs_link(deltas.by_team.$team.closed) FILTER html %]">
+            -[% deltas.by_team.$team.closed.size FILTER html %]
           </a>
         [% ELSE %]
-          [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+          0
         [% END %]
       </td>
-      <td style="border: 1px solid grey; text-align: right">
-        [% result.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.2f") FILTER html %]
+      <td style="padding: 0px 15px 10px 0px; text-align: right;  border-right: 1px solid grey;
+                [% IF deltas.by_team.$team.added.size %] background-color: #ffe6e6 [% END %]">
+        [% IF deltas.by_team.$team.added.size %]
+          <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_team.$team.added) FILTER html %]">
+            +[% deltas.by_team.$team.added.size FILTER html %]
+          </a>
+        [% ELSE %]
+          0
+        [% END %]
       </td>
-    [% END %]
-  </tr>
+      [% FOREACH result IN results.reverse %]
+        [% NEXT IF loop.count < 2 %]
+        [% LAST IF loop.count > 4 %]
+        <td style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+          [% IF result.bugs_by_team.$team.open.size %]
+            <a style="text-decoration: none;" href="[% build_bugs_link(result.bugs_by_team.$team.open) FILTER html %]">
+              [% result.bugs_by_team.$team.open.size FILTER html %]
+            </a>
+          [% ELSE %]
+            0
+          [% END %]
+        </td>
+      [% END %]
+    </tr>
   [% END %]
 </table>
+<br/>
 
-<h3>Sec-Critical + Sec-High [% terms.Bugs %] by Product</h3>
-<table style="border: 1px solid grey">
-  <tr>
-    <th style="border: 1px solid grey"></th>
-    [% FOREACH product IN products %]
-      <th style="border: 1px solid grey; text-align: center" colspan="2"><b>[% product FILTER html %]</b></th>
-    [% END %]
-  </tr>
+<h3>[% terms.Bugs %] By Severity</h3>
+<table style="border-spacing: 0">
   <tr>
-    <td style="border: 1px solid grey"></td>
-    [% FOREACH product IN products %]
-      <td style="border: 1px solid grey; text-align: right">Open Count</td>
-      <td style="border: 1px solid grey; text-align: right">Median Days Open</td>
+    <th style="padding: 0px 15px 10px 0px;">Category</th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">
+        Open<br>[% results.reverse.0.date FILTER time('%m/%d') %]
+    </th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">Closed<br />Last Week</th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right;">Added<br />Last Week</th>
+    <th style="padding: 0px 15px 10px 0px; text-align: right; border-right: 1px solid grey;">
+      Median Age<br/>of Open [% terms.Bugs %]<br>
+    </th>
+    [% FOREACH result IN results.reverse %]
+      [% NEXT IF loop.count < 2 %]
+      [% LAST IF loop.count > 4 %]
+      <th style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right;  [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+        Open<br>[% result.date FILTER time('%m/%d') %]
+      </th>
     [% END %]
   </tr>
-  [% FOREACH result IN results.reverse %]
-  <tr>
-    <td style="border: 1px solid grey">[% result.date.ymd('-') FILTER html %]</td>
-    [% FOREACH product IN products %]
-      <td style="border: 1px solid grey; text-align: right">
-        [% IF result.bugs_by_product.$product.open.size %]
-          <a href="[% build_bugs_link(result.bugs_by_product.$product.open, product) FILTER html %]">
-            [% result.bugs_by_product.$product.open.size FILTER html %]
+  [% FOREACH keyword IN sec_keywords %]
+    <tr>
+      <td style="padding: 0px 15px 10px 0px;">[% keyword FILTER html %]</td>
+      <td style="padding: 0px 15px 10px 0px; text-align: right;">
+          [% IF results.reverse.0.bugs_by_sec_keyword.$keyword.open %]
+            <a style="text-decoration: none;" href="[% build_bugs_link(results.reverse.0.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
+              [% results.reverse.0.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+            </a>
+          [% ELSE %]
+            0
+          [% END %]
+        </td>
+      <td style="padding: 0px 15px 10px 0px; text-align: right;
+                [% IF deltas.by_sec_keyword.$keyword.closed.size %] background-color: #e6ffe6 [% END %]">
+        [% IF deltas.by_sec_keyword.$keyword.closed.size %]
+          <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_sec_keyword.$keyword.closed) FILTER html %]">
+            -[% deltas.by_sec_keyword.$keyword.closed.size FILTER html %]
           </a>
         [% ELSE %]
-          [% result.bugs_by_product.$product.open.size FILTER html %]
+          0
         [% END %]
       </td>
-      <td style="border: 1px solid grey; text-align: right">
-        [% result.bugs_by_product.$product.median_age_open FILTER format("%.2f") FILTER html %]
+      <td style="padding: 0px 15px 10px 0px; text-align: right;
+                 [% IF deltas.by_sec_keyword.$keyword.added.size %] background-color: #ffe6e6 [% END %]">
+        [% IF deltas.by_sec_keyword.$keyword.added.size %]
+          <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_sec_keyword.$keyword.added) FILTER html %]">
+            +[% deltas.by_sec_keyword.$keyword.added.size FILTER html %]
+          </a>
+        [% ELSE %]
+          0
+        [% END %]
       </td>
-    [% END %]
-  </tr>
+      <td style="padding: 0px 15px 10px 0px; text-align: right; border-right: 1px solid grey; ">
+        [% results.-1.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.0f") FILTER html %] days
+      </td>
+      [% FOREACH result IN results.reverse %]
+        [% NEXT IF loop.count < 2 %]
+        [% LAST IF loop.count > 4 %]
+        <td style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right;  [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+          [% IF result.bugs_by_sec_keyword.$keyword.open %]
+            <a style="text-decoration: none;" href="[% build_bugs_link(result.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
+              [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+            </a>
+          [% ELSE %]
+            0
+          [% END %]
+        </td>
+      [% END %]
+    </tr>
   [% END %]
 </table>
+
+<p>
+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.
+</p>
+
+<p>Attached to this email are some graphs with stats for the past 12 months.</p>
+
 </body>
 </html>