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
+
+