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.rbto verify defined stages and add stages according to this logic:- When
dastscan is defined in policy anddaststage is not instagesdefined in.gitlab-ci.yml=> adddaststage to the end of thestageslist, - When any other scan is defined in policy and stages are not defined in
.gitlab-ci.ymlfile orteststage is defined instagesin.gitlab-ci.ymlfile => do not extendstageslist, - When any other scan is defined in policy and
teststage is not defined instagesin.gitlab-ci.ymlfile => addscan-policiesstage afterbuildstage (or at the beginning ifbuildis not defined) tostageslist,
- When
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,