diff --git a/app/validators/json_schemas/scan_result_policy_violation_data.json b/app/validators/json_schemas/scan_result_policy_violation_data.json
index 555f9bebcae81cff793a6f16ba16f075db9daf03..2e6f8c1f19a83012a08fe236837ffee43d0f6850 100644
--- a/app/validators/json_schemas/scan_result_policy_violation_data.json
+++ b/app/validators/json_schemas/scan_result_policy_violation_data.json
@@ -7,7 +7,28 @@
       "type": "object"
     },
     "violations": {
-      "type": "object"
+      "type": "object",
+      "properties": {
+        "any_merge_request": {
+          "type": "object",
+          "properties": {
+            "commits": {
+              "oneOf": [
+                {
+                  "type": "boolean"
+                },
+                {
+                  "type": "array",
+                  "minItems": 1,
+                  "items": {
+                    "type": "string"
+                  }
+                }
+              ]
+            }
+          }
+        }
+      }
     },
     "errors": {
       "type": "array",
diff --git a/ee/app/services/security/scan_result_policies/sync_any_merge_request_rules_service.rb b/ee/app/services/security/scan_result_policies/sync_any_merge_request_rules_service.rb
index 8b6762c4319963edcdfcafd1061eb0888e3c53cc..b28270bb40e8471f8a41a02c0c75275aa829b6e3 100644
--- a/ee/app/services/security/scan_result_policies/sync_any_merge_request_rules_service.rb
+++ b/ee/app/services/security/scan_result_policies/sync_any_merge_request_rules_service.rb
@@ -10,6 +10,7 @@ def initialize(merge_request)
         @merge_request = merge_request
         @violations = Security::SecurityOrchestrationPolicies::UpdateViolationsService
                         .new(merge_request, :any_merge_request)
+        @violations_by_policy = {}
       end
 
       def execute
@@ -20,7 +21,7 @@ def execute
 
       private
 
-      attr_reader :merge_request, :violations
+      attr_reader :merge_request, :violations, :violations_by_policy
 
       delegate :project, to: :merge_request, private: true
 
@@ -36,22 +37,30 @@ def sync_required_approvals
 
         log_violated_rules(violated_rules)
         # rubocop:disable CodeReuse/ActiveRecord
+        violated_policies_ids -= unviolated_rules.pluck(:scan_result_policy_id)
         violations.add(
-          violated_policies_ids - unviolated_rules.pluck(:scan_result_policy_id),
+          violated_policies_ids,
           unviolated_policies_ids + unviolated_rules.pluck(:scan_result_policy_id)
         )
         # rubocop:enable CodeReuse/ActiveRecord
+        save_violation_data(violated_policies_ids)
         violations.execute
         generate_policy_bot_comment(merge_request, violated_rules, :any_merge_request)
       end
 
       def evaluate_policy_violations(scan_result_policy_reads)
-        has_unsigned_commits = !merge_request.commits(load_from_gitaly: true).all?(&:has_signature?)
+        unsigned_commits = merge_request.commits(load_from_gitaly: true)
+                                         .select { |commit| !commit.has_signature? }.map(&:short_id)
         violated, unviolated = scan_result_policy_reads.partition do |scan_result_policy_read|
-          next false unless scan_result_policy_read.commits_any? ||
-            (scan_result_policy_read.commits_unsigned? && has_unsigned_commits)
+          targets_any_commits = scan_result_policy_read.commits_any?
+          next false unless targets_any_commits || (scan_result_policy_read.commits_unsigned? && unsigned_commits.any?)
 
-          policy_affected_by_target_branch?(scan_result_policy_read)
+          policy_affected_by_target_branch?(scan_result_policy_read).tap do |violated|
+            next unless violated
+
+            violations_by_policy[scan_result_policy_read.id] =
+              targets_any_commits ? true : Security::ScanResultPolicyViolation.trim_violations(unsigned_commits)
+          end
         end
         [violated.pluck(:id), unviolated.pluck(:id)] # rubocop:disable CodeReuse/ActiveRecord
       end
@@ -136,6 +145,12 @@ def log_violated_rule(**attributes)
         }
         Gitlab::AppJsonLogger.info(message: 'Updating MR approval rule', **default_attributes.merge(attributes))
       end
