From 3f546cd6e029c902c9e3097565fcabc6d8d8af16 Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Tue, 9 Sep 2025 09:26:00 +0200 Subject: [PATCH 1/6] Add model and table for FP detection - Refs: https://gitlab.com/gitlab-org/gitlab/-/work_items/567391 https://gitlab.com/gitlab-org/gitlab/-/work_items/567390 Changelog: added EE: true Experimental: true --- app/models/concerns/enums/vulnerability.rb | 8 ++ ...ulnerability_false_positive_detections.yml | 11 ++ ...vulnerability_false_positive_detections.rb | 19 +++ db/schema_migrations/20250908080031 | 1 + db/structure.sql | 38 ++++++ ee/app/models/ai/duo_workflows/workflow.rb | 1 + ee/app/models/ee/vulnerability.rb | 9 ++ .../false_positive_detection.rb | 36 ++++++ ...vulnerability_false_positive_detections.rb | 34 +++++ .../false_positive_detection_spec.rb | 118 ++++++++++++++++++ 10 files changed, 275 insertions(+) create mode 100644 db/docs/vulnerability_false_positive_detections.yml create mode 100644 db/migrate/20250908080031_create_vulnerability_false_positive_detections.rb create mode 100644 db/schema_migrations/20250908080031 create mode 100644 ee/app/models/vulnerabilities/false_positive_detection.rb create mode 100644 ee/spec/factories/vulnerability_false_positive_detections.rb create mode 100644 ee/spec/models/vulnerabilities/false_positive_detection_spec.rb diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 424e9bab1ef8eb..a7279cb7cf881a 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -47,6 +47,14 @@ module Vulnerability dismissed: 2 }.with_indifferent_access.freeze + FALSE_POSITIVE_STATUSES = { + not_started: 0, + in_progress: 1, + detected_as_fp: 2, + detected_as_not_fp: 3, + failed: 4 + }.freeze + OWASP_TOP_10_BY_YEAR = { '2017' => { "A1:2017-Injection" => 1, diff --git a/db/docs/vulnerability_false_positive_detections.yml b/db/docs/vulnerability_false_positive_detections.yml new file mode 100644 index 00000000000000..b04ffdeb369fdb --- /dev/null +++ b/db/docs/vulnerability_false_positive_detections.yml @@ -0,0 +1,11 @@ +--- +table_name: vulnerability_false_positive_detections +classes: +- Vulnerabilities::FalsePositiveDetection +feature_categories: +- vulnerability_management +description: 'Experimental: Represents a false positive detection on a SAST finding' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26619 +milestone: '18.4' +gitlab_schema: gitlab_main_org +table_size: small diff --git a/db/migrate/20250908080031_create_vulnerability_false_positive_detections.rb b/db/migrate/20250908080031_create_vulnerability_false_positive_detections.rb new file mode 100644 index 00000000000000..6c9f1677ee881b --- /dev/null +++ b/db/migrate/20250908080031_create_vulnerability_false_positive_detections.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateVulnerabilityFalsePositiveDetections < Gitlab::Database::Migration[2.3] + milestone '18.4' + + def change + create_table :vulnerability_false_positive_detections do |t| + t.references :vulnerability, null: false, foreign_key: { on_delete: :cascade }, + index: { name: 'i_v11y_on_fp_detec' } + t.references :workflow, foreign_key: { to_table: :duo_workflows_workflows, on_delete: :cascade }, null: false + t.integer :status, null: false, default: 0, limit: 2 + t.float :confidence_score, default: 0, null: false + t.timestamps_with_timezone null: false + + t.index [:vulnerability_id, :created_at], name: 'i_v11y_fp_detec_v11y_id_n_created_at' + t.index :status, name: 'index_vuln_fp_detections_on_status' + end + end +end diff --git a/db/schema_migrations/20250908080031 b/db/schema_migrations/20250908080031 new file mode 100644 index 00000000000000..bb0b220d9417aa --- /dev/null +++ b/db/schema_migrations/20250908080031 @@ -0,0 +1 @@ +185b17acdc4af136f4a6945dc12e077476173775bd5d8c5e1e0f737bb22b0fc9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 853a7b497d6981..1e6d57dd602de6 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -27121,6 +27121,25 @@ CREATE SEQUENCE vulnerability_external_issue_links_id_seq ALTER SEQUENCE vulnerability_external_issue_links_id_seq OWNED BY vulnerability_external_issue_links.id; +CREATE TABLE vulnerability_false_positive_detections ( + id bigint NOT NULL, + vulnerability_id bigint NOT NULL, + workflow_id bigint NOT NULL, + status smallint DEFAULT 0 NOT NULL, + confidence_score double precision DEFAULT 0.0 NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE vulnerability_false_positive_detections_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE vulnerability_false_positive_detections_id_seq OWNED BY vulnerability_false_positive_detections.id; + CREATE TABLE vulnerability_feedback ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -30594,6 +30613,8 @@ ALTER TABLE ONLY vulnerability_exports ALTER COLUMN id SET DEFAULT nextval('vuln ALTER TABLE ONLY vulnerability_external_issue_links ALTER COLUMN id SET DEFAULT nextval('vulnerability_external_issue_links_id_seq'::regclass); +ALTER TABLE ONLY vulnerability_false_positive_detections ALTER COLUMN id SET DEFAULT nextval('vulnerability_false_positive_detections_id_seq'::regclass); + ALTER TABLE ONLY vulnerability_feedback ALTER COLUMN id SET DEFAULT nextval('vulnerability_feedback_id_seq'::regclass); ALTER TABLE ONLY vulnerability_finding_evidences ALTER COLUMN id SET DEFAULT nextval('vulnerability_finding_evidences_id_seq'::regclass); @@ -34157,6 +34178,9 @@ ALTER TABLE ONLY vulnerability_exports ALTER TABLE ONLY vulnerability_external_issue_links ADD CONSTRAINT vulnerability_external_issue_links_pkey PRIMARY KEY (id); +ALTER TABLE ONLY vulnerability_false_positive_detections + ADD CONSTRAINT vulnerability_false_positive_detections_pkey PRIMARY KEY (id); + ALTER TABLE ONLY vulnerability_feedback ADD CONSTRAINT vulnerability_feedback_pkey PRIMARY KEY (id); @@ -36466,6 +36490,10 @@ CREATE INDEX i_software_license_policies_on_custom_software_license_id ON softwa CREATE UNIQUE INDEX i_uniq_external_control_name_per_requirement ON compliance_requirements_controls USING btree (compliance_requirement_id, external_control_name) WHERE ((external_control_name IS NOT NULL) AND (external_control_name <> ''::text)); +CREATE INDEX i_v11y_fp_detec_v11y_id_n_created_at ON vulnerability_false_positive_detections USING btree (vulnerability_id, created_at); + +CREATE INDEX i_v11y_on_fp_detec ON vulnerability_false_positive_detections USING btree (vulnerability_id); + CREATE INDEX i_vuln_occurrences_on_proj_report_loc_dep_pkg_ver_file_img ON vulnerability_occurrences USING btree (project_id, report_type, ((((location -> 'dependency'::text) -> 'package'::text) ->> 'name'::text)), (((location -> 'dependency'::text) ->> 'version'::text)), COALESCE((location ->> 'file'::text), (location ->> 'image'::text))) WHERE (report_type = ANY (ARRAY[2, 1])); CREATE UNIQUE INDEX i_wi_date_values_on_work_item_id_custom_field_id ON work_item_date_field_values USING btree (work_item_id, custom_field_id); @@ -41478,6 +41506,8 @@ CREATE UNIQUE INDEX index_virtual_registries_settings_on_group_id ON virtual_reg CREATE UNIQUE INDEX index_vuln_findings_on_uuid_including_vuln_id_1 ON vulnerability_occurrences USING btree (uuid) INCLUDE (vulnerability_id); +CREATE INDEX index_vuln_fp_detections_on_status ON vulnerability_false_positive_detections USING btree (status); + CREATE UNIQUE INDEX index_vuln_historical_statistics_on_project_id_and_date ON vulnerability_historical_statistics USING btree (project_id, date); CREATE INDEX index_vuln_mgmt_policy_rules_on_policy_mgmt_project_id ON vulnerability_management_policy_rules USING btree (security_policy_management_project_id); @@ -41556,6 +41586,8 @@ CREATE INDEX index_vulnerability_external_issue_links_on_author_id ON vulnerabil CREATE INDEX index_vulnerability_external_issue_links_on_project_id ON vulnerability_external_issue_links USING btree (project_id); +CREATE INDEX index_vulnerability_false_positive_detections_on_workflow_id ON vulnerability_false_positive_detections USING btree (workflow_id); + CREATE INDEX index_vulnerability_feedback_finding_uuid ON vulnerability_feedback USING hash (finding_uuid); CREATE INDEX index_vulnerability_feedback_on_author_id ON vulnerability_feedback USING btree (author_id); @@ -49501,6 +49533,9 @@ ALTER TABLE ONLY system_access_instance_microsoft_graph_access_tokens ALTER TABLE ONLY packages_debian_group_distribution_keys ADD CONSTRAINT fk_rails_779438f163 FOREIGN KEY (distribution_id) REFERENCES packages_debian_group_distributions(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_false_positive_detections + ADD CONSTRAINT fk_rails_77a0d6913c FOREIGN KEY (workflow_id) REFERENCES duo_workflows_workflows(id) ON DELETE CASCADE; + ALTER TABLE ONLY group_scim_identities ADD CONSTRAINT fk_rails_77cb698c8d FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; @@ -50239,6 +50274,9 @@ ALTER TABLE p_ci_job_annotations ALTER TABLE ONLY packages_rpm_repository_files ADD CONSTRAINT fk_rails_d545cfaed2 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_false_positive_detections + ADD CONSTRAINT fk_rails_d7286562be FOREIGN KEY (vulnerability_id) REFERENCES vulnerabilities(id) ON DELETE CASCADE; + ALTER TABLE p_ci_builds ADD CONSTRAINT fk_rails_d739f46384_p FOREIGN KEY (partition_id, commit_id) REFERENCES p_ci_pipelines(partition_id, id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/ee/app/models/ai/duo_workflows/workflow.rb b/ee/app/models/ai/duo_workflows/workflow.rb index 6f18b5a961d128..d378aaab7dcf79 100644 --- a/ee/app/models/ai/duo_workflows/workflow.rb +++ b/ee/app/models/ai/duo_workflows/workflow.rb @@ -17,6 +17,7 @@ class Workflow < ::ApplicationRecord has_many :events, class_name: 'Ai::DuoWorkflows::Event' has_many :workflows_workloads, class_name: 'Ai::DuoWorkflows::WorkflowsWorkload' has_many :workloads, through: :workflows_workloads, disable_joins: true + has_many :false_positive_detections, class_name: 'Vulnerabilities::FalsePositiveDetection' validates :status, presence: true validates :goal, length: { maximum: 16_384 } diff --git a/ee/app/models/ee/vulnerability.rb b/ee/app/models/ee/vulnerability.rb index 08ea3f978caf81..7580a3547e28e8 100644 --- a/ee/app/models/ee/vulnerability.rb +++ b/ee/app/models/ee/vulnerability.rb @@ -77,6 +77,15 @@ module Vulnerability has_many :sbom_occurrences, through: :sbom_occurrences_vulnerabilities, class_name: 'Sbom::Occurrence', source: :occurrence + has_many :false_positive_detections, + class_name: '::Vulnerabilities::FalsePositiveDetection', + inverse_of: :vulnerability + + has_one :latest_false_positive_detection, + -> { order(created_at: :desc).limit(1) }, + class_name: '::Vulnerabilities::FalsePositiveDetection', + inverse_of: :vulnerability + enum :state, ::Enums::Vulnerability.vulnerability_states enum :severity, ::Enums::Vulnerability.severity_levels, prefix: :severity enum :report_type, ::Enums::Vulnerability.report_types diff --git a/ee/app/models/vulnerabilities/false_positive_detection.rb b/ee/app/models/vulnerabilities/false_positive_detection.rb new file mode 100644 index 00000000000000..abf0b00258ae9f --- /dev/null +++ b/ee/app/models/vulnerabilities/false_positive_detection.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Vulnerabilities + class FalsePositiveDetection < ApplicationRecord + include Enums::Vulnerability + + self.table_name = :vulnerability_false_positive_detections + enum :status, FALSE_POSITIVE_STATUSES + + belongs_to :vulnerability, class_name: '::Vulnerability', optional: false + belongs_to :workflow, class_name: '::Ai::DuoWorkflows::Workflow', optional: false + + validates :vulnerability, presence: true + validates :workflow, presence: true + validates :status, presence: true + validates :confidence_score, + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 } + + scope :latest_for_vulnerability, ->(vulnerability_id) { + where(vulnerability_id: vulnerability_id) + .order(created_at: :desc) + .limit(1) + } + + scope :by_status, ->(status) { where status: status } + scope :with_confidence_above, ->(threshold) { where(confidence_score: threshold..) } + + def false_positive? + detected_as_fp? + end + + def completed? + detected_as_fp? || detected_as_not_fp? + end + end +end diff --git a/ee/spec/factories/vulnerability_false_positive_detections.rb b/ee/spec/factories/vulnerability_false_positive_detections.rb new file mode 100644 index 00000000000000..b18f34df5c8ae7 --- /dev/null +++ b/ee/spec/factories/vulnerability_false_positive_detections.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :vulnerability_false_positive_detection, class: 'Vulnerabilities::FalsePositiveDetection' do + vulnerability { association(:vulnerability) } + workflow { association(:duo_workflows_workflow) } + status { 0 } # :not_started + confidence_score { 0.0 } + + trait :in_progress do + status { 1 } + end + + trait :detected_as_fp do + status { 2 } + end + + trait :detected_as_not_fp do + status { 3 } + end + + trait :high_confidence do + confidence_score { 0.9 } + end + + trait :medium_confidence do + confidence_score { 0.6 } + end + + trait :low_confidence do + confidence_score { 0.3 } + end + end +end diff --git a/ee/spec/models/vulnerabilities/false_positive_detection_spec.rb b/ee/spec/models/vulnerabilities/false_positive_detection_spec.rb new file mode 100644 index 00000000000000..333d9990a8b0a0 --- /dev/null +++ b/ee/spec/models/vulnerabilities/false_positive_detection_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::FalsePositiveDetection, feature_category: :vulnerability_management do + describe 'associations' do + it { is_expected.to belong_to(:vulnerability).class_name('::Vulnerability').required } + it { is_expected.to belong_to(:workflow).class_name('::Ai::DuoWorkflows::Workflow').required } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:status).with_values(Enums::Vulnerability::FALSE_POSITIVE_STATUSES) } + end + + describe 'validations' do + subject { build(:vulnerability_false_positive_detection) } + + it { is_expected.to validate_presence_of(:vulnerability) } + it { is_expected.to validate_presence_of(:status) } + it { is_expected.to validate_presence_of(:workflow) } + + describe 'confidence_score validation' do + it { is_expected.to allow_value(0).for(:confidence_score) } + it { is_expected.to allow_value(0.5).for(:confidence_score) } + it { is_expected.to allow_value(1).for(:confidence_score) } + it { is_expected.not_to allow_value(-0.1).for(:confidence_score) } + it { is_expected.not_to allow_value(1.1).for(:confidence_score) } + end + end + + describe 'scopes' do + let_it_be(:vulnerability1) { create(:vulnerability) } + let_it_be(:vulnerability2) { create(:vulnerability) } + let_it_be(:detection1) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability1, created_at: 2.days.ago) + end + + let_it_be(:detection2) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability1, created_at: 1.day.ago) + end + + let_it_be(:detection3) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability2, created_at: 3.days.ago) + end + + describe '.latest_for_vulnerability' do + it 'returns the latest detection for a specific vulnerability' do + result = described_class.latest_for_vulnerability(vulnerability1.id) + expect(result).to contain_exactly(detection2) + end + end + + describe '.by_status' do + let_it_be(:fp_detection) { create(:vulnerability_false_positive_detection, status: :detected_as_fp) } + let_it_be(:not_fp_detection) { create(:vulnerability_false_positive_detection, status: :detected_as_not_fp) } + + it 'returns detections with the specified status' do + result = described_class.by_status(:detected_as_fp) + expect(result).to include(fp_detection) + expect(result).not_to include(not_fp_detection) + end + end + + describe '.with_confidence_above' do + let_it_be(:high_confidence) { create(:vulnerability_false_positive_detection, confidence_score: 0.8) } + let_it_be(:low_confidence) { create(:vulnerability_false_positive_detection, confidence_score: 0.3) } + + it 'returns detections with confidence score above threshold' do + result = described_class.with_confidence_above(0.5) + expect(result).to include(high_confidence) + expect(result).not_to include(low_confidence) + end + end + end + + describe 'instance methods' do + let(:detection) { build(:vulnerability_false_positive_detection) } + + describe '#false_positive?' do + it 'returns true when status is detected_as_fp' do + detection.status = :detected_as_fp + expect(detection.false_positive?).to be true + end + + it 'returns false when status is not detected_as_fp' do + detection.status = :detected_as_not_fp + expect(detection.false_positive?).to be false + end + end + + describe '#completed?' do + it 'returns true when status is detected_as_fp' do + detection.status = :detected_as_fp + expect(detection.completed?).to be true + end + + it 'returns true when status is detected_as_not_fp' do + detection.status = :detected_as_not_fp + expect(detection.completed?).to be true + end + + it 'returns false when status is in_progress' do + detection.status = :in_progress + expect(detection.completed?).to be false + end + + it 'returns false when status is not_started' do + detection.status = :not_started + expect(detection.completed?).to be false + end + + it 'returns false when status is failed' do + detection.status = :failed + expect(detection.completed?).to be false + end + end + end +end -- GitLab From aedd39b74618988a874ac53fe4b225dc87e40271 Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Wed, 10 Sep 2025 11:57:46 +0200 Subject: [PATCH 2/6] Add graphql for FP detections - Refs: https://gitlab.com/gitlab-org/gitlab/-/work_items/567392 Changelog: added EE: true Experimental: true --- .../false_positive_detections_resolver.rb | 19 ++++++++ .../false_positive_detection_status_enum.rb | 14 ++++++ .../false_positive_detection_type.rb | 34 ++++++++++++++ ee/app/graphql/types/vulnerability_type.rb | 39 +++++++++++++++ ...false_positive_detections_resolver_spec.rb | 42 +++++++++++++++++ ...lse_positive_detection_status_enum_spec.rb | 17 +++++++ .../false_positive_detection_type_spec.rb | 29 ++++++++++++ .../graphql/types/vulnerability_type_spec.rb | 47 +++++++++++++++++++ 8 files changed, 241 insertions(+) create mode 100644 ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb create mode 100644 ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb create mode 100644 ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb create mode 100644 ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb create mode 100644 ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb create mode 100644 ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb diff --git a/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb new file mode 100644 index 00000000000000..491386df71358f --- /dev/null +++ b/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + module Vulnerabilities + class FalsePositiveDetectionsResolver < BaseResolver + type Types::Vulnerabilities::FalsePositiveDetectionType.connection_type, null: false + + argument :status, [Types::Vulnerabilities::FalsePositiveDetectionStatusEnum], + required: false, + description: 'Filter by detection status.' + + def resolve(status: nil) + detections = object.false_positive_detections.order(created_at: :desc) + detections = detections.by_status(status) if status.present? + detections + end + end + end +end \ No newline at end of file diff --git a/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb b/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb new file mode 100644 index 00000000000000..2ce35eb22151f9 --- /dev/null +++ b/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Vulnerabilities + class FalsePositiveDetectionStatusEnum < BaseEnum + graphql_name 'VulnerabilityFalsePositiveDetectionStatus' + description 'Status of vulnerability false positive detection' + + ::Enums::Vulnerability::FALSE_POSITIVE_STATUSES.each do |status, _| + value status.upcase, value: status, description: "Detection is #{status.humanize.downcase}" + end + end + end +end \ No newline at end of file diff --git a/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb b/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb new file mode 100644 index 00000000000000..d8c60335d9a12d --- /dev/null +++ b/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Types + module Vulnerabilities + class FalsePositiveDetectionType < BaseObject + graphql_name 'VulnerabilityFalsePositiveDetection' + description 'Represents a false positive detection result for a vulnerability' + + authorize :read_vulnerability + + field :id, GraphQL::Types::ID, null: false, + description: 'ID of the false positive detection.' + + field :status, Types::Vulnerabilities::FalsePositiveDetectionStatusEnum, null: false, + description: 'Status of the false positive detection.' + + field :confidence_score, GraphQL::Types::Float, null: true, + description: 'Confidence score of the detection (0.0 to 1.0).' + + field :workflow_id, GraphQL::Types::String, null: true, + description: 'ID of the Duo workflow that performed the detection.' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp when the detection was created.' + + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp when the detection was last updated.' + + def workflow_id + object.workflow_id.to_s + end + end + end +end \ No newline at end of file diff --git a/ee/app/graphql/types/vulnerability_type.rb b/ee/app/graphql/types/vulnerability_type.rb index 235aa43c7096e8..a344fe5a14e87f 100644 --- a/ee/app/graphql/types/vulnerability_type.rb +++ b/ee/app/graphql/types/vulnerability_type.rb @@ -198,6 +198,16 @@ def self.authorization_scopes description: 'Status of the secret token associated with this vulnerability. Returns `null` if the `validity_checks` feature flag is disabled.', resolver: Resolvers::Vulnerabilities::FindingTokenStatusResolver + field :false_positive_detections, + Types::Vulnerabilities::FalsePositiveDetectionType.connection_type, + null: false, + description: 'History of false positive detection attempts for this vulnerability.' + + field :latest_false_positive_detection, + Types::Vulnerabilities::FalsePositiveDetectionType, + null: true, + description: 'Latest false positive detection result for this vulnerability.' + field :initial_detected_pipeline, Ci::PipelineType, method: :initial_finding_pipeline, null: true, experiment: { milestone: '18.2' }, @@ -323,6 +333,35 @@ def reachability end end + def false_positive_detections + BatchLoader::GraphQL.for(object.id).batch do |vulnerability_ids, loader| + detections = ::Vulnerabilities::FalsePositiveDetection + .where(vulnerability_id: vulnerability_ids) + .includes(:workflow) + .order(created_at: :desc) + .group_by(&:vulnerability_id) + + vulnerability_ids.each do |id| + loader.call(id, detections[id] || []) + end + end + end + + def latest_false_positive_detection + BatchLoader::GraphQL.for(object.id).batch do |vulnerability_ids, loader| + detections = ::Vulnerabilities::FalsePositiveDetection + .where(vulnerability_id: vulnerability_ids) + .includes(:workflow) + .order(created_at: :desc) + .group_by(&:vulnerability_id) + .transform_values(&:first) + + vulnerability_ids.each do |id| + loader.call(id, detections[id]) + end + end + end + private def archival_information_for(vulnerability) diff --git a/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb new file mode 100644 index 00000000000000..8c8a149361e4f0 --- /dev/null +++ b/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Vulnerabilities::FalsePositiveDetectionsResolver, feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:vulnerability) { create(:vulnerability) } + let_it_be(:detection1) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :not_started) } + let_it_be(:detection2) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :detected_as_fp) } + let_it_be(:detection3) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :failed) } + + let(:resolver) { described_class } + + describe '#resolve' do + subject { resolve(resolver, obj: vulnerability, args: args) } + + context 'without status filter' do + let(:args) { {} } + + it 'returns all detections ordered by created_at desc' do + expect(subject).to eq([detection3, detection2, detection1]) + end + end + + context 'with status filter' do + let(:args) { { status: ['detected_as_fp'] } } + + it 'returns only detections with the specified status' do + expect(subject).to eq([detection2]) + end + end + + context 'with multiple status filters' do + let(:args) { { status: ['not_started', 'failed'] } } + + it 'returns detections with any of the specified statuses' do + expect(subject).to contain_exactly(detection1, detection3) + end + end + end +end \ No newline at end of file diff --git a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb new file mode 100644 index 00000000000000..f764c742aa0897 --- /dev/null +++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['VulnerabilityFalsePositiveDetectionStatus'], feature_category: :vulnerability_management do + it 'exposes all the existing false positive detection statuses' do + expect(described_class.values.keys).to match_array(%w[NOT_STARTED IN_PROGRESS DETECTED_AS_FP DETECTED_AS_NOT_FP FAILED]) + end + + it 'has the correct descriptions' do + expect(described_class.values['NOT_STARTED'].description).to eq('Detection is not started') + expect(described_class.values['IN_PROGRESS'].description).to eq('Detection is in progress') + expect(described_class.values['DETECTED_AS_FP'].description).to eq('Detection is detected as fp') + expect(described_class.values['DETECTED_AS_NOT_FP'].description).to eq('Detection is detected as not fp') + expect(described_class.values['FAILED'].description).to eq('Detection is failed') + end +end \ No newline at end of file diff --git a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb new file mode 100644 index 00000000000000..28ba4c9c788674 --- /dev/null +++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['VulnerabilityFalsePositiveDetection'], feature_category: :vulnerability_management do + let_it_be(:fields) { %i[id status confidence_score workflow_id created_at updated_at] } + + it { expect(described_class).to have_graphql_fields(*fields) } + + it { expect(described_class).to require_graphql_authorizations(:read_vulnerability) } + + describe 'field types' do + specify { expect(described_class.fields['id'].type).to eq(!GraphQL::Types::ID) } + specify { expect(described_class.fields['status'].type).to eq(!GitlabSchema.types['VulnerabilityFalsePositiveDetectionStatus']) } + specify { expect(described_class.fields['confidenceScore'].type).to eq(GraphQL::Types::Float) } + specify { expect(described_class.fields['workflowId'].type).to eq(GraphQL::Types::String) } + specify { expect(described_class.fields['createdAt'].type).to eq(!GitlabSchema.types['Time']) } + specify { expect(described_class.fields['updatedAt'].type).to eq(!GitlabSchema.types['Time']) } + end + + describe '#workflow_id' do + let(:detection) { build(:vulnerability_false_positive_detection) } + let(:type_instance) { described_class.new(detection, {}) } + + it 'returns the workflow_id as a string' do + expect(type_instance.workflow_id).to eq(detection.workflow_id.to_s) + end + end +end \ No newline at end of file diff --git a/ee/spec/graphql/types/vulnerability_type_spec.rb b/ee/spec/graphql/types/vulnerability_type_spec.rb index 2c3ac9d2afbc8e..7c81693c9e326a 100644 --- a/ee/spec/graphql/types/vulnerability_type_spec.rb +++ b/ee/spec/graphql/types/vulnerability_type_spec.rb @@ -76,6 +76,8 @@ reachability archivalInformation findingTokenStatus + false_positive_detections + latest_false_positive_detection initialDetectedPipeline latestDetectedPipeline ] @@ -657,4 +659,49 @@ it_behaves_like "N+1 queries", single_query_count end end + + describe 'false_positive_detections' do + let(:query_field) { 'falsePositiveDetections { nodes { id status } }' } + let_it_be(:detection1) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :not_started) } + let_it_be(:detection2) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :detected_as_fp) } + + subject(:detections) { vulnerabilities.first['falsePositiveDetections']['nodes'] } + + it 'returns all false positive detections for the vulnerability' do + expect(detections).to have_attributes(size: 2) + expect(detections.map { |d| d['status'] }).to contain_exactly('NOT_STARTED', 'DETECTED_AS_FP') + end + + context 'N+1 queries' do + single_query_count = 12 + + it_behaves_like "N+1 queries", single_query_count + end + end + + describe 'latest_false_positive_detection' do + let(:query_field) { 'latestFalsePositiveDetection { id status }' } + let_it_be(:older_detection) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :not_started, created_at: 2.days.ago) } + let_it_be(:newer_detection) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :detected_as_fp, created_at: 1.day.ago) } + + subject(:latest_detection) { vulnerabilities.first['latestFalsePositiveDetection'] } + + it 'returns the most recent false positive detection' do + expect(latest_detection['status']).to eq('DETECTED_AS_FP') + end + + context 'when there are no detections' do + let_it_be(:vulnerability_without_detections) { create(:vulnerability, project: project) } + + it 'returns nil' do + expect(latest_detection).to be_nil + end + end + + context 'N+1 queries' do + single_query_count = 12 + + it_behaves_like "N+1 queries", single_query_count + end + end end -- GitLab From 88cd79ff770901426635bcb6f5c37539f227d6b1 Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Wed, 10 Sep 2025 12:13:36 +0200 Subject: [PATCH 3/6] Fix status enum type --- .../false_positive_detection_status_enum.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb b/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb index 2ce35eb22151f9..2ec4518280049b 100644 --- a/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb +++ b/ee/app/graphql/types/vulnerabilities/false_positive_detection_status_enum.rb @@ -5,10 +5,10 @@ module Vulnerabilities class FalsePositiveDetectionStatusEnum < BaseEnum graphql_name 'VulnerabilityFalsePositiveDetectionStatus' description 'Status of vulnerability false positive detection' - - ::Enums::Vulnerability::FALSE_POSITIVE_STATUSES.each do |status, _| - value status.upcase, value: status, description: "Detection is #{status.humanize.downcase}" + + ::Enums::Vulnerability::FALSE_POSITIVE_STATUSES.each_key do |status| + value status.upcase, value: status, description: "Detection is #{status.to_s.humanize.downcase}" end end end -end \ No newline at end of file +end -- GitLab From 245329367f6302824a68adbfb2bd66edbdf6c75a Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Wed, 10 Sep 2025 12:23:18 +0200 Subject: [PATCH 4/6] Add missing docs --- doc/api/graphql/reference/_index.md | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index ff8013243a2a2e..d63353bb2b640e 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -21848,6 +21848,29 @@ The edge type for [`VulnerabilityExternalIssueLink`](#vulnerabilityexternalissue | `cursor` | [`String!`](#string) | A cursor for use in pagination. | | `node` | [`VulnerabilityExternalIssueLink`](#vulnerabilityexternalissuelink) | The item at the end of the edge. | +#### `VulnerabilityFalsePositiveDetectionConnection` + +The connection type for [`VulnerabilityFalsePositiveDetection`](#vulnerabilityfalsepositivedetection). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `edges` | [`[VulnerabilityFalsePositiveDetectionEdge]`](#vulnerabilityfalsepositivedetectionedge) | A list of edges. | +| `nodes` | [`[VulnerabilityFalsePositiveDetection]`](#vulnerabilityfalsepositivedetection) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `VulnerabilityFalsePositiveDetectionEdge` + +The edge type for [`VulnerabilityFalsePositiveDetection`](#vulnerabilityfalsepositivedetection). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `cursor` | [`String!`](#string) | A cursor for use in pagination. | +| `node` | [`VulnerabilityFalsePositiveDetection`](#vulnerabilityfalsepositivedetection) | The item at the end of the edge. | + #### `VulnerabilityIssueLinkConnection` The connection type for [`VulnerabilityIssueLink`](#vulnerabilityissuelink). @@ -26691,12 +26714,14 @@ Represents a vulnerability. The connection type is countable. | `dismissedBy` | [`UserCore`](#usercore) | User that dismissed the vulnerability. | | `externalIssueLinks` | [`VulnerabilityExternalIssueLinkConnection!`](#vulnerabilityexternalissuelinkconnection) | List of external issue links related to the vulnerability. (see [Connections](#connections)) | | `falsePositive` | [`Boolean`](#boolean) | Indicates whether the vulnerability is a false positive. | +| `falsePositiveDetections` | [`VulnerabilityFalsePositiveDetectionConnection!`](#vulnerabilityfalsepositivedetectionconnection) | History of false positive detection attempts for the vulnerability. (see [Connections](#connections)) | | `findingTokenStatus` | [`VulnerabilityFindingTokenStatus`](#vulnerabilityfindingtokenstatus) | Status of the secret token associated with this vulnerability. Returns `null` if the `validity_checks` feature flag is disabled. | | `hasRemediations` | [`Boolean`](#boolean) | Indicates whether there is a remediation available for the vulnerability. | | `id` | [`ID!`](#id) | GraphQL ID of the vulnerability. | | `identifiers` | [`[VulnerabilityIdentifier!]!`](#vulnerabilityidentifier) | Identifiers of the vulnerability. | | `initialDetectedPipeline` {{< icon name="warning-solid" >}} | [`Pipeline`](#pipeline) | **Introduced** in GitLab 18.2. **Status**: Experiment. Pipeline where the vulnerability was first detected. | | `latestDetectedPipeline` {{< icon name="warning-solid" >}} | [`Pipeline`](#pipeline) | **Introduced** in GitLab 18.2. **Status**: Experiment. Pipeline where the vulnerability was last detected. | +| `latestFalsePositiveDetection` | [`VulnerabilityFalsePositiveDetection`](#vulnerabilityfalsepositivedetection) | Latest false positive detection result for the vulnerability. | | `links` | [`[VulnerabilityLink!]!`](#vulnerabilitylink) | List of links associated with the vulnerability. | | `location` | [`VulnerabilityLocation`](#vulnerabilitylocation) | Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability. | | `mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request that fixes the vulnerability. | @@ -44166,12 +44191,14 @@ Represents a vulnerability. | `dismissedBy` | [`UserCore`](#usercore) | User that dismissed the vulnerability. | | `externalIssueLinks` | [`VulnerabilityExternalIssueLinkConnection!`](#vulnerabilityexternalissuelinkconnection) | List of external issue links related to the vulnerability. (see [Connections](#connections)) | | `falsePositive` | [`Boolean`](#boolean) | Indicates whether the vulnerability is a false positive. | +| `falsePositiveDetections` | [`VulnerabilityFalsePositiveDetectionConnection!`](#vulnerabilityfalsepositivedetectionconnection) | History of false positive detection attempts for the vulnerability. (see [Connections](#connections)) | | `findingTokenStatus` | [`VulnerabilityFindingTokenStatus`](#vulnerabilityfindingtokenstatus) | Status of the secret token associated with this vulnerability. Returns `null` if the `validity_checks` feature flag is disabled. | | `hasRemediations` | [`Boolean`](#boolean) | Indicates whether there is a remediation available for the vulnerability. | | `id` | [`ID!`](#id) | GraphQL ID of the vulnerability. | | `identifiers` | [`[VulnerabilityIdentifier!]!`](#vulnerabilityidentifier) | Identifiers of the vulnerability. | | `initialDetectedPipeline` {{< icon name="warning-solid" >}} | [`Pipeline`](#pipeline) | **Introduced** in GitLab 18.2. **Status**: Experiment. Pipeline where the vulnerability was first detected. | | `latestDetectedPipeline` {{< icon name="warning-solid" >}} | [`Pipeline`](#pipeline) | **Introduced** in GitLab 18.2. **Status**: Experiment. Pipeline where the vulnerability was last detected. | +| `latestFalsePositiveDetection` | [`VulnerabilityFalsePositiveDetection`](#vulnerabilityfalsepositivedetection) | Latest false positive detection result for the vulnerability. | | `links` | [`[VulnerabilityLink!]!`](#vulnerabilitylink) | List of links associated with the vulnerability. | | `location` | [`VulnerabilityLocation`](#vulnerabilitylocation) | Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability. | | `mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request that fixes the vulnerability. | @@ -44596,6 +44623,21 @@ Represents an external issue link of a vulnerability. | `id` | [`VulnerabilitiesExternalIssueLinkID!`](#vulnerabilitiesexternalissuelinkid) | GraphQL ID of the external issue link. | | `linkType` | [`VulnerabilityExternalIssueLinkType!`](#vulnerabilityexternalissuelinktype) | Type of the external issue link. | +### `VulnerabilityFalsePositiveDetection` + +Represents a false positive detection result for a vulnerability. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `confidenceScore` | [`Float`](#float) | Confidence score of the detection (0.0 to 1.0). | +| `createdAt` | [`Time!`](#time) | Timestamp when the detection was created. | +| `id` | [`ID!`](#id) | ID of the false positive detection. | +| `status` | [`VulnerabilityFalsePositiveDetectionStatus!`](#vulnerabilityfalsepositivedetectionstatus) | Status of the false positive detection. | +| `updatedAt` | [`Time!`](#time) | Timestamp when the detection was last updated. | +| `workflowId` | [`String`](#string) | ID of the Duo workflow that performed the detection. | + ### `VulnerabilityFindingTokenStatus` Represents the status of a secret token found in a vulnerability. @@ -49980,6 +50022,18 @@ The type of the external issue link related to a vulnerability. | ----- | ----------- | | `CREATED` | Created link type. | +### `VulnerabilityFalsePositiveDetectionStatus` + +Status of vulnerability false positive detection. + +| Value | Description | +| ----- | ----------- | +| `DETECTED_AS_FP` | Detection is detected as fp. | +| `DETECTED_AS_NOT_FP` | Detection is detected as not fp. | +| `FAILED` | Detection is failed. | +| `IN_PROGRESS` | Detection is in progress. | +| `NOT_STARTED` | Detection is not started. | + ### `VulnerabilityFindingTokenStatusState` Status of a secret token found in a vulnerability. -- GitLab From a266de950a4c0d993e3c2c3602401922650f4404 Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Wed, 10 Sep 2025 12:25:19 +0200 Subject: [PATCH 5/6] Fix (most) rubocop errors --- .../false_positive_detections_resolver.rb | 10 +++---- .../false_positive_detection_type.rb | 28 +++++++++---------- ee/app/graphql/types/vulnerability_type.rb | 20 ++++++------- ...false_positive_detections_resolver_spec.rb | 18 ++++++++---- ...lse_positive_detection_status_enum_spec.rb | 5 ++-- .../false_positive_detection_type_spec.rb | 8 ++++-- 6 files changed, 51 insertions(+), 38 deletions(-) diff --git a/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb index 491386df71358f..6e547e03a2fdd3 100644 --- a/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb +++ b/ee/app/graphql/resolvers/vulnerabilities/false_positive_detections_resolver.rb @@ -4,11 +4,11 @@ module Resolvers module Vulnerabilities class FalsePositiveDetectionsResolver < BaseResolver type Types::Vulnerabilities::FalsePositiveDetectionType.connection_type, null: false - + argument :status, [Types::Vulnerabilities::FalsePositiveDetectionStatusEnum], - required: false, - description: 'Filter by detection status.' - + required: false, + description: 'Filter by detection status.' + def resolve(status: nil) detections = object.false_positive_detections.order(created_at: :desc) detections = detections.by_status(status) if status.present? @@ -16,4 +16,4 @@ def resolve(status: nil) end end end -end \ No newline at end of file +end diff --git a/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb b/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb index d8c60335d9a12d..a453ab323335fc 100644 --- a/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb +++ b/ee/app/graphql/types/vulnerabilities/false_positive_detection_type.rb @@ -5,30 +5,30 @@ module Vulnerabilities class FalsePositiveDetectionType < BaseObject graphql_name 'VulnerabilityFalsePositiveDetection' description 'Represents a false positive detection result for a vulnerability' - + authorize :read_vulnerability - + field :id, GraphQL::Types::ID, null: false, - description: 'ID of the false positive detection.' - + description: 'ID of the false positive detection.' + field :status, Types::Vulnerabilities::FalsePositiveDetectionStatusEnum, null: false, - description: 'Status of the false positive detection.' - + description: 'Status of the false positive detection.' + field :confidence_score, GraphQL::Types::Float, null: true, - description: 'Confidence score of the detection (0.0 to 1.0).' - + description: 'Confidence score of the detection (0.0 to 1.0).' + field :workflow_id, GraphQL::Types::String, null: true, - description: 'ID of the Duo workflow that performed the detection.' - + description: 'ID of the Duo workflow that performed the detection.' + field :created_at, Types::TimeType, null: false, - description: 'Timestamp when the detection was created.' - + description: 'Timestamp when the detection was created.' + field :updated_at, Types::TimeType, null: false, - description: 'Timestamp when the detection was last updated.' + description: 'Timestamp when the detection was last updated.' def workflow_id object.workflow_id.to_s end end end -end \ No newline at end of file +end diff --git a/ee/app/graphql/types/vulnerability_type.rb b/ee/app/graphql/types/vulnerability_type.rb index a344fe5a14e87f..8ecc57b5433972 100644 --- a/ee/app/graphql/types/vulnerability_type.rb +++ b/ee/app/graphql/types/vulnerability_type.rb @@ -198,15 +198,15 @@ def self.authorization_scopes description: 'Status of the secret token associated with this vulnerability. Returns `null` if the `validity_checks` feature flag is disabled.', resolver: Resolvers::Vulnerabilities::FindingTokenStatusResolver - field :false_positive_detections, - Types::Vulnerabilities::FalsePositiveDetectionType.connection_type, - null: false, - description: 'History of false positive detection attempts for this vulnerability.' + field :false_positive_detections, + Types::Vulnerabilities::FalsePositiveDetectionType.connection_type, + null: false, + description: 'History of false positive detection attempts for the vulnerability.' - field :latest_false_positive_detection, - Types::Vulnerabilities::FalsePositiveDetectionType, - null: true, - description: 'Latest false positive detection result for this vulnerability.' + field :latest_false_positive_detection, + Types::Vulnerabilities::FalsePositiveDetectionType, + null: true, + description: 'Latest false positive detection result for the vulnerability.' field :initial_detected_pipeline, Ci::PipelineType, method: :initial_finding_pipeline, @@ -340,7 +340,7 @@ def false_positive_detections .includes(:workflow) .order(created_at: :desc) .group_by(&:vulnerability_id) - + vulnerability_ids.each do |id| loader.call(id, detections[id] || []) end @@ -355,7 +355,7 @@ def latest_false_positive_detection .order(created_at: :desc) .group_by(&:vulnerability_id) .transform_values(&:first) - + vulnerability_ids.each do |id| loader.call(id, detections[id]) end diff --git a/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb index 8c8a149361e4f0..7f3479d5f49a80 100644 --- a/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb @@ -6,9 +6,17 @@ include GraphqlHelpers let_it_be(:vulnerability) { create(:vulnerability) } - let_it_be(:detection1) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :not_started) } - let_it_be(:detection2) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :detected_as_fp) } - let_it_be(:detection3) { create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :failed) } + let_it_be(:detection1) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :not_started) + end + + let_it_be(:detection2) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :detected_as_fp) + end + + let_it_be(:detection3) do + create(:vulnerability_false_positive_detection, vulnerability: vulnerability, status: :failed) + end let(:resolver) { described_class } @@ -32,11 +40,11 @@ end context 'with multiple status filters' do - let(:args) { { status: ['not_started', 'failed'] } } + let(:args) { { status: %w[not_started failed] } } it 'returns detections with any of the specified statuses' do expect(subject).to contain_exactly(detection1, detection3) end end end -end \ No newline at end of file +end diff --git a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb index f764c742aa0897..1e9d1acfb33489 100644 --- a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb +++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb @@ -4,7 +4,8 @@ RSpec.describe GitlabSchema.types['VulnerabilityFalsePositiveDetectionStatus'], feature_category: :vulnerability_management do it 'exposes all the existing false positive detection statuses' do - expect(described_class.values.keys).to match_array(%w[NOT_STARTED IN_PROGRESS DETECTED_AS_FP DETECTED_AS_NOT_FP FAILED]) + expect(described_class.values.keys).to match_array(%w[NOT_STARTED IN_PROGRESS DETECTED_AS_FP DETECTED_AS_NOT_FP + FAILED]) end it 'has the correct descriptions' do @@ -14,4 +15,4 @@ expect(described_class.values['DETECTED_AS_NOT_FP'].description).to eq('Detection is detected as not fp') expect(described_class.values['FAILED'].description).to eq('Detection is failed') end -end \ No newline at end of file +end diff --git a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb index 28ba4c9c788674..8b9be8f59bdc09 100644 --- a/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb +++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb @@ -11,7 +11,11 @@ describe 'field types' do specify { expect(described_class.fields['id'].type).to eq(!GraphQL::Types::ID) } - specify { expect(described_class.fields['status'].type).to eq(!GitlabSchema.types['VulnerabilityFalsePositiveDetectionStatus']) } + + specify do + expect(described_class.fields['status'].type).to eq(!GitlabSchema.types['VulnerabilityFalsePositiveDetectionStatus']) + end + specify { expect(described_class.fields['confidenceScore'].type).to eq(GraphQL::Types::Float) } specify { expect(described_class.fields['workflowId'].type).to eq(GraphQL::Types::String) } specify { expect(described_class.fields['createdAt'].type).to eq(!GitlabSchema.types['Time']) } @@ -26,4 +30,4 @@ expect(type_instance.workflow_id).to eq(detection.workflow_id.to_s) end end -end \ No newline at end of file +end -- GitLab From 4806d1b708e3697cde1519b83a105c3eaaf1abbc Mon Sep 17 00:00:00 2001 From: Sam Figueroa Date: Wed, 10 Sep 2025 12:56:26 +0200 Subject: [PATCH 6/6] Pluck over map --- ee/spec/graphql/types/vulnerability_type_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/spec/graphql/types/vulnerability_type_spec.rb b/ee/spec/graphql/types/vulnerability_type_spec.rb index 7c81693c9e326a..bde84335b5a6a7 100644 --- a/ee/spec/graphql/types/vulnerability_type_spec.rb +++ b/ee/spec/graphql/types/vulnerability_type_spec.rb @@ -669,7 +669,7 @@ it 'returns all false positive detections for the vulnerability' do expect(detections).to have_attributes(size: 2) - expect(detections.map { |d| d['status'] }).to contain_exactly('NOT_STARTED', 'DETECTED_AS_FP') + expect(detections.pluck(:status)).to contain_exactly('NOT_STARTED', 'DETECTED_AS_FP') end context 'N+1 queries' do -- GitLab