diff --git a/app/validators/json_schemas/scan_result_policy_violation_data.json b/app/validators/json_schemas/scan_result_policy_violation_data.json new file mode 100644 index 0000000000000000000000000000000000000000..555f9bebcae81cff793a6f16ba16f075db9daf03 --- /dev/null +++ b/app/validators/json_schemas/scan_result_policy_violation_data.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Scan result policy violation data", + "type": "object", + "properties": { + "context": { + "type": "object" + }, + "violations": { + "type": "object" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "error" + ] + } + } + } +} diff --git a/db/migrate/20240212155716_add_violation_data_to_scan_result_policy_violations.rb b/db/migrate/20240212155716_add_violation_data_to_scan_result_policy_violations.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa334c206c98b5a13d07291ad9bfefc20c2c1c31 --- /dev/null +++ b/db/migrate/20240212155716_add_violation_data_to_scan_result_policy_violations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddViolationDataToScanResultPolicyViolations < Gitlab::Database::Migration[2.2] + enable_lock_retries! + milestone '16.10' + + def change + add_column :scan_result_policy_violations, :violation_data, :jsonb, null: true + end +end diff --git a/db/schema_migrations/20240212155716 b/db/schema_migrations/20240212155716 new file mode 100644 index 0000000000000000000000000000000000000000..826b83b8f6fdd95efbebe17b953c7a4b6f1c9edd --- /dev/null +++ b/db/schema_migrations/20240212155716 @@ -0,0 +1 @@ +5f9073fc752c552134286eca244bae41e71a92f62b20328a7601f8ea8ef887dd \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 10ad3f81c3e4522fc59326e99be2a971fb8d6a4e..fd9027155d6a8ae5c3c1479e20cec644579f294a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15262,7 +15262,8 @@ CREATE TABLE scan_result_policy_violations ( merge_request_id bigint NOT NULL, project_id bigint NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + violation_data jsonb ); CREATE SEQUENCE scan_result_policy_violations_id_seq diff --git a/ee/app/models/security/scan_result_policy_violation.rb b/ee/app/models/security/scan_result_policy_violation.rb index 2ae82c74c74e64472bd4ed6d7c54b10a93db0c6c..dd1c64316aafd1615c28d518121aeddc696b3c50 100644 --- a/ee/app/models/security/scan_result_policy_violation.rb +++ b/ee/app/models/security/scan_result_policy_violation.rb @@ -13,6 +13,7 @@ class ScanResultPolicyViolation < ApplicationRecord belongs_to :merge_request, inverse_of: :scan_result_policy_violations validates :scan_result_policy_id, uniqueness: { scope: %i[merge_request_id] } + validates :violation_data, json_schema: { filename: 'scan_result_policy_violation_data' }, allow_blank: true scope :including_scan_result_policy_reads, -> { includes(:scan_result_policy_read) } diff --git a/ee/app/services/security/security_orchestration_policies/update_violations_service.rb b/ee/app/services/security/security_orchestration_policies/update_violations_service.rb index 9034e86bc75973c9723c5910ceb600c3dc9bbb92..4fa9383cf7908ba52f41e72b9981af95268eafc0 100644 --- a/ee/app/services/security/security_orchestration_policies/update_violations_service.rb +++ b/ee/app/services/security/security_orchestration_policies/update_violations_service.rb @@ -3,14 +3,13 @@ module Security module SecurityOrchestrationPolicies class UpdateViolationsService - attr_reader :merge_request, - :violated_policy_ids, - :unviolated_policy_ids + attr_reader :merge_request, :violated_policy_ids, :unviolated_policy_ids, :violation_data def initialize(merge_request) @merge_request = merge_request @violated_policy_ids = Set.new @unviolated_policy_ids = Set.new + @violation_data = {} end def add(violated_ids, unviolated_ids) @@ -18,6 +17,10 @@ def add(violated_ids, unviolated_ids) unviolated_policy_ids.merge(unviolated_ids) end + def set_violation_data(policy_id, data) + @violation_data[policy_id] = data + end + def execute Security::ScanResultPolicyViolation.transaction do delete_violations if unviolated_policy_ids.any? @@ -37,10 +40,12 @@ def delete_violations def create_violations attrs = violated_policy_ids.map do |id| - { scan_result_policy_id: id, merge_request_id: merge_request.id, project_id: merge_request.project_id } + { scan_result_policy_id: id, merge_request_id: merge_request.id, project_id: merge_request.project_id, + violation_data: violation_data[id] } end + return unless attrs.any? - Security::ScanResultPolicyViolation.insert_all(attrs) if attrs.any? + Security::ScanResultPolicyViolation.upsert_all(attrs, unique_by: %w[scan_result_policy_id merge_request_id]) end end end diff --git a/ee/spec/models/security/scan_result_policy_violation_spec.rb b/ee/spec/models/security/scan_result_policy_violation_spec.rb index 83dee3d1a51a612e05b09dd3e20967f4ab3fe3e2..7a876d7179dcf8d749755c0283706ca89bbc65d3 100644 --- a/ee/spec/models/security/scan_result_policy_violation_spec.rb +++ b/ee/spec/models/security/scan_result_policy_violation_spec.rb @@ -15,6 +15,26 @@ subject { violation } it { is_expected.to(validate_uniqueness_of(:scan_result_policy_id).scoped_to(%i[merge_request_id])) } + + describe 'violation_data' do + it { is_expected.not_to allow_value('string').for(:violation_data) } + it { is_expected.to allow_value({}).for(:violation_data) } + + it do + is_expected.to allow_value( + { violations: { uuids: { newly_detected: ['123'], previously_existing: ['456'] }, licenses: ['MIT'] }, + context: { pipeline_ids: [123], target_pipeline_ids: [456] } } + ).for(:violation_data) + end + + it do + is_expected.to allow_value( + { errors: [{ error: 'SCAN_REMOVED', missing_scans: ['sast'] }] } + ).for(:violation_data) + end + + it { is_expected.not_to allow_value({ errors: [{}] }).for(:violation_data) } + end end describe '.for_approval_rules' do diff --git a/ee/spec/services/security/security_orchestration_policies/update_violations_service_spec.rb b/ee/spec/services/security/security_orchestration_policies/update_violations_service_spec.rb index 730964ad53cbf83c950fff1218abf5c00db2e010..6605b4f8df01b213cdf27a2f2efe9affdbe4939e 100644 --- a/ee/spec/services/security/security_orchestration_policies/update_violations_service_spec.rb +++ b/ee/spec/services/security/security_orchestration_policies/update_violations_service_spec.rb @@ -11,8 +11,9 @@ let_it_be(:policy_a) { create(:scan_result_policy_read, project: project) } let_it_be(:policy_b) { create(:scan_result_policy_read, project: project) } + let(:violated_policies) { violations.map(&:scan_result_policy_read) } - subject(:violated_policies) { merge_request.scan_result_policy_violations.map(&:scan_result_policy_read) } + subject(:violations) { merge_request.scan_result_policy_violations } describe 'attributes' do subject(:attrs) { project.scan_result_policy_violations.last.attributes } @@ -31,17 +32,28 @@ end context 'without pre-existing violations' do - it 'creates violations' do + before do service.add([policy_b.id], []) + end + + it 'creates violations' do service.execute expect(violated_policies).to contain_exactly(policy_b) end + + it 'can persist violation data' do + service.set_violation_data(policy_b.id, { violations: { uuid: { newly_detected: [123] } } }) + service.execute + + expect(violations.last.violation_data).to eq({ "violations" => { "uuid" => { "newly_detected" => [123] } } }) + end end context 'with pre-existing violations' do before do service.add([policy_a.id], []) + service.set_violation_data(policy_a.id, { violations: { uuid: { newly_detected: [123] } } }) service.execute end @@ -52,11 +64,21 @@ expect(violated_policies).to contain_exactly(policy_b) end + it 'updates existing violation data' do + service.add([policy_a.id], []) + service.set_violation_data(policy_a.id, { errors: [{ error: 'SCAN_REMOVED', missing_scans: ['sast'] }] }) + + expect { service.execute } + .to change { violations.last.violation_data }.to( + { "errors" => [{ "error" => "SCAN_REMOVED", "missing_scans" => ["sast"] }] } + ) + end + context 'with identical state' do it 'does not clear violations' do service.add([policy_a.id], []) - service.execute + expect { service.execute }.not_to change { violations.last.violation_data } expect(violated_policies).to contain_exactly(policy_a) end end @@ -74,7 +96,7 @@ it 'removes only violations provided in unviolated ids' do service.execute - expect(merge_request.scan_result_policy_violations).to contain_exactly(unrelated_violation) + expect(violations).to contain_exactly(unrelated_violation) end end @@ -82,7 +104,7 @@ it 'clears all violations' do service.execute - expect(violated_policies).to be_empty + expect(violations).to be_empty end end end