diff --git a/config/audit_events/types/policy_pipeline_skipped.yml b/config/audit_events/types/policy_pipeline_skipped.yml new file mode 100644 index 0000000000000000000000000000000000000000..9f89393e34239913543d0c66a84386e104087ce8 --- /dev/null +++ b/config/audit_events/types/policy_pipeline_skipped.yml @@ -0,0 +1,10 @@ +--- +name: policy_pipeline_skipped +description: A security policy pipeline is skipped +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/539232 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195325 +feature_category: security_policy_management +milestone: '18.2' +saved_to_database: false +streamed: true +scope: [Project] diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 90ecc1cf996ee4667471c5b89a8f410fec6138dd..fe4fbf36e59d490f26c6b5178d98a5d2d3d35e36 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -963,6 +963,8 @@ - 1 - - security_policies_project_transfer - 1 +- - security_policies_skip_pipelines_audit + - 1 - - security_process_scan_result_policy - 1 - - security_recreate_orchestration_configuration diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 92e090c5b1fa205f542208901352774a2ebb723d..cfa97ca3e2d47fd30be3d2ac2b3914469ec88153 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -542,6 +542,7 @@ Audit event types belong to the following product categories. | [`merge_request_branch_bypassed_by_security_policy`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195942) | The merge request's approval is bypassed by the branches configured in the security policy | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549646) | Project | | [`merge_request_merged_with_policy_violations`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195775) | A merge request merged with security policy violations | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/549813) | Project | | [`policies_limit_exceeded`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196005) | Enabled policies count exceeded the maximum allowed limit for policy type | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/550891) | Project | +| [`policy_pipeline_skipped`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195325) | A security policy pipeline is skipped | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/539232) | Project | | [`policy_violations_detected`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violation is detected in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/549811) | Project | | [`policy_violations_resolved`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violations are resolved in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549812) | Project | | [`policy_yaml_invalidated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196721) | The policy YAML is invalidated in security policy project | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/550892) | Project | diff --git a/ee/app/models/concerns/security/pipeline_execution_policy.rb b/ee/app/models/concerns/security/pipeline_execution_policy.rb index 1622c75a2e6755543591b78739bc472b860e77d2..e8106ede1f9c4584304b7326160a835817608eb5 100644 --- a/ee/app/models/concerns/security/pipeline_execution_policy.rb +++ b/ee/app/models/concerns/security/pipeline_execution_policy.rb @@ -8,6 +8,10 @@ def active_pipeline_execution_policies pipeline_execution_policy.select { |config| config[:enabled] }.first(pipeline_execution_policy_limit) end + def active_pipeline_execution_policy_names + active_pipeline_execution_policies.pluck(:name) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- not an ActiveRecord model and active_pipeline_execution_policies has limit + end + def pipeline_execution_policy policy_by_type(:pipeline_execution_policy) end diff --git a/ee/app/models/concerns/security/scan_execution_policy.rb b/ee/app/models/concerns/security/scan_execution_policy.rb index 7a1a0d2829f54d840fbaa7392c174144cb82f28a..8b4654c8dce83b0ad89fd904e4298fcb024a54fe 100644 --- a/ee/app/models/concerns/security/scan_execution_policy.rb +++ b/ee/app/models/concerns/security/scan_execution_policy.rb @@ -64,6 +64,10 @@ def active_policies_for_project(ref, project, pipeline_source = nil) .select { |policy| applicable_for_pipeline_source?(block_given? ? yield(policy[:rules]) : policy[:rules], pipeline_source) } end + def active_scan_execution_policy_names(ref, project) + active_policies_for_project(ref, project).pluck(:name) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- not an ActiveRecord model and active_scan_execution_policies has limit + end + def active_pipeline_policies_for_project(ref, project, pipeline_source = nil) active_policies_for_project(ref, project, pipeline_source) do |policy_rules| policy_rules.select { |rule| rule[:type] == RULE_TYPES[:pipeline] } diff --git a/ee/app/models/ee/ci/pipeline.rb b/ee/app/models/ee/ci/pipeline.rb index f5877c7e4f4524c34aace4f4ebc1daabb98e8e85..5d48dccbe55d3dd72c8343a9b0595a569931f2b5 100644 --- a/ee/app/models/ee/ci/pipeline.rb +++ b/ee/app/models/ee/ci/pipeline.rb @@ -127,6 +127,14 @@ def self.latest_limited_pipeline_ids_per_source(pipelines, sha) Security::PipelineAnalyzersStatusUpdateWorker.perform_async(pipeline.id) if pipeline.default_branch? end end + + after_transition any => :skipped do |pipeline| + pipeline.run_after_commit do + if ::Feature.enabled?(:collect_security_policy_skipped_pipelines_audit_events, pipeline.project) + Security::Policies::SkipPipelinesAuditWorker.perform_async(pipeline.id) + end + end + end end end diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index 0b60c6b4905d7e62551ad69be6fa2020e677e166..505f2755dbcd491da7c3eddfa610e7247a6bdb59 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -3614,6 +3614,16 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: security_policies_skip_pipelines_audit + :worker_name: Security::Policies::SkipPipelinesAuditWorker + :feature_category: :security_policy_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: security_process_scan_result_policy :worker_name: Security::ProcessScanResultPolicyWorker :feature_category: :security_policy_management diff --git a/ee/app/workers/security/policies/skip_pipelines_audit_worker.rb b/ee/app/workers/security/policies/skip_pipelines_audit_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..8067b8ebb0881d71d2e041ae196e383f9fd7795f --- /dev/null +++ b/ee/app/workers/security/policies/skip_pipelines_audit_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Security + module Policies + class SkipPipelinesAuditWorker + include ApplicationWorker + + data_consistency :sticky + + feature_category :security_policy_management + urgency :low + idempotent! + deduplicate :until_executed + defer_on_database_health_signal :gitlab_main, [:project_audit_events], 1.minute + + # Audit stream to external destination with HTTP request if configured + worker_has_external_dependencies! + + def perform(pipeline_id) + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + return unless pipeline + + return unless pipeline.project.licensed_feature_available?(:security_orchestration_policies) + + Security::SecurityOrchestrationPolicies::PipelineSkippedAuditor.new(pipeline: pipeline).audit + end + end + end +end diff --git a/ee/config/feature_flags/gitlab_com_derisk/collect_security_policy_skipped_pipelines_audit_events.yml b/ee/config/feature_flags/gitlab_com_derisk/collect_security_policy_skipped_pipelines_audit_events.yml new file mode 100644 index 0000000000000000000000000000000000000000..6a6ce5e2103feb7322c18d7448b443a5e98ef25c --- /dev/null +++ b/ee/config/feature_flags/gitlab_com_derisk/collect_security_policy_skipped_pipelines_audit_events.yml @@ -0,0 +1,10 @@ +--- +name: collect_security_policy_skipped_pipelines_audit_events +description: Collects audit events for skipped pipelines with security policy jobs in merge requests. +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/539232 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195325 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/550772 +milestone: '18.2' +group: group::security policies +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/lib/security/security_orchestration_policies/pipeline_skipped_auditor.rb b/ee/lib/security/security_orchestration_policies/pipeline_skipped_auditor.rb new file mode 100644 index 0000000000000000000000000000000000000000..c8b5c1717f074c916f2105e63067470a6068f318 --- /dev/null +++ b/ee/lib/security/security_orchestration_policies/pipeline_skipped_auditor.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Security + module SecurityOrchestrationPolicies + class PipelineSkippedAuditor + include Gitlab::Utils::StrongMemoize + + def initialize(pipeline:) + @pipeline = pipeline + end + + def audit + return unless pipeline + return unless security_orchestration_policy_configurations.present? + return unless skipped_policies.present? + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + + private + + attr_reader :pipeline + + def audit_context + { + name: 'policy_pipeline_skipped', + author: pipeline.user, + scope: project, + target: pipeline, + target_details: pipeline.commit.present? ? pipeline.commit.title : pipeline.name, + message: "Pipeline: #{pipeline.id} with security policy jobs skipped", + additional_details: additional_details + } + end + + def additional_details + { + commit_sha: pipeline.sha, + merge_request_title: merge_request&.title, + merge_request_id: merge_request&.id, + merge_request_iid: merge_request&.iid, + source_branch: merge_request&.source_branch, + target_branch: merge_request&.target_branch, + project_id: project.id, + project_name: project.name, + project_full_path: project.full_path, + skipped_policies: skipped_policies + }.compact + end + + def skipped_policies + pipeline_execution_policies, scan_execution_policies = active_execution_policies + + skipped_seps = format_skipped_policies(scan_execution_policies, 'scan_execution_policy') + skipped_peps = format_skipped_policies(pipeline_execution_policies, 'pipeline_execution_policy') + + skipped_seps + skipped_peps + end + + def format_skipped_policies(policies, type) + policies.map { |name| { name: name, policy_type: type } } + end + + def active_execution_policies + scan_execution_policies = Set.new + pipeline_execution_policies = Set.new + + security_orchestration_policy_configurations.each do |policy| + if target_branch_ref + active_seps_for_policy = policy.active_scan_execution_policy_names(target_branch_ref, project) + end + + active_peps_for_policy = policy.active_pipeline_execution_policy_names + + scan_execution_policies.merge(active_seps_for_policy) if active_seps_for_policy.present? + pipeline_execution_policies.merge(active_peps_for_policy) if active_peps_for_policy.present? + end + + [pipeline_execution_policies, scan_execution_policies] + end + + strong_memoize_attr :skipped_policies + + def security_orchestration_policy_configurations + project.all_security_orchestration_policy_configurations + end + strong_memoize_attr :security_orchestration_policy_configurations + + def project + pipeline.project + end + strong_memoize_attr :project + + def merge_request + pipeline.merge_request + end + strong_memoize_attr :merge_request + + def target_branch_ref + merge_request&.target_branch_ref + end + strong_memoize_attr :target_branch_ref + end + end +end diff --git a/ee/spec/lib/security/security_orchestration_policies/pipeline_skipped_auditor_spec.rb b/ee/spec/lib/security/security_orchestration_policies/pipeline_skipped_auditor_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..07ebb22c997b73d8ca56ca5cb6fddba70429d13c --- /dev/null +++ b/ee/spec/lib/security/security_orchestration_policies/pipeline_skipped_auditor_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::SecurityOrchestrationPolicies::PipelineSkippedAuditor, feature_category: :security_policy_management do + let_it_be(:project) { build(:project) } + + describe '#audit' do + subject(:audit) { described_class.new(pipeline: pipeline).audit } + + shared_examples 'does not call Gitlab::Audit::Auditor' do + specify do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + audit + end + end + + context 'when pipeline is nil' do + let_it_be(:pipeline) { nil } + + it_behaves_like 'does not call Gitlab::Audit::Auditor' + end + + context 'when the pipeline is present' do + let_it_be(:commit_title) { '[skip ci]Add .gitlab-ci.yml' } + let_it_be(:pipeline) { build(:ci_pipeline, project: project, id: 42) } + let_it_be(:commit) { build(:commit, safe_message: commit_title) } + + context 'when there is no security_orchestration_policy_configuration assigned to project' do + it_behaves_like 'does not call Gitlab::Audit::Auditor' + end + + context 'when there is a security_orchestration_policy_configuration assigned to project' do + let_it_be(:security_orchestration_policy_configuration) do + build(:security_orchestration_policy_configuration, project: project) + end + + before do + allow(project).to receive(:all_security_orchestration_policy_configurations).and_return( + [security_orchestration_policy_configuration]) + + allow(security_orchestration_policy_configuration).to receive(:active_scan_execution_policy_names).with( + merge_request&.target_branch_ref, project).and_return(active_scan_execution_policy_names) + + allow(security_orchestration_policy_configuration).to receive(:active_pipeline_execution_policy_names) + .and_return(active_pipeline_execution_policy_names) + + allow(pipeline).to receive(:commit).and_return(commit) + end + + context 'when there are no active policies' do + let(:active_scan_execution_policy_names) { [] } + let(:active_pipeline_execution_policy_names) { [] } + let(:merge_request) { nil } + + it_behaves_like 'does not call Gitlab::Audit::Auditor' + end + + context 'when there are active policies' do + shared_examples_for 'calls Gitlab::Audit::Auditor.audit with the expected context' do + specify do + expect(::Gitlab::Audit::Auditor).to receive(:audit) do |context| + expect(context[:name]).to eq('policy_pipeline_skipped') + expect(context[:author]).to eq(pipeline.user) + expect(context[:scope]).to eq(project) + expect(context[:target]).to eq(pipeline) + expect(context[:target_details]).to eq(commit_title) + expect(context[:message]).to eq("Pipeline: #{pipeline.id} with security policy jobs skipped") + expect(context[:additional_details]).to eq(additional_details) + end + + audit + end + end + + shared_examples_for 'when the merge_request is present' do + context 'when the merge_request is present' do + let_it_be(:merge_request) do + build(:merge_request, id: 1, iid: 1, source_project: project, target_project: project) + end + + let(:additional_details) do + { + commit_sha: pipeline.sha, + merge_request_title: merge_request.title, + merge_request_id: merge_request.id, + merge_request_iid: merge_request.iid, + source_branch: merge_request.source_branch, + target_branch: merge_request.target_branch, + project_id: project.id, + project_name: project.name, + project_full_path: project.full_path, + skipped_policies: skipped_policies_details + } + end + + before do + pipeline.merge_request = merge_request + end + + it_behaves_like 'calls Gitlab::Audit::Auditor.audit with the expected context' + end + end + + context 'when there are active scan_execution_policies policies' do + let(:skipped_policy_name) { 'Skipped sep policy' } + let(:skipped_policies_details) { [{ name: skipped_policy_name, policy_type: 'scan_execution_policy' }] } + + let(:active_scan_execution_policy_names) { [skipped_policy_name] } + let(:active_pipeline_execution_policy_names) { [] } + + it_behaves_like 'when the merge_request is present' + end + + context 'when there are active pipeline_execution_policies policies' do + let(:skipped_policy_name) { 'Skipped pep policy' } + let(:skipped_policies_details) { [{ name: skipped_policy_name, policy_type: 'pipeline_execution_policy' }] } + + let(:active_scan_execution_policy_names) { [] } + let(:active_pipeline_execution_policy_names) { [skipped_policy_name] } + + it_behaves_like 'when the merge_request is present' + + context 'when merge_request is nil' do + let(:merge_request) { nil } + let(:additional_details) do + { + commit_sha: pipeline.sha, + project_id: project.id, + project_name: project.name, + project_full_path: project.full_path, + skipped_policies: skipped_policies_details + } + end + + before do + pipeline.merge_request = merge_request + end + + it_behaves_like 'calls Gitlab::Audit::Auditor.audit with the expected context' + end + end + + context 'when there are active scan_execution and pipeline_execution policies' do + let(:skipped_sep_policy_name) { 'Skipped sep policy' } + let(:skipped_pep_policy_name) { 'Skipped pep policy' } + let(:skipped_policies_details) do + [{ name: skipped_sep_policy_name, policy_type: 'scan_execution_policy' }, + { name: skipped_pep_policy_name, + policy_type: 'pipeline_execution_policy' }] + end + + let(:active_scan_execution_policy_names) { [skipped_sep_policy_name] } + let(:active_pipeline_execution_policy_names) { [skipped_pep_policy_name] } + + it_behaves_like 'when the merge_request is present' + end + end + end + end + end +end diff --git a/ee/spec/models/ci/pipeline_spec.rb b/ee/spec/models/ci/pipeline_spec.rb index e5d80af0b46c22ed0cf848f3cd31f6190f9dc787..3c776abe3d9ff2d2bbfff39e926ce6b59bbd2698 100644 --- a/ee/spec/models/ci/pipeline_spec.rb +++ b/ee/spec/models/ci/pipeline_spec.rb @@ -684,6 +684,8 @@ it 'does not schedule security status update worker' do expect(Security::PipelineAnalyzersStatusUpdateWorker).not_to receive(:perform_async).with(pipeline.id) + + transition_pipeline end end @@ -692,10 +694,39 @@ it 'does not schedule security status update worker' do expect(Security::PipelineAnalyzersStatusUpdateWorker).not_to receive(:perform_async).with(pipeline.id) + + transition_pipeline end end end end + + context 'Security::Policies::SkipPipelinesAuditWorker' do + let(:build) { create(:ci_empty_pipeline, project: project, status: from_status) } + let(:from_status) { Ci::HasStatus::ACTIVE_STATUSES[-1] } + + context 'on pipeline skipped' do + subject(:transition_pipeline) { pipeline.skip } + + context 'when the feature flag `collect_security_policy_skipped_pipelines_audit_events` is disabled' do + before do + stub_feature_flags(collect_security_policy_skipped_pipelines_audit_events: false) + end + + it 'does not enqueue SkipPipelinesAuditWorker' do + expect(Security::Policies::SkipPipelinesAuditWorker).not_to receive(:perform_async).with(pipeline.id) + + transition_pipeline + end + end + + it 'enqueue SkipPipelinesAuditWorker' do + expect(Security::Policies::SkipPipelinesAuditWorker).to receive(:perform_async).with(pipeline.id) + + transition_pipeline + end + end + end end describe '#latest_merged_result_pipeline?' do diff --git a/ee/spec/models/security/orchestration_policy_configuration_spec.rb b/ee/spec/models/security/orchestration_policy_configuration_spec.rb index 2a810b07cd3f4f2cd74062c4244dca85a742cf3f..fb7308f993125fe76f7c55385904d3caa623e04f 100644 --- a/ee/spec/models/security/orchestration_policy_configuration_spec.rb +++ b/ee/spec/models/security/orchestration_policy_configuration_spec.rb @@ -3433,7 +3433,7 @@ end end - context 'with scan policies targetting specific pipeline source' do + context 'with scan policies targeting specific pipeline source' do let(:container_scanning_policy) do build( :scan_execution_policy, @@ -3461,6 +3461,17 @@ end end + describe '#active_scan_execution_policy_names' do + include_context 'for policies with pipeline and scheduled rules' + + subject(:active_scan_execution_policy_names) { security_orchestration_policy_configuration.active_scan_execution_policy_names('refs/heads/master', project) } + + it 'includes pipeline and scheduled policy names' do + expect(active_scan_execution_policy_names).to contain_exactly(dast_policy[:name], sast_policy_with_schedule[:name], + container_scanning_policy[:name]) + end + end + describe 'active_pipeline_policies_for_project' do include_context 'for policies with pipeline and scheduled rules' @@ -3701,6 +3712,25 @@ end end + describe '#active_pipeline_execution_policy_names' do + let(:policy_yaml) { fixture_file('security_orchestration.yml', dir: 'ee') } + + subject(:active_pipeline_execution_policy_names) { security_orchestration_policy_configuration.active_pipeline_execution_policy_names } + + before do + allow(security_policy_management_project).to receive(:repository).and_return(repository) + allow(repository).to receive(:blob_data_at).with(default_branch, Security::OrchestrationPolicyConfiguration::POLICY_PATH).and_return(policy_yaml) + end + + it 'returns active pipeline execution policy names' do + expect(active_pipeline_execution_policy_names).to contain_exactly('Run custom pipeline configuration', + 'Second pipeline execution policy', + 'Third pipeline execution policy', + 'Fourth pipeline execution policy', + 'Fifth pipeline execution policy') + end + end + describe '#active_pipeline_execution_schedule_policies' do let(:policy_yaml) { fixture_file('security_orchestration.yml', dir: 'ee') } diff --git a/ee/spec/workers/security/policies/skip_pipelines_audit_worker_spec.rb b/ee/spec/workers/security/policies/skip_pipelines_audit_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..334ac70b0cc78e20249dc2be4e44e6a61bc85f3d --- /dev/null +++ b/ee/spec/workers/security/policies/skip_pipelines_audit_worker_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::Policies::SkipPipelinesAuditWorker, feature_category: :security_policy_management do + describe '#perform' do + let_it_be(:pipeline) { create(:ci_pipeline) } + + subject(:run_worker) { described_class.new.perform(pipeline_id) } + + shared_examples_for 'does not call PipelineSkippedAuditor' do + specify do + expect(Security::SecurityOrchestrationPolicies::PipelineSkippedAuditor).not_to receive(:new) + + run_worker + end + end + + context 'when pipeline is not found' do + let(:pipeline_id) { non_existing_record_id } + + it_behaves_like 'does not call PipelineSkippedAuditor' + end + + context 'when pipeline exist' do + let(:pipeline_id) { pipeline.id } + + context 'when security_orchestration_policies feature is available' do + before do + stub_licensed_features(security_orchestration_policies: true) + end + + it 'calls PipelineSkippedAuditor' do + expect_next_instance_of(Security::SecurityOrchestrationPolicies::PipelineSkippedAuditor, + pipeline: pipeline) do |auditor| + expect(auditor).to receive(:audit) + end + + run_worker + end + + it_behaves_like 'an idempotent worker' do + let(:job_args) { pipeline.id } + end + end + + context 'when security_orchestration_policies feature is not available' do + let(:pipeline_id) { pipeline.id } + + before do + stub_licensed_features(security_orchestration_policies: false) + end + + it_behaves_like 'does not call PipelineSkippedAuditor' + end + end + end +end