diff --git a/db/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment.rb b/db/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment.rb new file mode 100644 index 0000000000000000000000000000000000000000..e244c23f23a25eda7b5d76706520c94b0802fa91 --- /dev/null +++ b/db/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexOnIsKnownExploitToPmCveEnrichment < Gitlab::Database::Migration[2.3] + milestone '18.6' + + disable_ddl_transaction! + + INDEX_NAME = 'index_pm_cve_enrichment_on_is_known_exploit' + + def up + add_concurrent_index :pm_cve_enrichment, :is_known_exploit, name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :pm_cve_enrichment, INDEX_NAME + end +end diff --git a/db/migrate/20251215123000_add_is_known_exploit_to_vulnerability_reads.rb b/db/migrate/20251215123000_add_is_known_exploit_to_vulnerability_reads.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b12cc7d80555828ca7c432ae88f4b299e948dde --- /dev/null +++ b/db/migrate/20251215123000_add_is_known_exploit_to_vulnerability_reads.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddIsKnownExploitToVulnerabilityReads < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + restrict_gitlab_migration gitlab_schema: :gitlab_sec + + milestone '18.7' + + def up + unless column_exists?(:vulnerability_reads, :is_known_exploit) + add_column :vulnerability_reads, :is_known_exploit, :boolean + end + + add_concurrent_index :vulnerability_reads, :is_known_exploit, name: 'index_vr_on_is_known_exploit' unless index_exists?(:vulnerability_reads, :is_known_exploit, name: 'index_vr_on_is_known_exploit') + end + + def down + remove_concurrent_index_by_name :vulnerability_reads, 'index_vr_on_is_known_exploit' if index_exists_by_name?(:vulnerability_reads, 'index_vr_on_is_known_exploit') + remove_column :vulnerability_reads, :is_known_exploit if column_exists?(:vulnerability_reads, :is_known_exploit) + end +end + + + diff --git a/db/post_migrate/20251215123500_queue_backfill_is_known_exploit_in_vulnerability_reads.rb b/db/post_migrate/20251215123500_queue_backfill_is_known_exploit_in_vulnerability_reads.rb new file mode 100644 index 0000000000000000000000000000000000000000..f94d78c0579fcf7896f9e9b71d7d056d3d7f9e45 --- /dev/null +++ b/db/post_migrate/20251215123500_queue_backfill_is_known_exploit_in_vulnerability_reads.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class QueueBackfillIsKnownExploitInVulnerabilityReads < Gitlab::Database::Migration[2.3] + milestone '18.7' + restrict_gitlab_migration gitlab_schema: :gitlab_sec + + MIGRATION = 'BackfillIsKnownExploitInVulnerabilityReads' + + def up + queue_batched_background_migration( + MIGRATION, + :vulnerability_reads, + :id + ) + end + + def down + delete_batched_background_migration(MIGRATION, :vulnerability_reads, :id, []) + end +end + + diff --git a/db/schema_migrations/20251023120000 b/db/schema_migrations/20251023120000 new file mode 100644 index 0000000000000000000000000000000000000000..a3033bf9c7ef6772d8994788bbdf1291508d3223 --- /dev/null +++ b/db/schema_migrations/20251023120000 @@ -0,0 +1 @@ +e973d3d32ec67a5629e9634acca7d0ba5ea3c33e403ea2a8dcbe9199a0d2d9f6 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7809a7aedda1f67e53e1e54ad73b7791b2ad0ea9..699cc263c77730b16655a8bae1af6dffa7a452d1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -43704,6 +43704,8 @@ CREATE INDEX index_pm_affected_packages_on_purl_type_and_package_name ON pm_affe CREATE UNIQUE INDEX index_pm_cve_enrichment_on_cve ON pm_cve_enrichment USING btree (cve); +CREATE INDEX index_pm_cve_enrichment_on_is_known_exploit ON pm_cve_enrichment USING btree (is_known_exploit); + CREATE INDEX index_pm_package_version_licenses_on_pm_license_id ON pm_package_version_licenses USING btree (pm_license_id); CREATE INDEX index_pm_package_version_licenses_on_pm_package_version_id ON pm_package_version_licenses USING btree (pm_package_version_id); diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 4016fc5b2d319bacf40fe35126f9dc514e57787a..795f415d59b5c084a940cbf58f0237afd0d598dd 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2131,6 +2131,7 @@ four standard [pagination arguments](#pagination-arguments): | `dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | `hasAiResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which can likely be resolved by GitLab Duo Vulnerability Resolution. | | `hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | +| `hasKev` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Filter vulnerabilities by KEV (Known Exploited Vulnerabilities) status. | | `hasMergeRequest` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked merge requests. | | `hasRemediations` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have remediations. | | `hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | @@ -35126,6 +35127,7 @@ four standard [pagination arguments](#pagination-arguments): | `dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | `hasAiResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which can likely be resolved by GitLab Duo Vulnerability Resolution. | | `hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | +| `hasKev` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Filter vulnerabilities by KEV (Known Exploited Vulnerabilities) status. | | `hasMergeRequest` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked merge requests. | | `hasRemediations` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have remediations. | | `hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | @@ -44373,6 +44375,7 @@ four standard [pagination arguments](#pagination-arguments): | `dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | `hasAiResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which can likely be resolved by GitLab Duo Vulnerability Resolution. | | `hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | +| `hasKev` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Filter vulnerabilities by KEV (Known Exploited Vulnerabilities) status. | | `hasMergeRequest` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked merge requests. | | `hasRemediations` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have remediations. | | `hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | diff --git a/ee/app/finders/security/vulnerability_reads_finder.rb b/ee/app/finders/security/vulnerability_reads_finder.rb index 6c8b3fc5566383d37d32b22ad751c6586d02c58e..27f9af94f36ba12ea8b19a794c95cfb4102b1f95 100644 --- a/ee/app/finders/security/vulnerability_reads_finder.rb +++ b/ee/app/finders/security/vulnerability_reads_finder.rb @@ -62,6 +62,7 @@ def execute filter_by_has_remediations filter_by_owasp_top_10 filter_by_identifier_name + filter_by_kev sort end @@ -205,6 +206,12 @@ def filter_by_has_remediations @vulnerability_reads = vulnerability_reads.with_remediations(params[:has_remediations]) end + def filter_by_kev + return unless params[:has_kev].in?([true, false]) + + @vulnerability_reads = vulnerability_reads.with_kev(params[:has_kev]) + end + def sort if vulnerable_is_a_group? @vulnerability_reads.order_by_params_and_traversal_ids(params[:sort]) diff --git a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb index 257fd80929d4462594080b0acec073132ce24f90..f0fffe9bddefb0249800285fdb739ed9bbed5339 100644 --- a/ee/app/graphql/resolvers/vulnerabilities_resolver.rb +++ b/ee/app/graphql/resolvers/vulnerabilities_resolver.rb @@ -110,6 +110,11 @@ class VulnerabilitiesResolver < VulnerabilitiesBaseResolver experiment: { milestone: '18.5' }, description: 'Filter vulnerabilities by validity check.' + argument :has_kev, GraphQL::Types::Boolean, + required: false, + experiment: { milestone: '18.6' }, + description: 'Filter vulnerabilities by KEV (Known Exploited Vulnerabilities) status.' + argument :policy_violations, [::Types::SecurityOrchestration::PolicyViolationsEnum], required: false, experiment: { milestone: '18.6' }, diff --git a/ee/app/models/package_metadata/cve_enrichment.rb b/ee/app/models/package_metadata/cve_enrichment.rb index 0389e0154976f40cb0d0490bfff54d4ee65b5708..8481cb137c3c85f63410f6b660fee7940c6d8f11 100644 --- a/ee/app/models/package_metadata/cve_enrichment.rb +++ b/ee/app/models/package_metadata/cve_enrichment.rb @@ -21,6 +21,12 @@ class CveEnrichment < ApplicationRecord scope :by_cves, ->(cves) { where(cve: cves) } + def self.has_known_exploit?(cves) + return false if cves.blank? + + by_cves(Array(cves)).where(is_known_exploit: true).exists? + end + # rubocop:disable Layout/ClassStructure -- This is included at the bottom of the model definition because # BulkInsertSafe complains about the autosave callbacks generated # for the `has_many` associations otherwise. diff --git a/ee/app/models/vulnerabilities/read.rb b/ee/app/models/vulnerabilities/read.rb index 58e1fa61fe536e38069772c8aefde9bdca4b150f..a439262bf909cf0f61579afdfd02f0f62c656978 100644 --- a/ee/app/models/vulnerabilities/read.rb +++ b/ee/app/models/vulnerabilities/read.rb @@ -122,6 +122,7 @@ class << self scope :with_issues, ->(has_issues = true) { where(has_issues: has_issues) } scope :with_merge_request, ->(has_merge_request = true) { where(has_merge_request: has_merge_request) } scope :with_remediations, ->(has_remediations = true) { where(has_remediations: has_remediations) } + scope :with_kev, ->(has_kev = true) { where('COALESCE(vulnerability_reads.is_known_exploit, FALSE) = ?', has_kev) } scope :with_scanner_external_ids, ->(scanner_external_ids) { joins(:scanner).merge(::Vulnerabilities::Scanner.with_external_id(scanner_external_ids)) diff --git a/ee/app/services/package_metadata/ingestion/cve_enrichment/cve_enrichment_ingestion_task.rb b/ee/app/services/package_metadata/ingestion/cve_enrichment/cve_enrichment_ingestion_task.rb index d9869be6cc7ba972e4ea6fb37e838261e9820172..935ce2f83cd641bb8e533b5082ced0ccebbdd886 100644 --- a/ee/app/services/package_metadata/ingestion/cve_enrichment/cve_enrichment_ingestion_task.rb +++ b/ee/app/services/package_metadata/ingestion/cve_enrichment/cve_enrichment_ingestion_task.rb @@ -15,11 +15,12 @@ def initialize(import_data) end def execute - PackageMetadata::CveEnrichment.bulk_upsert!( + result = PackageMetadata::CveEnrichment.bulk_upsert!( valid_cve_enrichment_entries, unique_by: %w[cve], returns: %w[id cve epss_score is_known_exploit created_at updated_at] ) + enqueue_update_is_known_exploit(result) end private @@ -61,6 +62,15 @@ def cve_enrichment def now @now ||= Time.zone.now end + + def enqueue_update_is_known_exploit(upsert_result) + # upsert_result may be a PG result set depending on adapter; extract CVEs from provided data as a safe fallback. + cves = import_data.map(&:cve_id).compact.uniq + return if cves.empty? + + # Enqueue a worker to update vulnerability_reads.is_known_exploit for these CVEs + PackageMetadata::UpdateIsKnownExploitForCvesWorker.perform_async(cves) + end end end end diff --git a/ee/app/workers/package_metadata/update_is_known_exploit_for_cves_worker.rb b/ee/app/workers/package_metadata/update_is_known_exploit_for_cves_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..101714477cbf502e745e080678a0464874af7294 --- /dev/null +++ b/ee/app/workers/package_metadata/update_is_known_exploit_for_cves_worker.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module PackageMetadata + class UpdateIsKnownExploitForCvesWorker + include ApplicationWorker + + feature_category :vulnerability_management + data_consistency :delayed + idempotent! + + # We process a bounded list of CVE IDs (strings). + # For those CVEs: + # - fetch which are KEV (is_known_exploit: true) from pm_cve_enrichment (gitlab_pm) + # - in gitlab_sec, set vulnerability_reads.is_known_exploit: + # TRUE for rows whose identifier_names overlaps KEV CVEs + # FALSE for rows that overlap only non-KEV CVEs + # + # No cross-DB joins; only separate reads/writes per schema. + def perform(cve_ids) + cves = Array(cve_ids).compact.uniq + return if cves.empty? + + kev_cves = ::PackageMetadata::CveEnrichment.where(cve: cves, is_known_exploit: true).pluck(:cve) + non_kev_cves = cves - kev_cves + + set_true_for_cves(kev_cves) if kev_cves.any? + set_false_for_cves(non_kev_cves, kev_cves) if non_kev_cves.any? + end + + private + + def set_true_for_cves(kev_cves) + ::Vulnerabilities::Read + .where("identifier_names && ARRAY[?]::text[]", kev_cves) + .update_all(is_known_exploit: true) + end + + def set_false_for_cves(non_kev_cves, kev_cves) + # Only set FALSE when the row overlaps with non-KEV CVEs + # and does NOT overlap with any KEV CVE (to avoid flipping TRUE rows). + arel = ::Vulnerabilities::Read + .where("identifier_names && ARRAY[?]::text[]", non_kev_cves) + arel = arel.where("NOT (identifier_names && ARRAY[?]::text[])", kev_cves) if kev_cves.any? + arel.update_all(is_known_exploit: false) + end + end +end + + diff --git a/ee/elastic/docs/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.yml b/ee/elastic/docs/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a4bcf298e3b2cd348295a331d4cf7802334db59 --- /dev/null +++ b/ee/elastic/docs/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.yml @@ -0,0 +1,8 @@ +--- +name: AddIndexOnIsKnownExploitToPmCveEnrichmentSearch +introduced_by_url: +milestone: "18.6" +description: | + This migration corresponds to the database migration that adds an index + on is_known_exploit column to pm_cve_enrichment table. + No Elasticsearch changes are needed for this database index. \ No newline at end of file diff --git a/ee/elastic/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.rb b/ee/elastic/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.rb new file mode 100644 index 0000000000000000000000000000000000000000..dc371233aebec8dfe79d715c55febbcaae0bba34 --- /dev/null +++ b/ee/elastic/migrate/20251023120000_add_index_on_is_known_exploit_to_pm_cve_enrichment_search.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexOnIsKnownExploitToPmCveEnrichmentSearch < Elastic::Migration + batched! + throttle_delay 1.minute + + def migrate + # This migration enables the use of is_known_exploit field in vulnerability filtering + # after the corresponding database index is created + log "Enabling is_known_exploit filtering for vulnerabilities" + end + + def completed? + # Check if the database migration has been applied + helper.migration_has_finished?('AddIndexOnIsKnownExploitToPmCveEnrichment') + end +end diff --git a/ee/lib/ee/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb b/ee/lib/ee/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed042f99b23fd5ddd1d3e146035a766e2957f77f --- /dev/null +++ b/ee/lib/ee/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module BackgroundMigration + # EE implementation: + # For each batch of vulnerability_reads, compute whether any of the identifier_names + # is present in pm_cve_enrichment with is_known_exploit = true, and update the local column. + class BackfillIsKnownExploitInVulnerabilityReads < ::Gitlab::BackgroundMigration::BatchedMigrationJob + feature_category :vulnerability_management + + # Local AR models to avoid relying on main app models during migration + class VulnerabilityRead < ::SecApplicationRecord + self.table_name = 'vulnerability_reads' + end + + class CveEnrichment < ::ApplicationRecord + self.table_name = 'pm_cve_enrichment' + end + + def perform + each_sub_batch do |sub_batch| + rows = sub_batch.select(:id, :identifier_names).to_a + next if rows.empty? + + # Collect unique CVEs from the batch + cves = rows.flat_map { |r| Array(r.identifier_names) }.compact.uniq + known_exploited = if cves.empty? + [] + else + CveEnrichment.where(cve: cves, is_known_exploit: true).pluck(:cve) + end + known_set = known_exploited.to_set + + to_true_ids = rows.filter_map do |r| + r.id if Array(r.identifier_names).any? { |name| known_set.include?(name) } + end + + all_ids = rows.map(&:id) + to_false_ids = all_ids - to_true_ids + + # Update in two groups to avoid per-row writes + VulnerabilityRead.where(id: to_true_ids).update_all(is_known_exploit: true) unless to_true_ids.empty? + VulnerabilityRead.where(id: to_false_ids).update_all(is_known_exploit: false) unless to_false_ids.empty? + end + end + end + end + end +end + + diff --git a/ee/lib/search/elastic/references/vulnerability.rb b/ee/lib/search/elastic/references/vulnerability.rb index cec15c198a4cd9fca4a69689bf9119d36b940726..c7cefad70c4c9ed0075623d85ee2a87b45e5a0be 100644 --- a/ee/lib/search/elastic/references/vulnerability.rb +++ b/ee/lib/search/elastic/references/vulnerability.rb @@ -110,6 +110,8 @@ def as_indexed_json fields["false_positive"] = fetch_record_attribute(database_record, :false_positive) end + fields["is_known_exploit"] = fetch_is_known_exploit || false if known_exploit_migration_finished? + internal_es_fields.merge(fields) end @@ -204,6 +206,18 @@ def detected_at_migration_completed? def false_positive_migration_completed? ::Elastic::DataMigrationService.migration_has_finished?(:add_false_positive_field_to_vulnerability) end + + def known_exploit_migration_finished? + ::Elastic::DataMigrationService.migration_has_finished?( + :add_index_on_is_known_exploit_to_pm_cve_enrichment_search + ) + end + + def fetch_is_known_exploit + return false unless database_record&.identifier_names.present? + + ::PackageMetadata::CveEnrichment.has_known_exploit?(database_record.identifier_names) + end # # Private class methods for implementation details diff --git a/ee/lib/search/elastic/types/vulnerability.rb b/ee/lib/search/elastic/types/vulnerability.rb index 1301d88b56fc5c63ccdba1ba8a785df5a2228244..af80edc22c566f758c0c27b881b84f818e7a4d4f 100644 --- a/ee/lib/search/elastic/types/vulnerability.rb +++ b/ee/lib/search/elastic/types/vulnerability.rb @@ -76,8 +76,9 @@ def base_mappings risk_score: { type: 'float' }, policy_violations: { type: 'short' }, # enum false_positive: { type: 'boolean' }, - schema_version: { type: 'short' }, - security_project_tracked_context_id: { type: 'long' } + security_project_tracked_context_id: { type: 'long' }, + is_known_exploit: { type: 'boolean' }, + schema_version: { type: 'short' } } end diff --git a/ee/lib/search/elastic/vulnerability_filters.rb b/ee/lib/search/elastic/vulnerability_filters.rb index c33dfa278594134dc2f07c063fe51e47acf1b4a1..36589e096fa27b3c574f4d0aa6b29bbd87d1dd50 100644 --- a/ee/lib/search/elastic/vulnerability_filters.rb +++ b/ee/lib/search/elastic/vulnerability_filters.rb @@ -433,6 +433,18 @@ def by_validity_check(query_hash:, options:) end end + def by_has_kev(query_hash:, options:) + has_kev = options[:has_kev] + return query_hash if has_kev.nil? + return query_hash unless has_kev.in?([true, false]) + + context.name(:filters) do + add_filter(query_hash, :query, :bool, :filter) do + { term: { is_known_exploit: { _name: context.name(:has_kev), value: has_kev } } } + end + end + end + def by_policy_violations(query_hash:, options:) policy_violations = options[:policy_violations] return query_hash if policy_violations.blank? diff --git a/ee/lib/search/elastic/vulnerability_query_builder.rb b/ee/lib/search/elastic/vulnerability_query_builder.rb index 934e96c5eddec95e97496b23be8f7a504026b683..626fb36adff240f139d2aebda3f0ae5ff8bc6878 100644 --- a/ee/lib/search/elastic/vulnerability_query_builder.rb +++ b/ee/lib/search/elastic/vulnerability_query_builder.rb @@ -42,7 +42,6 @@ def build # rubocop:disable Metrics/AbcSize -- need all the filters in one place query_hash: query_hash, options: options) query_hash = ::Search::Elastic::VulnerabilityFilters.by_scanner_ids(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::VulnerabilityFilters.by_severities(query_hash: query_hash, options: options) - query_hash = ::Search::Elastic::VulnerabilityFilters.by_reachability(query_hash: query_hash, options: options) # For self-managed instances, 'backfill_vulnerabilities_for_self_managed' supersedes @@ -53,6 +52,11 @@ def build # rubocop:disable Metrics/AbcSize -- need all the filters in one place query_hash: query_hash, options: options) end + if ::Elastic::DataMigrationService + .migration_has_finished?(:add_index_on_is_known_exploit_to_pm_cve_enrichment_search) + query_hash = ::Search::Elastic::VulnerabilityFilters.by_has_kev(query_hash: query_hash, options: options) + end + if ::Elastic::DataMigrationService.migration_has_finished?(:add_policy_violations_field_to_vulnerability) query_hash = ::Search::Elastic::VulnerabilityFilters.by_policy_violations( query_hash: query_hash, options: options) diff --git a/ee/spec/finders/security/vulnerability_reads_finder_spec.rb b/ee/spec/finders/security/vulnerability_reads_finder_spec.rb index 8d182827d1f9833029b142b0be907ae95da24a62..8bd63a8777e43b640d5bf85c070b0f67cb1ddbf9 100644 --- a/ee/spec/finders/security/vulnerability_reads_finder_spec.rb +++ b/ee/spec/finders/security/vulnerability_reads_finder_spec.rb @@ -514,6 +514,72 @@ end end + context 'when filtered by has_kev argument' do + let(:filters) { { has_kev: has_kev } } + + let_it_be(:kev_vulnerability) do + create(:vulnerability, :with_finding, severity: :critical, report_type: :sast, state: :detected, project: project) + end + + let_it_be(:kev_vulnerability_read) do + kev_vulnerability.vulnerability_read.tap do |vr| + vr.update!(is_known_exploit: true) + end + end + + context 'when filter is set to true' do + let(:has_kev) { true } + + it 'only returns vulnerabilities that have known exploits' do + is_expected.to contain_exactly(kev_vulnerability_read) + end + end + + context 'when filter is set to false' do + let(:has_kev) { false } + + it 'only returns vulnerabilities that do not have known exploits' do + is_expected.to contain_exactly( + low_severity_vuln_read, + high_severity_vuln_read, + medium_severity_vuln_read, + vulnerability_dismissed_without_reason_read, + dismissed_vulnerability_read + ) + end + end + + context 'when filter is nil' do + let(:has_kev) { nil } + + it 'returns all vulnerabilities' do + is_expected.to contain_exactly( + low_severity_vuln_read, + high_severity_vuln_read, + medium_severity_vuln_read, + vulnerability_dismissed_without_reason_read, + dismissed_vulnerability_read, + kev_vulnerability_read + ) + end + end + + context 'when filter is not a boolean' do + let(:has_kev) { 'test' } + + it 'returns all vulnerabilities' do + is_expected.to contain_exactly( + low_severity_vuln_read, + high_severity_vuln_read, + medium_severity_vuln_read, + vulnerability_dismissed_without_reason_read, + dismissed_vulnerability_read, + kev_vulnerability_read + ) + end + end + end + context 'when filtered by more than one property' do let_it_be(:read4) do create(:vulnerability, :with_finding, severity: :medium, report_type: :sast, state: :detected, diff --git a/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb index 899b27eedb43e3077cf173b73dbb6a3bc0d6d190..deb3132343aefe665d095223e42968a5eff5dc67 100644 --- a/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/vulnerabilities_resolver_spec.rb @@ -220,6 +220,33 @@ end end + context 'when given value for has_kev argument' do + let(:params) { { has_kev: has_kev } } + + let_it_be(:kev_vulnerability) do + create(:vulnerability, :with_finding, :detected, :critical, :sast, project: project).tap do |vuln| + identifier_name = vuln.finding.identifiers.first&.name || 'CVE-2024-12345' + create(:pm_cve_enrichment, cve: identifier_name, is_known_exploit: true) + end + end + + context 'when has_kev is set to true' do + let(:has_kev) { true } + + it 'only returns vulnerabilities that have known exploits' do + is_expected.to contain_exactly(kev_vulnerability) + end + end + + context 'when has_kev is set to false' do + let(:has_kev) { false } + + it 'only returns vulnerabilities that do not have known exploits' do + is_expected.to contain_exactly(low_vulnerability, critical_vulnerability, high_vulnerability) + end + end + end + context 'when given project IDs' do let_it_be(:group) { create(:group) } let_it_be(:project2) { create(:project, namespace: group) } diff --git a/ee/spec/lib/search/advanced_finders/security/vulnerability/search_finder_spec.rb b/ee/spec/lib/search/advanced_finders/security/vulnerability/search_finder_spec.rb index 68db4f4f4fa91b99ed04d5c8f761dd92a94ed022..e04cabe08ab47a1607bad62aafa4a937d7f247b7 100644 --- a/ee/spec/lib/search/advanced_finders/security/vulnerability/search_finder_spec.rb +++ b/ee/spec/lib/search/advanced_finders/security/vulnerability/search_finder_spec.rb @@ -583,6 +583,64 @@ end end + context 'when filtered by has_kev argument' do + let(:filters) { { has_kev: has_kev } } + + let_it_be(:kev_vulnerability) do + create( + :vulnerability, + :with_finding, + severity: :critical, + report_type: :sast, + state: :detected, + project: project + ) + end + + let_it_be(:kev_vulnerability_read) do + kev_vulnerability.vulnerability_read.tap do + identifier_name = kev_vulnerability.finding.identifiers.first&.name || 'CVE-2024-12345' + create(:pm_cve_enrichment, cve: identifier_name, is_known_exploit: true) + end + end + + before do + Elastic::ProcessBookkeepingService.track!( + low_severity_vuln_read, + high_severity_vuln_read, + medium_severity_vuln_read, + dismissed_vulnerability_read, + vulnerability_dismissed_without_reason_read, + kev_vulnerability_read + ) + ensure_elasticsearch_index! + end + + context 'when filter is set to true' do + let(:has_kev) { true } + + it 'only returns vulnerabilities that have known exploits' do + is_expected.to match_array([kev_vulnerability_read].map(&:vulnerability)) + end + end + + context 'when filter is set to false' do + let(:has_kev) { false } + + it 'only returns vulnerabilities that do not have known exploits' do + is_expected.to match_array( + [ + low_severity_vuln_read, + high_severity_vuln_read, + medium_severity_vuln_read, + vulnerability_dismissed_without_reason_read, + dismissed_vulnerability_read + ].map(&:vulnerability) + ) + end + end + end + context 'when filtered by more than one property' do let_it_be(:read4) do create(:vulnerability, :with_finding, severity: :medium, report_type: :sast, state: :detected, diff --git a/ee/spec/lib/search/elastic/references/vulnerability_spec.rb b/ee/spec/lib/search/elastic/references/vulnerability_spec.rb index a7a53ff6e7bf4e47a9472062c6493ae7aacea140..88b68a309481621c9aa31a7564b457f900165810 100644 --- a/ee/spec/lib/search/elastic/references/vulnerability_spec.rb +++ b/ee/spec/lib/search/elastic/references/vulnerability_spec.rb @@ -81,6 +81,7 @@ policy_violations: [], risk_score: [], false_positive: [], + is_known_exploit: false, type: described_class::DOC_TYPE, schema_version: described_class::SCHEMA_VERSION, security_project_tracked_context_id: object.security_project_tracked_context_id diff --git a/ee/spec/lib/search/elastic/vulnerability_filters_spec.rb b/ee/spec/lib/search/elastic/vulnerability_filters_spec.rb index ef3d3fb56d0ffb52b46f8fdf914790ba46b48b8f..7647cf57430501da1288470c49c64fde6f52f611 100644 --- a/ee/spec/lib/search/elastic/vulnerability_filters_spec.rb +++ b/ee/spec/lib/search/elastic/vulnerability_filters_spec.rb @@ -908,4 +908,58 @@ end end end + + describe '.by_has_kev' do + subject(:by_has_kev) { described_class.by_has_kev(query_hash: query_hash, options: options) } + + context 'when options[:has_kev] is empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:has_kev] is nil' do + let(:options) { { has_kev: nil } } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:has_kev] is not a boolean' do + let(:options) { { has_kev: 'test' } } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:has_kev] is true' do + let(:options) { { has_kev: true } } + + it 'adds the has_kev filter to query_hash' do + query_hash = by_has_kev + expected_filter = [{ term: { is_known_exploit: { _name: 'filters:has_kev', value: true } } }] + + aggregate_failures do + expect(query_hash.dig(:query, :bool, :filter)).to eq(expected_filter) + expect(query_hash.dig(:query, :bool, :must)).to be_empty + expect(query_hash.dig(:query, :bool, :must_not)).to be_empty + expect(query_hash.dig(:query, :bool, :should)).to be_empty + end + end + end + + context 'when options[:has_kev] is false' do + let(:options) { { has_kev: false } } + + it 'adds the has_kev filter to query_hash' do + query_hash = by_has_kev + expected_filter = [{ term: { is_known_exploit: { _name: 'filters:has_kev', value: false } } }] + + aggregate_failures do + expect(query_hash.dig(:query, :bool, :filter)).to eq(expected_filter) + expect(query_hash.dig(:query, :bool, :must)).to be_empty + expect(query_hash.dig(:query, :bool, :must_not)).to be_empty + expect(query_hash.dig(:query, :bool, :should)).to be_empty + end + end + end + end end diff --git a/ee/spec/models/vulnerabilities/read_spec.rb b/ee/spec/models/vulnerabilities/read_spec.rb index 2a07ca1678341a00a4eb143060d413ab2a7caceb..81d2b8c8d5016e17a49183eae827bf5939a34285 100644 --- a/ee/spec/models/vulnerabilities/read_spec.rb +++ b/ee/spec/models/vulnerabilities/read_spec.rb @@ -584,6 +584,38 @@ end end + describe '.with_kev' do + let_it_be(:vulnerability_with_kev) do + create(:vulnerability, :with_finding, project: project).tap do |vuln| + vuln.vulnerability_read.update!(is_known_exploit: true) + end + end + + let_it_be(:vulnerability_without_kev) { create(:vulnerability, :with_finding, project: project) } + + subject { described_class.with_kev(with_kev) } + + context 'when no argument is provided' do + subject { described_class.with_kev } + + it { is_expected.to match_array([vulnerability_with_kev.vulnerability_read]) } + end + + context 'when the argument is provided' do + context 'when the given argument is `true`' do + let(:with_kev) { true } + + it { is_expected.to match_array([vulnerability_with_kev.vulnerability_read]) } + end + + context 'when the given argument is `false`' do + let(:with_kev) { false } + + it { is_expected.to match_array([vulnerability_without_kev.vulnerability_read]) } + end + end + end + describe '.as_vulnerabilities' do let!(:vulnerability_1) { create(:vulnerability, :with_finding, project: project) } let!(:vulnerability_2) { create(:vulnerability, :with_finding, project: project) } diff --git a/lib/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb b/lib/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d48ef87fc57d7559335914ced64206ec4177188 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # CE shim. EE implementation lives in ee/lib/ee/gitlab/background_migration/backfill_is_known_exploit_in_vulnerability_reads.rb + # + # This batched background migration will backfill `is_known_exploit` in `vulnerability_reads`. + class BackfillIsKnownExploitInVulnerabilityReads < BatchedMigrationJob + feature_category :vulnerability_management + + # CE does nothing; EE module will override. + def perform(*) + end + end + end +end + +Gitlab::BackgroundMigration::BackfillIsKnownExploitInVulnerabilityReads.prepend_mod + +