Stages are not injected for Scan Execution policies

Summary

See https://gitlab.com/gitlab-com/account-management/emea/travis-perkins/tp-pov/-/issues/11 for related context

Currently scan execution policies do not inject the test stage if it does not exist. This results in a way for developers to circumvent the scan execution policies by modifying the stages in their CI pipeline.

The intention of scan execution policies is that developers should be unable to tamper with them or to prevent them from running. To address this, we need to ensure that the scans run regardless of which stages the developers have defined in their gitlab-ci.yml file.

Steps to reproduce

Example Project

What is the current bug behavior?

What is the expected correct behavior?

Relevant logs and/or screenshots

Output of checks

Results of GitLab environment info

Expand for output related to GitLab environment info

(For installations with omnibus-gitlab package run and paste the output of:
`sudo gitlab-rake gitlab:env:info`)

(For installations from source run and paste the output of:
`sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production`)

Results of GitLab application Check

Expand for output related to the GitLab application check

(For installations with omnibus-gitlab package run and paste the output of: sudo gitlab-rake gitlab:check SANITIZE=true)

(For installations from source run and paste the output of: sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production SANITIZE=true)

(we will only investigate if the tests are passing)

Possible fixes

  • backend modify ee/lib/gitlab/ci/config/security_orchestration_policies/processor.rb to verify defined stages and add stages according to this logic:
    1. When dast scan is defined in policy and dast stage is not in stages defined in .gitlab-ci.yml => add dast stage to the end of the stages list,
    2. When any other scan is defined in policy and stages are not defined in .gitlab-ci.yml file or test stage is defined in stages in .gitlab-ci.yml file => do not extend stages list,
    3. When any other scan is defined in policy and test stage is not defined in stages in .gitlab-ci.yml file => add scan-policies stage after build stage (or at the beginning if build is not defined) to stages list,

Proposed change

diff --git a/ee/lib/gitlab/ci/config/security_orchestration_policies/processor.rb b/ee/lib/gitlab/ci/config/security_orchestration_policies/processor.rb
index b0720db138c..cb246244ffd 100644
--- a/ee/lib/gitlab/ci/config/security_orchestration_policies/processor.rb
+++ b/ee/lib/gitlab/ci/config/security_orchestration_policies/processor.rb
@@ -5,6 +5,13 @@ module Ci
     class Config
       module SecurityOrchestrationPolicies
         class Processor
+          DEFAULT_ON_DEMAND_STAGE = 'dast'
+          DEFAULT_SECURITY_JOB_STAGE = 'test'
+
+          DEFAULT_BUILD_STAGE = 'build'
+          DEFAULT_SCAN_POLICY_STAGE = 'scan-policies'
+          DEFAULT_STAGES = Gitlab::Ci::Config::Entry::Stages.default
+
           def initialize(config, project, ref, source)
             @config = config
             @project = project
@@ -18,9 +25,7 @@ def perform
             return @config if valid_security_orchestration_policy_configurations.blank?
             return @config unless extend_configuration?
 
-            merged_config = @config
-                              .deep_merge(on_demand_scans_template)
-                              .deep_merge(pipeline_scan_template)
+            merged_config = @config.deep_merge(merged_policies_with_stages(@config))
 
             observe_processing_duration(Time.current - @start)
 
@@ -38,15 +43,47 @@ def valid_security_orchestration_policy_configurations
               all_security_orchestration_policy_configurations&.select(&:policy_configuration_valid?)
           end
 
