diff --git a/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml b/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bf7c1946f7faf966213221514c326b7b4862a560
--- /dev/null
+++ b/config/feature_flags/wip/new_issue_attachment_from_vulnerability_bulk_action.yml
@@ -0,0 +1,9 @@
+---
+name: new_issue_attachment_from_vulnerability_bulk_action
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373814
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178686
+rollout_issue_url:
+milestone: '17.9'
+group: group::security insights
+type: wip
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index feec178ccf1ad070203535f1c46fb79240f5ea39..be6511666da8ce9c749cd62c33f7f02639c8f3b4 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -11471,6 +11471,30 @@ Input type: `VerifiedNamespaceCreateInput`
 | <a id="mutationverifiednamespacecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationverifiednamespacecreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 
+### `Mutation.vulnerabilitiesCreateIssue`
+
+DETAILS:
+**Introduced** in GitLab 17.9.
+**Status**: Experiment.
+
+Input type: `VulnerabilitiesCreateIssueInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationvulnerabilitiescreateissueclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationvulnerabilitiescreateissueproject"></a>`project` | [`ProjectID!`](#projectid) | ID of the project to attach the issue to. |
+| <a id="mutationvulnerabilitiescreateissuevulnerabilityids"></a>`vulnerabilityIds` | [`[VulnerabilityID!]!`](#vulnerabilityid) | IDs of vulnerabilities to link to the given issue. Up to 100 can be provided. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationvulnerabilitiescreateissueclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationvulnerabilitiescreateissueerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| <a id="mutationvulnerabilitiescreateissueissue"></a>`issue` | [`Issue`](#issue) | Issue created after mutation. |
+
 ### `Mutation.vulnerabilitiesDismiss`
 
 Input type: `VulnerabilitiesDismissInput`
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index b857ec8ea9aa9f24aa1726a3f0660bc5c6e7cd9a..4fef2aabf5249f6c6bb909c95e1927274374dd4a 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -81,6 +81,7 @@ def self.authorization_scopes
         mount_mutation ::Mutations::Vulnerabilities::CreateIssueLink
         mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink
         mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink
+        mount_mutation ::Mutations::Vulnerabilities::CreateIssue, experiment: { milestone: '17.9' }
         mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences,
           deprecated: { reason: 'Replaced by WorkItem type', milestone: '17.5' }
         mount_mutation ::Mutations::Boards::EpicBoards::Create,
diff --git a/ee/app/graphql/mutations/vulnerabilities/create_issue.rb b/ee/app/graphql/mutations/vulnerabilities/create_issue.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d8b81af6b5134135838c4765295a0e657890983b
--- /dev/null
+++ b/ee/app/graphql/mutations/vulnerabilities/create_issue.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Mutations
+  module Vulnerabilities
+    class CreateIssue < BaseMutation
+      graphql_name 'VulnerabilitiesCreateIssue'
+
+      MAX_VULNERABILITIES = 100
+
+      # This authorization is used for the **Issue only**.  The vulnerabilities
+      # access is checked against :admin_vulnerability_issue_link
+      authorize :create_issue
+
+      field :issue,
+        Types::IssueType,
+        null: true,
+        description: 'Issue created after mutation.'
+
+      argument :project, ::Types::GlobalIDType[::Project],
+        required: true,
+        description: 'ID of the project to attach the issue to.'
+
+      argument :vulnerability_ids,
+        [::Types::GlobalIDType[::Vulnerability]],
+        required: true,
+        validates: { length: { minimum: 1, maximum: MAX_VULNERABILITIES } },
+        description: "IDs of vulnerabilities to link to the given issue.  Up to #{MAX_VULNERABILITIES} can be provided."
+
+      def resolve(vulnerability_ids:, project:)
+        project = authorized_find!(id: project)
+
+        unless Feature.enabled?(:new_issue_attachment_from_vulnerability_bulk_action, project)
+          raise_resource_not_available_error!
+        end
+
+        issue_result = create_issue(project)
+
+        return { errors: [issue_result[:message]] } if issue_result.error?
+
+        result = create_issue_links(issue_result[:issue], vulnerabilities(vulnerability_ids))
+        {
+          issue: result.success? ? issue_result[:issue] : nil,
+          errors: result.errors
+        }
+      end
+
+      private
+
+      def vulnerabilities(vulnerability_ids)
+        vulnerabilities = Vulnerability.id_in(vulnerability_ids.map(&:model_id)).with_projects
+
+        raise_resource_not_available_error! unless vulnerabilities.all? do |vulnerability|
+          Ability.allowed?(current_user, :admin_vulnerability_issue_link, vulnerability)
+        end
+
+        vulnerabilities
+      end
+
+      def create_issue(project)
+        ::Vulnerabilities::CreateIssueFromBulkActionService.new(
+          project,
+          current_user).execute
+      end
+
+      def create_issue_links(issue, vulnerabilities)
+        ::VulnerabilityIssueLinks::BulkCreateService.new(current_user, issue, vulnerabilities).execute
+      end
+    end
+  end
+end
diff --git a/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb b/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a06ab8732d7a9892c53401bfc20b44447bc38702
--- /dev/null
+++ b/ee/app/services/vulnerabilities/create_issue_from_bulk_action_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Vulnerabilities
+  class CreateIssueFromBulkActionService < ::BaseService
+    def execute
+      unless can?(@current_user, :create_issue, @project)
+        return ServiceResponse.error(message: "User is not permitted to create issue")
+      end
+
+      issue_params = {
+        title: title,
+        confidential: true
+      }
+
+      result = ::Issues::CreateService
+        .new(container: @project, current_user: @current_user, params: issue_params, perform_spam_check: false)
+        .execute
+
+      if result.success?
+        ServiceResponse.success(payload: { issue: result[:issue] })
+      else
+        ServiceResponse.error(message: result.errors.join(', '))
+      end
+    end
+
+    private
+
+    def title
+      _("Investigate vulnerabilities")
+    end
+  end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb b/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b393527cbb5fc7593907a72ae21d089aef4d35e1
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/vulnerabilities/create_issue_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "Creating Issue", feature_category: :vulnerability_management do
+  include GraphqlHelpers
+
+  let_it_be(:project) { create(:project) }
+  let_it_be(:current_user) { create(:user) }
+  let_it_be(:issue) { create(:issue, project: project) }
+  let_it_be(:vulnerability_1) { create(:vulnerability, project: project) }
+  let_it_be(:vulnerability_2) { create(:vulnerability, project: project) }
+
+  let(:vulnerabilities) { [vulnerability_1, vulnerability_2] }
+  let(:issue_global_id) { issue.to_global_id.to_s }
+  let(:vulnerability_global_ids) { vulnerabilities.map { |v| v.to_global_id.to_s } }
+  let(:project_gid) { GitlabSchema.id_from_object(project) }
+
+  subject(:mutation) do
+    params = {
+      project: project_gid,
+      vulnerability_ids: vulnerability_global_ids
+    }
+
+    graphql_mutation(:vulnerabilities_create_issue, params)
+  end
+
+  def mutation_response
+    graphql_mutation_response(:vulnerabilities_create_issue)
+  end
+
+  context "when the user does not have access" do
+    it_behaves_like "a mutation that returns a top-level access error"
+  end
+
+  context "when the user has access" do
+    before_all do
+      project.add_developer(current_user)
+    end
+
+    context "when security_dashboard is disabled" do
+      before do
+        stub_licensed_features(security_dashboard: false)
+      end
+
+      it_behaves_like 'a mutation that returns top-level errors',
+        errors: ['The resource that you are attempting to access does not ' \
+          'exist or you don\'t have permission to perform this action']
+    end
+
+    context "when security_dashboard is enabled" do
+      before do
+        stub_licensed_features(security_dashboard: true)
+      end
+
+      context 'when new_issue_attachment_from_vulnerability_bulk_action feature flag is disabled' do
+        before do
+          stub_feature_flags(new_issue_attachment_from_vulnerability_bulk_action: false)
+        end
+
+        it_behaves_like 'a mutation that returns top-level errors',
+          errors: ['The resource that you are attempting to access does not ' \
+            'exist or you don\'t have permission to perform this action']
+      end
+
+      it "creates an issue" do
+        expect do
+          post_graphql_mutation(mutation, current_user: current_user)
+        end.to change { ::Issue.count }.by(1)
+      end
+
+      it "creates the issue links", :aggregate_failures do
+        expect do
+          post_graphql_mutation(mutation, current_user: current_user)
+        end.to change { ::Vulnerabilities::IssueLink.count }.by(2)
+
+        response_issue_link_gids = ::Vulnerabilities::IssueLink.all.map do |issue_link|
+          issue_link.to_global_id.to_s
+        end
+        expected_issue_link_gids = ::Issue.last.vulnerability_links.map do |issue_link|
+          issue_link.to_global_id.to_s
+        end
+
+        expect(response_issue_link_gids).to match_array(expected_issue_link_gids)
+        expect(response_issue_link_gids.count).to eq 2
+      end
+
+      context "when too many vulnerabilities are passed" do
+        let(:vulnerability_global_ids) do
+          Array.new(::Mutations::Vulnerabilities::CreateIssue::MAX_VULNERABILITIES + 1) do
+            'gid://gitlab/Vulnerability/1'
+          end
+        end
+
+        it_behaves_like 'a mutation that returns top-level errors',
+          errors: ["vulnerabilityIds is too long (maximum is 100)"]
+      end
+
+      context "when issue_id is nil" do
+        let(:project_gid) { nil }
+
+        it_behaves_like 'a mutation that returns top-level errors', errors: [/Expected value to not be null/]
+      end
+
+      context "when vulnerability_id is nil" do
+        let(:vulnerability_global_ids) { [nil] }
+
+        it_behaves_like 'a mutation that returns top-level errors', errors: [/Expected value to not be null/]
+      end
+
+      context "when vulnerability_ids are empty" do
+        let(:vulnerability_global_ids) { [] }
+
+        it_behaves_like 'a mutation that returns top-level errors',
+          errors: ["vulnerabilityIds is too short (minimum is 1)"]
+      end
+    end
+  end
+end
diff --git a/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb b/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5e21d348b921e49dff8648517ddb1ea4905b9267
--- /dev/null
+++ b/ee/spec/services/vulnerabilities/create_issue_from_bulk_action_service_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Vulnerabilities::CreateIssueFromBulkActionService, '#execute', feature_category: :vulnerability_management do
+  let_it_be(:group)   { create(:group) }
+  let_it_be(:project) { create(:project, :public, :repository, namespace: group) }
+  let_it_be(:user)    { create(:user) }
+
+  before_all do
+    group.add_developer(user)
+  end
+
+  shared_examples 'a created issue' do
+    let(:result) { described_class.new(project, user, params).execute }
+
+    it 'creates the issue with the given params' do
+      expect(result[:status]).to eq(:success)
+      issue = result[:issue]
+      expect(issue).to be_persisted
+      expect(issue.project).to eq(project)
+      expect(issue.author).to eq(user)
+      expect(issue.title).to eq(expected_title)
+      expect(issue.description).to eq(expected_description)
+      expect(issue).to be_confidential
+    end
+
+    context 'when Issues::CreateService fails' do
+      before do
+        allow_next_instance_of(Issues::CreateService) do |create_service|
+          allow(create_service).to receive(:execute).and_return(ServiceResponse.error(message: 'unexpected error'))
+        end
+      end
+
+      it 'returns an error' do
+        expect(result[:status]).to eq(:error)
+        expect(result[:message]).to eq('unexpected error')
+      end
+    end
+  end
+
+  context 'when user does not have permission to create issue' do
+    let(:result) { described_class.new(project, user, {}).execute }
+
+    before do
+      allow_next_instance_of(described_class) do |instance|
+        allow(instance).to receive(:can?).with(user, :create_issue, project).and_return(false)
+      end
+    end
+
+    it 'returns expected error' do
+      expect(result[:status]).to eq(:error)
+      expect(result[:message]).to eq("User is not permitted to create issue")
+    end
+  end
+
+  context 'when issues are disabled on project' do
+    let(:result) { described_class.new(project, user, {}).execute }
+    let(:project) { build(:project, :public, namespace: group, issues_access_level: ProjectFeature::DISABLED) }
+
+    it 'returns expected error' do
+      expect(result).to be_error
+      expect(result[:message]).to eq("User is not permitted to create issue")
+    end
+  end
+
+  context 'when successful' do
+    let(:title) { "Vulnerability Title" }
+    let(:params) { {} }
+
+    let(:expected_title) { "Investigate vulnerabilities" }
+    let(:expected_description) { nil }
+
+    it_behaves_like 'a created issue'
+
+    context 'when the title of the vulnerability is longer than maximum issue title' do
+      let(:max_title_length) { 255 }
+      let(:title) { ('a' * max_title_length) }
+      let(:expected_title) { "Investigate vulnerabilities" }
+
+      it_behaves_like 'a created issue'
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e4c6e9929c87e059fc3baccb4a1a2dc1b086f16b..a11b016fc7c23ca6c280b22f8db8ca8bf16bb14b 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -30807,6 +30807,9 @@ msgstr ""
 msgid "Invalidated"
 msgstr ""
 
+msgid "Investigate vulnerabilities"
+msgstr ""
+
 msgid "Investigate vulnerability: %{title}"
 msgstr ""