Commit 5825c12f authored by Avielle Wolfe's avatar Avielle Wolfe Committed by Imre Farkas

Add a historical count method to Vulnerability

Adds Vulnerability.count_by_day_and_severity. This method takes an
integer and returns vulnerability counts grouped by severity for that
number of days before the current day. It does not return rows when
there are no vulnerabilities for a day and severity.

The count does not include dismissed or resolved vulnerabilities.

This work is the first step towards moving the group security dashboard
history chart to first class vulnerabilities, and hopefully improving
its performance.

https://gitlab.com/gitlab-org/gitlab/-/issues/201792
parent aaca948a
......@@ -7,6 +7,10 @@ class Vulnerability < ApplicationRecord
include Noteable
include Awardable
TooManyDaysError = Class.new(StandardError)
MAX_DAYS_IN_PAST = 10
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description, issuable_state_filter_enabled: true
......@@ -63,6 +67,28 @@ class Vulnerability < ApplicationRecord
delegate :default_branch, to: :project, prefix: :project
def self.counts_by_day_and_severity(num_days_in_past, end_date = Date.current)
return [] unless Feature.enabled?(:vulnerability_history, default_enabled: true)
# this clause guards against query timeouts
raise TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS_IN_PAST} days" if num_days_in_past > MAX_DAYS_IN_PAST
quoted_num_days_in_past = connection.quote(num_days_in_past)
quoted_end_date = connection.quote(end_date)
select(
'DATE(calendar.entry) AS day, severity, COUNT(*)'
).from(
"generate_series(DATE #{quoted_end_date} - INTERVAL '#{quoted_num_days_in_past} days', DATE #{quoted_end_date}, INTERVAL '1 day') as calendar(entry)"
).joins(
'INNER JOIN vulnerabilities ON vulnerabilities.created_at <= calendar.entry'
).where(
'(vulnerabilities.dismissed_at IS NULL OR vulnerabilities.dismissed_at > calendar.entry) AND (vulnerabilities.resolved_at IS NULL OR vulnerabilities.resolved_at > calendar.entry)'
).group(
:day, :severity
)
end
# There will only be one finding associated with a vulnerability for the foreseeable future
def finding
findings.first
......
......@@ -167,6 +167,82 @@ describe Vulnerability do
end
end
describe '.counts_by_day_and_severity' do
context 'when the vulnerability_history feature flag is disabled' do
before do
stub_feature_flags(vulnerability_history: false)
end
it 'returns an empty array' do
create(:vulnerability, created_at: 1.day.ago)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(6)
expect(counts_by_day_and_severity).to be_empty
end
end
context 'when the vulnerability_history feature flag is enabled' do
before do
stub_feature_flags(vulnerability_history: true)
end
context 'when not given an end date' do
it 'returns the count of unresolved, undismissed vulnerabilities for each severity from the current day to the given number of days in the past' do
Timecop.freeze(Time.zone.parse('2019-10-31')) do
create(:vulnerability, created_at: 5.days.ago, dismissed_at: Date.current, severity: :critical)
create(:vulnerability, created_at: 5.days.ago, dismissed_at: 1.day.ago, severity: :high)
create(:vulnerability, created_at: 4.days.ago, resolved_at: 2.days.ago, severity: :critical)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(6)
expect(counts_by_day_and_severity.order(:day, :severity).to_json).to eq([
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-26', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-26', 'count' => 1 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-27', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-27', 'count' => 2 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-28', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-28', 'count' => 2 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-29', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-29', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-30', 'count' => 1 }
].to_json)
end
end
end
context 'when given an end date' do
it 'returns the count of unresolved, undismissed vulnerabilities for each severity for each day from the given end date to the given number of days in the past' do
Timecop.freeze(Time.zone.parse('2019-10-31')) do
create(:vulnerability, created_at: 5.days.ago, dismissed_at: Date.current, severity: :critical)
create(:vulnerability, created_at: 5.days.ago, dismissed_at: 1.day.ago, severity: :high)
create(:vulnerability, created_at: 4.days.ago, resolved_at: 2.days.ago, severity: :critical)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(6, Date.parse('2019-10-28'))
expect(counts_by_day_and_severity.order(:day, :severity).to_json).to eq([
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-26', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-26', 'count' => 1 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-27', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-27', 'count' => 2 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-28', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-28', 'count' => 2 }
].to_json)
end
end
end
context 'when given a number of past days greater than 10' do
it 'raises a TooManyDaysError' do
expect { Vulnerability.counts_by_day_and_severity(11) }.to raise_error(
Vulnerability::TooManyDaysError,
'Cannot fetch counts for more than 10 days'
)
end
end
end
end
describe '#finding' do
let_it_be(:project) { create(:project, :with_vulnerabilities) }
let_it_be(:vulnerability) { project.vulnerabilities.first }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment