diff --git a/ee/app/models/security/finding.rb b/ee/app/models/security/finding.rb index e939f0cdd5249bfc91736a529d0b70d116eb3167..83edb837ea8045df9fda5eacaddedbd2ce0fcdcb 100644 --- a/ee/app/models/security/finding.rb +++ b/ee/app/models/security/finding.rb @@ -312,6 +312,10 @@ def ai_resolution_enabled? ::Vulnerabilities::Finding::HIGH_CONFIDENCE_AI_RESOLUTION_CWES.include?(cwe_name&.upcase) end + def file + location&.dig(:file) + end + def requires_manual_resolution? ::Vulnerability::REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type) end diff --git a/ee/app/models/security/vulnerability_management_policy_rule.rb b/ee/app/models/security/vulnerability_management_policy_rule.rb index 0777c8c5ee4d8d655e0d819152beae33073c4868..a81c1f56658c2eb1b497572c84765f53483db639 100644 --- a/ee/app/models/security/vulnerability_management_policy_rule.rb +++ b/ee/app/models/security/vulnerability_management_policy_rule.rb @@ -38,39 +38,39 @@ def criteria content['criteria'] end - def match_detected?(vulnerability) + def match_detected?(vulnerability_or_finding) return false unless criteria.present? criteria.all? do |criterion| - match_vulnerability_criteria?(vulnerability, criterion) + match_vulnerability_criteria?(vulnerability_or_finding, criterion) end end - def match_vulnerability_criteria?(vulnerability, criterion) + def match_vulnerability_criteria?(vulnerability_or_finding, criterion) criterion_type = criterion['type'] criterion_value = criterion['value'] case criterion_type when 'file_path' - match_file_path?(vulnerability, criterion_value) + match_file_path?(vulnerability_or_finding, criterion_value) when 'directory' - match_directory?(vulnerability, criterion_value) + match_directory?(vulnerability_or_finding, criterion_value) when 'identifier' - match_identifier?(vulnerability, criterion_value) + match_identifier?(vulnerability_or_finding, criterion_value) else false end end - def match_file_path?(vulnerability, pattern) - file_path = vulnerability.file + def match_file_path?(vulnerability_or_finding, pattern) + file_path = vulnerability_or_finding.file return false unless file_path match_path?(pattern, file_path) end - def match_directory?(vulnerability, pattern) - file_path = vulnerability.file + def match_directory?(vulnerability_or_finding, pattern) + file_path = vulnerability_or_finding.file return false unless file_path directory = File.dirname(file_path) @@ -81,12 +81,26 @@ def match_path?(pattern, path) File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH) end - def match_identifier?(vulnerability, pattern) - identifiers = vulnerability.identifiers + def match_identifier?(vulnerability_or_finding, pattern) + identifiers = vulnerability_or_finding.identifiers return false if identifiers.blank? identifiers.any? do |identifier| - File.fnmatch?(pattern, identifier.name, File::FNM_CASEFOLD) + identifier_name = extract_identifier_name(identifier) + next if identifier_name.blank? + + File.fnmatch?(pattern, identifier_name, File::FNM_CASEFOLD) + end + end + + def extract_identifier_name(identifier) + # Security::Finding has identifiers as hashes + # Vulnerabilities::Finding has identifiers as ActiveRecord objects + case identifier + when Hash + identifier[:name] + else + identifier.name end end end diff --git a/ee/spec/factories/security/vulnerability_management_policy_rules.rb b/ee/spec/factories/security/vulnerability_management_policy_rules.rb index 95747b0a18f7dc42a845c9342d04b38f6f2e7e28..a525b53094193cff320f4164341f7cb54d35a02b 100644 --- a/ee/spec/factories/security/vulnerability_management_policy_rules.rb +++ b/ee/spec/factories/security/vulnerability_management_policy_rules.rb @@ -19,5 +19,56 @@ } end end + + trait :detected do + type { Security::VulnerabilityManagementPolicyRule.types[:detected] } + content do + { + type: 'detected', + criteria: [ + { + type: 'file_path', + value: '**/*' + } + ] + } + end + end + + trait :detected_file_path do + detected + + transient do + file_path { 'test/**/*' } + end + + after(:build) do |rule, evaluator| + rule.content[:criteria] = [{ type: 'file_path', value: evaluator.file_path }] + end + end + + trait :detected_directory do + detected + + transient do + directory { 'test' } + end + + after(:build) do |rule, evaluator| + rule.content[:criteria] = [{ type: 'directory', value: evaluator.directory }] + end + end + + trait :detected_identifier do + detected + + transient do + identifier { 'CVE-2025-12345' } + end + + after(:build) do |rule, evaluator| + rule.content[:criteria] = [{ type: 'identifier', value: evaluator.identifier }] + end + end end end diff --git a/ee/spec/models/security/finding_spec.rb b/ee/spec/models/security/finding_spec.rb index c24e8c5511b8d8331c8684c1ad938510d98eace6..59c6d30ee87ff832259acf02215de34a290349ac 100644 --- a/ee/spec/models/security/finding_spec.rb +++ b/ee/spec/models/security/finding_spec.rb @@ -835,4 +835,40 @@ end end end + + describe '#file' do + subject(:file) { finding.file } + + context 'when location has file key' do + let(:finding) do + create(:security_finding, :with_finding_data, location: { file: 'src/main.rb' }) + end + + it { is_expected.to eq('src/main.rb') } + end + + context 'when location does not have file key' do + let(:finding) do + create(:security_finding, :with_finding_data, location: { report_type: 'coverage_fuzzing' }) + end + + it { is_expected.to be_nil } + end + + context 'when location is empty' do + let(:finding) do + create(:security_finding, :with_finding_data, location: {}) + end + + it { is_expected.to be_nil } + end + + context 'when location is nil' do + let(:finding) do + create(:security_finding, location: nil) + end + + it { is_expected.to be_nil } + end + end 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 508510e1aef7fc6b96be59752e5f6946bc8a2bdf..e0139d8d9330b9d7c1a6e5ee95fa3fc17eccca68 100644 --- a/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb +++ b/ee/spec/models/security/vulnerability_management_policy_rule_spec.rb @@ -80,142 +80,217 @@ def should_not_match_if_partials_dont_match end context 'when rule type is detected' do - subject { rule.match?(vulnerability) } + context 'for vulnerability' do + subject { rule.match?(vulnerability) } - let_it_be(:project) { create(:project) } - let!(:vulnerability) { finding.vulnerability } - let(:rule) do - build_stubbed(:vulnerability_management_policy_rule, type: :detected, content: rule_content) - end + let_it_be(:project) { create(:project) } + let!(:vulnerability) { finding.vulnerability } + let(:rule) do + build_stubbed(:vulnerability_management_policy_rule, type: :detected, content: rule_content) + end - shared_examples_for 'finding without file location' do - context 'when finding has location but not file' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, project: project, file: nil) + shared_examples_for 'finding without file location' do + context 'when finding has location but not file' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, project: project, + file: nil) + end + + it { is_expected.to be false } end - it { is_expected.to be false } + context 'when finding has no location' do + let(:finding) do + create(:vulnerabilities_finding, :detected, raw_metadata: {}, location: nil) + end + + it { is_expected.to be false } + end end - context 'when finding has no location' do - let(:finding) do - create(:vulnerabilities_finding, :detected, raw_metadata: {}, location: nil) + context 'with file_path criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'file_path', + 'value' => 'test/**/*' + } + ] + } end - it { is_expected.to be false } - end - end + context 'when finding matches file path pattern' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, + project: project, + file: 'test/spec/example_spec.rb') + end - context 'with file_path criteria' do - let(:rule_content) do - { - 'criteria' => [ - { - 'type' => 'file_path', - 'value' => 'test/**/*' - } - ] - } - end + it { is_expected.to be true } + end - context 'when finding matches file path pattern' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, - project: project, - file: 'test/spec/example_spec.rb') + context 'when finding does not match file path pattern' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, + project: project, + file: 'src/main.rb') + end + + it { is_expected.to be false } end - it { is_expected.to be true } + it_behaves_like 'finding without file location' end - context 'when finding does not match file path pattern' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, - project: project, - file: 'src/main.rb') + context 'with directory criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'directory', + 'value' => 'test' + } + ] + } end - it { is_expected.to be false } - end + context 'when finding is in matching directory' do + let(:finding) do + create(:vulnerabilities_finding, + :detected, :with_dependency_scanning_metadata, + project: project, + file: 'test/example_spec.rb') + end - it_behaves_like 'finding without file location' - end + it { is_expected.to be true } + end - context 'with directory criteria' do - let(:rule_content) do - { - 'criteria' => [ - { - 'type' => 'directory', - 'value' => 'test' - } - ] - } - end + context 'when finding is in subdirectory of matching directory' do + let(:finding) do + create(:vulnerabilities_finding, + :detected, :with_dependency_scanning_metadata, + project: project, + file: 'test/unit/example_spec.rb') + end - context 'when finding is in matching directory' do - let(:finding) do - create(:vulnerabilities_finding, - :detected, :with_dependency_scanning_metadata, - project: project, - file: 'test/example_spec.rb') + it { is_expected.to be true } end - it { is_expected.to be true } - end + context 'when finding is in a non-matching directory' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, + project: project, file: 'src/main.rb') + end - context 'when finding is in subdirectory of matching directory' do - let(:finding) do - create(:vulnerabilities_finding, - :detected, :with_dependency_scanning_metadata, - project: project, - file: 'test/unit/example_spec.rb') + it { is_expected.to be false } end - it { is_expected.to be true } + it_behaves_like 'finding without file location' end - context 'when finding is in a non-matching directory' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_dependency_scanning_metadata, - project: project, file: 'src/main.rb') + context 'with identifier criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'identifier', + 'value' => 'CVE-2023-*' + } + ] + } end - it { is_expected.to be false } - end + context 'when finding has matching identifier' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_cve, project: project, cve_value: 'CVE-2023-12345') + end - it_behaves_like 'finding without file location' - end + it { is_expected.to be true } + end + + context 'when finding has non-matching identifier' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_cve, project: project, cve_value: 'CWE-79') + end + + it { is_expected.to be false } + end + + context 'when finding has no identifiers' do + let(:finding) do + create(:vulnerabilities_finding, :detected, project: project) + end - context 'with identifier criteria' do - let(:rule_content) do - { - 'criteria' => [ - { - 'type' => 'identifier', - 'value' => 'CVE-2023-*' - } - ] - } + it { is_expected.to be false } + end end - context 'when finding has matching identifier' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_cve, project: project, cve_value: 'CVE-2023-12345') + context 'with multiple criteria (AND logic)' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'file_path', + 'value' => 'test/**/*' + }, + { + 'type' => 'identifier', + 'value' => 'CVE-2023-*' + } + ] + } + end + + context 'when finding matches all criteria' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_cve, + project: project, + location: { 'file' => 'test/spec/example_spec.rb' }, + cve_value: 'CVE-2023-12345') + end + + it { is_expected.to be true } end - it { is_expected.to be true } + context 'when finding matches only some criteria' do + let(:finding) do + create(:vulnerabilities_finding, :detected, :with_cve, + project: project, + location: { 'file' => 'src/main.rb' }, + cve_value: 'CVE-2023-12345') + end + + it { is_expected.to be false } + end end - context 'when finding has non-matching identifier' do + context 'with empty criteria' do + let(:rule_content) do + { + 'criteria' => [] + } + end + let(:finding) do - create(:vulnerabilities_finding, :detected, :with_cve, project: project, cve_value: 'CWE-79') + create(:vulnerabilities_finding, :detected, project: project) end it { is_expected.to be false } end - context 'when finding has no identifiers' do + context 'with invalid criteria type' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'invalid_criteria', + 'value' => 'some_value' + } + ] + } + end + let(:finding) do create(:vulnerabilities_finding, :detected, project: project) end @@ -224,76 +299,150 @@ def should_not_match_if_partials_dont_match end end - context 'with multiple criteria (AND logic)' do - let(:rule_content) do - { - 'criteria' => [ - { - 'type' => 'file_path', - 'value' => 'test/**/*' - }, - { - 'type' => 'identifier', - 'value' => 'CVE-2023-*' - } - ] - } + context 'for security finding' do + subject { rule.match?(finding) } + + let_it_be(:project) { create(:project) } + let_it_be(:scan) { create(:security_scan, project: project) } + let(:rule) do + build_stubbed(:vulnerability_management_policy_rule, type: :detected, content: rule_content) end - context 'when finding matches all criteria' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_cve, - project: project, - location: { 'file' => 'test/spec/example_spec.rb' }, - cve_value: 'CVE-2023-12345') + shared_examples_for 'finding without file location' do + context 'when finding has location but not file' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { 'blob_path' => 'src/main.rb' }) + end + + it { is_expected.to be false } end - it { is_expected.to be true } + context 'when finding has no location' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: {}) + end + + it { is_expected.to be false } + end end - context 'when finding matches only some criteria' do - let(:finding) do - create(:vulnerabilities_finding, :detected, :with_cve, - project: project, - location: { 'file' => 'src/main.rb' }, - cve_value: 'CVE-2023-12345') + context 'with file_path criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'file_path', + 'value' => 'test/**/*' + } + ] + } end - it { is_expected.to be false } - end - end + context 'when finding matches file path pattern' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'test/spec/example_spec.rb' }) + end - context 'with empty criteria' do - let(:rule_content) do - { - 'criteria' => [] - } - end + it { is_expected.to be true } + end - let(:finding) do - create(:vulnerabilities_finding, :detected, project: project) - end + context 'when finding does not match file path pattern' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'src/main.c' }) + end - it { is_expected.to be false } - end + it { is_expected.to be false } + end - context 'with invalid criteria type' do - let(:rule_content) do - { - 'criteria' => [ - { - 'type' => 'invalid_criteria', - 'value' => 'some_value' - } - ] - } + it_behaves_like 'finding without file location' end - let(:finding) do - create(:vulnerabilities_finding, :detected, project: project) + context 'with directory criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'directory', + 'value' => 'test' + } + ] + } + end + + context 'when finding is in matching directory' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'test/example_spec.rb' }) + end + + it { is_expected.to be true } + end + + context 'when finding is in subdirectory of matching directory' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'test/unit/example_spec.rb' }) + end + + it { is_expected.to be true } + end + + context 'when finding is in a non-matching directory' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan, location: { file: 'src/main.c' }) + end + + it { is_expected.to be false } + end + + it_behaves_like 'finding without file location' end - it { is_expected.to be false } + context 'with identifier criteria' do + let(:rule_content) do + { + 'criteria' => [ + { + 'type' => 'identifier', + 'value' => 'CVE-2023-*' + } + ] + } + end + + context 'when finding has matching identifier' do + let(:finding) do + create_security_finding_with_cve('CVE-2023-12345') + end + + it { is_expected.to be true } + end + + context 'when finding has non-matching identifier' do + let(:finding) do + create_security_finding_with_cve('CWE-79') + end + + it { is_expected.to be false } + end + + context 'when finding has no identifiers' do + let(:finding) do + create(:security_finding, :with_finding_data, scan: scan) + end + + it { is_expected.to be false } + end + + context 'when finding has identifiers without name' do + let(:finding) { create_security_finding_with_cve(nil) } + + it { is_expected.to be false } + end + + def create_security_finding_with_cve(cve_value) + create(:security_finding, :with_finding_data, scan: scan, + identifiers: [create(:ci_reports_security_identifier, name: cve_value).to_hash]) + end + end end end end