Commit eb8cc151 authored by Lucas Charles's avatar Lucas Charles Committed by Stan Hu

Vulnerabilities API returns non-dismissed vulnerabilities by default

Previously, Vulnerabilities API would return all however it should only
return non-dismissed by default. This behavior can be changed with
`scope=all`
parent 7659c1f9
......@@ -21,12 +21,19 @@ GET /projects/:id/vulnerabilities
GET /projects/:id/vulnerabilities?report_type=sast
GET /projects/:id/vulnerabilities?report_type=container_scanning
GET /projects/:id/vulnerabilities?report_type=sast,dast
GET /projects/:id/vulnerabilities?scope=all
GET /projects/:id/vulnerabilities?scope=dismissed
GET /projects/:id/vulnerabilities?severity=high
GET /projects/:id/vulnerabilities?confidence=unknown,experimental
```
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. |
| `report_type` | Array[string] | no | Returns vulnerabilities belonging to specified report type. Valid values: `sast`, `dast`, `dependency_scanning`, or `container_scanning`. |
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. |
| `report_type` | Array[string] | no | Returns vulnerabilities belonging to specified report type. Valid values: `sast`, `dast`, `dependency_scanning`, or `container_scanning`. |
| `scope` | string | no | Returns vulnerabilities for the given scope: `all` or `dismissed`. Defaults to `dismissed` |
| `severity` | Array[string] | no | Returns vulnerabilities belonging to specified severity level: `undefined`, `info`, `unknown`, `low`, `medium`, `high`, or `critical`. Defaults to all' |
| `confidence` | Array[string] | no | Returns vulnerabilities belonging to specified confidence level: `undefined`, `ignore`, `unknown`, `experimental`, `low`, `medium`, `high`, or `confirmed`. Defaults to all |
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/vulnerabilities
......
......@@ -12,21 +12,24 @@
module Security
class PipelineVulnerabilitiesFinder
include Gitlab::Utils::StrongMemoize
attr_accessor :params
attr_reader :pipeline
def initialize(pipeline:, params: default_params)
def initialize(pipeline:, params: {})
@pipeline = pipeline
@params = params
end
def execute
pipeline_reports.each_with_object([]) do |(type, report), occurrences|
next unless requested_type?(report.type)
next unless requested_type?(type)
normalized_occurrences = normalize_report_occurrences(report.occurrences)
filtered_occurrences = filter(normalized_occurrences)
occurrences.concat(
normalize_report_occurrences(report.occurrences)
)
occurrences.concat(filtered_occurrences)
end
end
......@@ -53,12 +56,47 @@ module Security
end
end
def filter(occurrences)
occurrences.select do |occurrence|
next if !include_dismissed? && dismissal_feedback?(occurrence)
next unless confidence_levels.include?(occurrence.confidence)
next unless severity_levels.include?(occurrence.severity)
occurrence
end
end
def requested_type?(type)
Array(params[:report_type]).include?(type)
report_types.include?(type)
end
def include_dismissed?
params[:scope] == 'all'
end
def dismissal_feedback?(occurrence)
dismissal_feedback_by_fingerprint[occurrence.project_fingerprint]
end
def dismissal_feedback_by_fingerprint
strong_memoize(:dismissal_feedback_by_fingerprint) do
pipeline.project.vulnerability_feedback
.with_associations
.where(feedback_type: 'dismissal') # rubocop:disable CodeReuse/ActiveRecord
.group_by(&:project_fingerprint)
end
end
def confidence_levels
Array(params.fetch(:confidence, Vulnerabilities::Occurrence.confidences.keys))
end
def report_types
Array(params.fetch(:report_type, Vulnerabilities::Occurrence.report_types.keys))
end
def default_params
{ report_type: Vulnerabilities::Occurrence.report_types.keys }
def severity_levels
Array(params.fetch(:severity, Vulnerabilities::Occurrence.severities.keys))
end
end
end
---
title: 'Add Vulnerabilities API scoping: severity, confidence, and dismissal'
merge_request: 12076
author:
type: added
......@@ -29,6 +29,16 @@ module API
params do
optional :report_type, type: Array[String], desc: 'The type of report vulnerability belongs to', 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',
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',
default: ::Vulnerabilities::Occurrence.confidences.keys
use :pagination
end
......
......@@ -63,12 +63,104 @@ describe Security::PipelineVulnerabilitiesFinder do
end
end
context 'by scope' do
let(:ds_occurrence) { pipeline.security_reports.reports["dependency_scanning"].occurrences.first }
let(:sast_occurrence) { pipeline.security_reports.reports["sast"].occurrences.first }
let!(:feedback) do
[
create(
:vulnerability_feedback,
:dismissal,
:dependency_scanning,
project: project,
pipeline: pipeline,
project_fingerprint: ds_occurrence.project_fingerprint,
vulnerability_data: ds_occurrence.raw_metadata
),
create(
:vulnerability_feedback,
:dismissal,
:sast,
project: project,
pipeline: pipeline,
project_fingerprint: sast_occurrence.project_fingerprint,
vulnerability_data: sast_occurrence.raw_metadata
)
]
end
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
it 'returns non-dismissed vulnerabilities' do
expect(subject.count).to eq cs_count + dast_count + ds_count + sast_count - feedback.count
expect(subject.map(&:project_fingerprint)).not_to include(*feedback.map(&:project_fingerprint))
end
end
context 'when `dismissed`' do
subject { described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning], scope: 'dismissed' } ).execute }
it 'returns non-dismissed vulnerabilities' do
expect(subject.count).to eq(ds_count - 1)
expect(subject.map(&:project_fingerprint)).not_to include(ds_occurrence.project_fingerprint)
end
end
context 'when `all`' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scope: 'all' } }
it 'returns all vulnerabilities' do
expect(subject.count).to eq cs_count + dast_count + ds_count + sast_count
end
end
end
context 'by severity' do
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
it 'returns all vulnerability severity levels' do
expect(subject.map(&:severity).uniq).to match_array %w[undefined unknown low medium high critical]
end
end
context 'when `low`' do
subject { described_class.new(pipeline: pipeline, params: { severity: 'low' } ).execute }
it 'returns only low-severity vulnerabilities' do
expect(subject.map(&:severity).uniq).to match_array %w[low]
end
end
end
context 'by confidence' do
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
it 'returns all vulnerability confidence levels' do
expect(subject.map(&:confidence).uniq).to match_array %w[undefined low medium high]
end
end
context 'when `medium`' do
subject { described_class.new(pipeline: pipeline, params: { confidence: 'medium' } ).execute }
it 'returns only medium-confidence vulnerabilities' do
expect(subject.map(&:confidence).uniq).to match_array %w[medium]
end
end
end
context 'by all filters' do
context 'with found entity' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning] } }
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scope: 'all' } }
it 'filters by all params' do
expect(subject.count).to eq cs_count + dast_count + ds_count + sast_count
expect(subject.map(&:confidence).uniq).to match_array %w[undefined low medium high]
expect(subject.map(&:severity).uniq).to match_array %w[undefined unknown low medium high critical]
end
end
......
......@@ -6,61 +6,108 @@ describe API::Vulnerabilities do
set(:project) { create(:project, :public) }
set(:user) { create(:user) }
let(:pipeline) do
create(:ci_empty_pipeline, status: :created, project: project)
end
let(:pipeline) { create(:ci_empty_pipeline, 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"] }
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)
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
describe "GET /projects/:id/vulnerabilities" do
context 'with an authorized user with proper permissions' do
before do
project.add_developer(user)
stub_licensed_features(security_dashboard: true, sast: true, dependency_scanning: true, container_scanning: true)
end
it 'returns all vulnerabilities' do
it 'returns all non-dismissed vulnerabilities' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count - 1).to_s
get api("/projects/#{project.id}/vulnerabilities?per_page=40", user)
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('37')
expect(response.headers['X-Total-Pages']).to eql('1')
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.count).to eq 37
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning sast]
end
describe 'filtering' do
it 'returns vulnerabilities with sast report_type' do
occurrence_count = (sast_report.occurrences.count - 1).to_s
get api("/projects/#{project.id}/vulnerabilities", user), params: { report_type: 'sast' }
expect(response).to have_gitlab_http_status(200)
expect(json_response.count).to eq 20
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[sast]
expect(json_response.first['name']).to eq 'Probable insecure usage of temp file/directory.'
expect(json_response.first['name']).to eq 'Predictable pseudorandom number generator'
end
it 'returns vulnerabilities with dependency_scanning report_type' do
occurrence_count = ds_report.occurrences.count.to_s
get api("/projects/#{project.id}/vulnerabilities", user), params: { report_type: 'dependency_scanning' }
expect(response).to have_gitlab_http_status(200)
expect(json_response.count).to eq 4
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.map { |v| v['report_type'] }.uniq).to match_array %w[dependency_scanning]
expect(json_response.first['name']).to eq 'DoS by CPU exhaustion when using malicious SSL packets'
end
it 'returns dismissed vulnerabilities with `all` scope' do
occurrence_count = (sast_report.occurrences.count + ds_report.occurrences.count).to_s
get api("/projects/#{project.id}/vulnerabilities", user), params: { per_page: 40, scope: 'all' }
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Total']).to eq occurrence_count
expect(json_response.first['name']).to eq 'Probable insecure usage of temp file/directory.'
expect(json_response.first['vulnerability_feedback_dismissal_path']).to be_present
end
it 'returns vulnerabilities with low severity' do
get api("/projects/#{project.id}/vulnerabilities", 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 vulnerabilities with high confidence' do
get api("/projects/#{project.id}/vulnerabilities", 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
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