+
+      def save_violation_data(violated_policy_ids)
+        violated_policy_ids.each do |policy_id|
+          violations.add_violation(policy_id, { commits: violations_by_policy[policy_id] })
+        end
+      end
     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 08e00b47df88d2b4bf53edb821154f7c5e987e3b..a467005a7b8a3917e656d1f8bf7c4eaef5985ac2 100644
--- a/ee/spec/models/security/scan_result_policy_violation_spec.rb
+++ b/ee/spec/models/security/scan_result_policy_violation_spec.rb
@@ -20,20 +20,52 @@
       it { is_expected.not_to allow_value('string').for(:violation_data) }
       it { is_expected.to allow_value({}).for(:violation_data) }
 
-      it do
+      it 'allows combination of all possible values' do
         is_expected.to allow_value(
-          { violations: { uuids: { newly_detected: ['123'], previously_existing: ['456'] }, licenses: ['MIT'] },
-            context: { pipeline_ids: [123], target_pipeline_ids: [456] } }
+          {
+            violations: {
+              scan_finding: { uuids: { newly_detected: ['123'], previously_existing: ['456'] } },
+              license_scanning: { licenses: ['MIT'] },
+              any_merge_request: { commits: ['abcd1234'] }
+            },
+            context: { pipeline_ids: [123], target_pipeline_ids: [456] },
+            errors: [{ error: 'SCAN_REMOVED', missing_scans: ['sast'] }]
+          }
         ).for(:violation_data)
       end
 
-      it do
-        is_expected.to allow_value(
-          { errors: [{ error: 'SCAN_REMOVED', missing_scans: ['sast'] }] }
-        ).for(:violation_data)
+      describe 'errors' do
+        it do
+          is_expected.to allow_value(
+            { errors: [{ error: 'SCAN_REMOVED', missing_scans: ['sast'] }] }
+          ).for(:violation_data)
+        end
       end
 
       it { is_expected.not_to allow_value({ errors: [{}] }).for(:violation_data) }
+
+      describe 'violations' do
+        using RSpec::Parameterized::TableSyntax
+
+        describe 'commits' do
+          where(:report_type, :data, :valid) do
+            :any_merge_request | { commits: ['abcd1234'] } | true
+            :any_merge_request | { commits: true }         | true
+            :any_merge_request | { commits: 'abcd1234' }   | false
+            :any_merge_request | { commits: [] }           | false
+          end
+
+          with_them do
+            it do
+              if valid
+                expect(violation).to allow_value(violations: { report_type => data }).for(:violation_data)
+              else
+                expect(violation).not_to allow_value(violations: { report_type => data }).for(:violation_data)
+              end
+            end
+          end
+        end
+      end
     end
   end
 
diff --git a/ee/spec/services/security/scan_result_policies/sync_any_merge_request_rules_service_spec.rb b/ee/spec/services/security/scan_result_policies/sync_any_merge_request_rules_service_spec.rb
index 300bd79245232579621f88e3dc1df09982958575..8f44ec60e22f7155998dcec0cc31bc5e938f782e 100644
--- a/ee/spec/services/security/scan_result_policies/sync_any_merge_request_rules_service_spec.rb
+++ b/ee/spec/services/security/scan_result_policies/sync_any_merge_request_rules_service_spec.rb
@@ -18,8 +18,8 @@
     subject(:execute) { service.execute }
 
     let(:approvals_required) { 1 }
