Commit ecfbe63f authored by Max Woolf's avatar Max Woolf

Merge branch '10272-create-a-security-vulnerability-from-api' into 'master'

Manually create Vulnerabilities via API

See merge request gitlab-org/gitlab!68158
parents 2e83767c 51205f55
---
name: create_vulnerabilities_via_api
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68158
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338694
milestone: '14.3'
type: development
group: group::threat insights
default_enabled: false
......@@ -4459,6 +4459,39 @@ Input type: `VulnerabilityConfirmInput`
| <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. |
### `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 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 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 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`
Input type: `VulnerabilityDismissInput`
......@@ -13282,6 +13315,7 @@ Represents summary of a security report.
| <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="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="securityreportsummarysecretdetection"></a>`secretDetection` | [`SecurityReportSummarySection`](#securityreportsummarysection) | Aggregated counts for the `secret_detection` scan. |
......@@ -14162,7 +14196,7 @@ Represents a vulnerability.
| <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="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="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. |
......@@ -14456,6 +14490,16 @@ Represents the location of a vulnerability found by a dependency security scan.
| <a id="vulnerabilitylocationdependencyscanningdependency"></a>`dependency` | [`VulnerableDependency`](#vulnerabledependency) | Dependency containing the vulnerability. |
| <a id="vulnerabilitylocationdependencyscanningfile"></a>`file` | [`String`](#string) | Path to the vulnerable file. |
### `VulnerabilityLocationGeneric`
Represents the location of a vulnerability found by a generic scanner.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="vulnerabilitylocationgenericdescription"></a>`description` | [`String`](#string) | Free-form description of where the vulnerability is located. |
### `VulnerabilityLocationSast`
Represents the location of a vulnerability found by a SAST scan.
......@@ -15902,6 +15946,20 @@ Possible states of a user.
| <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. |
### `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`
The dismissal reason of the Vulnerability.
......@@ -15963,6 +16021,7 @@ The type of the security scan that found the vulnerability.
| <a id="vulnerabilityreporttypecoverage_fuzzing"></a>`COVERAGE_FUZZING` | |
| <a id="vulnerabilityreporttypedast"></a>`DAST` | |
| <a id="vulnerabilityreporttypedependency_scanning"></a>`DEPENDENCY_SCANNING` | |
| <a id="vulnerabilityreporttypegeneric"></a>`GENERIC` | |
| <a id="vulnerabilityreporttypesast"></a>`SAST` | |
| <a id="vulnerabilityreporttypesecret_detection"></a>`SECRET_DETECTION` | |
......@@ -16603,6 +16662,7 @@ One of:
- [`VulnerabilityLocationCoverageFuzzing`](#vulnerabilitylocationcoveragefuzzing)
- [`VulnerabilityLocationDast`](#vulnerabilitylocationdast)
- [`VulnerabilityLocationDependencyScanning`](#vulnerabilitylocationdependencyscanning)
- [`VulnerabilityLocationGeneric`](#vulnerabilitylocationgeneric)
- [`VulnerabilityLocationSast`](#vulnerabilitylocationsast)
- [`VulnerabilityLocationSecretDetection`](#vulnerabilitylocationsecretdetection)
......@@ -17381,3 +17441,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="updatediffimagepositioninputx"></a>`x` | [`Int`](#int) | X 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
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::ExportRequirements
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
mount_mutation ::Mutations::Vulnerabilities::Create
mount_mutation ::Mutations::Vulnerabilities::Dismiss
mount_mutation ::Mutations::Vulnerabilities::Resolve
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 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 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 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))
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:create_vulnerabilities_via_api, project)
params = build_vulnerability_params(attributes)
result = ::Vulnerabilities::ManuallyCreateService.new(
project,
current_user,
params: params
).execute
{
vulnerability: result.payload[:vulnerability],
errors: result.success? ? [] : Array(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
# frozen_string_literal: true
module Types
module VulnerabilityLocation
# rubocop: disable Graphql/AuthorizeTypes
class GenericType < BaseObject
graphql_name 'VulnerabilityLocationGeneric'
description 'Represents the location of a vulnerability found by a generic scanner.'
field :description, GraphQL::Types::String, null: true,
description: 'Free-form description of where the vulnerability is located.'
end
end
end
......@@ -12,7 +12,8 @@ module Types
VulnerabilityLocation::DastType,
VulnerabilityLocation::SastType,
VulnerabilityLocation::SecretDetectionType,
VulnerabilityLocation::CoverageFuzzingType
VulnerabilityLocation::CoverageFuzzingType,
VulnerabilityLocation::GenericType
def self.resolve_type(object, context)
case object[:report_type]
......@@ -28,6 +29,8 @@ module Types
VulnerabilityLocation::SecretDetectionType
when 'coverage_fuzzing'
VulnerabilityLocation::CoverageFuzzingType
when 'generic'
VulnerabilityLocation::GenericType
else
raise UnexpectedReportType, "Report type must be one of #{::Enums::Vulnerability.report_types.keys}"
end
......
......@@ -11,7 +11,8 @@ module EE
dast: 3,
coverage_fuzzing: 5,
api_fuzzing: 6,
cluster_image_scanning: 7
cluster_image_scanning: 7,
generic: 99
}.freeze
class_methods do
......
# frozen_string_literal: true
module Vulnerabilities
class ManuallyCreateService
include Gitlab::Allowable
METADATA_VERSION = "manual:1.0"
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
unless Feature.enabled?(:create_vulnerabilities_via_api, @project)
return ServiceResponse.error(message: "create_vulnerabilities_via_api feature flag is not enabled for this project")
end
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 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_MESSAGE if exists_in_vulnerability_params?(:dismissed_at)
when "confirmed"
return RESOLVED_MESSAGE if exists_in_vulnerability_params?(:resolved_at)
return DISMISSED_MESSAGE if exists_in_vulnerability_params?(:dismissed_at)
when "resolved"
return CONFIRMED_MESSAGE if exists_in_vulnerability_params?(:confirmed_at)
return DISMISSED_MESSAGE if exists_in_vulnerability_params?(:dismissed_at)
end
end
def exists_in_vulnerability_params?(column_name)
@params.dig(:vulnerability, column_name.to_sym).present?
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: METADATA_VERSION,
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] }
before do
stub_licensed_features(security_dashboard: true)
end
describe '#resolve' do
using RSpec::Parameterized::TableSyntax
context 'when a vulnerability with the same identifier already exists' do
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
before do
project.add_developer(user)
resolve(described_class, args: attributes, ctx: { current_user: user })
end
it 'returns the created vulnerability' do
expect(subject[:errors]).to contain_exactly("Vulnerability with those details already exists")
end
end
context 'with valid parameters' do
before do
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
context 'when feature flag is disabled' do
before do
stub_feature_flags(create_vulnerabilities_via_api: false)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when feature flag is enabled' do
before do
stub_feature_flags(create_vulnerabilities_via_api: project)
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", :aggregate_failures 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
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityConfidence'] do
it 'exposes all vulnerability confidence values' do
expect(described_class.values.keys).to match_array(%w[IGNORE UNKNOWN EXPERIMENTAL LOW MEDIUM HIGH CONFIRMED])
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::VulnerabilityIdentifierInputType do
specify { expect(described_class.graphql_name).to eq('VulnerabilityIdentifierInput') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[name url externalType externalId])
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityLocationGeneric'] do
it do
expect(described_class).to have_graphql_fields(
:description
)
end
end
......@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityReportType'] 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
......@@ -11,14 +11,17 @@ RSpec.describe Vulnerability do
end
let(:report_types) do
{ sast: 0,
{
sast: 0,
dependency_scanning: 1,
container_scanning: 2,
dast: 3,
secret_detection: 4,
coverage_fuzzing: 5,
api_fuzzing: 6,
cluster_image_scanning: 7 }
cluster_image_scanning: 7,
generic: 99
}
end
let_it_be(:project) { create(:project) }
......
......@@ -56,6 +56,9 @@ RSpec.describe 'Query.vulnerabilities.location' do
vulnerableMethod
blobPath
}
... on VulnerabilityLocationGeneric {
description
}
}
QUERY
end
......@@ -110,6 +113,32 @@ RSpec.describe 'Query.vulnerabilities.location' do
end
end
context 'when the vulnerability was found by a generic scanner' do
let_it_be(:vulnerability) do
create(:vulnerability, project: project, report_type: :generic)
end
let_it_be(:finding) do
create(
:vulnerabilities_finding,
vulnerability: vulnerability,
raw_metadata: metadata.to_json
)
end
let_it_be(:metadata) do
{
description: "Something really bad"
}
end
it 'returns a generic location' do
location = subject.first['location']
expect(location['__typename']).to eq('VulnerabilityLocationGeneric')
end
end
context 'when the vulnerability was found by a cluster image scan' do
let_it_be(:vulnerability) do
create(:vulnerability, project: project, report_type: :cluster_image_scanning)
......
# 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 'when feature flag is disabled' do
before do
stub_feature_flags(create_vulnerabilities_via_api: false)
end
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
it 'returns an error' do
result = subject
expect(result.success?).to be_falsey
expect(subject.message).to match(/create_vulnerabilities_via_api feature flag is not enabled for this project/)
end
end
context 'when feature flag is enabled' do
before do
stub_feature_flags(create_vulnerabilities_via_api: project)
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 }.not_to change(Vulnerabilities::Identifier, :count)
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
freeze_time 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
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
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