Commit f1f31f5b authored by Philip Cunningham's avatar Philip Cunningham

Create GraphQL mutation for DAST on-demand scans

In order to support the ability to run on-demand DAST scans we've added
a new GraphQL mutation, supported by a new service object, behind a new
feature flag.
parent ac1d1f1e
......@@ -1725,6 +1725,13 @@ type CreateSnippetPayload {
snippet: Snippet
}
enum DastScanTypeEnum {
"""
Passive DAST scan. This scan will not make active attacks against the target site.
"""
PASSIVE
}
"""
Autogenerated input type of DeleteAnnotation
"""
......@@ -7269,6 +7276,7 @@ type Mutation {
mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
runDastScan(input: RunDASTScanInput!): RunDASTScanPayload
todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
todoRestore(input: TodoRestoreInput!): TodoRestorePayload
todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload
......@@ -10297,6 +10305,56 @@ type RootStorageStatistics {
wikiSize: Float!
}
"""
Autogenerated input type of RunDASTScan
"""
input RunDASTScanInput {
"""
The branch to be associated with the scan.
"""
branch: String!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The project the DAST scan belongs to.
"""
projectPath: ID!
"""
The type of scan to be run.
"""
scanType: DastScanTypeEnum!
"""
The URL of the target to be scanned.
"""
targetUrl: String!
}
"""
Autogenerated return type of RunDASTScan
"""
type RunDASTScanPayload {
"""
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
}
"""
A Sentry error.
"""
......
......@@ -4570,6 +4570,23 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastScanTypeEnum",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PASSIVE",
"description": "Passive DAST scan. This scan will not make active attacks against the target site.",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DeleteAnnotationInput",
......@@ -21284,6 +21301,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runDastScan",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "RunDASTScanInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "RunDASTScanPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "todoMarkDone",
"description": null,
......@@ -30138,6 +30182,150 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "RunDASTScanInput",
"description": "Autogenerated input type of RunDASTScan",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the DAST scan belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "targetUrl",
"description": "The URL of the target to be scanned.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "branch",
"description": "The branch to be associated with the scan.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "scanType",
"description": "The type of scan to be run.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DastScanTypeEnum",
"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": "RunDASTScanPayload",
"description": "Autogenerated return type of RunDASTScan",
"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": "OBJECT",
"name": "SentryDetailedError",
......@@ -1450,6 +1450,16 @@ Counts of requirements by their state.
| `storageSize` | Float! | The total storage in bytes |
| `wikiSize` | Float! | The wiki size in bytes |
## RunDASTScanPayload
Autogenerated return type of RunDASTScan
| 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. |
## SentryDetailedError
A Sentry error.
......
......@@ -20,6 +20,7 @@ module EE
mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics
mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject
mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject
mount_mutation ::Mutations::Pipelines::RunDastScan
end
end
end
......
# frozen_string_literal: true
module Mutations
module Pipelines
class RunDastScan < BaseMutation
include ResolvesProject
graphql_name 'RunDASTScan'
field :pipeline_url, GraphQL::STRING_TYPE,
null: true,
description: 'URL of the pipeline that was created.'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the DAST scan belongs to.'
argument :target_url, GraphQL::STRING_TYPE,
required: true,
description: 'The URL of the target to be scanned.'
argument :branch, GraphQL::STRING_TYPE,
required: true,
description: 'The branch to be associated with the scan.'
argument :scan_type, Types::DastScanTypeEnum,
required: true,
description: 'The type of scan to be run.'
authorize :create_pipeline
def resolve(project_path:, target_url:, branch:, scan_type:)
project = authorized_find!(full_path: project_path)
raise_resource_not_available_error! unless Feature.enabled?(:security_on_demand_scans_feature_flag, project)
service = Ci::RunDastScanService.new(project: project, user: current_user)
pipeline = service.execute(branch: branch, target_url: target_url)
success_response(project: project, pipeline: pipeline)
rescue *Ci::RunDastScanService::EXCEPTIONS => err
error_response(err)
end
private
def find_object(full_path:)
resolve_project(full_path: full_path)
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(err)
{ errors: [err.message] }
end
end
end
end
# frozen_string_literal: true
module Types
class DastScanTypeEnum < BaseEnum
value 'PASSIVE', description: 'Passive DAST scan. This scan will not make active attacks against the target site.'
end
end
# frozen_string_literal: true
module Ci
class RunDastScanService
DEFAULT_SHA_FOR_PROJECTS_WITHOUT_COMMITS = :placeholder
EXCEPTIONS = [
NotAllowed = Class.new(StandardError),
CreatePipelineError = Class.new(StandardError),
CreateStageError = Class.new(StandardError),
CreateBuildError = Class.new(StandardError),
EnqueueError = Class.new(StandardError)
].freeze
def initialize(project:, user:)
@project = project
@user = user
end
def execute(branch:, target_url:)
raise NotAllowed unless allowed?
ActiveRecord::Base.transaction do
pipeline = create_pipeline!(branch)
stage = create_stage!(pipeline)
build = create_build!(pipeline, stage, branch, target_url)
enqueue!(build)
pipeline
end
end
private
attr_reader :project, :user
def allowed?
Ability.allowed?(user, :create_pipeline, project)
end
def create_pipeline!(branch)
reraise!(with: CreatePipelineError.new('Could not create pipeline')) do
Ci::Pipeline.create!(
project: project,
ref: branch,
sha: project.repository.commit&.id || DEFAULT_SHA_FOR_PROJECTS_WITHOUT_COMMITS,
source: :web,
user: user
)
end
end
def create_stage!(pipeline)
reraise!(with: CreateStageError.new('Could not create stage')) do
Ci::Stage.create!(
name: 'dast',
pipeline: pipeline,
project: project
)
end
end
def create_build!(pipeline, stage, branch, target_url)
reraise!(with: CreateBuildError.new('Could not create build')) do
Ci::Build.create!(
name: 'On demand DAST scan',
pipeline: pipeline,
project: project,
ref: branch,
scheduling_type: :stage,
stage: stage.name,
options: options,
yaml_variables: yaml_variables(target_url)
)
end
end
def enqueue!(build)
reraise!(with: EnqueueError.new('Could not enqueue build')) do
build.enqueue!
end
end
def reraise!(with:)
yield
rescue => err
Gitlab::ErrorTracking.track_exception(err)
raise with
end
def options
{
image: {
name: '$SECURE_ANALYZERS_PREFIX/dast:$DAST_VERSION'
},
artifacts: {
reports: {
dast: [
'gl-dast-report.json'
]
}
},
script: [
'export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}',
'/analyze'
]
}
end
def yaml_variables(target_url)
[
{
key: 'DAST_VERSION',
value: '1',
public: true
},
{
key: 'SECURE_ANALYZERS_PREFIX',
value: 'registry.gitlab.com/gitlab-org/security-products/analyzers',
public: true
},
{
key: 'DAST_WEBSITE',
value: target_url,
public: true
},
{
key: 'GIT_STRATEGY',
value: 'none',
public: true
}
]
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::Pipelines::RunDastScan do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:project_path) { project.full_path }
let(:target_url) { FFaker::Internet.uri(:https) }
let(:branch) { SecureRandom.hex }
let(:scan_type) { Types::DastScanTypeEnum.enum[:passive] }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
subject do
mutation.resolve(
branch: branch,
project_path: project_path,
target_url: target_url,
scan_type: scan_type
)
end
context 'when on demand scan feature is not enabled' do
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when on demand scan feature is enabled' do
before do
stub_feature_flags(security_on_demand_scans_feature_flag: true)
end
context 'when the project does not exist' do
let(:project_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user does not have permission to run a dast scan' do
it 'raises an exception' do
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 'has no errors' do
expect(subject[:errors]).to be_empty
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
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Running a DAST Scan' do
include GraphqlHelpers
let(:project) { create(:project) }
let(:current_user) { create(:user) }
let(:project_path) { project.full_path }
let(:target_url) { FFaker::Internet.uri(:https) }
let(:branch) { SecureRandom.hex }
let(:scan_type) { Types::DastScanTypeEnum.enum[:passive] }
let(:mutation) do
graphql_mutation(
:run_dast_scan,
branch: branch,
project_path: project_path,
target_url: target_url,
scan_type: scan_type
)
end
def mutation_response
graphql_mutation_response(:run_dast_scan)
end
context 'when on demand scan feature is not enabled' 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 on demand scan feature is enabled' do
before do
stub_feature_flags(security_on_demand_scans_feature_flag: true)
end
context 'when the user does not have permission to run a dast scan' 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 the user can run a dast scan' 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 the pipeline could not be created' do
before do
allow(Ci::Pipeline).to receive(:create!).and_raise(StandardError)
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['Could not create pipeline']
end
context 'when the stage could not be created' do
before do
allow(Ci::Stage).to receive(:create!).and_raise(StandardError)
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['Could not create stage']
end
context 'when the build could not be created' do
before do
allow(Ci::Build).to receive(:create!).and_raise(StandardError)
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['Could not create build']
end
context 'when the build could not be enqueued' do
before do
allow_any_instance_of(Ci::Build).to receive(:enqueue!).and_raise(StandardError)
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['Could not enqueue build']
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Ci::RunDastScanService do
let(:project) { create(:project) }
let(:branch) { SecureRandom.hex }
let(:target_url) { FFaker::Internet.uri(:http) }
let(:user) { create(:user) }
describe '#execute' do
subject { described_class.new(project: project, user: user).execute(branch: branch, target_url: target_url) }
context 'when the user does not have permission to run a dast scan' do
it 'raises an exception' do
expect { subject }.to raise_error(described_class::NotAllowed)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns a pipeline' do
expect(subject).to be_a(Ci::Pipeline)
end
it 'creates a pipeline' do
expect { subject }.to change(Ci::Pipeline, :count).by(1)
end
it 'sets the pipeline ref to the branch' do
expect(subject.ref).to eq(branch)
end
it 'creates a stage' do
expect { subject }.to change(Ci::Stage, :count).by(1)
end
it 'creates a build' do
expect { subject }.to change(Ci::Build, :count).by(1)
end
it 'creates a build with appropriate options' do
build = subject.builds.first
expected_options = {
"image" => {
"name" => "$SECURE_ANALYZERS_PREFIX/dast:$DAST_VERSION"
},
"script" => [
"export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}",
"/analyze"
],
"artifacts" => {
"reports" => {
"dast" => ["gl-dast-report.json"]
}
}
}
expect(build.options).to eq(expected_options)
end
it 'creates a build with appropriate variables' do
build = subject.builds.first
expected_variables = [
{
"key" => "DAST_VERSION",
"value" => "1",
"public" => true
}, {
"key" => "SECURE_ANALYZERS_PREFIX",
"value" => "registry.gitlab.com/gitlab-org/security-products/analyzers",
"public" => true
}, {
"key" => "DAST_WEBSITE",
"value" => target_url,
"public" => true
}, {
"key" => "GIT_STRATEGY",
"value" => "none",
"public" => true
}
]
expect(build.yaml_variables).to eq(expected_variables)
end
it 'enqueues a build' do
build = subject.builds.first
expect(build.queued_at).not_to be_nil
end
context 'when the repository has no commits' do
it 'uses a placeholder' do
expect(subject.sha).to eq("placeholder")
end
end
context 'when the pipeline could not be created' do
before do
allow(Ci::Pipeline).to receive(:create!).and_raise(StandardError)
end
it 'raises an exception' do
expect { subject }.to raise_error(Ci::RunDastScanService::CreatePipelineError)
end
end
context 'when the stage could not be created' do
before do
allow(Ci::Stage).to receive(:create!).and_raise(StandardError)
end
it 'raises an exception' do
expect { subject }.to raise_error(Ci::RunDastScanService::CreateStageError)
end
it 'does not create a pipeline' do
expect { subject rescue nil }.not_to change(Ci::Pipeline, :count)
end
end
context 'when the build could not be created' do
before do
allow(Ci::Build).to receive(:create!).and_raise(StandardError)
end
it 'raises an exception' do
expect { subject }.to raise_error(Ci::RunDastScanService::CreateBuildError)
end
it 'does not create a stage' do
expect { subject rescue nil }.not_to change(Ci::Pipeline, :count)
end
end
context 'when the build could not be enqueued' do
before do
allow_any_instance_of(Ci::Build).to receive(:enqueue!).and_raise(StandardError)
end
it 'raises an exception' do
expect { subject }.to raise_error(Ci::RunDastScanService::EnqueueError)
end
it 'does not create a build' do
expect { subject rescue nil }.not_to change(Ci::Pipeline, :count)
end
end
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