From d335883a989a09c696c327e2e09ede804fa90c0d Mon Sep 17 00:00:00 2001 From: Illya Klymov Date: Mon, 20 Oct 2025 09:29:48 +0300 Subject: [PATCH 1/2] drop: Just a stub backend implementation (graphql & rest) --- doc/api/graphql/reference/_index.md | 17 +++ .../pipeline_security_report_finding_type.rb | 21 ++++ .../types/security/finding_flag_type.rb | 35 ++++++ ee/app/models/security/finding.rb | 2 +- .../vulnerabilities/finding_entity.rb | 11 ++ ...uest_security_report_generation_service.rb | 8 ++ .../json_schemas/security_finding_data.json | 17 +++ ee/spec/factories/security/findings.rb | 19 +++ ...eline_security_report_finding_type_spec.rb | 116 +++++++++++++++++- .../types/security/finding_flag_type_spec.rb | 35 ++++++ .../security/finding_latest_flag_spec.rb | 46 +++++++ 11 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 ee/app/graphql/types/security/finding_flag_type.rb create mode 100644 ee/spec/graphql/types/security/finding_flag_type_spec.rb create mode 100644 ee/spec/models/security/finding_latest_flag_spec.rb diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 4abf36ac582f61..552deca026b0e2 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -39118,6 +39118,7 @@ Represents vulnerability finding of a security report on the pipeline. | `findingTokenStatus` | [`VulnerabilityFindingTokenStatus`](#vulnerabilityfindingtokenstatus) | Status of the secret token associated with this finding. Returns `null` if the `validity_checks_security_finding_status` feature flag is disabled. | | `identifiers` | [`[VulnerabilityIdentifier!]!`](#vulnerabilityidentifier) | Identifiers of the vulnerability finding. | | `issueLinks` | [`VulnerabilityIssueLinkConnection`](#vulnerabilityissuelinkconnection) | List of issue links related to the vulnerability. (see [Connections](#connections)) | +| `latestFlag` | [`SecurityFindingFlag`](#securityfindingflag) | Latest flag set on the security finding. | | `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. | @@ -43644,6 +43645,22 @@ A security category. | `securityAttributes` | [`[SecurityAttribute!]`](#securityattribute) | Security attributes belonging to the category. | | `templateType` | [`SecurityCategoryTemplateType`](#securitycategorytemplatetype) | Template type for predefined categories. | +### `SecurityFindingFlag` + +Represents a flag result for a security finding from JSON data. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `confidenceScore` | [`Float`](#float) | Confidence score of the detection (0.0 to 1.0). | +| `createdAt` | [`Time!`](#time) | Timestamp when the security finding was created. | +| `description` | [`String`](#string) | Description of the flag on the security finding. | +| `id` | [`String!`](#string) | ID of the security finding. | +| `origin` | [`String`](#string) | Origin of service that raised the flag on the security finding. | +| `status` | [`VulnerabilityFalsePositiveDetectionStatus`](#vulnerabilityfalsepositivedetectionstatus) | Status of the flag detection. | +| `updatedAt` | [`Time!`](#time) | Timestamp when the security finding was last updated. | + ### `SecurityFindingTokenStatus` Represents the status of a secret token found in a security finding. diff --git a/ee/app/graphql/types/pipeline_security_report_finding_type.rb b/ee/app/graphql/types/pipeline_security_report_finding_type.rb index f36313b0cf0fc1..ddef7b4f6b4fa4 100644 --- a/ee/app/graphql/types/pipeline_security_report_finding_type.rb +++ b/ee/app/graphql/types/pipeline_security_report_finding_type.rb @@ -4,6 +4,9 @@ module Types class PipelineSecurityReportFindingType < BaseObject graphql_name 'PipelineSecurityReportFinding' + # Struct to represent a flag from Security::Finding JSON data + SecurityFindingFlag = Struct.new(:id, :status, :origin, :confidence_score, :description, :created_at, :updated_at) + description 'Represents vulnerability finding of a security report on the pipeline.' authorize :read_security_resource @@ -161,6 +164,10 @@ class PipelineSecurityReportFindingType < BaseObject description: 'Indicates whether the specific finding can be resolved with AI.', method: :ai_resolution_enabled? + field :latest_flag, ::Types::Security::FindingFlagType, + null: true, + description: 'Latest flag set on the security finding.' + markdown_field :description_html, null: true markdown_field :solution_html, null: true @@ -248,6 +255,20 @@ def solution_html_resolver ::MarkupHelper.markdown(object.solution, context.to_h.dup) end + def latest_flag + return unless object.respond_to?(:latest_flag) && object.latest_flag.present? + + flag_data = object.latest_flag + return unless flag_data.is_a?(Hash) + + timestamp = object.scan&.created_at || Time.current + SecurityFindingFlag.new( + object.id, 'not_started', 'security_finding', + flag_data[:confidence_score]&.to_f, flag_data[:description], + timestamp, object.scan&.updated_at || Time.current + ) + end + private def expose_false_positive? diff --git a/ee/app/graphql/types/security/finding_flag_type.rb b/ee/app/graphql/types/security/finding_flag_type.rb new file mode 100644 index 00000000000000..be6a3d012e0835 --- /dev/null +++ b/ee/app/graphql/types/security/finding_flag_type.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Types + module Security + class FindingFlagType < BaseObject # rubocop:disable Graphql/AuthorizeTypes -- drop: naive backend implementation + graphql_name 'SecurityFindingFlag' + description 'Represents a flag result for a security finding from JSON data' + + field :id, GraphQL::Types::String, null: false, + description: 'ID of the security finding.' + + field :status, Types::Vulnerabilities::Flags::FalsePositiveDetectionStatusEnum, null: true, + description: 'Status of the flag detection.' + + field :confidence_score, GraphQL::Types::Float, null: true, + description: 'Confidence score of the detection (0.0 to 1.0).' + + field :origin, GraphQL::Types::String, null: true, + description: 'Origin of service that raised the flag on the security finding.' + + field :description, GraphQL::Types::String, null: true, + description: 'Description of the flag on the security finding.' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp when the security finding was created.' + + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp when the security finding was last updated.' + + def id + object.id.to_s + end + end + end +end diff --git a/ee/app/models/security/finding.rb b/ee/app/models/security/finding.rb index e939f0cdd5249b..14423575183d61 100644 --- a/ee/app/models/security/finding.rb +++ b/ee/app/models/security/finding.rb @@ -18,7 +18,7 @@ class Finding < ::SecApplicationRecord MAX_PARTITION_SIZE = 100.gigabytes ATTRIBUTES_DELEGATED_TO_FINDING_DATA = %i[name description solution location identifiers links false_positive? assets evidence details remediation_byte_offsets - raw_source_code_extract].freeze + raw_source_code_extract latest_flag].freeze self.table_name = 'security_findings' self.primary_key = :id # As ActiveRecord does not support compound PKs diff --git a/ee/app/serializers/vulnerabilities/finding_entity.rb b/ee/app/serializers/vulnerabilities/finding_entity.rb index f0cd059a003e57..0d658268e332e4 100644 --- a/ee/app/serializers/vulnerabilities/finding_entity.rb +++ b/ee/app/serializers/vulnerabilities/finding_entity.rb @@ -73,6 +73,17 @@ class Vulnerabilities::FindingEntity < Grape::Entity finding.respond_to?(:ai_resolution_enabled?) } + # Expose the latest flag added to Security::Finding.finding_data + # This handles both Vulnerabilities::Finding (which has vulnerability_flags) + # and Security::Finding (which has the latest_flag in finding_data) + expose :latest_flag do |finding| + if finding.respond_to?(:vulnerability_flags) && finding.vulnerability_flags.present? + finding.vulnerability_flags.last + elsif finding.respond_to?(:latest_flag) && finding.latest_flag.present? + finding.latest_flag + end + end + alias_method :occurrence, :object def current_user diff --git a/ee/app/services/security/merge_request_security_report_generation_service.rb b/ee/app/services/security/merge_request_security_report_generation_service.rb index 57836b69dfbe60..ff03da0b8447a7 100644 --- a/ee/app/services/security/merge_request_security_report_generation_service.rb +++ b/ee/app/services/security/merge_request_security_report_generation_service.rb @@ -60,6 +60,14 @@ def set_states_and_severities_of!(findings) finding['state'] = vulnerability_data&.dig(:state) || DEFAULT_FINDING_STATE finding['severity'] = vulnerability_data&.dig(:severity) || finding['severity'] finding['severity_override'] = vulnerability_data&.dig(:severity_override) + + # Add the latest_flag to the finding if it exists in finding_data + next unless finding.respond_to?(:[]=) + + security_finding = Security::Finding.find_by(uuid: finding['uuid']) # rubocop:disable CodeReuse/ActiveRecord + if security_finding&.finding_data&.dig('latest_flag').present? + finding['latest_flag'] = security_finding.finding_data['latest_flag'] + end end end diff --git a/ee/app/validators/json_schemas/security_finding_data.json b/ee/app/validators/json_schemas/security_finding_data.json index ca7f45f227ce99..a07edeb11da1a6 100644 --- a/ee/app/validators/json_schemas/security_finding_data.json +++ b/ee/app/validators/json_schemas/security_finding_data.json @@ -68,6 +68,23 @@ }, "details": { "type": "object" + }, + "latest_flag": { + "type": "object", + "description": "Latest flag data for the security finding", + "additionalProperties": false, + "properties": { + "confidence_score": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Confidence score of the flag detection (0.0 to 1.0)" + }, + "description": { + "type": "string", + "description": "Description of the flag on the security finding" + } + } } } } diff --git a/ee/spec/factories/security/findings.rb b/ee/spec/factories/security/findings.rb index 2e2bff63c7625c..e1544148064d47 100644 --- a/ee/spec/factories/security/findings.rb +++ b/ee/spec/factories/security/findings.rb @@ -141,5 +141,24 @@ finding.create_token_status(status: evaluator.token_status) end end + + trait :with_latest_flag do + with_finding_data + + transient do + flag_confidence_score { 0.85 } + flag_description { 'AI detected potential false positive' } + end + + after(:build) do |finding, evaluator| + existing_data = finding.finding_data || {} + finding.finding_data = existing_data.merge( + 'latest_flag' => { + 'confidence_score' => evaluator.flag_confidence_score, + 'description' => evaluator.flag_description + } + ) + end + end end end diff --git a/ee/spec/graphql/types/pipeline_security_report_finding_type_spec.rb b/ee/spec/graphql/types/pipeline_security_report_finding_type_spec.rb index 680ca7cec398be..71522c2e8b97b7 100644 --- a/ee/spec/graphql/types/pipeline_security_report_finding_type_spec.rb +++ b/ee/spec/graphql/types/pipeline_security_report_finding_type_spec.rb @@ -56,7 +56,8 @@ solution_html user_permissions ai_resolution_available - ai_resolution_enabled] + ai_resolution_enabled + latest_flag] end let(:sast_query) do @@ -139,6 +140,119 @@ end end + describe 'latest_flag' do + let(:query_for_test) do + %( + uuid + latestFlag { + id + status + origin + confidenceScore + description + createdAt + updatedAt + } + ) + end + + context 'when the security finding has latest_flag data' do + before do + allow_next_found_instance_of(Security::Finding) do |finding| + flag_data = { + confidence_score: 0.85, + description: 'AI detected potential false positive' + } + allow(finding).to receive(:latest_flag).and_return(flag_data) + end + end + + it 'returns latest_flag data' do + latest_flag = get_findings_from_response(subject).first['latestFlag'] + + expect(latest_flag).not_to be_nil + expect(latest_flag['confidenceScore']).to eq(0.85) + expect(latest_flag['description']).to eq('AI detected potential false positive') + expect(latest_flag['status']).to eq('NOT_STARTED') + expect(latest_flag['origin']).to eq('security_finding') + expect(latest_flag['createdAt']).to be_present + expect(latest_flag['updatedAt']).to be_present + end + end + + context 'when the security finding has no latest_flag data' do + before do + allow_next_found_instance_of(Security::Finding) do |finding| + allow(finding).to receive(:latest_flag).and_return(nil) + end + end + + it 'returns null for latest_flag field' do + expect(get_findings_from_response(subject).first['latestFlag']).to be_nil + end + end + end + + describe 'latest_flag integration' do + let(:query_for_test) do + %( + uuid + latestFlag { + id + status + origin + confidenceScore + description + createdAt + updatedAt + } + ) + end + + context 'when the security finding has latest_flag in finding_data JSON' do + before do + allow_next_found_instance_of(Security::Finding) do |finding| + allow(finding).to receive(:symbolized_finding_data).and_return({ + name: 'Test finding', + description: 'Test description', + latest_flag: { + confidence_score: 0.92, + description: 'ML model detected potential false positive' + } + }) + end + end + + it 'returns latest_flag data from JSON' do + latest_flag = get_findings_from_response(subject).first['latestFlag'] + + expect(latest_flag).not_to be_nil + expect(latest_flag['id']).to be_present + expect(latest_flag['confidenceScore']).to eq(0.92) + expect(latest_flag['description']).to eq('ML model detected potential false positive') + expect(latest_flag['status']).to eq('NOT_STARTED') + expect(latest_flag['origin']).to eq('security_finding') + expect(latest_flag['createdAt']).to be_present + expect(latest_flag['updatedAt']).to be_present + end + end + + context 'when the security finding has no latest_flag in finding_data' do + before do + allow_next_found_instance_of(Security::Finding) do |finding| + allow(finding).to receive(:symbolized_finding_data).and_return({ + name: 'Test finding', + description: 'Test description' + }) + end + end + + it 'returns null for latest_flag field' do + expect(get_findings_from_response(subject).first['latestFlag']).to be_nil + end + end + end + describe 'vulnerability' do let(:query_for_test) do %( diff --git a/ee/spec/graphql/types/security/finding_flag_type_spec.rb b/ee/spec/graphql/types/security/finding_flag_type_spec.rb new file mode 100644 index 00000000000000..9983a89dd6a3fe --- /dev/null +++ b/ee/spec/graphql/types/security/finding_flag_type_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['SecurityFindingFlag'], feature_category: :vulnerability_management do + let(:fields) do + %i[ + id + status + confidence_score + origin + description + created_at + updated_at + ] + end + + it { expect(described_class).to have_graphql_fields(fields) } + + describe 'field types' do + specify { expect(described_class.fields['id']).to have_graphql_type(GraphQL::Types::String, null: false) } + + specify do + expect(described_class.fields['status']).to have_graphql_type( + Types::Vulnerabilities::Flags::FalsePositiveDetectionStatusEnum + ) + end + + specify { expect(described_class.fields['confidenceScore']).to have_graphql_type(GraphQL::Types::Float) } + specify { expect(described_class.fields['origin']).to have_graphql_type(GraphQL::Types::String) } + specify { expect(described_class.fields['description']).to have_graphql_type(GraphQL::Types::String) } + specify { expect(described_class.fields['createdAt']).to have_graphql_type(Types::TimeType, null: false) } + specify { expect(described_class.fields['updatedAt']).to have_graphql_type(Types::TimeType, null: false) } + end +end diff --git a/ee/spec/models/security/finding_latest_flag_spec.rb b/ee/spec/models/security/finding_latest_flag_spec.rb new file mode 100644 index 00000000000000..5c280637d10069 --- /dev/null +++ b/ee/spec/models/security/finding_latest_flag_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::Finding, '#latest_flag', feature_category: :vulnerability_management do + describe 'with_latest_flag factory trait' do + let_it_be(:finding) { create(:security_finding, :with_latest_flag) } + + it 'creates a finding with latest_flag data' do + expect(finding.latest_flag).to be_present + expect(finding.latest_flag).to be_a(Hash) + expect(finding.latest_flag[:confidence_score]).to eq(0.85) + expect(finding.latest_flag[:description]).to eq('AI detected potential false positive') + end + + context 'with custom flag data' do + let_it_be(:custom_finding) do + create( + :security_finding, + :with_latest_flag, + flag_confidence_score: 0.95, + flag_description: 'Custom ML detection result' + ) + end + + it 'uses the custom values' do + expect(custom_finding.latest_flag[:confidence_score]).to eq(0.95) + expect(custom_finding.latest_flag[:description]).to eq('Custom ML detection result') + end + end + end + + describe 'delegated latest_flag method' do + let_it_be(:finding_with_flag) { create(:security_finding, :with_latest_flag) } + let_it_be(:finding_without_flag) { create(:security_finding, :with_finding_data) } + + it 'returns the latest_flag from finding_data when present' do + expect(finding_with_flag.latest_flag).to be_present + expect(finding_with_flag.latest_flag).to include(:confidence_score, :description) + end + + it 'returns nil when latest_flag is not present in finding_data' do + expect(finding_without_flag.latest_flag).to be_nil + end + end +end -- GitLab From a2c6e91296fb47d8097e2f1432786911dda544b1 Mon Sep 17 00:00:00 2001 From: Illya Klymov Date: Tue, 21 Oct 2025 22:00:22 +0300 Subject: [PATCH 2/2] wip: MR widget AI possible FP flag * add prototype implementation Changelog: added --- .../pipeline/vulnerability_finding_modal.vue | 1 + .../shared/ai_possible_fp_badge.vue | 6 +-- .../vulnerability_details_graphql/index.vue | 41 ++++++++++++++++++- .../security_report_finding.query.graphql | 11 ++++- .../mr_widget_security_report_details.vue | 27 +++++++++--- .../mr_widget_security_reports.vue | 10 ++--- .../ee/projects/merge_requests_controller.rb | 1 + 7 files changed, 81 insertions(+), 16 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/pipeline/vulnerability_finding_modal.vue b/ee/app/assets/javascripts/security_dashboard/components/pipeline/vulnerability_finding_modal.vue index 667af45f9473ce..09db20ba9adcc3 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/pipeline/vulnerability_finding_modal.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/pipeline/vulnerability_finding_modal.vue @@ -207,6 +207,7 @@ export default { projectFullPath, pipelineIid, findingUuid, + includeLatestFlag: Boolean(this.glFeatures.aiExperimentSastFpDetection), }; }, remediation() { diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/ai_possible_fp_badge.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/ai_possible_fp_badge.vue index 705583456e8ce8..e23785c3ed458f 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/ai_possible_fp_badge.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/ai_possible_fp_badge.vue @@ -1,5 +1,5 @@