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