diff --git a/ee/app/controllers/concerns/vulnerability_findings_actions.rb b/ee/app/controllers/concerns/vulnerability_findings_actions.rb index 613d719f9a7020ae60b636a97bf32a9e5a41a76f..1e3cb97dbb25c9e602bfdff59dde9389c0d707c9 100644 --- a/ee/app/controllers/concerns/vulnerability_findings_actions.rb +++ b/ee/app/controllers/concerns/vulnerability_findings_actions.rb @@ -40,6 +40,6 @@ def filter_params 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 diff --git a/ee/app/controllers/groups/security/vulnerabilities_controller.rb b/ee/app/controllers/groups/security/vulnerabilities_controller.rb index 5bfaacf9b1981a65f6b6cd74e8ac6bf1345ee5ed..19c84d06c26cedb73bb78ed24746f8d07d20a1d0 100644 --- a/ee/app/controllers/groups/security/vulnerabilities_controller.rb +++ b/ee/app/controllers/groups/security/vulnerabilities_controller.rb @@ -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 diff --git a/ee/app/finders/security/vulnerabilities_finder.rb b/ee/app/finders/security/vulnerabilities_finder.rb index ef10d00b408a4c1f4cb44649066eba798ddc6dc3..3954ca2c1629bcd72964cdfb7dd7adab76b80021 100644 --- a/ee/app/finders/security/vulnerabilities_finder.rb +++ b/ee/app/finders/security/vulnerabilities_finder.rb @@ -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 -# confidence: Array -# project: Array -# report_type: Array +# 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 diff --git a/ee/app/finders/security/vulnerability_findings_finder.rb b/ee/app/finders/security/vulnerability_findings_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..9f868d770ce4038de60d727360610d3fa5460858 --- /dev/null +++ b/ee/app/finders/security/vulnerability_findings_finder.rb @@ -0,0 +1,79 @@ +# 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 +# confidence: Array +# project: Array +# report_type: Array + +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 diff --git a/ee/app/serializers/vulnerability_entity.rb b/ee/app/serializers/vulnerability_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..abfeaafc46c51d8199dd0b704397a2f6faea94c0 --- /dev/null +++ b/ee/app/serializers/vulnerability_entity.rb @@ -0,0 +1,26 @@ +# 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 diff --git a/ee/lib/api/helpers/vulnerability_findings_helpers.rb b/ee/lib/api/helpers/vulnerability_findings_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..33c38c470fceae90ce746e1801d0990625793007 --- /dev/null +++ b/ee/lib/api/helpers/vulnerability_findings_helpers.rb @@ -0,0 +1,62 @@ +# 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 diff --git a/ee/lib/api/vulnerabilities.rb b/ee/lib/api/vulnerabilities.rb new file mode 100644 index 0000000000000000000000000000000000000000..63e58a366ba8266ce0193f8b55e279c275f7e4d4 --- /dev/null +++ b/ee/lib/api/vulnerabilities.rb @@ -0,0 +1,49 @@ +# 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 diff --git a/ee/lib/api/vulnerability_findings.rb b/ee/lib/api/vulnerability_findings.rb index 5322d1cafce016e99005913d351cab379ba51347..2dbcd0eb3091d8b1a47c86bb4811f9f8b892a761 100644 --- a/ee/lib/api/vulnerability_findings.rb +++ b/ee/lib/api/vulnerability_findings.rb @@ -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 @@ def respond_with_vulnerability_findings 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 diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index bc7de4f1e07a31284ea933a56685baa7d2143af2..375dd0a67f13bfd56883e7fb8f332327eb3fe363 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -35,6 +35,7 @@ module API mount ::API::Scim mount ::API::ManagedLicenses mount ::API::ProjectApprovals + mount ::API::Vulnerabilities mount ::API::VulnerabilityFindings mount ::API::MergeRequestApprovals mount ::API::MergeRequestApprovalRules diff --git a/ee/lib/gitlab/vulnerabilities/history.rb b/ee/lib/gitlab/vulnerabilities/history.rb index f0eb7882ed8494f67a942a42d53120710f41b9bb..749b50fe92f8c3e43d671e8a7b9e3540a585edd0 100644 --- a/ee/lib/gitlab/vulnerabilities/history.rb +++ b/ee/lib/gitlab/vulnerabilities/history.rb @@ -14,17 +14,17 @@ def initialize(group, filters) @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 diff --git a/ee/lib/gitlab/vulnerabilities/history_cache.rb b/ee/lib/gitlab/vulnerabilities/history_cache.rb index 236420da8bc33aea501b6ab0d32b032a25104c32..d6f48547f5949291d4bd62968ced7f48007d1890 100644 --- a/ee/lib/gitlab/vulnerabilities/history_cache.rb +++ b/ee/lib/gitlab/vulnerabilities/history_cache.rb @@ -12,17 +12,19 @@ def initialize(group, project_id) 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 diff --git a/ee/spec/factories/projects.rb b/ee/spec/factories/projects.rb index 2db74a89a6d05cbcd893af5971cf9b566e1e8a9f..2c97a617acae533239ba87aae6284793c33fea0d 100644 --- a/ee/spec/factories/projects.rb +++ b/ee/spec/factories/projects.rb @@ -89,5 +89,11 @@ 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 diff --git a/ee/spec/finders/security/vulnerabilities_finder_spec.rb b/ee/spec/finders/security/vulnerabilities_finder_spec.rb index 17a61c311ba7ea76334e93b1abb391d49a3fdf27..372bfa16e3b5c79dccbbe82e281cf56f64bad54b 100644 --- a/ee/spec/finders/security/vulnerabilities_finder_spec.rb +++ b/ee/spec/finders/security/vulnerabilities_finder_spec.rb @@ -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 diff --git a/ee/spec/finders/security/vulnerability_findings_finder_spec.rb b/ee/spec/finders/security/vulnerability_findings_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..acbbca915e0fb9b35c2d3bf80c77bd2ba15e2120 --- /dev/null +++ b/ee/spec/finders/security/vulnerability_findings_finder_spec.rb @@ -0,0 +1,144 @@ +# 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 diff --git a/ee/spec/fixtures/api/schemas/vulnerability.json b/ee/spec/fixtures/api/schemas/vulnerability.json new file mode 100644 index 0000000000000000000000000000000000000000..703c3a0097edb226a99b6072388fd7686cfc87d0 --- /dev/null +++ b/ee/spec/fixtures/api/schemas/vulnerability.json @@ -0,0 +1,53 @@ +{ + "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" } + } +} diff --git a/ee/spec/fixtures/api/schemas/vulnerability_list.json b/ee/spec/fixtures/api/schemas/vulnerability_list.json new file mode 100644 index 0000000000000000000000000000000000000000..430ac7a386bbfb07428788682758c27d3ec50435 --- /dev/null +++ b/ee/spec/fixtures/api/schemas/vulnerability_list.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "vulnerability.json" } +} diff --git a/ee/spec/lib/gitlab/vulnerabilities/history_spec.rb b/ee/spec/lib/gitlab/vulnerabilities/history_spec.rb index 6fee0f88610cc232a0b0f4ecf543c03aba90c4b7..160816591cb66a86e4af57049ebb5a81a3b0a5dd 100644 --- a/ee/spec/lib/gitlab/vulnerabilities/history_spec.rb +++ b/ee/spec/lib/gitlab/vulnerabilities/history_spec.rb @@ -13,8 +13,8 @@ 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 diff --git a/ee/spec/requests/api/vulnerabilities_spec.rb b/ee/spec/requests/api/vulnerabilities_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c93767ba603fcbf8111e95a7cd65f1d354ba2937 --- /dev/null +++ b/ee/spec/requests/api/vulnerabilities_spec.rb @@ -0,0 +1,48 @@ +# 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 diff --git a/ee/spec/requests/api/vulnerability_findings_spec.rb b/ee/spec/requests/api/vulnerability_findings_spec.rb index d10c3a712342ff198e61f21ca9c96c3fd1be41da..0c0141bdc29ec8f84aabac54e790235ebdf99cd7 100644 --- a/ee/spec/requests/api/vulnerability_findings_spec.rb +++ b/ee/spec/requests/api/vulnerability_findings_spec.rb @@ -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 diff --git a/ee/spec/serializers/vulnerability_entity_spec.rb b/ee/spec/serializers/vulnerability_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d422955b12fbaeeed2b970e7254b440fc9eaf25 --- /dev/null +++ b/ee/spec/serializers/vulnerability_entity_spec.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/ee/spec/support/shared_examples/requests/api/vulnerabilities_shared_examples.rb b/ee/spec/support/shared_examples/requests/api/vulnerabilities_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..f71cc233c9c3684495a4208ec1efaa2509927860 --- /dev/null +++ b/ee/spec/support/shared_examples/requests/api/vulnerabilities_shared_examples.rb @@ -0,0 +1,220 @@ +# 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