Commit 04ee7679 authored by Sean McGivern's avatar Sean McGivern

Merge branch '10242-get-vulnerabilities-list' into 'master'

API call to get a list of Project's Vulnerabilities

See merge request gitlab-org/gitlab!18269
parents 7f951780 0c8ca166
......@@ -40,6 +40,6 @@ module VulnerabilityFindingsActions
end
def vulnerability_findings(collection = :latest)
::Security::VulnerabilitiesFinder.new(vulnerable, params: filter_params).execute(collection)
::Security::VulnerabilityFindingsFinder.new(vulnerable, params: filter_params).execute(collection)
end
end
......@@ -7,7 +7,7 @@ class Groups::Security::VulnerabilitiesController < Groups::ApplicationControlle
alias_method :vulnerable, :group
def history
history_count = Gitlab::Vulnerabilities::History.new(group, filter_params).vulnerabilities_counter
history_count = Gitlab::Vulnerabilities::History.new(group, filter_params).findings_counter
respond_to do |format|
format.json do
......
......@@ -2,75 +2,21 @@
# Security::VulnerabilitiesFinder
#
# Used to filter Vulnerabilities::Occurrences by set of params for Security Dashboard
# Used to filter Vulnerability records for Vulnerabilities API
#
# Arguments:
# vulnerable - object to filter vulnerabilities
# params:
# severity: Array<String>
# confidence: Array<String>
# project: Array<String>
# report_type: Array<String>
# project: a Project to query for Vulnerabilities
module Security
class VulnerabilitiesFinder
attr_accessor :params
attr_reader :vulnerable
attr_reader :project
def initialize(vulnerable, params: {})
@vulnerable = vulnerable
@params = params
def initialize(project)
@project = project
end
def execute(scope = :latest)
collection = init_collection(scope)
collection = by_report_type(collection)
collection = by_project(collection)
collection = by_severity(collection)
collection = by_confidence(collection)
collection
end
private
def by_report_type(items)
return items unless params[:report_type].present?
items.by_report_types(
Vulnerabilities::Occurrence::REPORT_TYPES.values_at(
*params[:report_type]).compact)
end
def by_project(items)
return items unless params[:project_id].present?
items.by_projects(params[:project_id])
end
def by_severity(items)
return items unless params[:severity].present?
items.by_severities(
Vulnerabilities::Occurrence::SEVERITY_LEVELS.values_at(
*params[:severity]).compact)
end
def by_confidence(items)
return items unless params[:confidence].present?
items.by_confidences(
Vulnerabilities::Occurrence::CONFIDENCE_LEVELS.values_at(
*params[:confidence]).compact)
end
def init_collection(scope)
if scope == :all
vulnerable.all_vulnerabilities
elsif scope == :with_sha
vulnerable.latest_vulnerabilities_with_sha
else
vulnerable.latest_vulnerabilities
end
def execute
project.vulnerabilities
end
end
end
# frozen_string_literal: true
# Security::VulnerabilityFindingsFinder
#
# Used to filter Vulnerabilities::Occurrences by set of params for Security Dashboard
#
# Arguments:
# vulnerable - object to filter vulnerabilities
# params:
# severity: Array<String>
# confidence: Array<String>
# project: Array<String>
# report_type: Array<String>
module Security
class VulnerabilityFindingsFinder
attr_accessor :params
attr_reader :vulnerable
def initialize(vulnerable, params: {})
@vulnerable = vulnerable
@params = params
end
def execute(scope = :latest)
collection = init_collection(scope)
collection = by_report_type(collection)
collection = by_project(collection)
collection = by_severity(collection)
collection = by_confidence(collection)
collection
end
private
def by_report_type(items)
return items unless params[:report_type].present?
items.by_report_types(
Vulnerabilities::Occurrence::REPORT_TYPES.values_at(
*params[:report_type]).compact)
end
def by_project(items)
return items unless params[:project_id].present?
items.by_projects(params[:project_id])
end
def by_severity(items)
return items unless params[:severity].present?
items.by_severities(
Vulnerabilities::Occurrence::SEVERITY_LEVELS.values_at(
*params[:severity]).compact)
end
def by_confidence(items)
return items unless params[:confidence].present?
items.by_confidences(
Vulnerabilities::Occurrence::CONFIDENCE_LEVELS.values_at(
*params[:confidence]).compact)
end
def init_collection(scope)
case scope
when :all
vulnerable.all_vulnerabilities
when :with_sha
vulnerable.latest_vulnerabilities_with_sha
when :latest
vulnerable.latest_vulnerabilities
else
raise ArgumentError, "invalid value for 'scope': #{scope}"
end
end
end
end
# frozen_string_literal: true
class VulnerabilityEntity < Grape::Entity
expose :id
expose :title
expose :description
expose :state
expose :severity
expose :confidence
expose :project, using: ::ProjectEntity
expose :author_id
expose :updated_by_id
expose :last_edited_by_id
expose :closed_by_id
expose :start_date
expose :due_date
expose :created_at
expose :updated_at
expose :last_edited_at
expose :closed_at
end
# frozen_string_literal: true
module API
module Helpers
module VulnerabilityFindingsHelpers
extend Grape::API::Helpers
params :vulnerability_findings_params do
optional :report_type, type: Array[String], desc: 'The type of report vulnerability belongs to',
values: ::Vulnerabilities::Occurrence.report_types.keys,
default: ::Vulnerabilities::Occurrence.report_types.keys
optional :scope, type: String, desc: 'Return vulnerabilities for the given scope: `dismissed` or `all`',
default: 'dismissed', values: %w[all dismissed]
optional :severity,
type: Array[String],
desc: 'Returns vulnerabilities belonging to specified severity level: '\
'`undefined`, `info`, `unknown`, `low`, `medium`, `high`, or `critical`. Defaults to all',
values: ::Vulnerabilities::Occurrence.severities.keys,
default: ::Vulnerabilities::Occurrence.severities.keys
optional :confidence,
type: Array[String],
desc: 'Returns vulnerabilities belonging to specified confidence level: '\
'`undefined`, `ignore`, `unknown`, `experimental`, `low`, `medium`, `high`, or `confirmed`. '\
'Defaults to all',
values: ::Vulnerabilities::Occurrence.confidences.keys,
default: ::Vulnerabilities::Occurrence.confidences.keys
optional :pipeline_id, type: String, desc: 'The ID of the pipeline'
use :pagination
end
# TODO: rename to vulnerability_findings_by https://gitlab.com/gitlab-org/gitlab/issues/32963
def vulnerability_occurrences_by(params)
pipeline = if params[:pipeline_id]
user_project.all_pipelines.find_by(id: params[:pipeline_id]) # rubocop:disable CodeReuse/ActiveRecord
else
user_project.latest_pipeline_with_security_reports
end
return [] unless pipeline
Security::PipelineVulnerabilitiesFinder.new(pipeline: pipeline, params: params).execute
end
def respond_with_vulnerability_findings
authorize! :read_project_security_dashboard, user_project
vulnerability_occurrences = paginate(
Kaminari.paginate_array(
vulnerability_occurrences_by(declared_params)
)
)
Gitlab::Vulnerabilities::OccurrencesPreloader.preload_feedback!(vulnerability_occurrences)
present vulnerability_occurrences,
with: ::Vulnerabilities::OccurrenceEntity,
request: GrapeRequestProxy.new(request, current_user)
end
end
end
end
# frozen_string_literal: true
module API
class Vulnerabilities < Grape::API
include PaginationParams
helpers ::API::Helpers::VulnerabilityFindingsHelpers
helpers do
def vulnerabilities_by(project)
Security::VulnerabilitiesFinder.new(project).execute
end
end
before do
authenticate!
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
params do
# These params have no effect for Vulnerabilities API but are required to support falling back to
# responding with Vulnerability Findings when :first_class_vulnerabilities feature is disabled.
# TODO: remove usage of :vulnerability_findings_params when feature flag is removed
# https://gitlab.com/gitlab-org/gitlab/issues/33488
use :vulnerability_findings_params
end
desc 'Get a list of project vulnerabilities' do
success VulnerabilityEntity
end
get ':id/vulnerabilities' do
if Feature.enabled?(:first_class_vulnerabilities)
authorize! :read_project_security_dashboard, user_project
vulnerabilities = paginate(
vulnerabilities_by(user_project)
)
present vulnerabilities, with: VulnerabilityEntity
else
respond_with_vulnerability_findings
end
end
end
end
end
......@@ -4,65 +4,7 @@ module API
class VulnerabilityFindings < Grape::API
include PaginationParams
helpers do
params :vulnerability_findings_params do
optional :report_type, type: Array[String], desc: 'The type of report vulnerability belongs to',
values: ::Vulnerabilities::Occurrence.report_types.keys,
default: ::Vulnerabilities::Occurrence.report_types.keys
optional :scope, type: String, desc: 'Return vulnerabilities for the given scope: `dismissed` or `all`',
default: 'dismissed', values: %w[all dismissed]
optional :severity,
type: Array[String],
desc: 'Returns issues belonging to specified severity level: '\
'`undefined`, `info`, `unknown`, `low`, `medium`, `high`, or `critical`. Defaults to all',
values: ::Vulnerabilities::Occurrence.severities.keys,
default: ::Vulnerabilities::Occurrence.severities.keys
optional :confidence,
type: Array[String],
desc: 'Returns vulnerabilities belonging to specified confidence level: '\
'`undefined`, `ignore`, `unknown`, `experimental`, `low`, `medium`, `high`, or `confirmed`. '\
'Defaults to all',
values: ::Vulnerabilities::Occurrence.confidences.keys,
default: ::Vulnerabilities::Occurrence.confidences.keys
optional :pipeline_id, type: String, desc: 'The ID of the pipeline'
use :pagination
end
def vulnerability_occurrences_by(params)
pipeline = if params[:pipeline_id]
params[:project].all_pipelines.find_by(id: params[:pipeline_id]) # rubocop:disable CodeReuse/ActiveRecord
else
params[:project].latest_pipeline_with_security_reports
end
return [] unless pipeline
Security::PipelineVulnerabilitiesFinder.new(pipeline: pipeline, params: params).execute
end
def respond_with_vulnerabilities
# TODO: implement the "Get a list of project's Vulnerabilities" step
# of https://gitlab.com/gitlab-org/gitlab-ee/issues/10242#status
not_found!
end
def respond_with_vulnerability_findings
authorize! :read_project_security_dashboard, user_project
vulnerability_occurrences = paginate(
Kaminari.paginate_array(
vulnerability_occurrences_by(declared_params.merge(project: user_project))
)
)
Gitlab::Vulnerabilities::OccurrencesPreloader.preload_feedback!(vulnerability_occurrences)
present vulnerability_occurrences,
with: ::Vulnerabilities::OccurrenceEntity,
request: GrapeRequestProxy.new(request, current_user)
end
end
helpers ::API::Helpers::VulnerabilityFindingsHelpers
before do
authenticate!
......@@ -73,20 +15,6 @@ module API
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
params do
use :vulnerability_findings_params
end
desc 'Get a list of project vulnerabilities' do
success ::Vulnerabilities::OccurrenceEntity
end
get ':id/vulnerabilities' do
if Feature.enabled?(:first_class_vulnerabilities)
respond_with_vulnerabilities
else
respond_with_vulnerability_findings
end
end
params do
use :vulnerability_findings_params
end
......
......@@ -35,6 +35,7 @@ module EE
mount ::API::Scim
mount ::API::ManagedLicenses
mount ::API::ProjectApprovals
mount ::API::Vulnerabilities
mount ::API::VulnerabilityFindings
mount ::API::MergeRequestApprovals
mount ::API::MergeRequestApprovalRules
......
......@@ -14,17 +14,17 @@ module Gitlab
@filters = filters
end
def vulnerabilities_counter
def findings_counter
return cached_vulnerability_history if use_vulnerability_cache?
vulnerabilities = found_vulnerabilities.count_by_day_and_severity(HISTORY_RANGE)
::Vulnerabilities::HistorySerializer.new.represent(vulnerabilities)
findings = vulnerability_findings.count_by_day_and_severity(HISTORY_RANGE)
::Vulnerabilities::HistorySerializer.new.represent(findings)
end
private
def found_vulnerabilities
::Security::VulnerabilitiesFinder.new(group, params: filters).execute(:all)
def vulnerability_findings
::Security::VulnerabilityFindingsFinder.new(group, params: filters).execute(:all)
end
def cached_vulnerability_history
......
......@@ -12,17 +12,19 @@ module Gitlab
def fetch(range, force: false)
Rails.cache.fetch(cache_key, force: force, expires_in: 1.day) do
vulnerabilities = ::Security::VulnerabilitiesFinder
findings = ::Security::VulnerabilityFindingsFinder
.new(group, params: { project_id: [project_id] })
.execute(:all)
.count_by_day_and_severity(range)
::Vulnerabilities::HistorySerializer.new.represent(vulnerabilities)
::Vulnerabilities::HistorySerializer.new.represent(findings)
end
end
private
def cache_key
# TODO: rename 'vulnerabilities' to 'findings' in the cache key, but carefully
# https://gitlab.com/gitlab-org/gitlab/issues/32963
['projects', project_id, 'vulnerabilities']
end
end
......
......@@ -89,5 +89,11 @@ FactoryBot.modify do
trait :github_imported do
import_type { 'github' }
end
trait :with_vulnerabilities do
after(:create) do |project|
create_list(:vulnerability, 2, :opened, project: project)
end
end
end
end
......@@ -3,132 +3,11 @@
require 'spec_helper'
describe Security::VulnerabilitiesFinder do
describe '#execute' do
set(:group) { create(:group) }
set(:project1) { create(:project, :private, :repository, group: group) }
set(:project2) { create(:project, :private, :repository, group: group) }
set(:pipeline1) { create(:ci_pipeline, :success, project: project1) }
set(:pipeline2) { create(:ci_pipeline, :success, project: project2) }
let(:project) { create(:project, :with_vulnerabilities) }
set(:vulnerability1) { create(:vulnerabilities_occurrence, report_type: :sast, severity: :high, confidence: :high, pipelines: [pipeline1], project: project1) }
set(:vulnerability2) { create(:vulnerabilities_occurrence, report_type: :dependency_scanning, severity: :medium, confidence: :low, pipelines: [pipeline2], project: project2) }
set(:vulnerability3) { create(:vulnerabilities_occurrence, report_type: :sast, severity: :low, pipelines: [pipeline2], project: project2) }
set(:vulnerability4) { create(:vulnerabilities_occurrence, report_type: :dast, severity: :medium, pipelines: [pipeline1], project: project1) }
subject { described_class.new(project).execute }
subject { described_class.new(group, params: params).execute }
context 'by report type' do
context 'when sast' do
let(:params) { { report_type: %w[sast] } }
it 'includes only sast' do
is_expected.to contain_exactly(vulnerability1, vulnerability3)
end
end
context 'when dependency_scanning' do
let(:params) { { report_type: %w[dependency_scanning] } }
it 'includes only depscan' do
is_expected.to contain_exactly(vulnerability2)
end
end
end
context 'by severity' do
context 'when high' do
let(:params) { { severity: %w[high] } }
it 'includes only high' do
is_expected.to contain_exactly(vulnerability1)
end
end
context 'when medium' do
let(:params) { { severity: %w[medium] } }
it 'includes only medium' do
is_expected.to contain_exactly(vulnerability2, vulnerability4)
end
end
end
context 'by confidence' do
context 'when high' do
let(:params) { { confidence: %w[high] } }
it 'includes only high confidence vulnerabilities' do
is_expected.to contain_exactly(vulnerability1)
end
end
context 'when low' do
let(:params) { { confidence: %w[low] } }
it 'includes only low confidence vulnerabilities' do
is_expected.to contain_exactly(vulnerability2)
end
end
end
context 'by project' do
let(:params) { { project_id: [project2.id] } }
it 'includes only vulnerabilities for one project' do
is_expected.to contain_exactly(vulnerability2, vulnerability3)
end
end
# FIXME: unskip when this filter is implemented
context 'by dismissals' do
let!(:dismissal) do
create(:vulnerability_feedback, :sast, :dismissal,
pipeline: pipeline1,
project: project1,
project_fingerprint: vulnerability1.project_fingerprint)
end
let(:params) { { hide_dismissed: true } }
skip 'exclude dismissal' do
is_expected.to contain_exactly(vulnerability2, vulnerability3, vulnerability4)
end
end
context 'by all filters' do
context 'with found entity' do
let(:params) { { severity: %w[high medium low], project_id: [project1.id, project2.id], report_type: %w[sast dast] } }
it 'filters by all params' do
is_expected.to contain_exactly(vulnerability1, vulnerability3, vulnerability4)
end
end
context 'without found entity' do
let(:params) { { severity: %w[low], project_id: [project1.id], report_type: %w[sast] } }
it 'did not find anything' do
is_expected.to be_empty
end
end
end
context 'by some filters' do
context 'with found entity' do
let(:params) { { project_id: [project2.id], severity: %w[medium low] } }
it 'filters by all params' do
is_expected.to contain_exactly(vulnerability2, vulnerability3)
end
end
context 'without found entity' do
let(:params) { { project_id: project1.id, severity: %w[low] } }
it 'did not find anything' do
is_expected.to be_empty
end
end
end
it 'returns vulnerabilities of a project' do
expect(subject).to match_array(project.vulnerabilities)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Security::VulnerabilityFindingsFinder do
describe '#execute' do
let_it_be(:group) { create(:group) }
let_it_be(:project1) { create(:project, :private, :repository, group: group) }
let_it_be(:project2) { create(:project, :private, :repository, group: group) }
let_it_be(:pipeline1) { create(:ci_pipeline, :success, project: project1) }
let_it_be(:pipeline2) { create(:ci_pipeline, :success, project: project2) }
let_it_be(:finding1) { create(:vulnerabilities_occurrence, report_type: :sast, severity: :high, confidence: :high, pipelines: [pipeline1], project: project1) }
let_it_be(:finding2) { create(:vulnerabilities_occurrence, report_type: :dependency_scanning, severity: :medium, confidence: :low, pipelines: [pipeline2], project: project2) }
let_it_be(:finding3) { create(:vulnerabilities_occurrence, report_type: :sast, severity: :low, pipelines: [pipeline2], project: project2) }
let_it_be(:finding4) { create(:vulnerabilities_occurrence, report_type: :dast, severity: :medium, pipelines: [pipeline1], project: project1) }
subject { described_class.new(group, params: params).execute }
context 'by report type' do
context 'when sast' do
let(:params) { { report_type: %w[sast] } }
it 'includes only sast' do
is_expected.to contain_exactly(finding1, finding3)
end
end
context 'when dependency_scanning' do
let(:params) { { report_type: %w[dependency_scanning] } }
it 'includes only depscan' do
is_expected.to contain_exactly(finding2)
end
end
end
context 'by severity' do
context 'when high' do
let(:params) { { severity: %w[high] } }
it 'includes only high' do
is_expected.to contain_exactly(finding1)
end
end
context 'when medium' do
let(:params) { { severity: %w[medium] } }
it 'includes only medium' do
is_expected.to contain_exactly(finding2, finding4)
end
end
end
context 'by confidence' do
context 'when high' do
let(:params) { { confidence: %w[high] } }
it 'includes only high confidence vulnerabilities' do
is_expected.to contain_exactly(finding1)
end
end
context 'when low' do
let(:params) { { confidence: %w[low] } }
it 'includes only low confidence vulnerabilities' do
is_expected.to contain_exactly(finding2)
end
end
end
context 'by project' do
let(:params) { { project_id: [project2.id] } }
it 'includes only vulnerabilities for one project' do
is_expected.to contain_exactly(finding2, finding3)
end
end
context 'by all filters' do
context 'with found entity' do
let(:params) { { severity: %w[high medium low], project_id: [project1.id, project2.id], report_type: %w[sast dast] } }
it 'filters by all params' do
is_expected.to contain_exactly(finding1, finding3, finding4)
end
end
context 'without found entity' do
let(:params) { { severity: %w[low], project_id: [project1.id], report_type: %w[sast] } }
it 'did not find anything' do
is_expected.to be_empty
end
end
end
context 'by some filters' do
context 'with found entity' do
let(:params) { { project_id: [project2.id], severity: %w[medium low] } }
it 'filters by all params' do
is_expected.to contain_exactly(finding2, finding3)
end
end
context 'without found entity' do
let(:params) { { project_id: project1.id, severity: %w[low] } }
it 'did not find anything' do
is_expected.to be_empty
end
end
end
describe 'scope specifiers' do
using RSpec::Parameterized::TableSyntax
where(:scope) do
[
[:all],
[:with_sha],
[:latest]
]
end
with_them do
it 'accepts the scope specifier as valid' do
expect { described_class.new(group).execute(scope) }.not_to raise_error
end
end
context 'with an invalid scope specifier' do
it 'raises error' do
expect { described_class.new(group).execute(:invalid) }.to(
raise_error(ArgumentError, "invalid value for 'scope': invalid")
)
end
end
end
end
end
{
"type": "object",
"required": ["title", "state", "confidence", "severity", "project", "author_id"],
"properties": {
"title": {
"type": "string"
},
"description": { "type": ["string", "null"] },
"state": { "type": "string", "enum": ["opened", "closed"] },
"severity": {
"type": "string",
"enum": ["undefined", "info", "unknown", "low", "medium", "high", "critical"]
},
"confidence": {
"type": "string",
"enum": [
"undefined",
"ignore",
"unknown",
"experimental",
"low",
"medium",
"high",
"confirmed"
]
},
"project": {
"required": ["id", "name", "full_path", "full_name"],
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"full_path": {
"type": "string"
},
"full_name": {
"type": "string"
}
},
"author_id": { "type": "integer" },
"updated_by_id": { "type": ["integer", "null"] },
"last_edited_by_id": { "type": ["integer", "null"] },
"closed_by_id": { "type": ["integer", "null"] },
"start_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"last_edited_at": { "type": "date" },
"closed_at": { "type": "date" }
}
}
{
"type": "array",
"items": { "$ref": "vulnerability.json" }
}
......@@ -13,8 +13,8 @@ describe Gitlab::Vulnerabilities::History do
create_vulnerabilities(2, project2, { severity: :high, report_type: :sast })
end
describe '#vulnerabilities_counter', :use_clean_rails_memory_store_caching do
subject(:counter) { described_class.new(group, filters).vulnerabilities_counter }
describe '#findings_counter', :use_clean_rails_memory_store_caching do
subject(:counter) { described_class.new(group, filters).findings_counter }
context 'feature disabled' do
before do
......
# frozen_string_literal: true
require 'spec_helper'
describe API::Vulnerabilities do
before do
stub_licensed_features(security_dashboard: true)
end
let_it_be(:project) { create(:project, :public, :with_vulnerabilities) }
let_it_be(:user) { create(:user) }
describe "GET /projects/:id/vulnerabilities" do
let(:project_vulnerabilities_path) { "/projects/#{project.id}/vulnerabilities" }
context 'with an authorized user with proper permissions' do
before do
project.add_developer(user)
end
it 'returns all vulnerabilities of a project' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(response).to match_response_schema('vulnerability_list', dir: 'ee')
expect(response.headers['X-Total']).to eq project.vulnerabilities.count.to_s
end
it 'paginates the vulnerabilities according to the pagination params' do
get api("#{project_vulnerabilities_path}?page=2&per_page=1", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response.map { |v| v['id'] }).to contain_exactly(project.vulnerabilities.second.id)
end
context 'when "first-class vulnerabilities" feature is disabled' do
before do
stub_feature_flags(first_class_vulnerabilities: false)
end
it_behaves_like 'getting list of vulnerability findings'
end
end
it_behaves_like 'forbids access to vulnerability-like endpoint in expected cases'
end
end
......@@ -3,237 +3,31 @@
require 'spec_helper'
describe API::VulnerabilityFindings do
set(:project) { create(:project, :public) }
set(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:user) { create(:user) }
let(:pipeline) { create(:ci_empty_pipeline, status: :created, project: project) }
let(:pipeline_without_vulnerabilities) { create(:ci_pipeline_without_jobs, status: :created, project: project) }
let(:build_ds) { create(:ci_build, :success, name: 'ds_job', pipeline: pipeline, project: project) }
let(:build_sast) { create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) }
let(:ds_report) { pipeline.security_reports.reports["dependency_scanning"] }
let(:sast_report) { pipeline.security_reports.reports["sast"] }
let(:dismissal) do
create(:vulnerability_feedback, :dismissal, :sast,
project: project,
pipeline: pipeline,
project_fingerprint: sast_report.occurrences.first.project_fingerprint,
vulnerability_data: sast_report.occurrences.first.raw_metadata
)
end
before do
stub_licensed_features(security_dashboard: true, sast: true, dependency_scanning: true, container_scanning: true)
create(:ee_ci_job_artifact, :dependency_scanning, job: build_ds, project: project)
create(:ee_ci_job_artifact, :sast, job: build_sast, project: project)
dismissal
end
describe "GET /projects/:id/vulnerability_findings" do
let(:project_vulnerabilities_path) { "/projects/#{project.id}/vulnerability_findings" }
shared_examples 'getting list of vulnerability findings' do
let(:project_vulnerabilities_path) { "/projects/#{project.id}/#{api_resource_name}" }
it_behaves_like 'getting list of vulnerability findings'
it_behaves_like 'forbids access to vulnerability-like endpoint in expected cases'
context 'with an authorized user with proper permissions' do
before do
project.add_developer(user)
end
it 'returns all non-dismissed vulnerabilities' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count - 1).to_s
get api(project_vulnerabilities_path, user), params: { per_page: 40 }
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(response).to match_response_schema('vulnerabilities/occurrence_list', dir: 'ee')
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning sast]
end
it 'does not have N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
get api(project_vulnerabilities_path, user), params: { report_type: 'dependency_scanning' }
end.count
expect { get api(project_vulnerabilities_path, user) }.not_to exceed_query_limit(control_count)
end
describe 'filtering' do
it 'returns vulnerabilities with sast report_type' do
occurrence_count = (sast_report.occurrences.count - 1).to_s
get api(project_vulnerabilities_path, user), params: { report_type: 'sast' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[sast]
# occurrences are implicitly sorted by Security::MergeReportsService,
# occurrences order differs from what is present in fixture file
expect(json_response.first['name']).to eq 'ECB mode is insecure'
end
it 'returns vulnerabilities with dependency_scanning report_type' do
occurrence_count = ds_report.occurrences.count.to_s
get api(project_vulnerabilities_path, user), params: { report_type: 'dependency_scanning' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning]
# occurrences are implicitly sorted by Security::MergeReportsService,
# occurrences order differs from what is present in fixture file
expect(json_response.first['name']).to eq 'ruby-ffi DDL loading issue on Windows OS'
end
it 'returns a "bad request" response for an unknown report type' do
get api(project_vulnerabilities_path, user), params: { report_type: 'blah' }
expect(response).to have_gitlab_http_status(400)
end
it 'returns dismissed vulnerabilities with `all` scope' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count).to_s
get api(project_vulnerabilities_path, user), params: { per_page: 40, scope: 'all' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
end
it 'returns vulnerabilities with low severity' do
get api(project_vulnerabilities_path, user), params: { per_page: 40, severity: 'low' }
expect(response).to have_gitlab_http_status(200)
expect(json_response.map { |v| v['severity'] }.uniq).to eq %w[low]
end
it 'returns a "bad request" response for an unknown severity value' do
get api(project_vulnerabilities_path, user), params: { severity: 'foo' }
expect(response).to have_gitlab_http_status(400)
end
it 'returns vulnerabilities with high confidence' do
get api(project_vulnerabilities_path, user), params: { per_page: 40, confidence: 'high' }
expect(response).to have_gitlab_http_status(200)
expect(json_response.map { |v| v['confidence'] }.uniq).to eq %w[high]
end
it 'returns a "bad request" response for an unknown confidence value' do
get api(project_vulnerabilities_path, user), params: { confidence: 'qux' }
expect(response).to have_gitlab_http_status(400)
context 'when "first-class vulnerabilities" feature is disabled' do
before do
stub_feature_flags(first_class_vulnerabilities: false)
end
context 'when pipeline_id is supplied' do
it 'returns vulnerabilities from supplied pipeline' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count - 1).to_s
get api(project_vulnerabilities_path, user), params: { per_page: 40, pipeline_id: pipeline.id }
expect(response).to have_gitlab_http_status(200)
it 'responds with "not found"' do
get api(project_vulnerabilities_path, user)
expect(response.headers['X-Total']).to eq occurrence_count
end
context 'pipeline has no reports' do
it 'returns empty results' do
get api(project_vulnerabilities_path, user), params: { per_page: 40, pipeline_id: pipeline_without_vulnerabilities.id }
expect(json_response).to eq []
end
end
context 'with unknown pipeline' do
it 'returns empty results' do
get api(project_vulnerabilities_path, user), params: { per_page: 40, pipeline_id: 0 }
expect(json_response).to eq []
end
end
expect(response).to have_gitlab_http_status(404)
end
end
end
context 'with authorized user without read permissions' do
before do
project.add_reporter(user)
stub_licensed_features(security_dashboard: false, sast: true, dependency_scanning: true, container_scanning: true)
end
it 'responds with 403 Forbidden' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(403)
end
end
context 'with no project access' do
it 'responds with 404 Not Found' do
private_project = create(:project)
get api("/projects/#{private_project.id}/#{api_resource_name}", user)
expect(response).to have_gitlab_http_status(404)
end
end
context 'with unknown project' do
it 'responds with 404 Not Found' do
get api("/projects/0/#{api_resource_name}", user)
expect(response).to have_gitlab_http_status(404)
end
end
end
shared_examples 'not found vulnerabilities endpoint' do
it do
get api("/projects/#{project.id}/#{api_resource_name}?", user), params: { per_page: 40 }
expect(response).to have_gitlab_http_status(404)
end
end
describe "GET /projects/:id/vulnerabilities" do
let(:api_resource_name) { 'vulnerabilities' }
it_behaves_like 'not found vulnerabilities endpoint'
context 'when vulnerability findings API is disabled' do
before do
stub_feature_flags(first_class_vulnerabilities: false)
end
it_behaves_like 'getting list of vulnerability findings'
end
end
describe "GET /projects/:id/vulnerability_findings" do
let(:api_resource_name) { 'vulnerability_findings' }
it_behaves_like 'getting list of vulnerability findings'
context 'when vulnerability findings API is disabled' do
before do
stub_feature_flags(first_class_vulnerabilities: false)
end
it_behaves_like 'not found vulnerabilities endpoint'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe VulnerabilityEntity do
let(:vulnerability) do
create(:vulnerability)
end
let(:entity) do
described_class.represent(vulnerability)
end
subject { entity.to_json }
it { is_expected.to match_schema('vulnerability', dir: 'ee') }
end
# frozen_string_literal: true
shared_examples 'forbids access to vulnerability-like endpoint in expected cases' do
context 'with authorized user without read permissions' do
before do
project.add_reporter(user)
end
it 'responds with 403 Forbidden' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(403)
end
end
context 'with authorized user but when security dashboard is not available' do
before do
project.add_developer(user)
stub_licensed_features(security_dashboard: false)
end
it 'responds with 403 Forbidden' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(403)
end
end
context 'with no project access' do
let(:project) { create(:project) }
it 'responds with 404 Not Found' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(404)
end
end
context 'with unknown project' do
before do
project.id = 0
end
let(:project) { build(:project) }
it 'responds with 404 Not Found' do
get api(project_vulnerabilities_path, user)
expect(response).to have_gitlab_http_status(404)
end
end
end
shared_examples 'getting list of vulnerability findings' do
before do
stub_licensed_features(security_dashboard: true, sast: true, dependency_scanning: true, container_scanning: true)
create(:ee_ci_job_artifact, :dependency_scanning, job: build_ds, project: project)
create(:ee_ci_job_artifact, :sast, job: build_sast, project: project)
dismissal
end
let(:pipeline) { create(:ci_empty_pipeline, status: :created, project: project) }
let(:pipeline_without_vulnerabilities) { create(:ci_pipeline_without_jobs, status: :created, project: project) }
let(:build_ds) { create(:ci_build, :success, name: 'ds_job', pipeline: pipeline, project: project) }
let(:build_sast) { create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) }
let(:ds_report) { pipeline.security_reports.reports["dependency_scanning"] }
let(:sast_report) { pipeline.security_reports.reports["sast"] }
let(:dismissal) do
create(:vulnerability_feedback, :dismissal, :sast,
project: project,
pipeline: pipeline,
project_fingerprint: sast_report.occurrences.first.project_fingerprint,
vulnerability_data: sast_report.occurrences.first.raw_metadata
)
end
context 'with an authorized user with proper permissions' do
before do
project.add_developer(user)
end
# Because fixture reports that power :ee_ci_job_artifact factory contain long report lists,
# we need to make sure that all occurrences for both SAST and Dependency Scanning are included in the response.
# That's why the page size is 40.
let(:pagination) { { per_page: 40 } }
it 'returns all non-dismissed vulnerabilities' do
# all occurrences except one that was dismissed
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count - 1).to_s
get api(project_vulnerabilities_path, user), params: pagination
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(response).to match_response_schema('vulnerabilities/occurrence_list', dir: 'ee')
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning sast]
end
it 'does not have N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
get api(project_vulnerabilities_path, user), params: { report_type: 'dependency_scanning' }
end.count
expect { get api(project_vulnerabilities_path, user) }.not_to exceed_query_limit(control_count)
end
describe 'filtering' do
it 'returns vulnerabilities with sast report_type' do
occurrence_count = (sast_report.occurrences.count - 1).to_s # all SAST occurrences except one that was dismissed
get api(project_vulnerabilities_path, user), params: { report_type: 'sast' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[sast]
# occurrences are implicitly sorted by Security::MergeReportsService,
# occurrences order differs from what is present in fixture file
expect(json_response.first['name']).to eq 'ECB mode is insecure'
end
it 'returns vulnerabilities with dependency_scanning report_type' do
occurrence_count = ds_report.occurrences.count.to_s
get api(project_vulnerabilities_path, user), params: { report_type: 'dependency_scanning' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning]
# occurrences are implicitly sorted by Security::MergeReportsService,
# occurrences order differs from what is present in fixture file
expect(json_response.first['name']).to eq 'ruby-ffi DDL loading issue on Windows OS'
end
it 'returns a "bad request" response for an unknown report type' do
get api(project_vulnerabilities_path, user), params: { report_type: 'blah' }
expect(response).to have_gitlab_http_status(400)
end
it 'returns dismissed vulnerabilities with `all` scope' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count).to_s
get api(project_vulnerabilities_path, user), params: { scope: 'all' }.merge(pagination)
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
end
it 'returns vulnerabilities with low severity' do
get api(project_vulnerabilities_path, user), params: { severity: 'low' }.merge(pagination)
expect(response).to have_gitlab_http_status(200)
expect(json_response.map { |v| v['severity'] }.uniq).to eq %w[low]
end
it 'returns a "bad request" response for an unknown severity value' do
get api(project_vulnerabilities_path, user), params: { severity: 'foo' }
expect(response).to have_gitlab_http_status(400)
end
it 'returns vulnerabilities with high confidence' do
get api(project_vulnerabilities_path, user), params: { confidence: 'high' }.merge(pagination)
expect(response).to have_gitlab_http_status(200)
expect(json_response.map { |v| v['confidence'] }.uniq).to eq %w[high]
end
it 'returns a "bad request" response for an unknown confidence value' do
get api(project_vulnerabilities_path, user), params: { confidence: 'qux' }
expect(response).to have_gitlab_http_status(400)
end
context 'when pipeline_id is supplied' do
it 'returns vulnerabilities from supplied pipeline' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count - 1).to_s
get api(project_vulnerabilities_path, user), params: { pipeline_id: pipeline.id }.merge(pagination)
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
end
context 'pipeline has no reports' do
it 'returns empty results' do
get api(project_vulnerabilities_path, user), params: { pipeline_id: pipeline_without_vulnerabilities.id }.merge(pagination)
expect(json_response).to eq []
end
end
context 'with unknown pipeline' do
it 'returns empty results' do
get api(project_vulnerabilities_path, user), params: { pipeline_id: 0 }.merge(pagination)
expect(json_response).to eq []
end
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