Commit 7cb8dac3 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'use-existing-site-profiles-for-scans-230604' into 'master'

Create DAST on-demand scan using DastSiteProfile

See merge request gitlab-org/gitlab!37536
parents a7ca4f45 c18a76b6
...@@ -2152,6 +2152,46 @@ type CreateSnippetPayload { ...@@ -2152,6 +2152,46 @@ type CreateSnippetPayload {
snippet: Snippet snippet: Snippet
} }
"""
Autogenerated input type of DastOnDemandScanCreate
"""
input DastOnDemandScanCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
ID of the site profile to be used for the scan.
"""
dastSiteProfileId: ID!
"""
The project the site profile belongs to.
"""
fullPath: ID!
}
"""
Autogenerated return type of DastOnDemandScanCreate
"""
type DastOnDemandScanCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
URL of the pipeline that was created.
"""
pipelineUrl: String
}
enum DastScanTypeEnum { enum DastScanTypeEnum {
""" """
Passive DAST scan. This scan will not make active attacks against the target site. Passive DAST scan. This scan will not make active attacks against the target site.
...@@ -8275,6 +8315,7 @@ type Mutation { ...@@ -8275,6 +8315,7 @@ type Mutation {
createNote(input: CreateNoteInput!): CreateNotePayload createNote(input: CreateNoteInput!): CreateNotePayload
createRequirement(input: CreateRequirementInput!): CreateRequirementPayload createRequirement(input: CreateRequirementInput!): CreateRequirementPayload
createSnippet(input: CreateSnippetInput!): CreateSnippetPayload createSnippet(input: CreateSnippetInput!): CreateSnippetPayload
dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload
deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload
......
...@@ -5768,6 +5768,122 @@ ...@@ -5768,6 +5768,122 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "DastOnDemandScanCreateInput",
"description": "Autogenerated input type of DastOnDemandScanCreate",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "The project the site profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "dastSiteProfileId",
"description": "ID of the site profile to be used for the scan.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastOnDemandScanCreatePayload",
"description": "Autogenerated return type of DastOnDemandScanCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineUrl",
"description": "URL of the pipeline that was created.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "ENUM", "kind": "ENUM",
"name": "DastScanTypeEnum", "name": "DastScanTypeEnum",
...@@ -23712,6 +23828,33 @@ ...@@ -23712,6 +23828,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "dastOnDemandScanCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastOnDemandScanCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastOnDemandScanCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "dastScannerProfileCreate", "name": "dastScannerProfileCreate",
"description": null, "description": null,
...@@ -382,6 +382,16 @@ Autogenerated return type of CreateSnippet ...@@ -382,6 +382,16 @@ Autogenerated return type of CreateSnippet
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `snippet` | Snippet | The snippet after mutation | | `snippet` | Snippet | The snippet after mutation |
## DastOnDemandScanCreatePayload
Autogenerated return type of DastOnDemandScanCreate
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
## DastScannerProfileCreatePayload ## DastScannerProfileCreatePayload
Autogenerated return type of DastScannerProfileCreate Autogenerated return type of DastScannerProfileCreate
......
...@@ -22,6 +22,7 @@ module EE ...@@ -22,6 +22,7 @@ module EE
mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject
mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject
mount_mutation ::Mutations::Pipelines::RunDastScan mount_mutation ::Mutations::Pipelines::RunDastScan
mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::DastSiteProfiles::Create mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastScannerProfiles::Create mount_mutation ::Mutations::DastScannerProfiles::Create
mount_mutation ::Mutations::Namespaces::IncreaseStorageTemporarily mount_mutation ::Mutations::Namespaces::IncreaseStorageTemporarily
......
# frozen_string_literal: true
module Mutations
module DastOnDemandScans
class Create < BaseMutation
InvalidGlobalID = Class.new(StandardError)
include ResolvesProject
graphql_name 'DastOnDemandScanCreate'
field :pipeline_url, GraphQL::STRING_TYPE,
null: true,
description: 'URL of the pipeline that was created.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the site profile belongs to.'
argument :dast_site_profile_id, GraphQL::ID_TYPE,
required: true,
description: 'ID of the site profile to be used for the scan.'
authorize :run_ondemand_dast_scan
def resolve(full_path:, dast_site_profile_id:)
project = authorized_find!(full_path: full_path)
raise_resource_not_available_error! unless Feature.enabled?(:security_on_demand_scans_feature_flag, project)
dast_site_profile = find_dast_site_profile(project: project, dast_site_profile_id: dast_site_profile_id)
dast_site = dast_site_profile.dast_site
service = Ci::RunDastScanService.new(project, current_user)
result = service.execute(branch: project.default_branch, target_url: dast_site.url)
if result.success?
success_response(project: project, pipeline: result.payload)
else
error_response(result)
end
end
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
def find_dast_site_profile(project:, dast_site_profile_id:)
global_id = GlobalID.parse(dast_site_profile_id)
raise InvalidGlobalID.new('Incorrect class') unless global_id.model_class == DastSiteProfile
project
.dast_site_profiles
.with_dast_site
.find(global_id.model_id)
end
def success_response(project:, pipeline:)
pipeline_url = Rails.application.routes.url_helpers.project_pipeline_url(
project,
pipeline
)
{
errors: [],
pipeline_url: pipeline_url
}
end
def error_response(result)
{ errors: result.errors }
end
end
end
end
...@@ -8,6 +8,8 @@ class DastSiteProfile < ApplicationRecord ...@@ -8,6 +8,8 @@ class DastSiteProfile < ApplicationRecord
validates :project_id, :dast_site_id, presence: true validates :project_id, :dast_site_id, presence: true
validate :dast_site_project_id_fk validate :dast_site_project_id_fk
scope :with_dast_site, -> { includes(:dast_site) }
private private
def dast_site_project_id_fk def dast_site_project_id_fk
......
...@@ -5,11 +5,8 @@ FactoryBot.define do ...@@ -5,11 +5,8 @@ FactoryBot.define do
name { FFaker::Product.product_name } name { FFaker::Product.product_name }
before(:create) do |dast_site_profile| before(:create) do |dast_site_profile|
project = FactoryBot.create(:project) dast_site_profile.project ||= FactoryBot.create(:project)
dast_site = FactoryBot.create(:dast_site, project: project) dast_site_profile.dast_site ||= FactoryBot.create(:dast_site, project: dast_site_profile.project)
dast_site_profile.project = project
dast_site_profile.dast_site = dast_site
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::DastOnDemandScans::Create do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:project) { create(:project, :repository, group: group) }
let(:full_path) { project.full_path }
let(:dast_site_profile) { create(:dast_site_profile, project: project) }
let(:dast_site_profile_id) { dast_site_profile.to_global_id }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
dast_site_profile_id: dast_site_profile_id
)
end
context 'when on demand scan feature is enabled' do
context 'when the project does not exist' do
let(:full_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is not associated with the project' do
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is an owner' do
it 'has no errors' do
group.add_owner(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a maintainer' do
it 'has no errors' do
project.add_maintainer(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a developer' do
it 'has no errors' do
project.add_developer(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a reporter' do
it 'raises an exception' do
project.add_reporter(user)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is a guest' do
it 'raises an exception' do
project.add_guest(user)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns a pipeline_url containing the correct path' do
actual_url = subject[:pipeline_url]
pipeline = Ci::Pipeline.last
expected_url = Rails.application.routes.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(actual_url).to eq(expected_url)
end
context 'when the wrong type of gid is used' do
let(:dast_site_profile_id) { project.to_global_id }
it 'raises an exception' do
expect { subject }.to raise_error(described_class::InvalidGlobalID)
end
end
context 'when the dast_site_profile does not exist' do
it 'raises an exception' do
dast_site_profile.destroy!
expect { subject }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when on demand scan feature is not enabled' do
it 'raises an exception' do
stub_feature_flags(security_on_demand_scans_feature_flag: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
end
...@@ -29,4 +29,18 @@ RSpec.describe DastSiteProfile, type: :model do ...@@ -29,4 +29,18 @@ RSpec.describe DastSiteProfile, type: :model do
end end
end end
end end
describe 'scopes' do
describe '.with_dast_site' do
it 'eager loads the association' do
subject
recorder = ActiveRecord::QueryRecorder.new do
subject.dast_site
end
expect(recorder.count).to be_zero
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Running a DAST Scan' do
include GraphqlHelpers
let(:project) { create(:project, :repository, creator: current_user) }
let(:current_user) { create(:user) }
let(:full_path) { project.full_path }
let(:dast_site_profile) { create(:dast_site_profile, project: project) }
let(:mutation) do
graphql_mutation(
:dast_on_demand_scan_create,
full_path: full_path,
dast_site_profile_id: dast_site_profile.to_global_id.to_s
)
end
def mutation_response
graphql_mutation_response(:dast_on_demand_scan_create)
end
context 'when a user does not have access to the project' do
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 a user does not have access to run a dast scan on the project' do
before do
project.add_guest(current_user)
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 a user has access to run a dast scan on the project' do
before do
project.add_developer(current_user)
end
it 'returns a pipeline_url containing the correct path' do
post_graphql_mutation(mutation, current_user: current_user)
pipeline = Ci::Pipeline.last
expected_url = Rails.application.routes.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(mutation_response['pipelineUrl']).to eq(expected_url)
end
context 'when pipeline creation fails' do
before do
allow_any_instance_of(Ci::Pipeline).to receive(:created_successfully?).and_return(false)
allow_any_instance_of(Ci::Pipeline).to receive(:full_error_messages).and_return('error message')
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['error message']
end
context 'when on demand scan feature is disabled' do
before do
stub_feature_flags(security_on_demand_scans_feature_flag: 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
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment