diff --git a/ee/app/models/concerns/vulnerabilities/policy_auto_dismissable.rb b/ee/app/models/concerns/vulnerabilities/policy_auto_dismissable.rb new file mode 100644 index 0000000000000000000000000000000000000000..096c978f7af93b232f9567da44f64b1632609447 --- /dev/null +++ b/ee/app/models/concerns/vulnerabilities/policy_auto_dismissable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Vulnerabilities + module PolicyAutoDismissable + extend ActiveSupport::Concern + + included do + # Property is preloaded via `preload_auto_dismissal_checks` to indicate if it matches an auto-dismiss policy + attr_accessor :matches_auto_dismiss_policy + + alias_method :matches_auto_dismiss_policy?, :matches_auto_dismiss_policy + end + + class_methods do + def preload_auto_dismissal_checks!(project, findings) + return findings if findings.empty? + return findings if Feature.disabled?(:auto_dismiss_vulnerability_policies, project.group) + return findings unless project.licensed_feature_available?(:security_orchestration_policies) + + checker = Security::Findings::PolicyAutoDismissalChecker.new(project) + auto_dismissal_map = checker.check_batch(findings) + + findings.each do |finding| + finding.matches_auto_dismiss_policy = auto_dismissal_map.fetch(finding.uuid, false) + end + + findings + end + end + end +end diff --git a/ee/app/models/security/finding.rb b/ee/app/models/security/finding.rb index 83edb837ea8045df9fda5eacaddedbd2ce0fcdcb..90c594f26f67fd08f27c5ded7151f85f85ef7d9e 100644 --- a/ee/app/models/security/finding.rb +++ b/ee/app/models/security/finding.rb @@ -14,6 +14,7 @@ class Finding < ::SecApplicationRecord include EachBatch include Presentable include PartitionedTable + include ::Vulnerabilities::PolicyAutoDismissable MAX_PARTITION_SIZE = 100.gigabytes ATTRIBUTES_DELEGATED_TO_FINDING_DATA = %i[name description solution location identifiers links false_positive? diff --git a/ee/app/models/vulnerabilities/finding.rb b/ee/app/models/vulnerabilities/finding.rb index 20408cb6d7ad793c6df54753aadfe1bd118fdfe1..acffe75c45bea40a6afd230e80098ad343c41628 100644 --- a/ee/app/models/vulnerabilities/finding.rb +++ b/ee/app/models/vulnerabilities/finding.rb @@ -8,6 +8,7 @@ class Finding < ::SecApplicationRecord include ::VulnerabilityFindingHelpers include EachBatch include SafelyChangeColumnDefault + include PolicyAutoDismissable columns_changing_default :detected_at diff --git a/ee/app/services/security/findings/policy_auto_dismissal_checker.rb b/ee/app/services/security/findings/policy_auto_dismissal_checker.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae4e8dd7a626faad50cec4103a5a1df7c7f10da2 --- /dev/null +++ b/ee/app/services/security/findings/policy_auto_dismissal_checker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Security + module Findings + class PolicyAutoDismissalChecker + include Gitlab::Utils::StrongMemoize + + def initialize(project) + @project = project + end + + def check(finding) + rules.any? { |rule| rule.match?(finding) } + end + + def check_batch(findings) + return {} if policies.empty? + + findings.each_with_object({}) do |finding, result| + result[finding.uuid] = check(finding) + end + end + + private + + attr_reader :project + + def policies + return [] if Feature.disabled?(:auto_dismiss_vulnerability_policies, project.group) + return [] unless project.licensed_feature_available?(:security_orchestration_policies) + + project + .vulnerability_management_policies + .auto_dismiss_policies.including_rules + end + strong_memoize_attr :policies + + def rules + policies + .flat_map(&:vulnerability_management_policy_rules) + .select(&:type_detected?) + end + strong_memoize_attr :rules + end + end +end diff --git a/ee/spec/models/security/finding_spec.rb b/ee/spec/models/security/finding_spec.rb index 59c6d30ee87ff832259acf02215de34a290349ac..6aa76505aef6f5b633c4952377e68493d8685e37 100644 --- a/ee/spec/models/security/finding_spec.rb +++ b/ee/spec/models/security/finding_spec.rb @@ -871,4 +871,17 @@ it { is_expected.to be_nil } end end + + it_behaves_like 'policy auto-dismissable' do + let_it_be(:policy_rule_attributes) { { file_path: 'test/**/*' } } + let_it_be(:scan) { create(:security_scan, project: project) } + + let!(:matching_finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'test/spec/example_spec.rb' }) + end + + let!(:non_matching_finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'src/main.c' }) + end + end end diff --git a/ee/spec/models/vulnerabilities/finding_spec.rb b/ee/spec/models/vulnerabilities/finding_spec.rb index b51a18d4619327432ba74e96a26d116a6f5cd78f..5b198de483493868888723f93b5229f137ad0f84 100644 --- a/ee/spec/models/vulnerabilities/finding_spec.rb +++ b/ee/spec/models/vulnerabilities/finding_spec.rb @@ -2028,4 +2028,16 @@ def create_finding(state) end end end + + it_behaves_like 'policy auto-dismissable' do + let_it_be(:policy_rule_attributes) { { file_path: 'test/**/*' } } + + let!(:matching_finding) do + create(:vulnerabilities_finding, :with_dependency_scanning_metadata, project: project, file: 'test/spec/example_spec.rb') + end + + let!(:non_matching_finding) do + create(:vulnerabilities_finding, :with_dependency_scanning_metadata, project: project, file: 'src/main.c') + end + end end diff --git a/ee/spec/services/security/findings/policy_auto_dismissal_checker_spec.rb b/ee/spec/services/security/findings/policy_auto_dismissal_checker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a0af971eb251338ebb4c6f1fb945636b3063fd07 --- /dev/null +++ b/ee/spec/services/security/findings/policy_auto_dismissal_checker_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::Findings::PolicyAutoDismissalChecker, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project) } + let_it_be(:scan) { create(:security_scan, project: project) } + + subject(:checker) { described_class.new(project) } + + before do + stub_licensed_features(security_orchestration_policies: true) + end + + describe '#check' do + context 'when there are no policies' do + it 'returns false' do + finding = create(:security_finding, :with_finding_data, scan: scan) + + result = checker.check(finding) + + expect(result).to be false + end + end + + context 'when there are policies but no rules' do + let_it_be(:policy) do + create(:security_policy, :vulnerability_management_policy, :auto_dismiss, linked_projects: [project]) + end + + it 'returns false' do + finding = create(:security_finding, :with_finding_data, scan: scan) + + result = checker.check(finding) + + expect(result).to be false + end + end + + context 'when there are policies with detected rules' do + let_it_be(:policy) do + create(:security_policy, :vulnerability_management_policy, :auto_dismiss, linked_projects: [project]) + end + + let_it_be(:rule) do + create(:vulnerability_management_policy_rule, :detected_file_path, + security_policy: policy, + file_path: 'test/**/*' + ) + end + + subject(:check) { checker.check(finding) } + + context 'when finding matches the rule' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, + location: { file: 'test/spec/example_spec.rb' }) + end + + it { is_expected.to be true } + + context 'when feature is not licensed' do + before do + stub_licensed_features(security_orchestration_policies: false) + end + + it { is_expected.to be false } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(auto_dismiss_vulnerability_policies: false) + end + + it { is_expected.to be false } + end + end + + context 'when finding does not match the rule' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'src/main.rb' }) + end + + it { is_expected.to be false } + end + + context 'with multiple rules' do + let_it_be(:rule2) do + create(:vulnerability_management_policy_rule, :detected_identifier, + security_policy: policy, identifier: 'CWE-99') + end + + context 'when any rule matches' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, + location: { file: 'test/spec/example_spec.rb' }, + identifiers: [create(:ci_reports_security_identifier, name: 'CWE-79').to_hash]) + end + + it { is_expected.to be true } + end + + context 'when no rules match' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, + location: { file: 'src/main.c' }, + identifiers: [create(:ci_reports_security_identifier, name: 'CWE-78').to_hash]) + end + + it { is_expected.to be false } + end + end + end + end + + describe '#check_batch' do + context 'when there are no policies' do + it 'returns an empty hash' do + findings = [create(:security_finding, :with_finding_data, scan: scan)] + + result = checker.check_batch(findings) + + expect(result).to eq({}) + end + end + + context 'when there are policies but no rules' do + let_it_be(:policy) do + create(:security_policy, :vulnerability_management_policy, :auto_dismiss, linked_projects: [project]) + end + + it 'returns a hash with all findings mapped to false' do + finding1 = create(:security_finding, :with_finding_data, scan: scan) + finding2 = create(:security_finding, :with_finding_data, scan: scan) + findings = [finding1, finding2] + + result = checker.check_batch(findings) + + expect(result).to eq({ + finding1.uuid => false, + finding2.uuid => false + }) + end + end + + context 'when there are policies with detected rules' do + let_it_be(:policy) do + create(:security_policy, :vulnerability_management_policy, :auto_dismiss, linked_projects: [project]) + end + + let_it_be(:rule) do + create(:vulnerability_management_policy_rule, :detected_file_path, + security_policy: policy, + file_path: 'test/**/*' + ) + end + + it 'returns a hash mapping finding UUIDs to match results' do + matching_finding = create(:security_finding, :with_finding_data, scan: scan, + location: { file: 'test/spec/example_spec.rb' }) + non_matching_finding = create(:security_finding, :with_finding_data, scan: scan, + location: { file: 'src/main.rb' }) + + findings = [matching_finding, non_matching_finding] + + result = checker.check_batch(findings) + + expect(result).to eq({ + matching_finding.uuid => true, + non_matching_finding.uuid => false + }) + end + end + + context 'with empty findings array' do + it 'returns an empty hash' do + result = checker.check_batch([]) + + expect(result).to eq({}) + end + end + end +end diff --git a/ee/spec/support/shared_examples/models/concerns/vulnerabilities/policy_auto_dismissable_shared_examples.rb b/ee/spec/support/shared_examples/models/concerns/vulnerabilities/policy_auto_dismissable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..92bea4d0ec9ad2da9234eecc6bad44dcf0f2adc4 --- /dev/null +++ b/ee/spec/support/shared_examples/models/concerns/vulnerabilities/policy_auto_dismissable_shared_examples.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'policy auto-dismissable' do + let_it_be(:project) { create(:project) } + let(:feature_licensed) { true } + + before do + stub_licensed_features(security_orchestration_policies: feature_licensed) + end + + describe '#matches_auto_dismiss_policy?' do + context 'when matches_auto_dismiss_policy is set' do + it 'returns the precomputed value when true' do + finding = matching_finding.dup + finding.matches_auto_dismiss_policy = true + expect(finding.matches_auto_dismiss_policy?).to be true + end + + it 'returns the precomputed value when false' do + finding = matching_finding.dup + finding.matches_auto_dismiss_policy = false + expect(finding.matches_auto_dismiss_policy?).to be false + end + end + + context 'when the property is not set via preloading' do + it 'returns nil' do + expect(matching_finding.matches_auto_dismiss_policy?).to be_nil + expect(non_matching_finding.matches_auto_dismiss_policy?).to be_nil + end + end + end + + describe '.preload_auto_dismissal_checks!' do + shared_examples_for 'does not process auto-dismiss' do + it 'sets matches_auto_dismiss_policy to nil for all findings without processing', :aggregate_failures do + expect(Security::Findings::PolicyAutoDismissalChecker).not_to receive(:new) + + result = described_class.preload_auto_dismissal_checks!(project, [matching_finding, non_matching_finding]) + expect(result).to match_array([matching_finding, non_matching_finding]) + + expect(matching_finding.matches_auto_dismiss_policy).to be_nil + expect(non_matching_finding.matches_auto_dismiss_policy).to be_nil + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(auto_dismiss_vulnerability_policies: false) + end + + it_behaves_like 'does not process auto-dismiss' + end + + context 'when the feature is not licensed' do + let(:feature_licensed) { false } + + it_behaves_like 'does not process auto-dismiss' + end + + context 'when findings list is empty' do + it 'returns the empty list' do + result = described_class.preload_auto_dismissal_checks!(project, []) + expect(result).to eq([]) + end + end + + context 'when there are no auto-dismiss policies' do + it 'sets matches_auto_dismiss_policy to false for all findings' do + described_class.preload_auto_dismissal_checks!(project, [matching_finding, non_matching_finding]) + + expect(matching_finding.matches_auto_dismiss_policy).to be false + expect(non_matching_finding.matches_auto_dismiss_policy).to be false + end + end + + context 'when there are auto-dismiss policies' do + let_it_be(:policy) do + create(:security_policy, :vulnerability_management_policy, :auto_dismiss, linked_projects: [project]) + end + + let_it_be(:rule) do + create(:vulnerability_management_policy_rule, :detected_file_path, + security_policy: policy, **policy_rule_attributes) + end + + before do + described_class.preload_auto_dismissal_checks!(project, [matching_finding, non_matching_finding]) + end + + it 'sets matches_auto_dismiss_policy to true for matching findings' do + expect(matching_finding.matches_auto_dismiss_policy).to be true + end + + it 'sets matches_auto_dismiss_policy to false for non-matching findings' do + expect(non_matching_finding.matches_auto_dismiss_policy).to be false + end + end + end +end