diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb
index 424e9bab1ef8eb531d925db31c86b27960098033..a7279cb7cf881aa5f5ddbc735c0e6b80c1cf03b0 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 0000000000000000000000000000000000000000..b04ffdeb369fdb58d154535d5569959ec3544ce6
--- /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 0000000000000000000000000000000000000000..6c9f1677ee881bcf6c71c342439704490f08c87f
--- /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 0000000000000000000000000000000000000000..bb0b220d9417aa980ad2616f2c58e3e8255f7618
--- /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 853a7b497d6981d82a2b7224685697b67a9b1b44..1e6d57dd602de6eabbf00427ae8a3fabc0d2f25d 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/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index ff8013243a2a2e3b42add2978b53cbd936c0d467..d63353bb2b640e6f9f7c37708d0eef74a52d099f 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.
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 0000000000000000000000000000000000000000..6e547e03a2fdd34f56d046679aaef94da0e89b08
--- /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
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 0000000000000000000000000000000000000000..2ec4518280049bf896b7a6247fe14173174feecb
--- /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_key do |status|
+ value status.upcase, value: status, description: "Detection is #{status.to_s.humanize.downcase}"
+ end
+ end
+ end
+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
new file mode 100644
index 0000000000000000000000000000000000000000..a453ab323335fcb275305d67b2a3e2d1f814057f
--- /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
diff --git a/ee/app/graphql/types/vulnerability_type.rb b/ee/app/graphql/types/vulnerability_type.rb
index 235aa43c7096e836024392e8d4640a1d93138870..8ecc57b54339721a02dc66ec8cc21d8a83dff682 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 the 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,
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/app/models/ai/duo_workflows/workflow.rb b/ee/app/models/ai/duo_workflows/workflow.rb
index 6f18b5a961d128e83aa0758127cec8bb59e8fb59..d378aaab7dcf798463dbf481f153288d02740372 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 08ea3f978caf810b41dff3cd9a2dbe6447733328..7580a3547e28e8d36b8a10b0c8661917789f6a6c 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 0000000000000000000000000000000000000000..abf0b00258ae9fbdd82d783a3aa1df42e91da719
--- /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 0000000000000000000000000000000000000000..b18f34df5c8ae7c432e83ef011d09790fa5c0c80
--- /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/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 0000000000000000000000000000000000000000..7f3479d5f49a8019f07b161b57b27fa42409f70c
--- /dev/null
+++ b/ee/spec/graphql/resolvers/vulnerabilities/false_positive_detections_resolver_spec.rb
@@ -0,0 +1,50 @@
+# 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) 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 }
+
+ 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: %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
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 0000000000000000000000000000000000000000..1e9d1acfb33489dc48703fb1590106352069801a
--- /dev/null
+++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_status_enum_spec.rb
@@ -0,0 +1,18 @@
+# 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
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 0000000000000000000000000000000000000000..8b9be8f59bdc09b15faebd7524b15e76873d1207
--- /dev/null
+++ b/ee/spec/graphql/types/vulnerabilities/false_positive_detection_type_spec.rb
@@ -0,0 +1,33 @@
+# 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 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']) }
+ 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
diff --git a/ee/spec/graphql/types/vulnerability_type_spec.rb b/ee/spec/graphql/types/vulnerability_type_spec.rb
index 2c3ac9d2afbc8ef23c7d775a2a975c6489c1323b..bde84335b5a6a726b4c7e1999381166152d4845a 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.pluck(: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
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 0000000000000000000000000000000000000000..333d9990a8b0a06069a99e818cec5ab3aa265c2a
--- /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