diff --git a/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json index e0051179a1dd93db522ea80f76c7a497c2135f8f..a21108e297293f1448971a0e324c253880370174 100644 --- a/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json +++ b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json @@ -8,6 +8,37 @@ }, "fix_available": { "type": "boolean" + }, + "known_exploited": { + "type": "boolean", + "description": "Filter vulnerabilities based on CISA Known Exploited Vulnerabilities (KEV) catalog. When true, only includes vulnerabilities actively exploited in the wild." + }, + "epss_score": { + "type": "object", + "description": "Filter vulnerabilities based on Exploit Prediction Scoring System (EPSS) score. EPSS estimates the probability (0-1) that a vulnerability will be exploited.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "greater_than_or_equal_to", + "greater_than", + "less_than_or_equal_to", + "less_than" + ], + "description": "Comparison operator for EPSS score threshold." + }, + "value": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "EPSS score threshold value (0.0 to 1.0). For example, 0.8 represents 80% exploitation probability." + } + }, + "required": [ + "operator", + "value" + ], + "additionalProperties": false } }, "additionalProperties": false diff --git a/db/docs/security_finding_enrichments.yml b/db/docs/security_finding_enrichments.yml new file mode 100644 index 0000000000000000000000000000000000000000..43ff80bcd537fae55d0455b9e48dc06f0eb81a32 --- /dev/null +++ b/db/docs/security_finding_enrichments.yml @@ -0,0 +1,11 @@ +--- +table_name: security_finding_enrichments +classes: +- Security::FindingEnrichment +feature_categories: +- security_policy_management +description: Links security findings to CVE enrichment data through a foreign key relationship. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26619 +milestone: '18.8' +gitlab_schema: gitlab_pm # See discussion on keys for pm_ tables https://gitlab.com/gitlab-org/gitlab/-/issues/434988#note_1827421068 +table_size: small diff --git a/db/migrate/20251202105409_create_security_finding_enrichments.rb b/db/migrate/20251202105409_create_security_finding_enrichments.rb new file mode 100644 index 0000000000000000000000000000000000000000..a93be9b74233e6b2703f0518a2baa8caa211a911 --- /dev/null +++ b/db/migrate/20251202105409_create_security_finding_enrichments.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateSecurityFindingEnrichments < Gitlab::Database::Migration[2.3] + milestone '18.8' + + def up + create_table :security_finding_enrichments, if_not_exists: true do |t| + t.uuid :finding_uuid, null: false + t.references :cve_enrichment, null: false, foreign_key: { to_table: :pm_cve_enrichment, on_delete: :cascade } + + t.index [:finding_uuid, :cve_enrichment_id], + unique: true, + name: 'index_sec_finding_enrichments_on_finding_and_enrichment' + + t.timestamps_with_timezone null: false + end + end + + def down + drop_table :security_finding_enrichments + end +end diff --git a/db/schema_migrations/20251202105409 b/db/schema_migrations/20251202105409 new file mode 100644 index 0000000000000000000000000000000000000000..9244e2c42c59872be63bc988b0da450b693fb45b --- /dev/null +++ b/db/schema_migrations/20251202105409 @@ -0,0 +1 @@ +88fbf4ad36e4b7773d1ca08f5bf7b2366148ffc5485e2c60ade31560d07205ca \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d941aa0cce6e56a27df20d8de45abcf04c80a292..90cdaa8109c1aae78f30425455296a93bc8c0170 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -26892,6 +26892,23 @@ CREATE SEQUENCE security_categories_id_seq ALTER SEQUENCE security_categories_id_seq OWNED BY security_categories.id; +CREATE TABLE security_finding_enrichments ( + id bigint NOT NULL, + finding_uuid uuid NOT NULL, + cve_enrichment_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE security_finding_enrichments_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE security_finding_enrichments_id_seq OWNED BY security_finding_enrichments.id; + CREATE TABLE security_finding_token_statuses ( security_finding_id bigint NOT NULL, project_id bigint NOT NULL, @@ -33164,6 +33181,8 @@ ALTER TABLE ONLY security_attributes ALTER COLUMN id SET DEFAULT nextval('securi ALTER TABLE ONLY security_categories ALTER COLUMN id SET DEFAULT nextval('security_categories_id_seq'::regclass); +ALTER TABLE ONLY security_finding_enrichments ALTER COLUMN id SET DEFAULT nextval('security_finding_enrichments_id_seq'::regclass); + ALTER TABLE ONLY security_findings ALTER COLUMN id SET DEFAULT nextval('security_findings_id_seq'::regclass); ALTER TABLE ONLY security_inventory_filters ALTER COLUMN id SET DEFAULT nextval('security_inventory_filters_id_seq'::regclass); @@ -36961,6 +36980,9 @@ ALTER TABLE ONLY security_attributes ALTER TABLE ONLY security_categories ADD CONSTRAINT security_categories_pkey PRIMARY KEY (id); +ALTER TABLE ONLY security_finding_enrichments + ADD CONSTRAINT security_finding_enrichments_pkey PRIMARY KEY (id); + ALTER TABLE ONLY security_finding_token_statuses ADD CONSTRAINT security_finding_token_statuses_pkey PRIMARY KEY (security_finding_id); @@ -44322,6 +44344,8 @@ CREATE UNIQUE INDEX index_scim_oauth_access_tokens_on_group_id_and_token_encrypt CREATE INDEX index_scim_oauth_access_tokens_on_organization_id ON scim_oauth_access_tokens USING btree (organization_id); +CREATE UNIQUE INDEX index_sec_finding_enrichments_on_finding_and_enrichment ON security_finding_enrichments USING btree (finding_uuid, cve_enrichment_id); + CREATE INDEX index_secret_rotation_infos_on_next_reminder_at ON secret_rotation_infos USING btree (next_reminder_at); CREATE INDEX index_security_attributes_on_namespace_id ON security_attributes USING btree (namespace_id); @@ -44334,6 +44358,8 @@ CREATE UNIQUE INDEX index_security_categories_namespace_name ON security_categor CREATE INDEX index_security_categories_on_namespace_id_where_not_deleted ON security_categories USING btree (namespace_id) WHERE (deleted_at IS NULL); +CREATE INDEX index_security_finding_enrichments_on_cve_enrichment_id ON security_finding_enrichments USING btree (cve_enrichment_id); + CREATE UNIQUE INDEX index_security_inventory_filters_on_project_id ON security_inventory_filters USING btree (project_id); CREATE INDEX index_security_inventory_filters_on_traversal_ids ON security_inventory_filters USING btree (traversal_ids); @@ -53403,6 +53429,9 @@ ALTER TABLE ONLY group_deploy_tokens ALTER TABLE ONLY reviews ADD CONSTRAINT fk_rails_64798be025 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY security_finding_enrichments + ADD CONSTRAINT fk_rails_648935d719 FOREIGN KEY (cve_enrichment_id) REFERENCES pm_cve_enrichment(id) ON DELETE CASCADE; + ALTER TABLE ONLY operations_feature_flags ADD CONSTRAINT fk_rails_648e241be7 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/ee/app/finders/security/scan_result_policies/findings_finder.rb b/ee/app/finders/security/scan_result_policies/findings_finder.rb index b8047c2a1697a9f18dd90e6d4ed019650c364e06..c40fe9fab6f7cb4bfc3e75c6eb5796eb2d1151a1 100644 --- a/ee/app/finders/security/scan_result_policies/findings_finder.rb +++ b/ee/app/finders/security/scan_result_policies/findings_finder.rb @@ -39,6 +39,7 @@ def execute findings = findings.fix_available if params[:fix_available] == true findings = findings.no_fix_available if params[:fix_available] == false findings = findings.with_scan_partition_number.by_uuid(params[:uuids]) if params[:uuids].present? + findings = apply_cve_enrichment_filters(findings) if params[:known_exploited].present? || epss_score_valid? findings end @@ -72,6 +73,18 @@ def only_new_undismissed_findings? def undismissed_security_findings(findings) findings.undismissed_by_vulnerability end + + def epss_score_valid? + ::Security::ScanResultPolicy.epss_score_valid?(params[:epss_score]) + end + + def apply_cve_enrichment_filters(findings) + findings.with_cve_enrichment_filters( + known_exploited: params[:known_exploited], + epss_operator: params.dig(:epss_score, :operator), + epss_value: params.dig(:epss_score, :value) + ) + end end end end diff --git a/ee/app/finders/security/scan_result_policies/vulnerabilities_finder.rb b/ee/app/finders/security/scan_result_policies/vulnerabilities_finder.rb index 926ee0299002da8ee54fed1f6f34ddf0ff922477..ff8c6402f8499b4cf495de671453d8bda185d499 100644 --- a/ee/app/finders/security/scan_result_policies/vulnerabilities_finder.rb +++ b/ee/app/finders/security/scan_result_policies/vulnerabilities_finder.rb @@ -19,7 +19,7 @@ def initialize(project, params = {}) attr_reader :project, :params - def execute + def execute # rubocop:disable Metrics/AbcSize -- Needs refactoring vulnerabilities = project.vulnerabilities.for_default_branch vulnerabilities = vulnerabilities.with_limit(params[:limit]) if params[:limit].present? @@ -29,6 +29,7 @@ def execute vulnerabilities = vulnerabilities.by_age(vulnerability_age[:operator], age_in_days) if vulnerability_age_valid? vulnerabilities = vulnerabilities.with_fix_available(params[:fix_available]) unless params[:fix_available].nil? vulnerabilities = vulnerabilities.with_findings_by_uuid(params[:uuids]) if params[:uuids].present? + vulnerabilities = apply_cve_enrichment_filters(vulnerabilities) if params[:known_exploited] || epss_score_valid? unless params[:false_positive].nil? vulnerabilities = vulnerabilities.with_false_positive(params[:false_positive]) @@ -53,6 +54,18 @@ def vulnerability_age_valid? def age_in_days vulnerability_age[:value] * INTERVAL_IN_DAYS[vulnerability_age[:interval]] end + + def epss_score_valid? + ::Security::ScanResultPolicy.epss_score_valid?(params[:epss_score]) + end + + def apply_cve_enrichment_filters(vulnerabilities) + vulnerabilities.with_cve_enrichment_filters( + known_exploited: params[:known_exploited], + epss_operator: params.dig(:epss_score, :operator), + epss_value: params.dig(:epss_score, :value) + ) + end end end end diff --git a/ee/app/models/approval_project_rule.rb b/ee/app/models/approval_project_rule.rb index c09ef1760547742fe70fe72ab0f23c7e350f874f..3841a90455636e0969c3f5bf53257eb1e4a134c1 100644 --- a/ee/app/models/approval_project_rule.rb +++ b/ee/app/models/approval_project_rule.rb @@ -73,8 +73,6 @@ class ApprovalProjectRule < ApplicationRecord validates :severity_levels, inclusion: { in: ::Enums::Vulnerability.severity_levels.keys } validates :vulnerability_states, inclusion: { in: APPROVAL_VULNERABILITY_STATES.keys } - delegate :vulnerability_attributes, to: :scan_result_policy_read, allow_nil: true - override :vulnerability_attribute_false_positive def vulnerability_attribute_false_positive vulnerability_attributes&.dig('false_positive') diff --git a/ee/app/models/concerns/approval_rule_like.rb b/ee/app/models/concerns/approval_rule_like.rb index 292e7d7f9331651e2a53699116a45691d37f676e..2d87183a83929e4e29574edbcb278029dfbaf20e 100644 --- a/ee/app/models/concerns/approval_rule_like.rb +++ b/ee/app/models/concerns/approval_rule_like.rb @@ -83,6 +83,8 @@ module ApprovalRuleLike scope :by_report_types, ->(report_types) { where(report_type: report_types) } end + delegate :vulnerability_attributes, to: :scan_result_policy_read, allow_nil: true + def security_report_time_window return unless approval_policy_rule @@ -97,6 +99,14 @@ def vulnerability_attribute_fix_available nil end + def vulnerability_attribute_known_exploited + vulnerability_attributes&.dig('known_exploited') + end + + def vulnerability_attribute_epss_score + vulnerability_attributes&.dig('epss_score')&.symbolize_keys + end + def audit_add(_model) raise NotImplementedError end diff --git a/ee/app/models/concerns/security/scan_result_policy.rb b/ee/app/models/concerns/security/scan_result_policy.rb index ac7962f810b740ce372cc6c905bc3d1053ac100f..328f8879d38d12589d27e77d8e57c9051db1131a 100644 --- a/ee/app/models/concerns/security/scan_result_policy.rb +++ b/ee/app/models/concerns/security/scan_result_policy.rb @@ -21,6 +21,13 @@ module ScanResultPolicy SEND_BOT_MESSAGE = 'send_bot_message' ALLOWED_ROLES = %w[developer maintainer owner].freeze + VALID_EPSS_SCORE_OPERATORS = %i[greater_than_or_equal_to greater_than less_than_or_equal_to less_than].freeze + + def self.epss_score_valid?(epss_score) + return false unless epss_score.present? + + epss_score[:operator]&.to_sym&.in?(VALID_EPSS_SCORE_OPERATORS) && epss_score[:value].is_a?(Numeric) + end included do has_many :scan_result_policy_reads, diff --git a/ee/app/models/ee/vulnerability.rb b/ee/app/models/ee/vulnerability.rb index 74bd76c791a53401cd829df8c274cdea8d4294c0..1b02e1606701fa205bb95839f1b53929bf489368 100644 --- a/ee/app/models/ee/vulnerability.rb +++ b/ee/app/models/ee/vulnerability.rb @@ -96,6 +96,7 @@ module Vulnerability scope :with_author, -> { preload(:author) } scope :with_author_and_project, -> { includes(:author, :project) } scope :with_findings, -> { includes(:findings) } + scope :with_findings_cve_enrichments, -> { includes(findings: :cve_enrichments) } scope :with_triaging_users, -> { preload(:resolved_by, :dismissed_by, :confirmed_by) } scope :with_state_transitions, -> { includes(:state_transitions) } scope :with_findings_by_uuid, ->(uuid) { with_findings.where(findings: { uuid: uuid }) } @@ -165,6 +166,16 @@ module Vulnerability joins(:findings).merge(Vulnerabilities::Finding.with_fix_available(fix_available)) end + scope :with_cve_enrichment_filters, ->(known_exploited: nil, epss_operator: nil, epss_value: nil) do + joins(:findings).merge( + Vulnerabilities::Finding.with_cve_enrichment_filters( + known_exploited: known_exploited, + epss_operator: epss_operator, + epss_value: epss_value + ) + ) + end + scope :for_default_branch, ->(present_on_default_branch = true) { where(present_on_default_branch: present_on_default_branch) } scope :present_on_default_branch, -> { where('present_on_default_branch IS TRUE') } diff --git a/ee/app/models/package_metadata/cve_enrichment.rb b/ee/app/models/package_metadata/cve_enrichment.rb index 0389e0154976f40cb0d0490bfff54d4ee65b5708..c71cf047dc1da285a49c7e61e0ddf32e1d9e4131 100644 --- a/ee/app/models/package_metadata/cve_enrichment.rb +++ b/ee/app/models/package_metadata/cve_enrichment.rb @@ -15,6 +15,15 @@ class CveEnrichment < ApplicationRecord foreign_key: :cve, inverse_of: :cve_enrichment + has_many :finding_enrichments, + class_name: 'Security::FindingEnrichment', + inverse_of: :cve_enrichment + + has_many :security_findings, + through: :finding_enrichments, + source: :security_finding, + class_name: 'Security::Finding' + validates :cve, presence: true, format: { with: CVE_REGEX } validates :epss_score, presence: true validates :is_known_exploit, inclusion: { in: [true, false] } diff --git a/ee/app/models/security/finding.rb b/ee/app/models/security/finding.rb index e939f0cdd5249bfc91736a529d0b70d116eb3167..48112cb0256bc596bf5b4c5fcaa8d3bb7be511f8 100644 --- a/ee/app/models/security/finding.rb +++ b/ee/app/models/security/finding.rb @@ -58,6 +58,17 @@ class Finding < ::SecApplicationRecord primary_key: 'uuid', foreign_key: 'finding_uuid' + has_many :finding_enrichments, + class_name: 'Security::FindingEnrichment', + inverse_of: :security_finding, + primary_key: 'uuid', + foreign_key: 'finding_uuid' + + has_many :cve_enrichments, + through: :finding_enrichments, + source: :cve_enrichment, + class_name: 'PackageMetadata::CveEnrichment' + enum :severity, ::Enums::Vulnerability.severity_levels, prefix: :severity validates :uuid, presence: true @@ -165,6 +176,27 @@ class Finding < ::SecApplicationRecord ) end + scope :with_cve_enrichment_filters, ->(known_exploited: nil, epss_operator: nil, epss_value: nil) do + break none unless known_exploited || (epss_operator && epss_value) + + enrichments = PackageMetadata::CveEnrichment.arel_table + query = joins(:cve_enrichments) + query = query.where(enrichments[:is_known_exploit].eq(true)) if known_exploited + + if epss_operator && epss_value + epss_score = enrichments[:epss_score] + condition = case epss_operator.to_sym + when :greater_than_or_equal_to then epss_score.gteq(epss_value) + when :greater_than then epss_score.gt(epss_value) + when :less_than_or_equal_to then epss_score.lteq(epss_value) + when :less_than then epss_score.lt(epss_value) + end + query = query.where(condition) if condition + end + + query.distinct + end + scope :except_scanners, ->(scanners) do where.not(scanner: scanners) end @@ -173,6 +205,10 @@ class Finding < ::SecApplicationRecord preload(scan: { project: { namespace: :namespace_settings_with_ancestors_inherited_settings } }) end + scope :with_cve_enrichments, -> do + includes(:cve_enrichments) + end + delegate :scan_type, :project, :pipeline, :remediations_proxy, to: :scan, allow_nil: true delegate :sha, to: :pipeline diff --git a/ee/app/models/security/finding_enrichment.rb b/ee/app/models/security/finding_enrichment.rb new file mode 100644 index 0000000000000000000000000000000000000000..b56d548c7ae29416607dccf12318ae41096bf29f --- /dev/null +++ b/ee/app/models/security/finding_enrichment.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Security + class FindingEnrichment < ApplicationRecord + self.table_name = 'security_finding_enrichments' + + belongs_to :security_finding, + class_name: 'Security::Finding', + inverse_of: :finding_enrichments, + primary_key: 'uuid', + foreign_key: 'finding_uuid' + + belongs_to :cve_enrichment, + class_name: 'PackageMetadata::CveEnrichment', + inverse_of: :finding_enrichments + + validates :finding_uuid, presence: true, uniqueness: { scope: :cve_enrichment_id } + end +end diff --git a/ee/app/models/vulnerabilities/finding.rb b/ee/app/models/vulnerabilities/finding.rb index 20408cb6d7ad793c6df54753aadfe1bd118fdfe1..3d5b86bc29185e368e240ed5b529e6cc3a819ca9 100644 --- a/ee/app/models/vulnerabilities/finding.rb +++ b/ee/app/models/vulnerabilities/finding.rb @@ -90,7 +90,7 @@ class Finding < ::SecApplicationRecord has_many :finding_identifiers, class_name: 'Vulnerabilities::FindingIdentifier', inverse_of: :finding, foreign_key: 'occurrence_id' has_many :identifiers, through: :finding_identifiers, class_name: 'Vulnerabilities::Identifier' - has_many :cve_identifiers, -> { where('LOWER(external_type) = ?', 'cve') }, through: :finding_identifiers, source: :identifier, class_name: 'Vulnerabilities::Identifier' + has_many :cve_identifiers, -> { where('LOWER(vulnerability_identifiers.external_type) = ?', 'cve') }, through: :finding_identifiers, source: :identifier, class_name: 'Vulnerabilities::Identifier' has_many :cve_enrichments, through: :cve_identifiers, disable_joins: true has_one :finding_token_status, class_name: 'Vulnerabilities::FindingTokenStatus', foreign_key: 'vulnerability_occurrence_id', inverse_of: :finding @@ -218,6 +218,27 @@ class Finding < ::SecApplicationRecord fix_available ? solution_query.or(exist_query) : solution_query.and(exist_query) end + scope :with_cve_enrichment_filters, ->(known_exploited: nil, epss_operator: nil, epss_value: nil) do + break none unless known_exploited || (epss_operator && epss_value) + + enrichments = PackageMetadata::CveEnrichment.arel_table + query = joins(:cve_enrichments) + query = query.where(enrichments[:is_known_exploit].eq(true)) if known_exploited + + if epss_operator && epss_value + epss_score = enrichments[:epss_score] + condition = case epss_operator.to_sym + when :greater_than_or_equal_to then epss_score.gteq(epss_value) + when :greater_than then epss_score.gt(epss_value) + when :less_than_or_equal_to then epss_score.lteq(epss_value) + when :less_than then epss_score.lt(epss_value) + end + query = query.where(condition) if condition + end + + query.distinct + end + scope :scoped_project, -> { where('vulnerability_occurrences.project_id = projects.id') } scope :eager_load_vulnerability_flags, -> { includes(:vulnerability_flags) } scope :by_location_image, ->(images) do diff --git a/ee/app/services/security/scan_result_policies/sync_preexisting_states_approval_rules_service.rb b/ee/app/services/security/scan_result_policies/sync_preexisting_states_approval_rules_service.rb index b455ae4121ebfe5028b6a01f24879138a55996b1..7a81010156722f26875857c76cd2717aad04ff28 100644 --- a/ee/app/services/security/scan_result_policies/sync_preexisting_states_approval_rules_service.rb +++ b/ee/app/services/security/scan_result_policies/sync_preexisting_states_approval_rules_service.rb @@ -61,7 +61,9 @@ def vulnerabilities(approval_rule) report_type: approval_rule.scanners, fix_available: approval_rule.vulnerability_attribute_fix_available, false_positive: approval_rule.vulnerability_attribute_false_positive, - vulnerability_age: approval_rule.scan_result_policy_read&.vulnerability_age + vulnerability_age: approval_rule.scan_result_policy_read&.vulnerability_age, + known_exploited: approval_rule.vulnerability_attribute_known_exploited, + epss_score: approval_rule.vulnerability_attribute_epss_score } ::Security::ScanResultPolicies::VulnerabilitiesFinder.new(project, finder_params).execute end diff --git a/ee/app/services/security/scan_result_policies/update_approvals_service.rb b/ee/app/services/security/scan_result_policies/update_approvals_service.rb index ab1795f91a474c404a68ab06eda803ca8bdf560d..70105d79576604d743429aa9735bbd11feb2c298 100644 --- a/ee/app/services/security/scan_result_policies/update_approvals_service.rb +++ b/ee/app/services/security/scan_result_policies/update_approvals_service.rb @@ -290,6 +290,8 @@ def findings_uuids(pipeline, approval_rule, pipeline_ids, check_dismissed = fals scanners: approval_rule.scanners, fix_available: approval_rule.vulnerability_attribute_fix_available, false_positive: approval_rule.vulnerability_attribute_false_positive, + known_exploited: approval_rule.vulnerability_attribute_known_exploited, + epss_score: approval_rule.vulnerability_attribute_epss_score, check_dismissed: check_dismissed } diff --git a/ee/app/services/security/store_findings_service.rb b/ee/app/services/security/store_findings_service.rb index 703c295e6482d398184a454dac3a30df83efaad1..02b9efc8592f7e81864638963c2b6a661b65fde4 100644 --- a/ee/app/services/security/store_findings_service.rb +++ b/ee/app/services/security/store_findings_service.rb @@ -46,6 +46,8 @@ def report_findings def store_finding_batch(batch) batch.map { finding_data(_1) } .then { import_batch(_1) } + + populate_finding_enrichment_cache(batch) end def import_batch(report_finding_data) @@ -92,5 +94,59 @@ def finding_data_for(report_finding) raw_source_code_extract: report_finding.raw_source_code_extract } end + + def populate_finding_enrichment_cache(report_findings) + cve_names = extract_unique_cve_names(report_findings) + return if cve_names.empty? + + enrichments_by_cve = load_enrichments_by_cve(cve_names) + return if enrichments_by_cve.empty? + + finding_enrichments = build_finding_enrichments(report_findings, enrichments_by_cve) + return if finding_enrichments.empty? + + Security::FindingEnrichment.upsert_all( + finding_enrichments, + unique_by: %i[finding_uuid cve_enrichment_id] + ) + rescue StandardError => exception + Gitlab::ErrorTracking.track_exception( + exception, + security_scan_id: security_scan.id, + project_id: project.id, + class: self.class.name + ) + end + + def extract_unique_cve_names(report_findings) + report_findings + .flat_map(&:identifiers) + .select(&:cve?) + .filter_map(&:name) + .uniq + end + + def load_enrichments_by_cve(cve_names) + PackageMetadata::CveEnrichment + .by_cves(cve_names) + .index_by(&:cve) + end + + def build_finding_enrichments(report_findings, enrichments_by_cve) + report_findings.flat_map do |finding| + cve_identifiers = finding.identifiers.select(&:cve?) + next [] if cve_identifiers.empty? + + cve_identifiers.filter_map do |identifier| + enrichment = enrichments_by_cve[identifier.name] + next unless enrichment + + { + finding_uuid: finding.uuid, + cve_enrichment_id: enrichment.id + } + end + end + end end end diff --git a/ee/app/validators/json_schemas/approval_policy_rule_content.json b/ee/app/validators/json_schemas/approval_policy_rule_content.json index dab31171f35b915b5fd9bf8af28fdab16529079d..a84e5e51143116086a0a1db7c2b63ea94421a251 100644 --- a/ee/app/validators/json_schemas/approval_policy_rule_content.json +++ b/ee/app/validators/json_schemas/approval_policy_rule_content.json @@ -114,6 +114,37 @@ }, "fix_available": { "type": "boolean" + }, + "known_exploited": { + "type": "boolean", + "description": "Filter vulnerabilities based on CISA Known Exploited Vulnerabilities (KEV) catalog. When true, only includes vulnerabilities actively exploited in the wild." + }, + "epss_score": { + "type": "object", + "description": "Filter vulnerabilities based on Exploit Prediction Scoring System (EPSS) score. EPSS estimates the probability (0-1) that a vulnerability will be exploited.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "greater_than_or_equal_to", + "greater_than", + "less_than_or_equal_to", + "less_than" + ], + "description": "Comparison operator for EPSS score threshold." + }, + "value": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "EPSS score threshold value (0.0 to 1.0). For example, 0.8 represents 80% exploitation probability." + } + }, + "required": [ + "operator", + "value" + ], + "additionalProperties": false } }, "additionalProperties": false diff --git a/ee/app/validators/json_schemas/security_orchestration_policy.json b/ee/app/validators/json_schemas/security_orchestration_policy.json index 785013f42d4405bbb4eebc93159aeaa6383c1a0c..8558dd45fd303e3db3a30f474d94dcc044c7207b 100644 --- a/ee/app/validators/json_schemas/security_orchestration_policy.json +++ b/ee/app/validators/json_schemas/security_orchestration_policy.json @@ -1279,6 +1279,37 @@ }, "fix_available": { "type": "boolean" + }, + "known_exploited": { + "type": "boolean", + "description": "Filter vulnerabilities based on CISA Known Exploited Vulnerabilities (KEV) catalog. When true, only includes vulnerabilities actively exploited in the wild." + }, + "epss_score": { + "type": "object", + "description": "Filter vulnerabilities based on Exploit Prediction Scoring System (EPSS) score. EPSS estimates the probability (0-1) that a vulnerability will be exploited.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "greater_than_or_equal_to", + "greater_than", + "less_than_or_equal_to", + "less_than" + ], + "description": "Comparison operator for EPSS score threshold." + }, + "value": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "EPSS score threshold value (0.0 to 1.0). For example, 0.8 represents 80% exploitation probability." + } + }, + "required": [ + "operator", + "value" + ], + "additionalProperties": false } }, "additionalProperties": false diff --git a/ee/lib/security/scan_result_policies/policy_violation_comment.rb b/ee/lib/security/scan_result_policies/policy_violation_comment.rb index 13a094b1c950802239b588c9d2262b535be30e32..53720dcfd912d27ea909edab1e0d95397765fb21 100644 --- a/ee/lib/security/scan_result_policies/policy_violation_comment.rb +++ b/ee/lib/security/scan_result_policies/policy_violation_comment.rb @@ -390,9 +390,24 @@ def build_scan_finding_violation_line(violation) end line += " (#{violation.report_type.humanize})" if violation.report_type + + # Add CVE enrichment data inline + enrichment_info = build_enrichment_info(violation) + line += "\n #{enrichment_info}" if enrichment_info.present? + line end + def build_enrichment_info(violation) + enrichments = violation.cve_enrichments + return if enrichments.empty? + + enrichments.map do |enrichment| + " - `#{enrichment.cve}` **·** EPSS: #{enrichment.epss_score} **·** " \ + "Known Exploit: #{enrichment.is_known_exploit ? 'Yes :rotating_light:' : 'No'}" + end.join("\n") + end + def any_merge_request_overview(violations, bypassable: false) list = violations.sort_by(&:name).map do |violation| description = case violation.commits diff --git a/ee/lib/security/scan_result_policies/policy_violation_details.rb b/ee/lib/security/scan_result_policies/policy_violation_details.rb index 18d1e6d89f88a6508c6d7ad1d4b3d5111458d7cc..805e94cb4e73f8db4d4c4e9a89a1ef35b777f106 100644 --- a/ee/lib/security/scan_result_policies/policy_violation_details.rb +++ b/ee/lib/security/scan_result_policies/policy_violation_details.rb @@ -8,7 +8,8 @@ class PolicyViolationDetails Violation = Struct.new(:report_type, :name, :scan_result_policy_id, :data, :warning, :status, :warn_mode, :security_policy, :security_policy_id, :enforcement_type, :dismissed, keyword_init: true) ViolationError = Struct.new(:report_type, :error, :data, :message, :warning, keyword_init: true) - ScanFindingViolation = Struct.new(:name, :report_type, :severity, :location, :path, keyword_init: true) + ScanFindingViolation = Struct.new(:name, :report_type, :severity, :location, :path, :cve_enrichments, + keyword_init: true) AnyMergeRequestViolation = Struct.new(:name, :commits, :warn_mode, keyword_init: true) LicenseScanningViolation = Struct.new(:license, :dependencies, :url, keyword_init: true) ComparisonPipelines = Struct.new(:report_type, :source, :target, keyword_init: true) @@ -60,7 +61,7 @@ def initialize(merge_request) end def violations - merge_request.scan_result_policy_violations.filter_map do |violation| + merge_request.scan_result_policy_violations.including_security_policies.filter_map do |violation| rule = scan_result_policy_rules[violation.scan_result_policy_id] next if rule.blank? # there may be a race condition situation where rule is missing @@ -282,14 +283,16 @@ def previously_existing_violations(uuids) return [] if uuids.blank? Security::ScanResultPolicies::VulnerabilitiesFinder.new(project, - { limit: uuids_limit, uuids: uuids.first(uuids_limit) }).execute.map do |vulnerability| + { limit: uuids_limit, uuids: uuids.first(uuids_limit) }).execute + .with_findings_cve_enrichments.map do |vulnerability| finding = vulnerability.finding ScanFindingViolation.new( report_type: finding.report_type, severity: finding.severity, path: vulnerability.present.location_link, location: finding.location.with_indifferent_access, - name: finding.name + name: finding.name, + cve_enrichments: finding.cve_enrichments ) end end @@ -299,13 +302,14 @@ def newly_detected_violations(uuids, related_pipeline_ids) Security::ScanResultPolicies::FindingsFinder.new(project, pipeline, { related_pipeline_ids: related_pipeline_ids, uuids: uuids.first(uuids_limit) }).execute - .uniq(&:uuid).map do |finding| + .with_cve_enrichments.distinct(&:uuid).map do |finding| # rubocop:disable CodeReuse/ActiveRecord -- logic specific to the service ScanFindingViolation.new( report_type: finding.report_type, severity: finding.severity, path: finding.finding_data.present? ? finding.present.blob_url : nil, location: finding.finding_data.present? ? finding.location : nil, - name: finding.finding_data.present? ? finding.name : nil + name: finding.finding_data.present? ? finding.name : nil, + cve_enrichments: finding.cve_enrichments ) end end diff --git a/ee/spec/factories/security/finding_enrichments.rb b/ee/spec/factories/security/finding_enrichments.rb new file mode 100644 index 0000000000000000000000000000000000000000..f103f3061bbe947511a643694589c529ae8cc4d1 --- /dev/null +++ b/ee/spec/factories/security/finding_enrichments.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :security_finding_enrichment, class: 'Security::FindingEnrichment' do + finding_uuid { Gitlab::UUID.v5(SecureRandom.hex) } + association :cve_enrichment, factory: :pm_cve_enrichment + end +end