-          def on_demand_scans_template
+          def merged_policies_with_stages(config)
+            merged_config = config
+            defined_stages = config[:stages].presence || DEFAULT_STAGES # if stages are not defined use the default list (https://docs.gitlab.com/ee/ci/yaml/#stages)
+
+            on_demand_scan_template = prepare_on_demand_scans_template
+            if on_demand_scan_template.present?
+              # add DAST when scan template is provided for on demand scans
+              defined_stages << DEFAULT_ON_DEMAND_STAGE
+              merged_config = merged_config.deep_merge(on_demand_scan_template)
+            end
+
+            pipeline_scan_template = prepare_pipeline_scans_template
+            if pipeline_scan_template.present?
+              # if `test` stage is not defined, change the list of defined stages to include `scan-policies` stage
+              unless defined_stages.include?(DEFAULT_SECURITY_JOB_STAGE)
+                # find index to insert `scan-policies` stage into list of defined stages
+                scan_stage_index = defined_stages.index(DEFAULT_BUILD_STAGE).then { |index| index.nil? ? 0 : index + 1 }
+                # insert scan-policies stage after `build` stage (or at the beginning of the list of stages)
+                defined_stages.insert(scan_stage_index, DEFAULT_SCAN_POLICY_STAGE)
+                # modify all jobs prepared for policies to have stage defined as scan-policies
+                pipeline_scan_template = pipeline_scan_template.transform_values { |job_config| job_config.merge(stage: DEFAULT_SCAN_POLICY_STAGE) }
+              end
+
+              merged_config = merged_config.deep_merge(pipeline_scan_template)
+            end
+
+            merged_config[:stages] = defined_stages.uniq if (defined_stages - DEFAULT_STAGES).present?
+
+            merged_config
+          end
+
+          def prepare_on_demand_scans_template
             ::Security::SecurityOrchestrationPolicies::OnDemandScanPipelineConfigurationService
               .new(project)
               .execute(on_demand_scan_actions)
           end
 
-          def pipeline_scan_template
+          def prepare_pipeline_scans_template
             ::Security::SecurityOrchestrationPolicies::ScanPipelineService
-              .new.execute(pipeline_scan_actions)
+              .new
+              .execute(pipeline_scan_actions)
           end
 
           def on_demand_scan_actions
diff --git a/ee/spec/lib/gitlab/ci/config/security_orchestration_policies/processor_spec.rb b/ee/spec/lib/gitlab/ci/config/security_orchestration_policies/processor_spec.rb
index 58f88607f3e..2ccde986229 100644
--- a/ee/spec/lib/gitlab/ci/config/security_orchestration_policies/processor_spec.rb
+++ b/ee/spec/lib/gitlab/ci/config/security_orchestration_policies/processor_spec.rb
@@ -7,10 +7,11 @@
 
   subject { described_class.new(config, project, ref, source).perform }
 
-  let_it_be(:config) { { image: 'ruby:3.0.1' } }
+  let(:config) { { image: 'ruby:3.0.1' } }
 
   let(:ref) { 'refs/heads/master' }
   let(:source) { 'pipeline' }
+  let(:scan_policy_stage) { 'test' }
 
   let_it_be(:namespace) { create(:group) }
   let_it_be(:namespace_policies_repository) { create(:project, :repository) }
@@ -58,6 +59,34 @@
     it 'extends config with additional jobs' do
       expect(subject).to include(expected_configuration)
     end
+
+    context 'when test stage is not available' do
+      let(:scan_policy_stage) { 'scan-policies' }
+
+      context 'when build stage is available' do
+        let(:config) { { stages: %w[build not-test release], image: 'ruby:3.0.1' } }
+
+        it 'includes scan-policies stage after build stage' do
+          expect(subject[:stages]).to eq(%w[build scan-policies not-test release dast])
+        end
+
+        it 'extends config with additional jobs' do
+          expect(subject).to include(expected_configuration)
+        end
+      end
+
+      context 'when build stage is not available' do
+        let(:config) { { stages: %w[not-test release], image: 'ruby:3.0.1' } }
+
+        it 'includes scan-policies stage as a first stage' do
+          expect(subject[:stages]).to eq(%w[scan-policies not-test release dast])
+        end
+
+        it 'extends config with additional jobs' do
+          expect(subject).to include(expected_configuration)
+        end
+      end
+    end
   end
 
   shared_examples 'when policy is invalid' do
@@ -160,7 +189,7 @@
             {
               'secret-detection-0': hash_including(
                 rules: [{ if: '$SECRET_DETECTION_DISABLED', when: 'never' }, { if: '$CI_COMMIT_BRANCH' }],
-                stage: 'test',
+                stage: scan_policy_stage,
                 image: '$SECURE_ANALYZERS_PREFIX/secrets:$SECRETS_ANALYZER_VERSION',
                 services: [],
                 allow_failure: true,
Edited by Alan (Maciej) Paruszewski