Commit 7ff151eb authored by Avielle Wolfe's avatar Avielle Wolfe Committed by Heinrich Lee Yu

Add vulnerability history resolver and field

* Add VulnerabilitiesHistoryResolver
* Add vulnerabilitySeveritiesCountByDay field to ProjectType
* Add day field to VulnerabilitySeveritiesCountType

TODO:

* Use `days` arg to determine number of days to fetch
* Iterate over groups of 10 days so we don't break the DB
* Testing!
parent d33ad2c0
......@@ -4302,6 +4302,41 @@ type Group {
state: [VulnerabilityState!]
): VulnerabilityConnection
"""
Number of vulnerabilities per severity level, per day, for the projects in the group and its subgroups
"""
vulnerabilitiesCountByDayAndSeverity(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Last day for which to fetch vulnerability history
"""
endDate: ISO8601Date!
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
First day for which to fetch vulnerability history
"""
startDate: ISO8601Date!
): VulnerabilitiesCountByDayAndSeverityConnection
"""
Web URL of the group
"""
......@@ -4324,6 +4359,11 @@ enum HealthStatus {
onTrack
}
"""
An ISO 8601-encoded date
"""
scalar ISO8601Date
type InstanceSecurityDashboard {
"""
Projects selected in Instance Security Dashboard
......@@ -8223,6 +8263,41 @@ type Query {
"""
state: [VulnerabilityState!]
): VulnerabilityConnection
"""
Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard
"""
vulnerabilitiesCountByDayAndSeverity(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Last day for which to fetch vulnerability history
"""
endDate: ISO8601Date!
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
First day for which to fetch vulnerability history
"""
startDate: ISO8601Date!
): VulnerabilitiesCountByDayAndSeverityConnection
}
"""
......@@ -10720,6 +10795,61 @@ enum VisibilityScopesEnum {
public
}
"""
Represents the number of vulnerabilities for a particular severity on a particular day
"""
type VulnerabilitiesCountByDayAndSeverity {
"""
Number of vulnerabilities
"""
count: Int
"""
Date for the count
"""
day: ISO8601Date
"""
Severity of the counted vulnerabilities
"""
severity: VulnerabilitySeverity
}
"""
The connection type for VulnerabilitiesCountByDayAndSeverity.
"""
type VulnerabilitiesCountByDayAndSeverityConnection {
"""
A list of edges.
"""
edges: [VulnerabilitiesCountByDayAndSeverityEdge]
"""
A list of nodes.
"""
nodes: [VulnerabilitiesCountByDayAndSeverity]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type VulnerabilitiesCountByDayAndSeverityEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: VulnerabilitiesCountByDayAndSeverity
}
"""
Represents a vulnerability.
"""
......
......@@ -1633,6 +1633,16 @@ Autogenerated return type of UpdateSnippet
| --- | ---- | ---------- |
| `createSnippet` | Boolean! | Indicates the user can perform `create_snippet` on this resource |
## VulnerabilitiesCountByDayAndSeverity
Represents the number of vulnerabilities for a particular severity on a particular day
| Name | Type | Description |
| --- | ---- | ---------- |
| `count` | Int | Number of vulnerabilities |
| `day` | ISO8601Date | Date for the count |
| `severity` | VulnerabilitySeverity | Severity of the counted vulnerabilities |
## Vulnerability
Represents a vulnerability.
......
......@@ -31,6 +31,12 @@ module EE
null: true,
description: 'Vulnerabilities reported on the projects in the group and its subgroups',
resolver: ::Resolvers::VulnerabilitiesResolver
field :vulnerabilities_count_by_day_and_severity,
::Types::VulnerabilitiesCountByDayAndSeverityType.connection_type,
null: true,
description: 'Number of vulnerabilities per severity level, per day, for the projects in the group and its subgroups',
resolver: ::Resolvers::VulnerabilitiesHistoryResolver
end
end
end
......
......@@ -15,6 +15,12 @@ module EE
description: "Vulnerabilities reported on projects on the current user's instance security dashboard",
resolver: ::Resolvers::VulnerabilitiesResolver
field :vulnerabilities_count_by_day_and_severity,
::Types::VulnerabilitiesCountByDayAndSeverityType.connection_type,
null: true,
description: "Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard",
resolver: ::Resolvers::VulnerabilitiesHistoryResolver
field :design_management, ::Types::DesignManagementType,
null: false,
description: 'Fields related to design management'
......
# frozen_string_literal: true
# VulnerabilitiesBaseResolver is an abstract class that is inherited by
# vulnerability related resolvers. It contains the somewhat obtuse logic related
# to finding the object to get vulnerabilities from so that developers writing
# new resolvers don't have to repeat it.
module Resolvers
class VulnerabilitiesBaseResolver < BaseResolver
include Gitlab::Utils::StrongMemoize
protected
# `vulnerable` will be a Project, Group, or InstanceSecurityDashboard
def vulnerable
# A project or group could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project or group to query for vulnerabilities, so
# make sure it's loaded and not `nil` before continuing.
strong_memoize(:vulnerable) do
if resolve_vulnerabilities_for_instance_security_dashboard?
::InstanceSecurityDashboard.new(current_user)
elsif object.respond_to?(:sync)
object.sync
else
object
end
end
end
def resolve_vulnerabilities_for_instance_security_dashboard?
# object will be nil when we're fetching vulnerabilities from QueryType,
# which is the source of vulnerability data for the instance security
# dashboard
object.nil? && current_user.present?
end
end
end
# frozen_string_literal: true
module Resolvers
class VulnerabilitiesHistoryResolver < VulnerabilitiesBaseResolver
include Gitlab::Utils::StrongMemoize
MAX_DAYS = ::Vulnerability::MAX_DAYS_OF_HISTORY
type Types::VulnerabilitiesCountByDayAndSeverityType, null: true
argument :start_date, GraphQL::Types::ISO8601Date, required: true,
description: 'First day for which to fetch vulnerability history'
argument :end_date, GraphQL::Types::ISO8601Date, required: true,
description: 'Last day for which to fetch vulnerability history'
def resolve(**args)
return [] unless vulnerable
start_date = args[:start_date]
end_date = args[:end_date]
days = end_date - start_date + 1
if days > MAX_DAYS
raise ::Vulnerability::TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS} days"
else
vulnerable.vulnerabilities.counts_by_day_and_severity(start_date, end_date).to_a
end
end
end
end
# frozen_string_literal: true
module Resolvers
class VulnerabilitiesResolver < BaseResolver
class VulnerabilitiesResolver < VulnerabilitiesBaseResolver
include Gitlab::Utils::StrongMemoize
type Types::VulnerabilityType, null: true
......@@ -30,32 +30,8 @@ module Resolvers
private
# `vulnerable` will be a Project, Group, or InstanceSecurityDashboard
def vulnerable
# A project or group could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project or group to query for vulnerabilities, so
# make sure it's loaded and not `nil` before continuing.
strong_memoize(:vulnerable) do
if resolve_vulnerabilities_for_instance_security_dashboard?
::InstanceSecurityDashboard.new(current_user)
elsif object.respond_to?(:sync)
object.sync
else
object
end
end
end
def vulnerabilities(filters)
Security::VulnerabilitiesFinder.new(vulnerable, filters).execute
end
def resolve_vulnerabilities_for_instance_security_dashboard?
# object will be nil when we're fetching vulnerabilities from QueryType,
# which is the source of vulnerability data for the instance security
# dashboard
object.nil? && current_user.present?
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class VulnerabilitiesCountByDayAndSeverityType < BaseObject
graphql_name 'VulnerabilitiesCountByDayAndSeverity'
description 'Represents the number of vulnerabilities for a particular severity on a particular day'
field :count, GraphQL::INT_TYPE, null: true,
description: 'Number of vulnerabilities'
field :day, GraphQL::Types::ISO8601Date, null: true,
description: 'Date for the count'
field :severity, VulnerabilitySeverityEnum, null: true,
description: 'Severity of the counted vulnerabilities'
end
end
......@@ -9,7 +9,7 @@ class Vulnerability < ApplicationRecord
TooManyDaysError = Class.new(StandardError)
MAX_DAYS_IN_PAST = 10
MAX_DAYS_OF_HISTORY = 10
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description, issuable_state_filter_enabled: true
......@@ -67,19 +67,21 @@ class Vulnerability < ApplicationRecord
scope :with_states, -> (states) { where(state: states) }
scope :counts_by_severity, -> { group(:severity).count }
def self.counts_by_day_and_severity(num_days_in_past, end_date = Date.current)
def self.counts_by_day_and_severity(start_date, end_date)
return [] unless Feature.enabled?(:vulnerability_history, default_enabled: true)
num_days_of_history = end_date - start_date + 1
# 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
raise TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS_OF_HISTORY} days" if num_days_of_history > MAX_DAYS_OF_HISTORY
quoted_num_days_in_past = connection.quote(num_days_in_past)
quoted_start_date = connection.quote(start_date)
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)"
"generate_series(DATE #{quoted_start_date}, DATE #{quoted_end_date}, INTERVAL '1 day') as calendar(entry)"
).joins(
'INNER JOIN vulnerabilities ON vulnerabilities.created_at <= calendar.entry'
).where(
......
---
title: Add vulnerability history to graphQL
merge_request: 30674
author:
type: added
......@@ -12,6 +12,7 @@ describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:groupTimelogsEnabled) }
it { expect(described_class).to have_graphql_field(:timelogs, complexity: 5) }
it { expect(described_class).to have_graphql_field(:vulnerabilities) }
it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day_and_severity) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::VulnerabilitiesHistoryResolver do
include GraphqlHelpers
subject { resolve(described_class, obj: group, args: args, ctx: { current_user: user }) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:user) { create(:user) }
describe '#resolve' do
let(:args) { { start_date: Date.parse('2019-10-15'), end_date: Date.parse('2019-10-21') } }
it "fetches historical vulnerability data from the start date to the end date" do
Timecop.freeze(Date.parse('2019-10-31')) do
create(:vulnerability, :critical, created_at: 15.days.ago, dismissed_at: 10.days.ago, project: project)
create(:vulnerability, :high, created_at: 15.days.ago, dismissed_at: 11.days.ago, project: project)
create(:vulnerability, :critical, created_at: 14.days.ago, resolved_at: 12.days.ago, project: project)
ordered_history = subject.sort_by { |count| [count['day'], count['severity']] }
expect(ordered_history.to_json).to eq([
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-16', 'count' => 1 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-16', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-17', 'count' => 2 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-17', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-18', 'count' => 2 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-18', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-19', 'count' => 1 },
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-19', 'count' => 1 },
{ 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-20', 'count' => 1 }
].to_json)
end
end
context 'when given more than 10 days' do
let(:args) { { start_date: Date.parse('2019-10-11'), end_date: Date.parse('2019-10-21') } }
it 'raises an error stating that no more than 10 days can be requested' do
expect { subject }.to raise_error(::Vulnerability::TooManyDaysError, 'Cannot fetch counts for more than 10 days')
end
end
end
end
......@@ -8,7 +8,8 @@ describe GitlabSchema.types['Query'] do
:design_management,
:geo_node,
:vulnerabilities,
:instance_security_dashboard
:instance_security_dashboard,
:vulnerabilities_count_by_day_and_severity
).at_least
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['VulnerabilitiesCountByDayAndSeverity'] do
it { expect(described_class).to have_graphql_fields(:count, :day, :severity) }
end
......@@ -178,7 +178,7 @@ describe Vulnerability do
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)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(1.day.ago, Date.current)
expect(counts_by_day_and_severity).to be_empty
end
......@@ -189,54 +189,28 @@ describe Vulnerability 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
it 'returns the count of unresolved, undismissed vulnerabilities for each severity for each day from the start date to the end date' 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(Date.parse('2019-10-22'), 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
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
context 'there are more than 10 days between the start and end dates' do
it 'raises a TooManyDaysError' do
expect { Vulnerability.counts_by_day_and_severity(11) }.to raise_error(
expect { Vulnerability.counts_by_day_and_severity(10.days.ago.to_date, Date.current) }.to raise_error(
Vulnerability::TooManyDaysError,
'Cannot fetch counts for more than 10 days'
)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'group(fullPath).vulnerabilitiesCountByDayAndSeverity' do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:current_user) { create(:user) }
let(:query) { graphql_query_for(:group, { fullPath: group.full_path }, history_field) }
let(:query_result) { graphql_data.dig('group', 'vulnerabilitiesCountByDayAndSeverity', 'nodes') }
let(:history_field) do
query_graphql_field(
:vulnerabilitiesCountByDayAndSeverity,
{
start_date: Date.parse('2019-10-15').iso8601,
end_date: Date.parse('2019-10-21').iso8601
},
history_fields
)
end
let(:history_fields) do
query_graphql_field(:nodes, nil, <<~FIELDS)
count
day
severity
FIELDS
end
it "fetches historical vulnerability data from the start date to the end date for projects in the group and its subgroups" do
Timecop.freeze(Time.zone.parse('2019-10-31')) do
project.add_developer(current_user)
create(:vulnerability, :critical, created_at: 15.days.ago, dismissed_at: 10.days.ago, project: project)
create(:vulnerability, :high, created_at: 15.days.ago, dismissed_at: 11.days.ago, project: project)
create(:vulnerability, :critical, created_at: 14.days.ago, resolved_at: 12.days.ago, project: project)
post_graphql(query, current_user: current_user)
ordered_history = query_result.sort_by { |count| [count['day'], count['severity']] }
expect(ordered_history).to eq([
{ 'severity' => 'CRITICAL', 'day' => '2019-10-16', 'count' => 1 },
{ 'severity' => 'HIGH', 'day' => '2019-10-16', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-17', 'count' => 2 },
{ 'severity' => 'HIGH', 'day' => '2019-10-17', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-18', 'count' => 2 },
{ 'severity' => 'HIGH', 'day' => '2019-10-18', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-19', 'count' => 1 },
{ 'severity' => 'HIGH', 'day' => '2019-10-19', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-20', 'count' => 1 }
])
end
end
end
......@@ -92,4 +92,54 @@ describe 'Query' do
end
end
end
describe '.vulnerabilitiesCountByDayAndSeverity' do
let(:query_result) { graphql_data.dig('vulnerabilitiesCountByDayAndSeverity', 'nodes') }
let(:query) do
graphql_query_for(
:vulnerabilitiesCountByDayAndSeverity,
{
start_date: Date.parse('2019-10-15').iso8601,
end_date: Date.parse('2019-10-21').iso8601
},
history_fields
)
end
let(:history_fields) do
query_graphql_field(:nodes, nil, <<~FIELDS)
count
day
severity
FIELDS
end
it "fetches historical vulnerability data from the start date to the end date for projects on the current user's instance security dashboard" do
Timecop.freeze(Time.zone.parse('2019-10-31')) do
current_user.security_dashboard_projects << project
project.add_developer(developer)
create(:vulnerability, :critical, created_at: 15.days.ago, dismissed_at: 10.days.ago, project: project)
create(:vulnerability, :high, created_at: 15.days.ago, dismissed_at: 11.days.ago, project: project)
create(:vulnerability, :critical, created_at: 14.days.ago, resolved_at: 12.days.ago, project: project)
post_graphql(query, current_user: current_user)
ordered_history = query_result.sort_by { |count| [count['day'], count['severity']] }
expect(ordered_history).to eq([
{ 'severity' => 'CRITICAL', 'day' => '2019-10-16', 'count' => 1 },
{ 'severity' => 'HIGH', 'day' => '2019-10-16', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-17', 'count' => 2 },
{ 'severity' => 'HIGH', 'day' => '2019-10-17', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-18', 'count' => 2 },
{ 'severity' => 'HIGH', 'day' => '2019-10-18', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-19', 'count' => 1 },
{ 'severity' => 'HIGH', 'day' => '2019-10-19', 'count' => 1 },
{ 'severity' => 'CRITICAL', 'day' => '2019-10-20', 'count' => 1 }
])
end
end
end
end
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