Commit 93fc4940 authored by Michał Zając's avatar Michał Zając

Add VulnerabilityCreate GraphQL mutation

This mutation allows users to create Vulnerability objects manually via
GraphQL query.

* Update GraphQL descriptions
* Add specs
* Add GraphQL specs
* Add VulnerabilityIdentifierInputType
* Add VulnerabilityConfidenceEnum

Changelog: added
EE: true
parent 523cb9df
...@@ -22,6 +22,7 @@ Graphql/Descriptions: ...@@ -22,6 +22,7 @@ Graphql/Descriptions:
- 'ee/app/graphql/types/vulnerability_report_type_enum.rb' - 'ee/app/graphql/types/vulnerability_report_type_enum.rb'
- 'ee/app/graphql/types/vulnerability_severity_enum.rb' - 'ee/app/graphql/types/vulnerability_severity_enum.rb'
- 'ee/app/graphql/types/vulnerability_state_enum.rb' - 'ee/app/graphql/types/vulnerability_state_enum.rb'
- 'ee/app/graphql/types/vulnerability_confidence_enum.rb'
- 'app/graphql/mutations/commits/create.rb' - 'app/graphql/mutations/commits/create.rb'
- 'app/graphql/mutations/concerns/mutations/assignable.rb' - 'app/graphql/mutations/concerns/mutations/assignable.rb'
- 'app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb' - 'app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb'
......
...@@ -4421,6 +4421,39 @@ Input type: `VulnerabilityConfirmInput` ...@@ -4421,6 +4421,39 @@ Input type: `VulnerabilityConfirmInput`
| <a id="mutationvulnerabilityconfirmerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationvulnerabilityconfirmerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationvulnerabilityconfirmvulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | The vulnerability after state change. | | <a id="mutationvulnerabilityconfirmvulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | The vulnerability after state change. |
### `Mutation.vulnerabilityCreate`
Input type: `VulnerabilityCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationvulnerabilitycreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationvulnerabilitycreateconfidence"></a>`confidence` | [`VulnerabilityConfidence`](#vulnerabilityconfidence) | Confidence of the vulnerability (defaults to `unknown`). |
| <a id="mutationvulnerabilitycreateconfirmedat"></a>`confirmedAt` | [`Time`](#time) | Timestamp of when the vulnerability state was changed to confirmed (defaults to creation time if status is `confirmed`). |
| <a id="mutationvulnerabilitycreatedescription"></a>`description` | [`String!`](#string) | Description of the vulnerability. |
| <a id="mutationvulnerabilitycreatedetectedat"></a>`detectedAt` | [`Time`](#time) | Timestamp of when the vulnerability was first detected (defaults to creation time). |
| <a id="mutationvulnerabilitycreatedismissedat"></a>`dismissedAt` | [`Time`](#time) | Timestamp of when the vulnerability state was changed to dismissed (defaults to creation time if status is `dismissed`). |
| <a id="mutationvulnerabilitycreateidentifiers"></a>`identifiers` | [`[VulnerabilityIdentifierInput!]!`](#vulnerabilityidentifierinput) | Array of CVE or CWE identifiers for the vulnerability. |
| <a id="mutationvulnerabilitycreatemessage"></a>`message` | [`String`](#string) | Additional information about the vulnerability. |
| <a id="mutationvulnerabilitycreateproject"></a>`project` | [`ProjectID!`](#projectid) | ID of the project to attach the Vulnerability to. |
| <a id="mutationvulnerabilitycreateresolvedat"></a>`resolvedAt` | [`Time`](#time) | Timestamp of when the vulnerability state was changed to resolved (defaults to creation time if status is `resolved`). |
| <a id="mutationvulnerabilitycreatescannername"></a>`scannerName` | [`String!`](#string) | Name of the security scanner used to discover the vulnerability. |
| <a id="mutationvulnerabilitycreatescannertype"></a>`scannerType` | [`SecurityScannerType!`](#securityscannertype) | Type of the security scanner used to discover the vulnerability. |
| <a id="mutationvulnerabilitycreateseverity"></a>`severity` | [`VulnerabilitySeverity`](#vulnerabilityseverity) | Severity of the vulnerability (defaults to `unknown`). |
| <a id="mutationvulnerabilitycreatesolution"></a>`solution` | [`String`](#string) | How to fix this vulnerability. |
| <a id="mutationvulnerabilitycreatestate"></a>`state` | [`VulnerabilityState`](#vulnerabilitystate) | State of the vulnerability (defaults to `detected`). |
| <a id="mutationvulnerabilitycreatetitle"></a>`title` | [`String!`](#string) | Title of the vulnerability. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationvulnerabilitycreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationvulnerabilitycreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationvulnerabilitycreatevulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | Vulnerability created. |
### `Mutation.vulnerabilityDismiss` ### `Mutation.vulnerabilityDismiss`
Input type: `VulnerabilityDismissInput` Input type: `VulnerabilityDismissInput`
...@@ -13188,6 +13221,7 @@ Represents summary of a security report. ...@@ -13188,6 +13221,7 @@ Represents summary of a security report.
| <a id="securityreportsummarycoveragefuzzing"></a>`coverageFuzzing` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `coverage_fuzzing` scan. | | <a id="securityreportsummarycoveragefuzzing"></a>`coverageFuzzing` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `coverage_fuzzing` scan. |
| <a id="securityreportsummarydast"></a>`dast` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `dast` scan. | | <a id="securityreportsummarydast"></a>`dast` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `dast` scan. |
| <a id="securityreportsummarydependencyscanning"></a>`dependencyScanning` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `dependency_scanning` scan. | | <a id="securityreportsummarydependencyscanning"></a>`dependencyScanning` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `dependency_scanning` scan. |
| <a id="securityreportsummarygeneric"></a>`generic` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `generic` scan. |
| <a id="securityreportsummarysast"></a>`sast` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `sast` scan. | | <a id="securityreportsummarysast"></a>`sast` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `sast` scan. |
| <a id="securityreportsummarysecretdetection"></a>`secretDetection` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `secret_detection` scan. | | <a id="securityreportsummarysecretdetection"></a>`secretDetection` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `secret_detection` scan. |
...@@ -14068,7 +14102,7 @@ Represents a vulnerability. ...@@ -14068,7 +14102,7 @@ Represents a vulnerability.
| <a id="vulnerabilitynotes"></a>`notes` | [`NoteConnection!`](#noteconnection) | All notes on this noteable. (see [Connections](#connections)) | | <a id="vulnerabilitynotes"></a>`notes` | [`NoteConnection!`](#noteconnection) | All notes on this noteable. (see [Connections](#connections)) |
| <a id="vulnerabilityprimaryidentifier"></a>`primaryIdentifier` | [`VulnerabilityIdentifier`](#vulnerabilityidentifier) | Primary identifier of the vulnerability. | | <a id="vulnerabilityprimaryidentifier"></a>`primaryIdentifier` | [`VulnerabilityIdentifier`](#vulnerabilityidentifier) | Primary identifier of the vulnerability. |
| <a id="vulnerabilityproject"></a>`project` | [`Project`](#project) | The project on which the vulnerability was found. | | <a id="vulnerabilityproject"></a>`project` | [`Project`](#project) | The project on which the vulnerability was found. |
| <a id="vulnerabilityreporttype"></a>`reportType` | [`VulnerabilityReportType`](#vulnerabilityreporttype) | Type of the security report that found the vulnerability (SAST, DEPENDENCY_SCANNING, CONTAINER_SCANNING, DAST, SECRET_DETECTION, COVERAGE_FUZZING, API_FUZZING, CLUSTER_IMAGE_SCANNING). `Scan Type` in the UI. | | <a id="vulnerabilityreporttype"></a>`reportType` | [`VulnerabilityReportType`](#vulnerabilityreporttype) | Type of the security report that found the vulnerability (SAST, DEPENDENCY_SCANNING, CONTAINER_SCANNING, DAST, SECRET_DETECTION, COVERAGE_FUZZING, API_FUZZING, CLUSTER_IMAGE_SCANNING, GENERIC). `Scan Type` in the UI. |
| <a id="vulnerabilityresolvedat"></a>`resolvedAt` | [`Time`](#time) | Timestamp of when the vulnerability state was changed to resolved. | | <a id="vulnerabilityresolvedat"></a>`resolvedAt` | [`Time`](#time) | Timestamp of when the vulnerability state was changed to resolved. |
| <a id="vulnerabilityresolvedby"></a>`resolvedBy` | [`UserCore`](#usercore) | The user that resolved the vulnerability. | | <a id="vulnerabilityresolvedby"></a>`resolvedBy` | [`UserCore`](#usercore) | The user that resolved the vulnerability. |
| <a id="vulnerabilityresolvedondefaultbranch"></a>`resolvedOnDefaultBranch` | [`Boolean!`](#boolean) | Indicates whether the vulnerability is fixed on the default branch or not. | | <a id="vulnerabilityresolvedondefaultbranch"></a>`resolvedOnDefaultBranch` | [`Boolean!`](#boolean) | Indicates whether the vulnerability is fixed on the default branch or not. |
...@@ -15795,6 +15829,20 @@ Possible states of a user. ...@@ -15795,6 +15829,20 @@ Possible states of a user.
| <a id="visibilityscopesenumprivate"></a>`private` | The snippet is visible only to the snippet creator. | | <a id="visibilityscopesenumprivate"></a>`private` | The snippet is visible only to the snippet creator. |
| <a id="visibilityscopesenumpublic"></a>`public` | The snippet can be accessed without any authentication. | | <a id="visibilityscopesenumpublic"></a>`public` | The snippet can be accessed without any authentication. |
### `VulnerabilityConfidence`
Confidence that a given vulnerability is present in the codebase.
| Value | Description |
| ----- | ----------- |
| <a id="vulnerabilityconfidenceconfirmed"></a>`CONFIRMED` | |
| <a id="vulnerabilityconfidenceexperimental"></a>`EXPERIMENTAL` | |
| <a id="vulnerabilityconfidencehigh"></a>`HIGH` | |
| <a id="vulnerabilityconfidenceignore"></a>`IGNORE` | |
| <a id="vulnerabilityconfidencelow"></a>`LOW` | |
| <a id="vulnerabilityconfidencemedium"></a>`MEDIUM` | |
| <a id="vulnerabilityconfidenceunknown"></a>`UNKNOWN` | |
### `VulnerabilityDismissalReason` ### `VulnerabilityDismissalReason`
The dismissal reason of the Vulnerability. The dismissal reason of the Vulnerability.
...@@ -15856,6 +15904,7 @@ The type of the security scan that found the vulnerability. ...@@ -15856,6 +15904,7 @@ The type of the security scan that found the vulnerability.
| <a id="vulnerabilityreporttypecoverage_fuzzing"></a>`COVERAGE_FUZZING` | | | <a id="vulnerabilityreporttypecoverage_fuzzing"></a>`COVERAGE_FUZZING` | |
| <a id="vulnerabilityreporttypedast"></a>`DAST` | | | <a id="vulnerabilityreporttypedast"></a>`DAST` | |
| <a id="vulnerabilityreporttypedependency_scanning"></a>`DEPENDENCY_SCANNING` | | | <a id="vulnerabilityreporttypedependency_scanning"></a>`DEPENDENCY_SCANNING` | |
| <a id="vulnerabilityreporttypegeneric"></a>`GENERIC` | |
| <a id="vulnerabilityreporttypesast"></a>`SAST` | | | <a id="vulnerabilityreporttypesast"></a>`SAST` | |
| <a id="vulnerabilityreporttypesecret_detection"></a>`SECRET_DETECTION` | | | <a id="vulnerabilityreporttypesecret_detection"></a>`SECRET_DETECTION` | |
...@@ -17274,3 +17323,14 @@ A time-frame defined as a closed inclusive range of two dates. ...@@ -17274,3 +17323,14 @@ A time-frame defined as a closed inclusive range of two dates.
| <a id="updatediffimagepositioninputwidth"></a>`width` | [`Int`](#int) | Total width of the image. | | <a id="updatediffimagepositioninputwidth"></a>`width` | [`Int`](#int) | Total width of the image. |
| <a id="updatediffimagepositioninputx"></a>`x` | [`Int`](#int) | X position of the note. | | <a id="updatediffimagepositioninputx"></a>`x` | [`Int`](#int) | X position of the note. |
| <a id="updatediffimagepositioninputy"></a>`y` | [`Int`](#int) | Y position of the note. | | <a id="updatediffimagepositioninputy"></a>`y` | [`Int`](#int) | Y position of the note. |
### `VulnerabilityIdentifierInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="vulnerabilityidentifierinputexternalid"></a>`externalId` | [`String`](#string) | External ID of the vulnerability identifier. |
| <a id="vulnerabilityidentifierinputexternaltype"></a>`externalType` | [`String`](#string) | External type of the vulnerability identifier. |
| <a id="vulnerabilityidentifierinputname"></a>`name` | [`String!`](#string) | Name of the vulnerability identifier. |
| <a id="vulnerabilityidentifierinputurl"></a>`url` | [`String!`](#string) | URL of the vulnerability identifier. |
...@@ -33,6 +33,7 @@ module EE ...@@ -33,6 +33,7 @@ module EE
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::ExportRequirements mount_mutation ::Mutations::RequirementsManagement::ExportRequirements
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
mount_mutation ::Mutations::Vulnerabilities::Create
mount_mutation ::Mutations::Vulnerabilities::Dismiss mount_mutation ::Mutations::Vulnerabilities::Dismiss
mount_mutation ::Mutations::Vulnerabilities::Resolve mount_mutation ::Mutations::Vulnerabilities::Resolve
mount_mutation ::Mutations::Vulnerabilities::Confirm mount_mutation ::Mutations::Vulnerabilities::Confirm
......
# frozen_string_literal: true
module Mutations
module Vulnerabilities
class Create < BaseMutation
graphql_name 'VulnerabilityCreate'
authorize :admin_vulnerability
argument :project, ::Types::GlobalIDType[::Project],
required: true,
description: 'ID of the project to attach the Vulnerability to.'
argument :title, GraphQL::Types::String,
required: true,
description: 'Title of the vulnerability.'
argument :description, GraphQL::Types::String,
required: true,
description: 'Description of the vulnerability.'
argument :scanner_type, Types::SecurityScannerTypeEnum,
required: true,
description: 'Type of the security scanner used to discover the vulnerability.'
argument :scanner_name, GraphQL::Types::String,
required: true,
description: 'Name of the security scanner used to discover the vulnerability.'
argument :identifiers, [Types::VulnerabilityIdentifierInputType],
required: true,
description: 'Array of CVE or CWE identifiers for the vulnerability.'
argument :state, Types::VulnerabilityStateEnum,
required: false,
description: 'State of the vulnerability (defaults to `detected`).',
default_value: 'detected'
argument :severity, Types::VulnerabilitySeverityEnum,
required: false,
description: 'Severity of the vulnerability (defaults to `unknown`).',
default_value: 'unknown'
argument :confidence, Types::VulnerabilityConfidenceEnum,
required: false,
description: 'Confidence of the vulnerability (defaults to `unknown`).',
default_value: 'unknown'
argument :solution, GraphQL::Types::String,
required: false,
description: 'How to fix this vulnerability.'
argument :message, GraphQL::Types::String,
required: false,
description: 'Additional information about the vulnerability.'
argument :detected_at, Types::TimeType,
required: false,
description: 'Timestamp of when the vulnerability was first detected (defaults to creation time).'
argument :confirmed_at, Types::TimeType,
required: false,
description: 'Timestamp of when the vulnerability state was changed to confirmed (defaults to creation time if status is `confirmed`).'
argument :resolved_at, Types::TimeType,
required: false,
description: 'Timestamp of when the vulnerability state was changed to resolved (defaults to creation time if status is `resolved`).'
argument :dismissed_at, Types::TimeType,
required: false,
description: 'Timestamp of when the vulnerability state was changed to dismissed (defaults to creation time if status is `dismissed`).'
field :vulnerability, Types::VulnerabilityType,
null: true,
description: 'Vulnerability created.'
def resolve(**attributes)
project = authorized_find!(id: attributes.fetch(:project))
params = build_vulnerability_params(attributes)
result = ::Vulnerabilities::ManuallyCreateService.new(
project,
current_user,
params: params
).execute
vulnerability = result.payload[:vulnerability]
{
vulnerability: vulnerability,
errors: result.success? ? [] : result.message
}
end
private
def find_object(id:)
# TODO: remove explicit coercion once compatibility layer has been removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Project].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
def build_vulnerability_params(params)
vulnerability_params = params.slice(*%i[
title
state
severity
confidence
message
solution
detected_at
confirmed_at
resolved_at
dismissed_at
identifiers
])
scanner_params = {
name: params.fetch(:scanner_name),
type: params.fetch(:scanner_type)
}
{
vulnerability: vulnerability_params
.merge(scanner: scanner_params)
}
end
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilityConfidenceEnum < BaseEnum
graphql_name 'VulnerabilityConfidence'
description 'Confidence that a given vulnerability is present in the codebase'
::Enums::Vulnerability.confidence_levels.keys.each do |confidence_level|
value confidence_level.to_s.upcase, value: confidence_level.to_s
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilityIdentifierInputType < BaseInputObject
argument :name, GraphQL::Types::String,
description: 'Name of the vulnerability identifier.',
required: true
argument :url, GraphQL::Types::String,
description: 'URL of the vulnerability identifier.',
required: true
argument :external_type, GraphQL::Types::String,
description: 'External type of the vulnerability identifier.',
required: false
argument :external_id, GraphQL::Types::String,
description: 'External ID of the vulnerability identifier.',
required: false
end
end
...@@ -11,7 +11,8 @@ module EE ...@@ -11,7 +11,8 @@ module EE
dast: 3, dast: 3,
coverage_fuzzing: 5, coverage_fuzzing: 5,
api_fuzzing: 6, api_fuzzing: 6,
cluster_image_scanning: 7 cluster_image_scanning: 7,
generic: 99
}.freeze }.freeze
class_methods do class_methods do
......
# frozen_string_literal: true
module Vulnerabilities
class ManuallyCreateService
include Gitlab::Allowable
GENERIC_REPORT_TYPE = ::Enums::Vulnerability.report_types[:generic]
MANUAL_LOCATION_FINGERPRINT = Digest::SHA1.hexdigest("manually added").freeze
CONFIRMED_MESSAGE = "confirmed_at can only be set when state is confirmed"
RESOLVED_MESSAGE = "resolved_at can only be set when state is resolved"
DISMISSED_MESSAGE = "dismissed_at can only be set when state is dismissed"
def initialize(project, author, params:)
@project = project
@author = author
@params = params
end
def execute
raise Gitlab::Access::AccessDeniedError unless can?(@author, :create_vulnerability, @project)
timestamps_dont_match_state_message = match_state_fields_with_state
return ServiceResponse.error(message: timestamps_dont_match_state_message) if timestamps_dont_match_state_message
vulnerability = initialize_vulnerability(@params[:vulnerability])
identifiers = initialize_identifiers(@params[:vulnerability][:identifiers])
scanner = initialize_scanner(@params[:vulnerability][:scanner])
finding = initialize_finding(vulnerability, identifiers, scanner, @params[:message], @params[:solution])
Vulnerability.transaction(requires_new: true) do
vulnerability.save!
finding.save!
Statistics::UpdateService.update_for(vulnerability)
HistoricalStatistics::UpdateService.update_for(@project)
ServiceResponse.success(payload: { vulnerability: vulnerability })
end
rescue ActiveRecord::RecordNotUnique => e
Gitlab::AppLogger.error(e.message)
ServiceResponse.error(message: "Vulnerability with those details already exists")
rescue ActiveRecord::RecordInvalid => e
ServiceResponse.error(message: e.message)
end
private
def match_state_fields_with_state
state = @params.dig(:vulnerability, :state)
case state
when "detected"
return CONFIRMED_MESSAGE if exists_in_vulnerability_params?(:confirmed_at)
return RESOLVED_MESSAGE if exists_in_vulnerability_params?(:resolved_at)
return DISMISSED_FIELDS if exists_in_vulnerability_params?(:dismissed_at)
when "confirmed"
return RESOLVED_MESSAGE if exists_in_vulnerability_params?(:resolved_at)
return DISMISSED_FIELDS if exists_in_vulnerability_params?(:dismissed_at)
when "resolved"
return CONFIRMED_MESSAGE if exists_in_vulnerability_params?(:confirmed_at)
return DISMISSED_FIELDS if exists_in_vulnerability_params?(:dismissed_at)
end
end
def exists_in_vulnerability_params?(column_name)
!@params.dig(:vulnerability, column_name.to_sym).blank?
end
def initialize_vulnerability(vulnerability_hash)
attributes = vulnerability_hash
.slice(*%i[
state
severity
confidence
detected_at
confirmed_at
resolved_at
dismissed_at
])
.merge(
project: @project,
author: @author,
title: vulnerability_hash[:title]&.truncate(::Issuable::TITLE_LENGTH_MAX),
report_type: GENERIC_REPORT_TYPE
)
vulnerability = Vulnerability.new(**attributes)
vulnerability.confirmed_by = @author if vulnerability.confirmed?
vulnerability.resolved_by = @author if vulnerability.resolved?
vulnerability.dismissed_by = @author if vulnerability.dismissed?
vulnerability
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_identifiers(identifier_hashes)
identifier_hashes.map do |identifier|
name = identifier.dig(:name)
external_type = map_external_type_from_name(name)
external_id = name
fingerprint = Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
url = identifier.dig(:url)
Vulnerabilities::Identifier.find_or_initialize_by(name: name) do |i|
i.fingerprint = fingerprint
i.project = @project
i.external_type = external_type
i.external_id = external_id
i.url = url
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def map_external_type_from_name(name)
return 'cve' if name.match?(/CVE/i)
return 'cwe' if name.match?(/CWE/i)
'other'
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash.dig(:name)
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = Gitlab::Utils.slugify(name)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def initialize_finding(vulnerability, identifiers, scanner, message, solution)
uuid = ::Security::VulnerabilityUUID.generate(
report_type: GENERIC_REPORT_TYPE,
primary_identifier_fingerprint: identifiers.first.fingerprint,
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
project_id: @project.id
)
Vulnerabilities::Finding.new(
project: @project,
identifiers: identifiers,
primary_identifier: identifiers.first,
vulnerability: vulnerability,
name: vulnerability.title,
severity: vulnerability.severity,
confidence: vulnerability.confidence,
report_type: vulnerability.report_type,
project_fingerprint: Digest::SHA1.hexdigest(identifiers.first.name),
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
metadata_version: 'manual:1.0',
raw_metadata: {},
scanner: scanner,
uuid: uuid,
message: message,
solution: solution
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Vulnerabilities::Create do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let(:project) { create(:project) }
let(:mutated_vulnerability) { subject[:vulnerability] }
describe '#resolve' do
using RSpec::Parameterized::TableSyntax
context 'with valid parameters' do
before do
stub_licensed_features(security_dashboard: true)
project.add_developer(user)
end
subject { resolve(described_class, args: attributes, ctx: { current_user: user }) }
let(:project_gid) { GitlabSchema.id_from_object(project) }
let(:identifier_attributes) do
{
name: "Test identifier",
url: "https://vulnerabilities.com/test"
}
end
let(:attributes) do
{
project: project_gid,
title: "Test vulnerability",
description: "Test vulnerability created via GraphQL",
scanner_type: "dast",
scanner_name: "My custom DAST scanner",
identifiers: [identifier_attributes],
state: "detected",
severity: "unknown",
confidence: "unknown",
solution: "rm -rf --no-preserve-root /",
message: "You can't fix this"
}
end
it 'returns the created vulnerability' do
expect(mutated_vulnerability).to be_detected
expect(subject[:errors]).to be_empty
end
context 'with custom state' do
let(:custom_timestamp) { Time.new(2020, 6, 21, 14, 22, 20) }
where(:state, :detected_at, :confirmed_at, :confirmed_by, :resolved_at, :resolved_by, :dismissed_at, :dismissed_by) do
[
['confirmed', ref(:custom_timestamp), ref(:custom_timestamp), ref(:user), nil, nil, nil, nil],
['resolved', ref(:custom_timestamp), nil, nil, ref(:custom_timestamp), ref(:user), nil, nil],
['dismissed', ref(:custom_timestamp), nil, nil, nil, nil, ref(:custom_timestamp), ref(:user)]
]
end
with_them do
let(:attributes) do
{
project: project_gid,
title: "Test vulnerability",
description: "Test vulnerability created via GraphQL",
scanner_type: "dast",
scanner_name: "My custom DAST scanner",
identifiers: [identifier_attributes],
state: state,
severity: "unknown",
confidence: "unknown",
detected_at: detected_at,
confirmed_at: confirmed_at,
resolved_at: resolved_at,
dismissed_at: dismissed_at,
solution: "rm -rf --no-preserve-root /",
message: "You can't fix this"
}
end
it "returns a #{params[:state]} vulnerability" do
expect(mutated_vulnerability.state).to eq(state)
expect(mutated_vulnerability.detected_at).to eq(detected_at)
expect(mutated_vulnerability.confirmed_at).to eq(confirmed_at)
expect(mutated_vulnerability.confirmed_by).to eq(confirmed_by)
expect(mutated_vulnerability.resolved_at).to eq(resolved_at)
expect(mutated_vulnerability.resolved_by).to eq(resolved_by)
expect(mutated_vulnerability.dismissed_at).to eq(dismissed_at)
expect(mutated_vulnerability.dismissed_by).to eq(dismissed_by)
expect(subject[:errors]).to be_empty
end
end
end
end
end
end
...@@ -4,6 +4,6 @@ require 'spec_helper' ...@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityReportType'] do RSpec.describe GitlabSchema.types['VulnerabilityReportType'] do
it 'exposes all vulnerability report types' do it 'exposes all vulnerability report types' do
expect(described_class.values.keys).to match_array(%w[SAST SECRET_DETECTION DAST CLUSTER_IMAGE_SCANNING CONTAINER_SCANNING DEPENDENCY_SCANNING COVERAGE_FUZZING API_FUZZING]) expect(described_class.values.keys).to match_array(%w[SAST SECRET_DETECTION DAST CLUSTER_IMAGE_SCANNING CONTAINER_SCANNING DEPENDENCY_SCANNING COVERAGE_FUZZING API_FUZZING GENERIC])
end end
end end
...@@ -11,14 +11,17 @@ RSpec.describe Vulnerability do ...@@ -11,14 +11,17 @@ RSpec.describe Vulnerability do
end end
let(:report_types) do let(:report_types) do
{ sast: 0, {
sast: 0,
dependency_scanning: 1, dependency_scanning: 1,
container_scanning: 2, container_scanning: 2,
dast: 3, dast: 3,
secret_detection: 4, secret_detection: 4,
coverage_fuzzing: 5, coverage_fuzzing: 5,
api_fuzzing: 6, api_fuzzing: 6,
cluster_image_scanning: 7 } cluster_image_scanning: 7,
generic: 99
}
end end
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::ManuallyCreateService do
before do
stub_licensed_features(security_dashboard: true)
end
let_it_be(:user) { create(:user) }
let(:project) { create(:project) } # cannot use let_it_be here: caching causes problems with permission-related tests
subject { described_class.new(project, user, params: params).execute }
context 'with an authorized user with proper permissions' do
before do
project.add_developer(user)
end
context 'with valid parameters' do
let(:scanner_params) do
{
name: "My manual scanner"
}
end
let(:identifier_params) do
{
name: "Test identifier 1",
url: "https://test.com"
}
end
let(:params) do
{
vulnerability: {
title: "Test vulnerability",
state: "detected",
severity: "unknown",
confidence: "unknown",
identifiers: [identifier_params],
scanner: scanner_params
}
}
end
let(:vulnerability) { subject.payload[:vulnerability] }
it 'does not exceed query limit' do
expect { subject }.not_to exceed_query_limit(20)
end
it 'creates a new Vulnerability' do
expect { subject }.to change(Vulnerability, :count).by(1)
end
it 'creates a Vulnerability with correct attributes' do
expect(vulnerability.report_type).to eq("generic")
expect(vulnerability.state).to eq(params.dig(:vulnerability, :state))
expect(vulnerability.severity).to eq(params.dig(:vulnerability, :severity))
expect(vulnerability.confidence).to eq(params.dig(:vulnerability, :confidence))
end
it 'creates associated objects', :aggregate_failures do
expect { subject }.to change(Vulnerabilities::Finding, :count).by(1)
.and change(Vulnerabilities::Scanner, :count).by(1)
.and change(Vulnerabilities::Identifier, :count).by(1)
end
context 'when Scanner already exists' do
let!(:scanner) { create(:vulnerabilities_scanner, name: scanner_params[:name]) }
it 'does not create a new Scanner' do
expect { subject }.to change(Vulnerabilities::Scanner, :count).by(0)
end
end
context 'when Identifier already exists' do
let!(:identifier) { create(:vulnerabilities_identifier, name: identifier_params[:name]) }
it 'does not create a new Identifier' do
expect { subject }.to change(Vulnerabilities::Identifier, :count).by(0)
end
end
it 'creates all objects with correct attributes' do
expect(vulnerability.title).to eq(params.dig(:vulnerability, :title))
expect(vulnerability.report_type).to eq("generic")
expect(vulnerability.state).to eq(params.dig(:vulnerability, :state))
expect(vulnerability.severity).to eq(params.dig(:vulnerability, :severity))
expect(vulnerability.confidence).to eq(params.dig(:vulnerability, :confidence))
finding = vulnerability.finding
expect(finding.report_type).to eq("generic")
expect(finding.severity).to eq(params.dig(:vulnerability, :severity))
expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence))
scanner = finding.scanner
expect(scanner.name).to eq(params.dig(:vulnerability, :scanner, :name))
primary_identifier = finding.primary_identifier
expect(primary_identifier.name).to eq(params.dig(:vulnerability, :identifiers, 0, :name))
end
context "when state fields match state" do
let(:params) do
{
vulnerability: {
title: "Test vulnerability",
state: "confirmed",
severity: "unknown",
confidence: "unknown",
confirmed_at: Time.now.iso8601,
identifiers: [identifier_params],
scanner: scanner_params
}
}
end
it 'creates Vulnerability in a different state with timestamps' do
expect(vulnerability.state).to eq(params.dig(:vulnerability, :state))
expect(vulnerability.confirmed_at).to eq(params.dig(:vulnerability, :confirmed_at))
expect(vulnerability.confirmed_by).to eq(user)
end
end
context "when state fields don't match state" do
let(:params) do
{
vulnerability: {
title: "Test vulnerability",
state: "detected",
severity: "unknown",
confidence: "unknown",
confirmed_at: Time.now.iso8601,
identifiers: [identifier_params],
scanner: scanner_params
}
}
end
it 'returns an error' do
result = subject
expect(result.success?).to be_falsey
expect(subject.message).to match(/confirmed_at can only be set/)
end
end
end
context 'with invalid parameters' do
let(:params) do
{
vulnerability: {
identifiers: [{
name: "Test identfier 1",
url: "https://test.com"
}],
scanner: {
name: "My manual scanner"
}
}
}
end
it 'returns an error' do
expect(subject.error?).to be_truthy
end
end
end
context 'when user does not have rights to dismiss a vulnerability' do
let(:params) { {} }
before do
project.add_reporter(user)
end
it 'raises an "access denied" error' do
expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
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