diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 89b3bbac9384a032e976ab5fae58938f4da52232..d9ee329897e64c22be688bcc642af4ee2c5dc704 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -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. """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 3e845667e80c9d665a8fb518a3054efab50ff90d..aec6b19fbb314850ffa8d53b942a7c572c41f66b 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -11939,6 +11939,87 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "vulnerabilitiesCountByDayAndSeverity", + "description": "Number of vulnerabilities per severity level, per day, for the projects in the group and its subgroups", + "args": [ + { + "name": "startDate", + "description": "First day for which to fetch vulnerability history", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "endDate", + "description": "Last day for which to fetch vulnerability history", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverityConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "webUrl", "description": "Web URL of the group", @@ -12035,6 +12116,16 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "SCALAR", + "name": "ISO8601Date", + "description": "An ISO 8601-encoded date", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "InstanceSecurityDashboard", @@ -24232,6 +24323,87 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "vulnerabilitiesCountByDayAndSeverity", + "description": "Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard", + "args": [ + { + "name": "startDate", + "description": "First day for which to fetch vulnerability history", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "endDate", + "description": "Last day for which to fetch vulnerability history", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverityConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -31893,6 +32065,173 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverity", + "description": "Represents the number of vulnerabilities for a particular severity on a particular day", + "fields": [ + { + "name": "count", + "description": "Number of vulnerabilities", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "day", + "description": "Date for the count", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "severity", + "description": "Severity of the counted vulnerabilities", + "args": [ + + ], + "type": { + "kind": "ENUM", + "name": "VulnerabilitySeverity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverityConnection", + "description": "The connection type for VulnerabilitiesCountByDayAndSeverity.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverityEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverity", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverityEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "VulnerabilitiesCountByDayAndSeverity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Vulnerability", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3ca7164bff521c04c8c625660454c8af928cc1d5..f289a057cbcfa03e955135aa09bd10cf85b401ce 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -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. diff --git a/ee/app/graphql/ee/types/group_type.rb b/ee/app/graphql/ee/types/group_type.rb index 4780135346eb9b71d6eb4dce885c7364ec010218..a09d07b11e205d313ca9a629b7e02735c32084cf 100644 --- a/ee/app/graphql/ee/types/group_type.rb +++ b/ee/app/graphql/ee/types/group_type.rb @@ -31,6 +31,12 @@ module GroupType 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 diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index 60839e3940664b355f8e389bb0fcd3424e8d908b..64ae6414ef4922392c58463cdb3b850f3e22fdeb 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -15,6 +15,12 @@ module QueryType 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' diff --git a/ee/app/graphql/resolvers/vulnerabilities_base_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_base_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..dfd1e44380a03d4de932a7465906a3e7f95e0361 --- /dev/null +++ b/ee/app/graphql/resolvers/vulnerabilities_base_resolver.rb @@ -0,0 +1,38 @@ +# 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 diff --git a/ee/app/graphql/resolvers/vulnerabilities_history_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_history_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..d4fbbc8eff47bedd4934ce06a52ae383648338a6 --- /dev/null +++ b/ee/app/graphql/resolvers/vulnerabilities_history_resolver.rb @@ -0,0 +1,31 @@ +# 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 diff --git a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb index 7db52be0547da4a73716cfa5fb47c1d5e6f63c84..031476d8b142bc1e7e936906c2c81fda69f14863 100644 --- a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb +++ b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb @@ -1,7 +1,7 @@ # 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 @@ def resolve(**args) 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 diff --git a/ee/app/graphql/types/vulnerabilities_count_by_day_and_severity_type.rb b/ee/app/graphql/types/vulnerabilities_count_by_day_and_severity_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..90f9a78c4a6eab1a2adfbb5d8a75e460c5876eb1 --- /dev/null +++ b/ee/app/graphql/types/vulnerabilities_count_by_day_and_severity_type.rb @@ -0,0 +1,18 @@ +# 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 diff --git a/ee/app/models/vulnerability.rb b/ee/app/models/vulnerability.rb index 919cce8fded28d2571327ceab4ed1ee413e3d453..ab096d9be50d43146751cb9ab9d55066ff952b60 100644 --- a/ee/app/models/vulnerability.rb +++ b/ee/app/models/vulnerability.rb @@ -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 @@ def with_vulnerability_links 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( diff --git a/ee/changelogs/unreleased/add-vulnerability-history-to-graphql.yml b/ee/changelogs/unreleased/add-vulnerability-history-to-graphql.yml new file mode 100644 index 0000000000000000000000000000000000000000..bf8751381aec77c46a67f7d57e1784fccc8ca91f --- /dev/null +++ b/ee/changelogs/unreleased/add-vulnerability-history-to-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Add vulnerability history to graphQL +merge_request: 30674 +author: +type: added diff --git a/ee/spec/graphql/ee/types/group_type_spec.rb b/ee/spec/graphql/ee/types/group_type_spec.rb index 8d10d8eae271b62e6b0bf8ed3ea3111dc991aca5..311de82c5937b3b0fc44306408ead5a060f138fc 100644 --- a/ee/spec/graphql/ee/types/group_type_spec.rb +++ b/ee/spec/graphql/ee/types/group_type_spec.rb @@ -12,6 +12,7 @@ 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'] } diff --git a/ee/spec/graphql/resolvers/vulnerabilities_history_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerabilities_history_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab0ad0227e22e93783e340defaa0f6044f6a83f7 --- /dev/null +++ b/ee/spec/graphql/resolvers/vulnerabilities_history_resolver_spec.rb @@ -0,0 +1,47 @@ +# 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 diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index c14fd3441ae751cc6c5a4a027ba9d63b32e968c9..6c846d35279f297006cc06dd304b230383263689 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -8,7 +8,8 @@ :design_management, :geo_node, :vulnerabilities, - :instance_security_dashboard + :instance_security_dashboard, + :vulnerabilities_count_by_day_and_severity ).at_least end end diff --git a/ee/spec/graphql/types/vulnerabilities_count_by_day_and_severity_type_spec.rb b/ee/spec/graphql/types/vulnerabilities_count_by_day_and_severity_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..94a70e5f4453f65a6a8919b0123bee2cccaac721 --- /dev/null +++ b/ee/spec/graphql/types/vulnerabilities_count_by_day_and_severity_type_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['VulnerabilitiesCountByDayAndSeverity'] do + it { expect(described_class).to have_graphql_fields(:count, :day, :severity) } +end diff --git a/ee/spec/models/vulnerability_spec.rb b/ee/spec/models/vulnerability_spec.rb index 63969f25e12130e584aeac20babc9bc794a130f8..67a9db499af79548b3115c41eabe88be936ae02a 100644 --- a/ee/spec/models/vulnerability_spec.rb +++ b/ee/spec/models/vulnerability_spec.rb @@ -178,7 +178,7 @@ 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 @@ 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' ) diff --git a/ee/spec/requests/api/graphql/group/vulnerability_severities_count_by_day_spec.rb b/ee/spec/requests/api/graphql/group/vulnerability_severities_count_by_day_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d4be1f0c2553c9543a63af601cbf2866b2c5aca1 --- /dev/null +++ b/ee/spec/requests/api/graphql/group/vulnerability_severities_count_by_day_spec.rb @@ -0,0 +1,59 @@ +# 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 diff --git a/ee/spec/requests/api/graphql/query_spec.rb b/ee/spec/requests/api/graphql/query_spec.rb index 26b4c6eafd7c834da70936af356c738444ed0646..e421daf3437b640d70c08641f9540cf5f7aa609b 100644 --- a/ee/spec/requests/api/graphql/query_spec.rb +++ b/ee/spec/requests/api/graphql/query_spec.rb @@ -92,4 +92,54 @@ 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