diff --git a/ee/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb similarity index 60% rename from ee/app/presenters/projects/security/configuration_presenter.rb rename to app/presenters/projects/security/configuration_presenter.rb index 6d0f874599bcee54706fab72148c13790b1982c3..89fca1a451a0cfaf4b2b72c2965680d3650193e3 100644 --- a/ee/app/presenters/projects/security/configuration_presenter.rb +++ b/app/presenters/projects/security/configuration_presenter.rb @@ -17,14 +17,14 @@ def to_h features: features, help_page_path: help_page_path('user/application_security/index'), latest_pipeline_path: latest_pipeline_path, - auto_fix_enabled: autofix_enabled, - can_toggle_auto_fix_settings: auto_fix_permission, # TODO: gitlab_ci_present will incorrectly report `false` if the CI/CD configuration file name # has been customized and a file with the given custom name exists in the repo. This edge case # will be addressed in https://gitlab.com/gitlab-org/gitlab/-/issues/342465 gitlab_ci_present: project.repository.gitlab_ci_yml.present?, gitlab_ci_history_path: gitlab_ci_history_path, - auto_fix_user_path: '/' # TODO: real link will be updated with https://gitlab.com/gitlab-org/gitlab/-/issues/215669 + auto_fix_enabled: autofix_enabled, + can_toggle_auto_fix_settings: can_toggle_autofix, + auto_fix_user_path: auto_fix_user_path } end @@ -38,12 +38,9 @@ def to_html_data_attribute private - def autofix_enabled - { - dependency_scanning: project_settings&.auto_fix_dependency_scanning, - container_scanning: project_settings&.auto_fix_container_scanning - } - end + def autofix_enabled; end + + def auto_fix_user_path; end def can_enable_auto_devops? feature_available?(:builds, current_user) && @@ -51,11 +48,13 @@ def can_enable_auto_devops? !archived? end + def can_toggle_autofix; end + def gitlab_ci_history_path return '' if project.empty_repo? - gitlab_ci = Gitlab::FileDetector::PATTERNS[:gitlab_ci] - Gitlab::Routing.url_helpers.project_blame_path(project, File.join(project.default_branch_or_main, gitlab_ci)) + gitlab_ci = ::Gitlab::FileDetector::PATTERNS[:gitlab_ci] + ::Gitlab::Routing.url_helpers.project_blame_path(project, File.join(project.default_branch_or_main, gitlab_ci)) end def features @@ -75,11 +74,13 @@ def latest_pipeline_path end def scan(type, configured: false) + scan = ::Gitlab::Security::ScanConfiguration.new(project: project, type: type, configured: configured) + { - type: type, - configured: configured, - configuration_path: configuration_path(type), - available: feature_available(type) + type: scan.type, + configured: scan.configured?, + configuration_path: scan.configuration_path, + available: scan.available? } end @@ -90,23 +91,8 @@ def scan_types def project_settings project.security_setting end - - def configuration_path(type) - { - sast: project_security_configuration_sast_path(project), - dast: project_security_configuration_dast_path(project), - dast_profiles: project_security_configuration_dast_scans_path(project), - api_fuzzing: project_security_configuration_api_fuzzing_path(project), - corpus_management: (project_security_configuration_corpus_management_path(project) if ::Feature.enabled?(:corpus_management, project, default_enabled: :yaml) && scanner_enabled?(:coverage_fuzzing)) - }[type] - end - - def feature_available(type) - # SAST and Secret Detection are always available, but this isn't - # reflected by our license model yet. - # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/333113 - %w[sast secret_detection].include?(type) || project.licensed_feature_available?(type) - end end end end + +Projects::Security::ConfigurationPresenter.prepend_mod_with('Projects::Security::ConfigurationPresenter') diff --git a/ee/app/presenters/ee/projects/security/configuration_presenter.rb b/ee/app/presenters/ee/projects/security/configuration_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..611a51a676fbe310fb2c6aafbb6a671fb96856e1 --- /dev/null +++ b/ee/app/presenters/ee/projects/security/configuration_presenter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EE + module Projects + module Security + module ConfigurationPresenter + extend ::Gitlab::Utils::Override + + private + + override :can_toggle_autofix + def can_toggle_autofix + try(:auto_fix_permission) + end + + override :autofix_enabled + def autofix_enabled + { + dependency_scanning: project_settings&.auto_fix_dependency_scanning, + container_scanning: project_settings&.auto_fix_container_scanning + } + end + + override :auto_fix_user_path + def auto_fix_user_path + '/' # TODO: real link will be updated with https://gitlab.com/gitlab-org/gitlab/-/issues/348463 + end + end + end + end +end diff --git a/ee/lib/ee/gitlab/security/scan_configuration.rb b/ee/lib/ee/gitlab/security/scan_configuration.rb new file mode 100644 index 0000000000000000000000000000000000000000..61b1ece733818e8d8ab2ed0bd144583f1131a2e1 --- /dev/null +++ b/ee/lib/ee/gitlab/security/scan_configuration.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Security + module ScanConfiguration + extend ::Gitlab::Utils::Override + + override :available? + def available? + super || project.licensed_feature_available?(type) + end + + override :configuration_path + def configuration_path + super if available? || always_available? + end + + private + + override :configurable_scans + def configurable_scans + strong_memoize(:configurable_scans) do + { + dast: project_security_configuration_dast_path(project), + dast_profiles: project_security_configuration_dast_scans_path(project), + api_fuzzing: project_security_configuration_api_fuzzing_path(project), + corpus_management: (project_security_configuration_corpus_management_path(project) if ::Feature.enabled?(:corpus_management, project, default_enabled: :yaml)) + }.merge(super) + end + end + + def always_available? + [:corpus_management, :dast_profiles].include?(type) + end + end + end + end +end diff --git a/ee/lib/gitlab/graphql/aggregations/security_orchestration_policies/lazy_dast_profile_aggregate.rb b/ee/lib/gitlab/graphql/aggregations/security_orchestration_policies/lazy_dast_profile_aggregate.rb index 76b40048c8d2bca5c925ae2615b0ddee63bdaa30..627179f1db9800f85d1c2a1735fd6d50166ed1a0 100644 --- a/ee/lib/gitlab/graphql/aggregations/security_orchestration_policies/lazy_dast_profile_aggregate.rb +++ b/ee/lib/gitlab/graphql/aggregations/security_orchestration_policies/lazy_dast_profile_aggregate.rb @@ -39,7 +39,7 @@ def load_records_into_loaded_objects # The record hasn't been loaded yet, so # hit the database with all pending IDs to prevent N+1 profiles_by_project_id = @lazy_state[:dast_pending_profiles].group_by(&:project_id) - policy_configurations = Security::OrchestrationPolicyConfiguration.for_project(profiles_by_project_id.keys).index_by(&:project_id) + policy_configurations = ::Security::OrchestrationPolicyConfiguration.for_project(profiles_by_project_id.keys).index_by(&:project_id) profiles_by_project_id.each do |project_id, dast_pending_profiles| dast_pending_profiles.each do |profile| diff --git a/ee/spec/features/projects/security/user_views_security_configuration_spec.rb b/ee/spec/features/projects/security/user_views_security_configuration_spec.rb index 4d4dd1ea0891ab84785bfc95c53800ef093e8f99..8bed3494dfb06db114393695b076abe3709498a2 100644 --- a/ee/spec/features/projects/security/user_views_security_configuration_spec.rb +++ b/ee/spec/features/projects/security/user_views_security_configuration_spec.rb @@ -8,7 +8,7 @@ let_it_be(:pipeline) { create(:ci_pipeline, project: project) } before_all do - project.add_developer(user) + project.add_maintainer(user) end before do @@ -17,12 +17,14 @@ context 'with security_dashboard feature available' do before do - stub_licensed_features(security_dashboard: true, sast: true, sast_iac: true, dast: true) + stub_licensed_features(security_dashboard: true, sast: true, sast_iac: true, dast: true, + dependency_scanning: true, container_scanning: true, coverage_fuzzing: true, + cluster_image_scanning: true, api_fuzzing: true) end context 'with no SAST report' do it 'shows SAST is not enabled' do - visit(project_security_configuration_path(project)) + visit_configuration_page within_sast_card do expect(page).to have_text('SAST') @@ -38,7 +40,7 @@ end it 'shows SAST is enabled' do - visit(project_security_configuration_path(project)) + visit_configuration_page within_sast_card do expect(page).to have_text('SAST') @@ -50,7 +52,7 @@ context 'enabling SAST IaC' do it 'redirects to new MR page' do - visit(project_security_configuration_path(project)) + visit_configuration_page within_sast_iac_card do expect(page).to have_text('Infrastructure as Code (IaC) Scanning') @@ -67,12 +69,13 @@ context 'with no DAST report' do it 'shows DAST is not enabled' do - visit(project_security_configuration_path(project)) + visit_configuration_page within_dast_card do expect(page).to have_text('DAST') expect(page).to have_text('Not enabled') expect(page).to have_link('Enable DAST') + expect(page).to have_link('Manage scans') end end end @@ -83,15 +86,108 @@ end it 'shows DAST is enabled' do - visit(project_security_configuration_path(project)) + visit_configuration_page within_dast_card do expect(page).to have_text('DAST') expect(page).to have_text('Enabled') expect(page).to have_link('Configure DAST') + expect(page).to have_link('Manage scans') end end end + + context 'with no Dependency Scanning report' do + it 'shows Dependency Scanning is disabled' do + visit_configuration_page + + within_dependency_scanning_card do + expect(page).to have_text('Dependency Scanning') + expect(page).to have_text('Not enabled') + expect(page).to have_button('Configure with a merge request') + end + end + end + + context 'with Dependency Scanning report' do + before do + create(:ci_build, :dependency_scanning, pipeline: pipeline, status: 'success') + end + + it 'shows Dependency Scanning is enabled' do + visit_configuration_page + + within_dependency_scanning_card do + expect(page).to have_text('Dependency Scanning') + expect(page).to have_text('Enabled') + expect(page).to have_link('Configuration guide') + end + end + end + + context 'with no Container Scanning report' do + it 'shows Container Scanning is disabled' do + visit_configuration_page + + within_container_scanning_card do + expect(page).to have_text('Container Scanning') + expect(page).to have_text('Not enabled') + expect(page).to have_link('Configuration guide') + end + end + end + + context 'with no Cluster Image scanning report' do + it 'shows Cluster Image scanning is disabled' do + visit_configuration_page + + within_cluster_image_card do + expect(page).to have_text('Cluster Image Scanning') + expect(page).to have_text('Not enabled') + expect(page).to have_link('Configuration guide') + end + end + end + + context 'with no Secret Detection report' do + it 'shows Secret Detection is disabled' do + visit_configuration_page + + within_secret_detection_card do + expect(page).to have_text('Secret Detection') + expect(page).to have_text('Not enabled') + expect(page).to have_button('Configure with a merge request') + end + end + end + + context 'with no API Fuzzing report' do + it 'shows API Fuzzing is disabled' do + visit_configuration_page + + within_api_fuzzing_card do + expect(page).to have_text('API Fuzzing') + expect(page).to have_text('Not enabled') + expect(page).to have_link('Enable API Fuzzing') + end + end + end + + context 'with no Coverage Fuzzing' do + it 'shows Coverage Fuzzing is disabled' do + visit_configuration_page + + within_coverage_fuzzing_card do + expect(page).to have_text('Coverage Fuzzing') + expect(page).to have_text('Not enabled') + expect(page).to have_link('Configuration guide') + end + end + end + end + + def visit_configuration_page + visit(project_security_configuration_path(project)) end def within_sast_card @@ -111,4 +207,40 @@ def within_dast_card yield end end + + def within_dependency_scanning_card + within '[data-testid="security-testing-card"]:nth-of-type(4)' do + yield + end + end + + def within_container_scanning_card + within '[data-testid="security-testing-card"]:nth-of-type(5)' do + yield + end + end + + def within_cluster_image_card + within '[data-testid="security-testing-card"]:nth-of-type(6)' do + yield + end + end + + def within_secret_detection_card + within '[data-testid="security-testing-card"]:nth-of-type(7)' do + yield + end + end + + def within_api_fuzzing_card + within '[data-testid="security-testing-card"]:nth-of-type(8)' do + yield + end + end + + def within_coverage_fuzzing_card + within '[data-testid="security-testing-card"]:nth-of-type(9)' do + yield + end + end end diff --git a/ee/spec/lib/ee/gitlab/security/scan_configuration_spec.rb b/ee/spec/lib/ee/gitlab/security/scan_configuration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4c585d4832d99f635b0bdeb78c0af15f045e477f --- /dev/null +++ b/ee/spec/lib/ee/gitlab/security/scan_configuration_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Security::ScanConfiguration do + let_it_be(:project) { create(:project, :repository) } + + let(:scan) { described_class.new(project: project, type: type, configured: configured) } + + describe '#available?' do + subject { scan.available? } + + let(:configured) { true } + + context 'with a core scanner' do + let(:type) { :sast } + + before do + stub_licensed_features(sast: false) + end + + it 'core scanners (SAST, Secret Detection) are always available' do + is_expected.to be_truthy + end + end + + context 'with licensed scanner that is available' do + let(:type) { :api_fuzzing } + + before do + stub_licensed_features(api_fuzzing: true) + end + + it { is_expected.to be_truthy } + end + + context 'with licensed scanner that is not available' do + let(:type) { :api_fuzzing } + + before do + stub_licensed_features(api_fuzzing: false) + end + + it { is_expected.to be_falsey } + end + + context 'with custom scanner' do + let(:type) { :my_scanner } + + it { is_expected.to be_falsey } + end + end + + describe '#configuration_path' do + subject { scan.configuration_path } + + let(:configured) { true } + + context 'with licensed scanner' do + let(:type) { :dast } + let(:configuration_path) { "/#{project.namespace.path}/#{project.name}/-/security/configuration/dast" } + + before do + stub_licensed_features(dast: true) + end + + it { is_expected.to eq(configuration_path) } + end + + context 'with always available scanner' do + let(:type) { :dast_profiles } + let(:configuration_path) { "/#{project.namespace.path}/#{project.name}/-/security/configuration/dast_scans" } + + it { is_expected.to eq(configuration_path) } + end + + context 'with a scanner under feature flag' do + let(:type) { :corpus_management } + let(:configuration_path) { "/#{project.namespace.path}/#{project.name}/-/security/configuration/corpus_management" } + + it { is_expected.to eq(configuration_path) } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(corpus_management: false) + end + + it { is_expected.to be_nil } + end + end + end +end diff --git a/ee/spec/presenters/ee/projects/security/configuration_presenter_spec.rb b/ee/spec/presenters/ee/projects/security/configuration_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..01d45dc302ec1d953d9febb713b3a6b26614790c --- /dev/null +++ b/ee/spec/presenters/ee/projects/security/configuration_presenter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Security::ConfigurationPresenter do + include Gitlab::Routing.url_helpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user) } + + describe '#to_h' do + subject(:result) { described_class.new(project, auto_fix_permission: true, current_user: current_user).to_h } + + it 'includes settings for auto_fix feature' do + auto_fix = result[:auto_fix_enabled] + + expect(auto_fix[:dependency_scanning]).to be_truthy + expect(auto_fix[:container_scanning]).to be_truthy + end + + it 'reports auto_fix permissions' do + expect(result[:can_toggle_auto_fix_settings]).to be_truthy + end + end +end diff --git a/ee/spec/presenters/projects/security/configuration_presenter_spec.rb b/ee/spec/presenters/projects/security/configuration_presenter_spec.rb deleted file mode 100644 index d49ca08319ecc0b0d7a14e82637e1174f092eb12..0000000000000000000000000000000000000000 --- a/ee/spec/presenters/projects/security/configuration_presenter_spec.rb +++ /dev/null @@ -1,351 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::Security::ConfigurationPresenter do - include Gitlab::Routing.url_helpers - - let(:project) { create(:project, :repository) } - let(:project_with_no_repo) { create(:project) } - let(:current_user) { create(:user) } - - it 'presents the given project' do - presenter = described_class.new(project) - - expect(presenter.id).to be(project.id) - end - - before do - project.add_maintainer(current_user) - stub_licensed_features(licensed_scan_types.to_h { |type| [type, true] }) - end - - describe '#to_h' do - subject { described_class.new(project, auto_fix_permission: true, current_user: current_user).to_html_data_attribute } - - it 'includes links to auto devops and secure product docs' do - expect(subject[:auto_devops_help_page_path]).to eq(help_page_path('topics/autodevops/index')) - expect(subject[:help_page_path]).to eq(help_page_path('user/application_security/index')) - end - - it 'includes settings for auto_fix feature' do - auto_fix = Gitlab::Json.parse(subject[:auto_fix_enabled]) - - expect(auto_fix['dependency_scanning']).to be_truthy - expect(auto_fix['container_scanning']).to be_truthy - end - - it 'includes the path to gitlab_ci history' do - expect(subject[:gitlab_ci_history_path]).to eq(project_blame_path(project, 'master/.gitlab-ci.yml')) - end - - context 'when the project is empty' do - subject { described_class.new(project_with_no_repo, auto_fix_permission: true, current_user: current_user).to_html_data_attribute } - - it 'includes a blank gitlab_ci history path' do - expect(subject[:gitlab_ci_history_path]).to eq('') - end - end - - context 'when the project has no default branch set' do - before do - allow(project).to receive(:default_branch).and_return(nil) - end - - it 'includes the path to gitlab_ci history' do - expect(subject[:gitlab_ci_history_path]).to eq(project_blame_path(project, 'master/.gitlab-ci.yml')) - end - end - - context "when the latest default branch pipeline's source is auto devops" do - before do - pipeline = create( - :ci_pipeline, - :auto_devops_source, - project: project, - ref: project.default_branch, - sha: project.commit.sha - ) - create(:ci_build, :sast, pipeline: pipeline, status: 'success') - create(:ci_build, :dast, pipeline: pipeline, status: 'success') - create(:ci_build, :secret_detection, pipeline: pipeline, status: 'pending') - end - - it 'reports that auto devops is enabled' do - expect(subject[:auto_devops_enabled]).to be_truthy - end - - it 'reports auto_fix permissions' do - expect(subject[:can_toggle_auto_fix_settings]).to be_truthy - end - - it 'reports that all scanners are configured for which latest pipeline has builds' do - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: true), - security_scan(:sast, configured: true), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: true), - security_scan(:coverage_fuzzing, configured: false), - security_scan(:api_fuzzing, configured: false), - security_scan(:dast_profiles, configured: true), - security_scan(:corpus_management, configured: true) - ) - end - end - - context "when coverage fuzzing has run in a pipeline with feature flag off" do - before do - stub_feature_flags(corpus_management: false) - pipeline = create( - :ci_pipeline, - :auto_devops_source, - project: project, - ref: project.default_branch, - sha: project.commit.sha - ) - create(:ci_build, :coverage_fuzzing, pipeline: pipeline, status: 'success') - end - - it 'reports that coverage fuzzing, corpus management, and DAST are configured' do - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: false), - security_scan(:sast, configured: false), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: false), - security_scan(:coverage_fuzzing, configured: true), - security_scan(:api_fuzzing, configured: false), - security_scan(:dast_profiles, configured: true), - security_scan(:corpus_management, configured: true) - ) - end - end - - context "when coverage fuzzing has run in a pipeline with feature flag on" do - before do - stub_feature_flags(corpus_management: true) - pipeline = create( - :ci_pipeline, - :auto_devops_source, - project: project, - ref: project.default_branch, - sha: project.commit.sha - ) - create(:ci_build, :coverage_fuzzing, pipeline: pipeline, status: 'success') - end - - it 'reports that coverage fuzzing, corpus management, and DAST are configured' do - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: false), - security_scan(:sast, configured: false), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: false), - security_scan(:coverage_fuzzing, configured: true), - security_scan(:api_fuzzing, configured: false), - security_scan(:dast_profiles, configured: true), - security_scan(:corpus_management, configured: true, configuration_path: project_security_configuration_corpus_management_path(project)) - ) - end - end - - context 'when the project has no default branch pipeline' do - it 'reports that auto devops is disabled' do - expect(subject[:auto_devops_enabled]).to be_falsy - end - - it 'includes a link to CI pipeline docs' do - expect(subject[:latest_pipeline_path]).to eq(help_page_path('ci/pipelines')) - end - - it 'reports all security jobs as unconfigured' do - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: false), - security_scan(:sast, configured: false), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: false), - security_scan(:coverage_fuzzing, configured: false), - security_scan(:api_fuzzing, configured: false), - security_scan(:dast_profiles, configured: true), - security_scan(:corpus_management, configured: true) - ) - end - end - - context 'when latest default branch pipeline`s source is not auto devops' do - let(:pipeline) do - create( - :ci_pipeline, - project: project, - ref: project.default_branch, - sha: project.commit.sha - ) - end - - before do - create(:ci_build, :sast, pipeline: pipeline) - create(:ci_build, :dast, pipeline: pipeline) - create(:ci_build, :secret_detection, pipeline: pipeline) - end - - it 'uses the latest default branch pipeline to determine whether a security job is configured' do - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: true), - security_scan(:dast_profiles, configured: true), - security_scan(:sast, configured: true), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: true), - security_scan(:coverage_fuzzing, configured: false), - security_scan(:api_fuzzing, configured: false), - security_scan(:corpus_management, configured: true) - ) - end - - it 'detects security jobs even when the job has more than one report' do - config = { artifacts: { reports: { other_job: ['gl-other-report.json'], sast: ['gl-sast-report.json'] } } } - complicated_job = build_stubbed(:ci_build, options: config) - - allow_next_instance_of(::Security::SecurityJobsFinder) do |finder| - allow(finder).to receive(:execute).and_return([complicated_job]) - end - - subject - - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: false), - security_scan(:dast_profiles, configured: true), - security_scan(:sast, configured: true), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: false), - security_scan(:secret_detection, configured: false), - security_scan(:coverage_fuzzing, configured: false), - security_scan(:api_fuzzing, configured: false), - security_scan(:corpus_management, configured: true) - ) - end - - it 'detect new license compliance job' do - create(:ci_build, :license_scanning, pipeline: pipeline) - - expect(Gitlab::Json.parse(subject[:features])).to contain_exactly( - security_scan(:dast, configured: true), - security_scan(:dast_profiles, configured: true), - security_scan(:sast, configured: true), - security_scan(:sast_iac, configured: false), - security_scan(:container_scanning, configured: false), - security_scan(:cluster_image_scanning, configured: false), - security_scan(:dependency_scanning, configured: false), - security_scan(:license_scanning, configured: true), - security_scan(:secret_detection, configured: true), - security_scan(:coverage_fuzzing, configured: false), - security_scan(:api_fuzzing, configured: false), - security_scan(:corpus_management, configured: true) - ) - end - - it 'includes a link to the latest pipeline' do - expect(subject[:latest_pipeline_path]).to eq(project_pipeline_path(project, pipeline)) - end - - context "while retrieving information about gitlab ci file" do - context 'when a .gitlab-ci.yml file exists' do - before do - project.repository.create_file( - project.creator, - Gitlab::FileDetector::PATTERNS[:gitlab_ci], - 'contents go here', - message: 'test', - branch_name: 'master') - end - - it 'expects gitlab_ci_present to be true' do - expect(subject[:gitlab_ci_present]).to eq(true) - end - end - - context 'when a .gitlab-ci.yml file does not exist' do - it 'expects gitlab_ci_present to be false if the file is not present' do - expect(subject[:gitlab_ci_present]).to eq(false) - end - end - end - - it 'includes the auto_devops_path' do - expect(subject[:auto_devops_path]).to eq(project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) - end - - context "while retrieving information about user's ability to enable auto_devops" do - using RSpec::Parameterized::TableSyntax - - where(:is_admin, :archived, :feature_available, :result) do - true | true | true | false - false | true | true | false - true | false | true | true - false | false | true | false - true | true | false | false - false | true | false | false - true | false | false | false - false | false | false | false - end - - with_them do - before do - allow_any_instance_of(described_class).to receive(:can?).and_return(is_admin) - allow_any_instance_of(described_class).to receive(:archived?).and_return(archived) - allow_any_instance_of(described_class).to receive(:feature_available?).and_return(feature_available) - end - - it 'includes can_enable_auto_devops' do - expect(subject[:can_enable_auto_devops]).to eq(result) - end - end - end - end - end - - def security_scan(type, configured:, configuration_path: nil) - path = configuration_path || configuration_path(type) - - { - "type" => type.to_s, - "configured" => configured, - "configuration_path" => path, - "available" => licensed_scan_types.include?(type) - } - end - - def configuration_path(type) - { - dast: project_security_configuration_dast_path(project), - dast_profiles: project_security_configuration_dast_scans_path(project), - sast: project_security_configuration_sast_path(project), - api_fuzzing: project_security_configuration_api_fuzzing_path(project), - corpus_management: nil - }[type] - end - - def licensed_scan_types - ::Security::SecurityJobsFinder.allowed_job_types + ::Security::LicenseComplianceJobsFinder.allowed_job_types - [:cluster_image_scanning] - end -end diff --git a/lib/gitlab/security/scan_configuration.rb b/lib/gitlab/security/scan_configuration.rb new file mode 100644 index 0000000000000000000000000000000000000000..eaccbb3be7e6d6783e85495db86b700c3c87d66e --- /dev/null +++ b/lib/gitlab/security/scan_configuration.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gitlab + module Security + class ScanConfiguration + include ::Gitlab::Utils::StrongMemoize + include Gitlab::Routing.url_helpers + + attr_reader :type + + def initialize(project:, type:, configured: false) + @project = project + @type = type + @configured = configured + end + + def available? + # SAST and Secret Detection are always available, but this isn't + # reflected by our license model yet. + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/333113 + %i[sast secret_detection].include?(type) + end + + def configured? + configured + end + + def configuration_path + configurable_scans[type] + end + + private + + attr_reader :project, :configured + + def configurable_scans + strong_memoize(:configurable_scans) do + { + sast: project_security_configuration_sast_path(project) + } + end + end + end + end +end + +Gitlab::Security::ScanConfiguration.prepend_mod_with('Gitlab::Security::ScanConfiguration') diff --git a/spec/lib/gitlab/security/scan_configuration_spec.rb b/spec/lib/gitlab/security/scan_configuration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0af029968e86250f74f76ff4c3c172672aa823b0 --- /dev/null +++ b/spec/lib/gitlab/security/scan_configuration_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Security::ScanConfiguration do + let_it_be(:project) { create(:project, :repository) } + + let(:scan) { described_class.new(project: project, type: type, configured: configured) } + + describe '#available?' do + subject { scan.available? } + + let(:configured) { true } + + context 'with a core scanner' do + let(:type) { :sast } + + it { is_expected.to be_truthy } + end + + context 'with custom scanner' do + let(:type) { :my_scanner } + + it { is_expected.to be_falsey } + end + end + + describe '#configured?' do + subject { scan.configured? } + + let(:type) { :sast } + let(:configured) { false } + + it { is_expected.to be_falsey } + end + + describe '#configuration_path' do + subject { scan.configuration_path } + + let(:configured) { true } + + context 'with a non configurable scanner' do + let(:type) { :secret_detection } + + it { is_expected.to be_nil } + end + + context 'with licensed scanner for FOSS environment' do + let(:type) { :dast } + + before do + stub_env('FOSS_ONLY', '1') + end + + it { is_expected.to be_nil } + end + + context 'with custom scanner' do + let(:type) { :my_scanner } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/presenters/projects/security/configuration_presenter_spec.rb b/spec/presenters/projects/security/configuration_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..836753d04832075d3b1faf495bddc7bf6563a26c --- /dev/null +++ b/spec/presenters/projects/security/configuration_presenter_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Security::ConfigurationPresenter do + include Gitlab::Routing.url_helpers + using RSpec::Parameterized::TableSyntax + + let(:project_with_repo) { create(:project, :repository) } + let(:project_with_no_repo) { create(:project) } + let(:current_user) { create(:user) } + let(:presenter) { described_class.new(project, current_user: current_user) } + + before do + stub_licensed_features(licensed_scan_types.to_h { |type| [type, true] }) + + stub_feature_flags(corpus_management: false) + end + + describe '#to_html_data_attribute' do + subject(:html_data) { presenter.to_html_data_attribute } + + context 'when latest default branch pipeline`s source is not auto devops' do + let(:project) { project_with_repo } + + let(:pipeline) do + create( + :ci_pipeline, + project: project, + ref: project.default_branch, + sha: project.commit.sha + ) + end + + let!(:build_sast) { create(:ci_build, :sast, pipeline: pipeline) } + let!(:build_dast) { create(:ci_build, :dast, pipeline: pipeline) } + let!(:build_license_scanning) { create(:ci_build, :license_scanning, pipeline: pipeline) } + + it 'includes links to auto devops and secure product docs' do + expect(html_data[:auto_devops_help_page_path]).to eq(help_page_path('topics/autodevops/index')) + expect(html_data[:help_page_path]).to eq(help_page_path('user/application_security/index')) + end + + it 'returns info that Auto DevOps is not enabled' do + expect(html_data[:auto_devops_enabled]).to eq(false) + expect(html_data[:auto_devops_path]).to eq(project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + end + + it 'includes a link to the latest pipeline' do + expect(html_data[:latest_pipeline_path]).to eq(project_pipeline_path(project, pipeline)) + end + + it 'has stubs for autofix' do + expect(html_data.keys).to include(:can_toggle_auto_fix_settings, :auto_fix_enabled, :auto_fix_user_path) + end + + context "while retrieving information about user's ability to enable auto_devops" do + where(:is_admin, :archived, :feature_available, :result) do + true | true | true | false + false | true | true | false + true | false | true | true + false | false | true | false + true | true | false | false + false | true | false | false + true | false | false | false + false | false | false | false + end + + with_them do + before do + allow_next_instance_of(described_class) do |presenter| + allow(presenter).to receive(:can?).and_return(is_admin) + allow(presenter).to receive(:archived?).and_return(archived) + allow(presenter).to receive(:feature_available?).and_return(feature_available) + end + end + + it 'includes can_enable_auto_devops' do + expect(html_data[:can_enable_auto_devops]).to eq(result) + end + end + end + + it 'includes feature information' do + feature = Gitlab::Json.parse(html_data[:features]).find { |scan| scan['type'] == 'sast' } + + expect(feature['type']).to eq('sast') + expect(feature['configured']).to eq(true) + expect(feature['configuration_path']).to eq(project_security_configuration_sast_path(project)) + expect(feature['available']).to eq(true) + end + + context 'when checking features configured status' do + let(:features) { Gitlab::Json.parse(html_data[:features]) } + + where(:type, :configured) do + :dast | true + :dast_profiles | true + :sast | true + :sast_iac | false + :container_scanning | false + :cluster_image_scanning | false + :dependency_scanning | false + :license_scanning | true + :secret_detection | false + :coverage_fuzzing | false + :api_fuzzing | false + :corpus_management | true + end + + with_them do + it 'returns proper configuration status' do + feature = features.find { |scan| scan['type'] == type.to_s } + + expect(feature['configured']).to eq(configured) + end + end + end + + context 'when the job has more than one report' do + let(:features) { Gitlab::Json.parse(html_data[:features]) } + + let!(:artifacts) do + { artifacts: { reports: { other_job: ['gl-other-report.json'], sast: ['gl-sast-report.json'] } } } + end + + let!(:complicated_job) { build_stubbed(:ci_build, options: artifacts) } + + before do + allow_next_instance_of(::Security::SecurityJobsFinder) do |finder| + allow(finder).to receive(:execute).and_return([complicated_job]) + end + end + + where(:type, :configured) do + :dast | false + :dast_profiles | true + :sast | true + :sast_iac | false + :container_scanning | false + :cluster_image_scanning | false + :dependency_scanning | false + :license_scanning | true + :secret_detection | false + :coverage_fuzzing | false + :api_fuzzing | false + :corpus_management | true + end + + with_them do + it 'properly detects security jobs' do + feature = features.find { |scan| scan['type'] == type.to_s } + + expect(feature['configured']).to eq(configured) + end + end + end + + it 'includes a link to the latest pipeline' do + expect(subject[:latest_pipeline_path]).to eq(project_pipeline_path(project, pipeline)) + end + + context "while retrieving information about gitlab ci file" do + context 'when a .gitlab-ci.yml file exists' do + let!(:ci_config) do + project.repository.create_file( + project.creator, + Gitlab::FileDetector::PATTERNS[:gitlab_ci], + 'contents go here', + message: 'test', + branch_name: 'master') + end + + it 'expects gitlab_ci_present to be true' do + expect(html_data[:gitlab_ci_present]).to eq(true) + end + end + + context 'when a .gitlab-ci.yml file does not exist' do + it 'expects gitlab_ci_present to be false if the file is not present' do + expect(html_data[:gitlab_ci_present]).to eq(false) + end + end + end + + it 'includes the path to gitlab_ci history' do + expect(subject[:gitlab_ci_history_path]).to eq(project_blame_path(project, 'master/.gitlab-ci.yml')) + end + end + + context 'when the project is empty' do + let(:project) { project_with_no_repo } + + it 'includes a blank gitlab_ci history path' do + expect(html_data[:gitlab_ci_history_path]).to eq('') + end + end + + context 'when the project has no default branch set' do + let(:project) { project_with_repo } + + it 'includes the path to gitlab_ci history' do + allow(project).to receive(:default_branch).and_return(nil) + + expect(html_data[:gitlab_ci_history_path]).to eq(project_blame_path(project, 'master/.gitlab-ci.yml')) + end + end + + context "when the latest default branch pipeline's source is auto devops" do + let(:project) { project_with_repo } + + let(:pipeline) do + create( + :ci_pipeline, + :auto_devops_source, + project: project, + ref: project.default_branch, + sha: project.commit.sha + ) + end + + let!(:build_sast) { create(:ci_build, :sast, pipeline: pipeline, status: 'success') } + let!(:build_dast) { create(:ci_build, :dast, pipeline: pipeline, status: 'success') } + let!(:ci_build) { create(:ci_build, :secret_detection, pipeline: pipeline, status: 'pending') } + + it 'reports that auto devops is enabled' do + expect(html_data[:auto_devops_enabled]).to be_truthy + end + + context 'when gathering feature data' do + let(:features) { Gitlab::Json.parse(html_data[:features]) } + + where(:type, :configured) do + :dast | true + :dast_profiles | true + :sast | true + :sast_iac | false + :container_scanning | false + :cluster_image_scanning | false + :dependency_scanning | false + :license_scanning | false + :secret_detection | true + :coverage_fuzzing | false + :api_fuzzing | false + :corpus_management | true + end + + with_them do + it 'reports that all scanners are configured for which latest pipeline has builds' do + feature = features.find { |scan| scan['type'] == type.to_s } + + expect(feature['configured']).to eq(configured) + end + end + end + end + + context 'when the project has no default branch pipeline' do + let(:project) { project_with_repo } + + it 'reports that auto devops is disabled' do + expect(html_data[:auto_devops_enabled]).to be_falsy + end + + it 'includes a link to CI pipeline docs' do + expect(html_data[:latest_pipeline_path]).to eq(help_page_path('ci/pipelines')) + end + + context 'when gathering feature data' do + let(:features) { Gitlab::Json.parse(html_data[:features]) } + + where(:type, :configured) do + :dast | false + :dast_profiles | true + :sast | false + :sast_iac | false + :container_scanning | false + :cluster_image_scanning | false + :dependency_scanning | false + :license_scanning | false + :secret_detection | false + :coverage_fuzzing | false + :api_fuzzing | false + :corpus_management | true + end + + with_them do + it 'reports all security jobs as unconfigured with exception of "fake" jobs' do + feature = features.find { |scan| scan['type'] == type.to_s } + + expect(feature['configured']).to eq(configured) + end + end + end + end + + def licensed_scan_types + ::Security::SecurityJobsFinder.allowed_job_types + ::Security::LicenseComplianceJobsFinder.allowed_job_types - [:cluster_image_scanning] + end + end +end