Commit d75b5723 authored by Victor Zagorodny's avatar Victor Zagorodny

Introduce GET Project's Vulnerabilities API call

Extract Vulnerabilities API into a separate module
and implement GET projects/:id/vulnerabilities API
operation. Respect :first_class_vulnerabilities
feature flag.
parent 8e2844e4
# 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 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
# TODO: rename to vulnerability_findings_by https://gitlab.com/gitlab-org/gitlab/issues/32963
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_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
end
end
# frozen_string_literal: true
module API
class Vulnerabilities < Grape::API
include PaginationParams
helpers ::API::Helpers::VulnerabilityFindingsHelpers
helpers do
def vulnerabilities_by(project, params)
Security::VulnerabilitiesFinder.new(project, params).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, declared_params)
)
present vulnerabilities, with: VulnerabilityEntity
else
respond_with_vulnerability_findings
end
end
end
end
end
...@@ -4,65 +4,7 @@ module API ...@@ -4,65 +4,7 @@ module API
class VulnerabilityFindings < Grape::API class VulnerabilityFindings < Grape::API
include PaginationParams include PaginationParams
helpers do helpers ::API::Helpers::VulnerabilityFindingsHelpers
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
before do before do
authenticate! authenticate!
...@@ -73,20 +15,6 @@ module API ...@@ -73,20 +15,6 @@ module API
end end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 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 params do
use :vulnerability_findings_params use :vulnerability_findings_params
end end
......
...@@ -35,6 +35,7 @@ module EE ...@@ -35,6 +35,7 @@ module EE
mount ::API::Scim mount ::API::Scim
mount ::API::ManagedLicenses mount ::API::ManagedLicenses
mount ::API::ProjectApprovals mount ::API::ProjectApprovals
mount ::API::Vulnerabilities
mount ::API::VulnerabilityFindings mount ::API::VulnerabilityFindings
mount ::API::MergeRequestApprovals mount ::API::MergeRequestApprovals
mount ::API::MergeRequestApprovalRules mount ::API::MergeRequestApprovalRules
......
{
"type": "array",
"items": { "$ref": "vulnerability.json" }
}
# 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
end
it_behaves_like 'forbids access to vulnerability-like endpoint in expected cases'
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
end
...@@ -3,237 +3,24 @@ ...@@ -3,237 +3,24 @@
require 'spec_helper' require 'spec_helper'
describe API::VulnerabilityFindings do describe API::VulnerabilityFindings do
set(:project) { create(:project, :public) } let_it_be(:project) { create(:project, :public) }
set(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:pipeline) { create(:ci_empty_pipeline, status: :created, project: project) } describe "GET /projects/:id/vulnerability_findings" do
let(:pipeline_without_vulnerabilities) { create(:ci_pipeline_without_jobs, status: :created, project: project) } let(:project_vulnerabilities_path) { "/projects/#{project.id}/vulnerability_findings" }
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
shared_examples 'getting list of vulnerability findings' do
let(:project_vulnerabilities_path) { "/projects/#{project.id}/#{api_resource_name}" }
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)
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)
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 [] it_behaves_like 'getting list of vulnerability findings'
end
end
end
end
end
context 'with authorized user without read permissions' do context 'when "first-class vulnerabilities" feature is disabled' do
before do before do
project.add_reporter(user) stub_feature_flags(first_class_vulnerabilities: false)
stub_licensed_features(security_dashboard: false, sast: true, dependency_scanning: true, container_scanning: true)
end end
it 'responds with 403 Forbidden' do it 'responds with "not found"' do
get api(project_vulnerabilities_path, user) 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) expect(response).to have_gitlab_http_status(404)
end end
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
end 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
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)
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)
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
end
end
end
it_behaves_like 'forbids access to vulnerability-like endpoint in expected cases'
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