-    let(:signed_commit) { instance_double(Commit, has_signature?: true) }
-    let(:unsigned_commit) { instance_double(Commit, has_signature?: false) }
+    let(:signed_commit) { instance_double(Commit, has_signature?: true, short_id: 'abcd1234') }
+    let(:unsigned_commit) { instance_double(Commit, has_signature?: false, short_id: 'dcba5678') }
     let_it_be(:protected_branch) do
       create(:protected_branch, name: merge_request.target_branch, project: project)
     end
@@ -59,6 +59,10 @@
 
       it_behaves_like 'does not update approval rules'
       it_behaves_like 'does not trigger policy bot comment'
+
+      it 'creates no violation records' do
+        expect { execute }.not_to change { merge_request.scan_result_policy_violations.count }
+      end
     end
 
     describe 'approval rules' do
@@ -108,7 +112,7 @@
           it_behaves_like 'triggers policy bot comment', :any_merge_request, true, requires_approval: false
         end
 
-        context 'when approval are required but approval_merge_request_rules have been made optional' do
+        context 'when approvals are required but approval_merge_request_rules have been made optional' do
           let!(:approval_project_rule) do
             create(:approval_project_rule, :any_merge_request, project: project, approvals_required: 1,
               scan_result_policy_read: scan_result_policy_read)
@@ -127,11 +131,11 @@
           it_behaves_like 'triggers policy bot comment', :any_merge_request, true
         end
 
-        where(:policy_commits, :merge_request_commits) do
-          :unsigned | [ref(:unsigned_commit)]
-          :unsigned | [ref(:signed_commit), ref(:unsigned_commit)]
-          :any      | [ref(:signed_commit)]
-          :any      | [ref(:unsigned_commit)]
+        where(:policy_commits, :merge_request_commits, :expected_violation) do
+          :unsigned | [ref(:unsigned_commit)] | ['dcba5678']
+          :unsigned | [ref(:signed_commit), ref(:unsigned_commit)] | ['dcba5678']
+          :any      | [ref(:signed_commit)] | true
+          :any      | [ref(:unsigned_commit)] | true
         end
 
         with_them do
@@ -145,6 +149,25 @@
             execute
           end
 
+          it 'persists violation details' do
+            expect { execute }
+              .to change { merge_request.scan_result_policy_violations.last&.violation_data }
+                              .from(nil)
+                              .to('violations' => { 'any_merge_request' => { 'commits' => expected_violation } })
+          end
+
+          context 'when feature flag "save_policy_violation_data" is disabled' do
+            before do
+              stub_feature_flags(save_policy_violation_data: false)
+            end
+
+            it 'does not persist violation details' do
+              execute
+
+              expect(merge_request.scan_result_policy_violations.last.violation_data).to be_nil
+            end
+          end
+
           it_behaves_like 'when no policy is applicable due to the policy scope' do
             it_behaves_like 'does not update approval rules'
           end
@@ -155,15 +178,20 @@
         let!(:approver_rule) { nil }
 
         context 'when policies target commits' do
+          let(:violation) { merge_request.scan_result_policy_violations.first }
           let_it_be(:scan_result_policy_read_with_commits, reload: true) do
             create(:scan_result_policy_read, project: project, commits: :unsigned, rule_idx: 0)
           end
 
+          before do
+            allow(merge_request).to receive(:commits).and_return([unsigned_commit])
+          end
+
           it 'creates violations for policies that have no approval rules' do
             expect { execute }.to change { merge_request.scan_result_policy_violations.count }.by(1)
-            expect(merge_request.scan_result_policy_violations.first.scan_result_policy_read).to(
-              eq scan_result_policy_read_with_commits
-            )
+            expect(violation.scan_result_policy_read).to(eq scan_result_policy_read_with_commits)
+            expect(violation.violation_data)
+              .to match('violations' => { 'any_merge_request' => { 'commits' => ['dcba5678'] } })
           end
 
           context 'with previous violation for policy that is now unviolated' do