diff --git a/db/docs/vulnerability_resolutions.yml b/db/docs/vulnerability_resolutions.yml new file mode 100644 index 0000000000000000000000000000000000000000..b4cefa67ce234c19f98ef28815ff4fc77562beaf --- /dev/null +++ b/db/docs/vulnerability_resolutions.yml @@ -0,0 +1,13 @@ +--- +table_name: vulnerability_resolutions +classes: +- Vulnerabilities::Resolution +feature_categories: +- vulnerability_management +description: Stores the metadata for vulnerability resolution by duo workflow +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/206498 +milestone: '18.5' +gitlab_schema: gitlab_sec +sharding_key: + project_id: projects +table_size: small diff --git a/db/migrate/20250925104000_create_vulnerability_resolutions.rb b/db/migrate/20250925104000_create_vulnerability_resolutions.rb new file mode 100644 index 0000000000000000000000000000000000000000..b44f6cda4b63f25f1eb3832f37521a1c96890281 --- /dev/null +++ b/db/migrate/20250925104000_create_vulnerability_resolutions.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateVulnerabilityResolutions < Gitlab::Database::Migration[2.3] + milestone '18.5' + + def change + create_table :vulnerability_resolutions do |t| # rubocop:disable Migration/EnsureFactoryForTable -- https://gitlab.com/gitlab-org/gitlab/-/issues/468630 + t.timestamps_with_timezone null: false + + t.bigint :vulnerability_finding_id, null: false + t.bigint :workflow_id, null: false + t.bigint :merge_request_id, null: true + t.bigint :project_id, null: false + t.float :readiness_score, null: false, default: 0.0 + + t.index :vulnerability_finding_id + t.index :workflow_id, unique: true + t.index :merge_request_id + t.index :project_id + end + end +end diff --git a/db/migrate/20250926101216_add_foreign_key_to_vulnerability_resolutions_finding_id.rb b/db/migrate/20250926101216_add_foreign_key_to_vulnerability_resolutions_finding_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6a9fe5b486b1e493eb1d7892d6db805433de973 --- /dev/null +++ b/db/migrate/20250926101216_add_foreign_key_to_vulnerability_resolutions_finding_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddForeignKeyToVulnerabilityResolutionsFindingId < Gitlab::Database::Migration[2.3] + milestone '18.5' + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :vulnerability_resolutions, :vulnerability_occurrences, + column: :vulnerability_finding_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :vulnerability_resolutions, column: :vulnerability_finding_id + end + end +end diff --git a/db/migrate/20250926103019_add_foreign_key_to_vulnerability_resolutions_workflow_id.rb b/db/migrate/20250926103019_add_foreign_key_to_vulnerability_resolutions_workflow_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..32f641cd891c775f7b3f930e93d8b150546fe177 --- /dev/null +++ b/db/migrate/20250926103019_add_foreign_key_to_vulnerability_resolutions_workflow_id.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddForeignKeyToVulnerabilityResolutionsWorkflowId < Gitlab::Database::Migration[2.3] + milestone '18.5' + disable_ddl_transaction! + + UNIQ_INDEX_NAME = 'unique_vulnerability_resolutions_on_workflow_id' + + def up + add_concurrent_foreign_key :vulnerability_resolutions, :duo_workflows_workflows, column: :workflow_id + end + + def down + with_lock_retries do + remove_foreign_key :vulnerability_resolutions, column: :workflow_id + end + end +end diff --git a/db/migrate/20250929135804_add_foreign_key_to_vulnerability_resolutions_project_id.rb b/db/migrate/20250929135804_add_foreign_key_to_vulnerability_resolutions_project_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..9992cf74bc01b57b48e0f451d4ec7fe20575270d --- /dev/null +++ b/db/migrate/20250929135804_add_foreign_key_to_vulnerability_resolutions_project_id.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddForeignKeyToVulnerabilityResolutionsProjectId < Gitlab::Database::Migration[2.3] + milestone '18.5' + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :vulnerability_resolutions, :projects, column: :project_id + end + + def down + with_lock_retries do + remove_foreign_key :vulnerability_resolutions, column: :project_id + end + end +end diff --git a/db/migrate/20250929142629_add_foreign_key_to_vulnerability_resolutions_merge_request_id.rb b/db/migrate/20250929142629_add_foreign_key_to_vulnerability_resolutions_merge_request_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..4eb885b47a19647a39fe74fbf3331e61fca61a52 --- /dev/null +++ b/db/migrate/20250929142629_add_foreign_key_to_vulnerability_resolutions_merge_request_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddForeignKeyToVulnerabilityResolutionsMergeRequestId < Gitlab::Database::Migration[2.3] + milestone '18.5' + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :vulnerability_resolutions, :merge_requests, column: :merge_request_id, + on_delete: :nullify + end + + def down + with_lock_retries do + remove_foreign_key :vulnerability_resolutions, column: :merge_request_id + end + end +end diff --git a/db/schema_migrations/20250925104000 b/db/schema_migrations/20250925104000 new file mode 100644 index 0000000000000000000000000000000000000000..64d33ed5f1346763166896cd71e56d261ca922b2 --- /dev/null +++ b/db/schema_migrations/20250925104000 @@ -0,0 +1 @@ +c0bebc5a2869f5f684e66bb43a67986f72e5874d64deba53af4ef2ecced4ac94 \ No newline at end of file diff --git a/db/schema_migrations/20250926101216 b/db/schema_migrations/20250926101216 new file mode 100644 index 0000000000000000000000000000000000000000..329402e2ad36e230e1a1739b7ca8987506bf3851 --- /dev/null +++ b/db/schema_migrations/20250926101216 @@ -0,0 +1 @@ +112726bc5e36fc73f68152d91de8bcd224b1356d661b6be7059aeaa3a8ae2cad \ No newline at end of file diff --git a/db/schema_migrations/20250926103019 b/db/schema_migrations/20250926103019 new file mode 100644 index 0000000000000000000000000000000000000000..b07721b8494b48bd5b041da4338d3143770868ce --- /dev/null +++ b/db/schema_migrations/20250926103019 @@ -0,0 +1 @@ +ec8f5b70ce04261de8c823a33ca787a01e2af68f002def9c011b5f149e37f785 \ No newline at end of file diff --git a/db/schema_migrations/20250929135804 b/db/schema_migrations/20250929135804 new file mode 100644 index 0000000000000000000000000000000000000000..4ff6b6b0d951141ae463fa124e21b7bace237bce --- /dev/null +++ b/db/schema_migrations/20250929135804 @@ -0,0 +1 @@ +bbccc0b80ab3320e4a9ea284b7670c49a39f7bc3fbfa660d4877fa4cd1fdb2b1 \ No newline at end of file diff --git a/db/schema_migrations/20250929142629 b/db/schema_migrations/20250929142629 new file mode 100644 index 0000000000000000000000000000000000000000..0152c4ab2f4e22eeea3f6e0190923f56b16759ee --- /dev/null +++ b/db/schema_migrations/20250929142629 @@ -0,0 +1 @@ +4f4efef3064b00c542f09356309d6a9b06d3b6cbfd53d05fcd86767815819aa8 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 03667dd3bf924be02b1a426c8e11774e92ac8d92..66432f61229dfb48edbcba9bf4488256b0067602 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -28512,6 +28512,26 @@ CREATE TABLE vulnerability_representation_information ( vulnerability_occurrence_id bigint ); +CREATE TABLE vulnerability_resolutions ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + vulnerability_finding_id bigint NOT NULL, + workflow_id bigint NOT NULL, + merge_request_id bigint, + project_id bigint NOT NULL, + readiness_score double precision DEFAULT 0.0 NOT NULL +); + +CREATE SEQUENCE vulnerability_resolutions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE vulnerability_resolutions_id_seq OWNED BY vulnerability_resolutions.id; + CREATE TABLE vulnerability_scanners ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -31609,6 +31629,8 @@ ALTER TABLE ONLY vulnerability_reads ALTER COLUMN id SET DEFAULT nextval('vulner ALTER TABLE ONLY vulnerability_remediations ALTER COLUMN id SET DEFAULT nextval('vulnerability_remediations_id_seq'::regclass); +ALTER TABLE ONLY vulnerability_resolutions ALTER COLUMN id SET DEFAULT nextval('vulnerability_resolutions_id_seq'::regclass); + ALTER TABLE ONLY vulnerability_scanners ALTER COLUMN id SET DEFAULT nextval('vulnerability_scanners_id_seq'::regclass); ALTER TABLE ONLY vulnerability_severity_overrides ALTER COLUMN id SET DEFAULT nextval('vulnerability_severity_overrides_id_seq'::regclass); @@ -35316,6 +35338,9 @@ ALTER TABLE ONLY vulnerability_remediations ALTER TABLE ONLY vulnerability_representation_information ADD CONSTRAINT vulnerability_representation_information_pkey PRIMARY KEY (vulnerability_id); +ALTER TABLE ONLY vulnerability_resolutions + ADD CONSTRAINT vulnerability_resolutions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY vulnerability_scanners ADD CONSTRAINT vulnerability_scanners_pkey PRIMARY KEY (id); @@ -42772,6 +42797,14 @@ CREATE UNIQUE INDEX index_vulnerability_reads_on_vulnerability_id ON vulnerabili CREATE UNIQUE INDEX index_vulnerability_remediations_on_project_id_and_checksum ON vulnerability_remediations USING btree (project_id, checksum); +CREATE INDEX index_vulnerability_resolutions_on_merge_request_id ON vulnerability_resolutions USING btree (merge_request_id); + +CREATE INDEX index_vulnerability_resolutions_on_project_id ON vulnerability_resolutions USING btree (project_id); + +CREATE INDEX index_vulnerability_resolutions_on_vulnerability_finding_id ON vulnerability_resolutions USING btree (vulnerability_finding_id); + +CREATE UNIQUE INDEX index_vulnerability_resolutions_on_workflow_id ON vulnerability_resolutions USING btree (workflow_id); + CREATE UNIQUE INDEX index_vulnerability_risk_scores_on_vulnerability_finding ON vulnerability_finding_risk_scores USING btree (finding_id); CREATE INDEX index_vulnerability_risk_scores_on_vulnerability_project ON vulnerability_finding_risk_scores USING btree (project_id); @@ -47258,6 +47291,9 @@ ALTER TABLE ONLY lists ALTER TABLE ONLY subscription_user_add_on_assignments ADD CONSTRAINT fk_0d89020c49 FOREIGN KEY (add_on_purchase_id) REFERENCES subscription_add_on_purchases(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_resolutions + ADD CONSTRAINT fk_0ddc4a650b FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE SET NULL; + ALTER TABLE ONLY approval_project_rules_users ADD CONSTRAINT fk_0dfcd9e339 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -47780,6 +47816,9 @@ ALTER TABLE ONLY wiki_page_slugs ALTER TABLE ONLY security_orchestration_policy_rule_schedules ADD CONSTRAINT fk_3e78b9a150 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_resolutions + ADD CONSTRAINT fk_3f44621def FOREIGN KEY (vulnerability_finding_id) REFERENCES vulnerability_occurrences(id) ON DELETE CASCADE; + ALTER TABLE ONLY abuse_reports ADD CONSTRAINT fk_3fe6467b93 FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL; @@ -48350,6 +48389,9 @@ ALTER TABLE ONLY bulk_import_export_uploads ALTER TABLE ONLY merge_request_metrics ADD CONSTRAINT fk_7f28d925f3 FOREIGN KEY (merged_by_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE ONLY vulnerability_resolutions + ADD CONSTRAINT fk_7f31d42e99 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY namespaces ADD CONSTRAINT fk_7f813d8c90 FOREIGN KEY (parent_id) REFERENCES namespaces(id) ON DELETE RESTRICT NOT VALID; @@ -49346,6 +49388,9 @@ ALTER TABLE ONLY board_labels ALTER TABLE ONLY packages_debian_project_distribution_keys ADD CONSTRAINT fk_eb2224a3c0 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_resolutions + ADD CONSTRAINT fk_eba3f28622 FOREIGN KEY (workflow_id) REFERENCES duo_workflows_workflows(id) ON DELETE CASCADE; + ALTER TABLE ONLY compliance_requirements ADD CONSTRAINT fk_ebf5c3365b FOREIGN KEY (framework_id) REFERENCES compliance_management_frameworks(id) ON DELETE CASCADE; diff --git a/ee/app/models/ai/duo_workflows/workflow.rb b/ee/app/models/ai/duo_workflows/workflow.rb index 7d39818b7d2cd2b9de602d7d77f43b40881d9bab..debdaf1b6c193cb45d45e614c1fb7ef0a6702cb1 100644 --- a/ee/app/models/ai/duo_workflows/workflow.rb +++ b/ee/app/models/ai/duo_workflows/workflow.rb @@ -18,6 +18,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 :vulnerability_resolutions, class_name: '::Vulnerabilities::Resolution', inverse_of: :workflow validates :status, presence: true validates :goal, length: { maximum: 16_384 } diff --git a/ee/app/models/vulnerabilities/finding.rb b/ee/app/models/vulnerabilities/finding.rb index 4279427e8defeb8f14c1bf0fd15e57bd2e112f51..7ee25abd4e71ab2a91ff04bc1cbb45ccbb8ac56f 100644 --- a/ee/app/models/vulnerabilities/finding.rb +++ b/ee/app/models/vulnerabilities/finding.rb @@ -109,6 +109,8 @@ class Finding < ::SecApplicationRecord has_one :finding_evidence, class_name: 'Vulnerabilities::Finding::Evidence', inverse_of: :finding, foreign_key: 'vulnerability_occurrence_id' + has_many :vulnerability_resolutions, class_name: '::Vulnerabilities::Resolution', inverse_of: :finding + has_many :security_findings, class_name: 'Security::Finding', primary_key: :uuid, diff --git a/ee/app/models/vulnerabilities/resolution.rb b/ee/app/models/vulnerabilities/resolution.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc170949abad965bf6a58f460d46964cb9144c38 --- /dev/null +++ b/ee/app/models/vulnerabilities/resolution.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Vulnerabilities + class Resolution < ::SecApplicationRecord + self.table_name = 'vulnerability_resolutions' + + belongs_to :finding, class_name: '::Vulnerabilities::Finding', foreign_key: 'vulnerability_finding_id', + inverse_of: :vulnerability_resolutions, optional: false + belongs_to :workflow, class_name: '::Ai::DuoWorkflows::Workflow', optional: false + belongs_to :merge_request, class_name: 'MergeRequest', optional: true + + validates :workflow_id, uniqueness: true + validates :readiness_score, inclusion: { in: 0.0..1.0 } + end +end diff --git a/ee/spec/factories/vulnerabilities/resolutions.rb b/ee/spec/factories/vulnerabilities/resolutions.rb new file mode 100644 index 0000000000000000000000000000000000000000..8a27c86d72f11ec1cf9a9e02b2b387b100d3857e --- /dev/null +++ b/ee/spec/factories/vulnerabilities/resolutions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :vulnerability_resolution, class: 'Vulnerabilities::Resolution' do + finding { association(:vulnerabilities_finding) } + workflow { association(:duo_workflows_workflow) } + merge_request { nil } + readiness_score { 0.5 } + + after(:build) do |resolution| + resolution.project_id = resolution.finding.project_id + end + end +end diff --git a/ee/spec/models/vulnerabilities/resolution_spec.rb b/ee/spec/models/vulnerabilities/resolution_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d5023aaa020f4214b1fd7b80d90506b6bd0e600 --- /dev/null +++ b/ee/spec/models/vulnerabilities/resolution_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Resolution, feature_category: :vulnerability_management do + describe 'associations' do + it 'validates association for finding id' do + is_expected.to belong_to(:finding).class_name('::Vulnerabilities::Finding') + .with_foreign_key('vulnerability_finding_id').inverse_of(:vulnerability_resolutions).required + end + + it { is_expected.to belong_to(:workflow).class_name('::Ai::DuoWorkflows::Workflow').required } + it { is_expected.to belong_to(:merge_request).class_name('MergeRequest').optional } + end + + describe 'validations' do + let_it_be(:project) { create(:project, :with_vulnerability) } + + let_it_be(:finding) { create(:vulnerabilities_finding, vulnerability: project.vulnerabilities.first) } + let_it_be(:workflow) { create(:duo_workflows_workflow, project: project) } + + subject { create(:vulnerability_resolution, finding: finding, workflow: workflow, project_id: project.id) } + + it { is_expected.to validate_uniqueness_of(:workflow_id) } + it { is_expected.to validate_inclusion_of(:readiness_score).in_range(0.0..1.0) } + + describe 'readiness_score validation' do + it 'allows valid readiness scores' do + [0.0, 0.5, 1.0, 0.25, 0.75].each do |score| + resolution = build(:vulnerability_resolution, readiness_score: score) + expect(resolution).to be_valid + end + end + + it 'rejects invalid readiness scores' do + [-0.1, 1.1, -1.0, 2.0].each do |score| + resolution = build(:vulnerability_resolution, readiness_score: score) + expect(resolution).not_to be_valid + expect(resolution.errors[:readiness_score]).to include('is not included in the list') + end + end + end + + describe 'workflow_id uniqueness' do + let_it_be(:existing_resolution) { create(:vulnerability_resolution) } + + it 'prevents duplicate workflow_id' do + duplicate_resolution = build(:vulnerability_resolution, workflow_id: existing_resolution.workflow_id) + + expect(duplicate_resolution).not_to be_valid + expect(duplicate_resolution.errors[:workflow_id]).to include('has already been taken') + end + + it 'allows different workflow_ids' do + different_workflow = create(:duo_workflows_workflow) + new_resolution = build(:vulnerability_resolution, workflow: different_workflow) + + expect(new_resolution).to be_valid + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index d98fb6bdc1693d314f833d5ec782a0641b1aeb0b..b966f2978be6486516bf689ad51ca6820b11433f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -1288,6 +1288,7 @@ vulnerability_finding: - security_findings - finding_token_status - finding_risk_score + - vulnerability_resolutions scanner: - findings - security_findings