From 804d1a55fcf9b3c2851de3ff1d3de1d6e92b59a0 Mon Sep 17 00:00:00 2001 From: Brian Williams <bwilliams@gitlab.com> Date: Wed, 20 Nov 2024 10:25:14 -0600 Subject: [PATCH] Auto-resolve vulnerabilities based on vulnerability management policy Automatically resolve vulnerabilities if a vulnerability management policy is configured. --- .../vulnerability_management_policy.rb | 10 +++ ee/app/models/security/policy.rb | 1 + .../vulnerability_management_policy_rule.rb | 20 +++++ .../ingestion/mark_as_resolved_service.rb | 19 ++++- .../vulnerabilities/auto_resolve_service.rb | 74 +++++++++++-------- .../beta/auto_resolve_vulnerabilities.yml | 9 +++ ee/spec/factories/security/policies.rb | 10 +++ ...lnerability_management_policy_rule_spec.rb | 54 ++++++++++++++ .../mark_as_resolved_service_spec.rb | 42 ++++++++--- .../auto_resolve_service_spec.rb | 21 +++++- 10 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 ee/config/feature_flags/beta/auto_resolve_vulnerabilities.yml diff --git a/ee/app/models/concerns/security/vulnerability_management_policy.rb b/ee/app/models/concerns/security/vulnerability_management_policy.rb index 0e23907a03be9..8e9d31ad25965 100644 --- a/ee/app/models/concerns/security/vulnerability_management_policy.rb +++ b/ee/app/models/concerns/security/vulnerability_management_policy.rb @@ -11,5 +11,15 @@ def active_vulnerability_management_policies def vulnerability_management_policy policy_by_type(:vulnerability_management_policy) end + + def match?(vulnerability) + no_longer_detected_rules.any? { |rule| rule.match?(vulnerability) } + end + + def no_longer_detected_rules + vulnerability_management_policy_rules + .undeleted + .no_longer_detected + end end end diff --git a/ee/app/models/security/policy.rb b/ee/app/models/security/policy.rb index c1943dc8dcc01..a8e251c38b6d1 100644 --- a/ee/app/models/security/policy.rb +++ b/ee/app/models/security/policy.rb @@ -3,6 +3,7 @@ module Security class Policy < ApplicationRecord include EachBatch + include Security::VulnerabilityManagementPolicy self.table_name = 'security_policies' self.inheritance_column = :_type_disabled diff --git a/ee/app/models/security/vulnerability_management_policy_rule.rb b/ee/app/models/security/vulnerability_management_policy_rule.rb index 71d45b1cce2e7..74ef4f2a1e5e9 100644 --- a/ee/app/models/security/vulnerability_management_policy_rule.rb +++ b/ee/app/models/security/vulnerability_management_policy_rule.rb @@ -11,5 +11,25 @@ class VulnerabilityManagementPolicyRule < ApplicationRecord belongs_to :security_policy, class_name: 'Security::Policy', inverse_of: :vulnerability_management_policy_rules validates :typed_content, json_schema: { filename: "vulnerability_management_policy_rule_content" } + + scope :no_longer_detected, -> { where(type: :no_longer_detected) } + + def match?(vulnerability) + scanners.include?(vulnerability.report_type) && severity_levels.include?(vulnerability.severity) + end + + private + + def scanners + return ::Enums::Vulnerability.report_types.keys if content['scanners'].blank? + + content['scanners'] + end + + def severity_levels + return ::Enums::Vulnerability.severity_levels.keys if content['severity_levels'].blank? + + content['severity_levels'] + end end end diff --git a/ee/app/services/security/ingestion/mark_as_resolved_service.rb b/ee/app/services/security/ingestion/mark_as_resolved_service.rb index e9db98d46e2e5..44091f3f69dec 100644 --- a/ee/app/services/security/ingestion/mark_as_resolved_service.rb +++ b/ee/app/services/security/ingestion/mark_as_resolved_service.rb @@ -8,6 +8,7 @@ module Ingestion # on the default branch if they were not detected again. class MarkAsResolvedService include Gitlab::InternalEventsTracking + include Gitlab::Utils::StrongMemoize CVS_SCANNER_EXTERNAL_ID = 'gitlab-sbom-vulnerability-scanner' CS_SCANNERS_EXTERNAL_IDS = %w[trivy].freeze @@ -44,10 +45,13 @@ def execute delegate :vulnerability_reads, to: :project, private: true def process_batch(batch) - (batch.pluck_primary_key - ingested_ids).then { |missing_ids| mark_as_resolved(missing_ids) } + (batch.pluck_primary_key - ingested_ids).then do |missing_ids| + mark_as_no_longer_detected(missing_ids) + auto_resolve(missing_ids) + end end - def mark_as_resolved(missing_ids) + def mark_as_no_longer_detected(missing_ids) return if missing_ids.blank? resolved_count = Vulnerability.id_in(missing_ids) @@ -58,6 +62,17 @@ def mark_as_resolved(missing_ids) track_no_longer_detected_vulnerabilities(resolved_count) end + def auto_resolve(missing_ids) + return unless auto_resolve_enabled? + + Vulnerabilities::AutoResolveService.new(project, missing_ids).execute + end + + def auto_resolve_enabled? + ::Feature.enabled?(:auto_resolve_vulnerabilities, project) + end + strong_memoize_attr :auto_resolve_enabled? + def process_existing_cvs_vulnerabilities_for_container_scanning vulnerability_reads .by_scanner_ids(cvs_scanner_id) diff --git a/ee/app/services/vulnerabilities/auto_resolve_service.rb b/ee/app/services/vulnerabilities/auto_resolve_service.rb index 4b146c6d58860..4a776517ff9c6 100644 --- a/ee/app/services/vulnerabilities/auto_resolve_service.rb +++ b/ee/app/services/vulnerabilities/auto_resolve_service.rb @@ -2,20 +2,20 @@ module Vulnerabilities class AutoResolveService + include Gitlab::Utils::StrongMemoize + MAX_BATCH = 100 - def initialize(project, vulnerability_ids, security_policy_name) + def initialize(project, vulnerability_ids) @project = project - @vulnerability_ids = vulnerability_ids - @security_policy_name = security_policy_name + @vulnerabilities = Vulnerability.id_in(vulnerability_ids.first(MAX_BATCH)) end def execute + return ServiceResponse.success if policies.blank? return error_response unless can_create_state_transitions? - vulnerability_ids.each_slice(MAX_BATCH).each do |ids| - resolve(Vulnerability.id_in(ids)) - end + resolve_vulnerabilities refresh_statistics ServiceResponse.success @@ -25,24 +25,37 @@ def execute private - attr_reader :project, :vulnerability_ids, :security_policy_name + attr_reader :project, :vulnerabilities - def resolve(vulnerabilities) - # rubocop:disable CodeReuse/ActiveRecord -- context specific - # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- Caller limits to 100 records - vulnerability_attrs = vulnerabilities.pluck(:id, :state) - # rubocop:enable CodeReuse/ActiveRecord - # rubocop:enable Database/AvoidUsingPluckWithoutLimit + def vulnerabilities_to_resolve + policies_by_vulnerability.keys + end - return if vulnerability_attrs.empty? + def policies_by_vulnerability + policies.each_with_object({}) do |policy, memo| + vulnerabilities.each do |vulnerability| + if policy.match?(vulnerability) + memo[vulnerability] ||= [] + memo[vulnerability].push(policy) + end + end + end + end + strong_memoize_attr :policies_by_vulnerability + + def policies + # TODO: This should only include policies that have a `no_longer_detected` rule + # and an `auto_resolve` action + project.security_policies.type_vulnerability_management_policy + end - state_transitions = transition_attributes_for(vulnerability_attrs) - system_notes = system_note_attributes_for(vulnerability_attrs) + def resolve_vulnerabilities + return if vulnerabilities_to_resolve.empty? Vulnerability.transaction do - Vulnerabilities::StateTransition.insert_all!(state_transitions) + Vulnerabilities::StateTransition.insert_all!(state_transition_attrs) - vulnerabilities.update_all( + Vulnerability.id_in(vulnerabilities_to_resolve.map(&:id)).update_all( state: :resolved, auto_resolved: true, resolved_by_id: user.id, @@ -50,28 +63,28 @@ def resolve(vulnerabilities) updated_at: now ) end - Note.insert_all!(system_notes) + Note.insert_all!(system_note_attrs) end - def transition_attributes_for(attrs) - attrs.map do |id, state| + def state_transition_attrs + vulnerabilities_to_resolve.map do |vulnerability| { - vulnerability_id: id, - from_state: state, + vulnerability_id: vulnerability.id, + from_state: vulnerability.state, to_state: :resolved, author_id: user.id, - comment: comment, + comment: comment(vulnerability), created_at: now, updated_at: now } end end - def system_note_attributes_for(attrs) - attrs.map do |id, _| + def system_note_attrs + vulnerabilities_to_resolve.map do |vulnerability| { noteable_type: "Vulnerability", - noteable_id: id, + noteable_id: vulnerability.id, project_id: project.id, namespace_id: project.project_namespace_id, system: true, @@ -79,7 +92,7 @@ def system_note_attributes_for(attrs) 'changed', :resolved, nil, - comment + comment(vulnerability) ), author_id: user.id, created_at: now, @@ -88,8 +101,9 @@ def system_note_attributes_for(attrs) end end - def comment - _("Auto-resolved by vulnerability management policy") + " #{security_policy_name}" + def comment(vulnerability) + policy_names = policies_by_vulnerability[vulnerability].map(&:name) + _("Auto-resolved by vulnerability management policy") + " #{policy_names.join(', ')}" end def user diff --git a/ee/config/feature_flags/beta/auto_resolve_vulnerabilities.yml b/ee/config/feature_flags/beta/auto_resolve_vulnerabilities.yml new file mode 100644 index 0000000000000..7f3d40dfe8038 --- /dev/null +++ b/ee/config/feature_flags/beta/auto_resolve_vulnerabilities.yml @@ -0,0 +1,9 @@ +--- +name: auto_resolve_vulnerabilities +feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/5708 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/173437 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/505711 +milestone: '17.7' +group: group::security insights +type: beta +default_enabled: false diff --git a/ee/spec/factories/security/policies.rb b/ee/spec/factories/security/policies.rb index 5ff9e12c603c7..f0b49e6c148d6 100644 --- a/ee/spec/factories/security/policies.rb +++ b/ee/spec/factories/security/policies.rb @@ -37,6 +37,16 @@ end require_approval + transient do + linked_projects { [] } + end + + after(:create) do |policy, evaluator| + evaluator.linked_projects.each do |project| + create(:security_policy_project_link, project: project, security_policy: policy) + end + end + trait :deleted do policy_index { -1 } end diff --git a/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb b/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb index 673d20ec65ebb..568ebe0a97e23 100644 --- a/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb +++ b/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb @@ -23,4 +23,58 @@ end end end + + describe '#match?' do + def severity_wildcard_should_match_all_severities + ::Enums::Vulnerability.severity_levels.keys.map do |severity| + [['sast'], [], :sast, severity, true] + end + end + + def scanner_wildcard_should_match_all_scanners + ::Enums::Vulnerability.report_types.keys.map do |report_type| + [[], ['critical'], report_type, 'critical', true] + end + end + + def should_match_if_partials_match + [ + [%w[dependency_scanning container_scanning], %w[info low], :dependency_scanning, :info, true], + [%w[dependency_scanning container_scanning], %w[info low], :dependency_scanning, :low, true], + [%w[dependency_scanning container_scanning], %w[info low], :container_scanning, :info, true], + [%w[dependency_scanning container_scanning], %w[info low], :container_scanning, :low, true] + ] + end + + def should_not_match_if_partials_dont_match + [ + [%w[dependency_scanning container_scanning], %w[info low], :sast, :info, false], + [%w[dependency_scanning container_scanning], %w[info low], :dependency_scanning, :critical, false], + [%w[dependency_scanning container_scanning], %w[info low], :sast, :critical, false] + ] + end + + where(:policy_scanners, :policy_severities, :vulnerability_report_type, :vulnerability_severity, :expected) do + severity_wildcard_should_match_all_severities + + scanner_wildcard_should_match_all_scanners + + should_match_if_partials_match + + should_not_match_if_partials_dont_match + end + + with_them do + specify do + rule = build_stubbed(:vulnerability_management_policy_rule, + type: :no_longer_detected, + content: { 'scanners' => policy_scanners, 'severity_levels' => policy_severities } + ) + + vulnerability = build_stubbed(:vulnerability, + report_type: vulnerability_report_type, + severity: vulnerability_severity + ) + + expect(rule.match?(vulnerability)).to eq(expected) + end + end + end end diff --git a/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb b/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb index 6701417eae13f..cb38746c3241a 100644 --- a/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb +++ b/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb @@ -11,17 +11,41 @@ let(:ingested_ids) { [] } let_it_be(:scanner) { create(:vulnerabilities_scanner, project: project) } - it 'resolves non-generic vulnerabilities detected by the scanner' do - vulnerability = create(:vulnerability, :sast, - project: project, - present_on_default_branch: true, - resolved_on_default_branch: false, - findings: [create(:vulnerabilities_finding, project: project, scanner: scanner)] - ) + context 'when there is a vulnerability to be resolved' do + let_it_be(:vulnerability) do + create(:vulnerability, :sast, + project: project, + present_on_default_branch: true, + resolved_on_default_branch: false, + findings: [create(:vulnerabilities_finding, project: project, scanner: scanner)] + ) + end - command.execute + it 'resolves non-generic vulnerabilities detected by the scanner' do + command.execute + + expect(vulnerability.reload).to be_resolved_on_default_branch + end + + it 'calls AutoResolveService on missing_ids' do + expect_next_instance_of(Vulnerabilities::AutoResolveService, project, [vulnerability.id]) do |service| + expect(service).to receive(:execute) + end - expect(vulnerability.reload).to be_resolved_on_default_branch + command.execute + end + + context 'when auto_resolve_vulnerabilities feature flag is disabled' do + before do + stub_feature_flags(auto_resolve_vulnerabilities: false) + end + + it 'does not call AutoResolveService' do + expect(Vulnerabilities::AutoResolveService).not_to receive(:new) + + command.execute + end + end end context 'with multiple vulnerabilities' do diff --git a/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb b/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb index 6d8aa8cb7edb3..2295635785ad9 100644 --- a/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb +++ b/ee/spec/services/vulnerabilities/auto_resolve_service_spec.rb @@ -7,11 +7,24 @@ let_it_be(:namespace) { create(:namespace) } let_it_be_with_reload(:project) { create(:project, namespace: namespace) } let_it_be(:vulnerability) { create(:vulnerability, :with_findings, :detected, :high_severity, project: project) } + let_it_be(:policy) { create(:security_policy, :vulnerability_management_policy, linked_projects: [project]) } + let_it_be(:policy_rule) do + create(:vulnerability_management_policy_rule, + security_policy: policy, + content: { + type: 'no_longer_detected', + scanners: [], + severity_levels: [] + } + ) + end + let(:vulnerability_ids) { [vulnerability.id] } + let(:comment) { _("Auto-resolved by vulnerability management policy") + " #{security_policy_name}" } - let(:security_policy_name) { 'resolve_low_severity_vulnerabilities' } + let(:security_policy_name) { policy.name } - subject(:service) { described_class.new(project, vulnerability_ids, security_policy_name) } + subject(:service) { described_class.new(project, vulnerability_ids) } before_all do project.add_guest(user) @@ -128,14 +141,14 @@ it 'does not introduce N+1 queries' do control = ActiveRecord::QueryRecorder.new do - described_class.new(project, vulnerability_ids, security_policy_name).execute + described_class.new(project, vulnerability_ids).execute end new_vulnerability = create(:vulnerability, :with_findings, project: project) vulnerability_ids << new_vulnerability.id expect do - described_class.new(project, vulnerability_ids, security_policy_name).execute + described_class.new(project, vulnerability_ids).execute end.not_to exceed_query_limit(control) end end -- GitLab