From 441cad8a96aadf07a82fc8bd1a8b6a8f3dcdf544 Mon Sep 17 00:00:00 2001 From: Mehmet Emin INAC <minac@gitlab.com> Date: Mon, 12 Dec 2022 14:51:47 +0000 Subject: [PATCH] Use `finding_data` to generate response This commit introduces a new finder class to be used while we are transitioning from using artifacts to utilizing data stored in the DB. This new finder class will mainly use the data stored in the database except the remediations are still read from report artifacts by using the `multi_read` method. With this change, we will also no longer show the findings of purged security scans. Changelog: fixed EE: true --- app/uploaders/gitlab_uploader.rb | 9 + ee/app/finders/security/findings_finder.rb | 5 +- .../finders/security/pure_findings_finder.rb | 39 ++ ee/app/models/security/finding.rb | 71 +++- ee/app/models/security/remediations_proxy.rb | 30 ++ ee/app/models/security/scan.rb | 4 + ee/app/models/vulnerabilities/finding.rb | 2 +- ee/app/models/vulnerabilities/read.rb | 6 + .../vulnerabilities/finding_entity.rb | 6 +- .../development/utilize_finding_data.yml | 8 + ee/lib/api/vulnerability_findings.rb | 33 +- ee/spec/factories/security_scans.rb | 21 + .../finders/security/findings_finder_spec.rb | 381 ++---------------- .../security/pure_findings_finder_spec.rb | 22 + ee/spec/models/security/finding_spec.rb | 138 ++++++- .../security/remediations_proxy_spec.rb | 55 +++ ee/spec/models/security/scan_spec.rb | 17 +- ee/spec/models/vulnerabilities/read_spec.rb | 3 +- .../api/vulnerability_findings_spec.rb | 92 +++-- .../findings_finder_shared_examples.rb | 343 ++++++++++++++++ spec/uploaders/gitlab_uploader_spec.rb | 15 +- 21 files changed, 868 insertions(+), 432 deletions(-) create mode 100644 ee/app/finders/security/pure_findings_finder.rb create mode 100644 ee/app/models/security/remediations_proxy.rb create mode 100644 ee/config/feature_flags/development/utilize_finding_data.yml create mode 100644 ee/spec/finders/security/pure_findings_finder_spec.rb create mode 100644 ee/spec/models/security/remediations_proxy_spec.rb create mode 100644 ee/spec/support/shared_examples/finders/security/findings_finder_shared_examples.rb diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 29091eb93ef1..62024bff4c08 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -115,6 +115,15 @@ def open end end + def multi_read(offsets) + open do |stream| + offsets.map do |start_offset, end_offset| + stream.seek(start_offset) + stream.read(end_offset - start_offset + 1) + end + end + end + # Used to replace an existing upload with another +file+ without modifying stored metadata # Use this method only to repair/replace an existing upload, or to upload to a Geo secondary node # diff --git a/ee/app/finders/security/findings_finder.rb b/ee/app/finders/security/findings_finder.rb index 19f127fa01f7..7846422a8b5c 100644 --- a/ee/app/finders/security/findings_finder.rb +++ b/ee/app/finders/security/findings_finder.rb @@ -19,7 +19,8 @@ class FindingsFinder include ::VulnerabilityFindingHelpers ResultSet = Struct.new(:relation, :findings) do - delegate :current_page, :limit_value, :total_pages, :total_count, :next_page, :prev_page, to: :relation + delegate :current_page, :limit_value, :total_pages, :total_count, + :next_page, :prev_page, :page, :empty?, to: :relation end DEFAULT_PAGE = 1 @@ -31,7 +32,7 @@ def initialize(pipeline, params: {}) end def execute - return unless has_security_findings? + return ResultSet.new(Security::Finding.none.page(DEFAULT_PAGE), []) unless has_security_findings? ResultSet.new(security_findings, findings) end diff --git a/ee/app/finders/security/pure_findings_finder.rb b/ee/app/finders/security/pure_findings_finder.rb new file mode 100644 index 000000000000..b123a0b4a06c --- /dev/null +++ b/ee/app/finders/security/pure_findings_finder.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Security::PureFindingsFinder +# +# This finder returns Active Record relation of `Security::Finding` model +# which is different than the other finders maintained by threat insights. +# It's called pure because it does not depend on report artifacts(mostly) +# and uses the data stored in the `Security::Finding` model. +# +# Arguments: +# pipeline - object to filter findings +# params: +# severity: Array<String> +# confidence: Array<String> +# report_type: Array<String> +# scope: String +# page: Int +# per_page: Int + +module Security + class PureFindingsFinder < FindingsFinder + def execute + security_findings.tap do |findings| + findings.each(&:remediations) # initiates Batchloader + end + end + + def available? + pipeline.security_findings.first&.finding_data.present? + end + + private + + def security_findings + super.with_feedbacks + .with_vulnerability + end + end +end diff --git a/ee/app/models/security/finding.rb b/ee/app/models/security/finding.rb index e12f1d68f654..e61b4855f881 100644 --- a/ee/app/models/security/finding.rb +++ b/ee/app/models/security/finding.rb @@ -10,9 +10,12 @@ module Security class Finding < ApplicationRecord include EachBatch + include Presentable include PartitionedTable MAX_PARTITION_SIZE = 10.gigabyte + ATTRIBUTES_DELEGATED_TO_FINDING_DATA = %i[name description solution location identifiers links false_positive? + assets evidence details remediation_byte_offsets].freeze self.table_name = 'security_findings' self.primary_key = :id # As ActiveRecord does not support compound PKs @@ -26,8 +29,15 @@ class Finding < ApplicationRecord belongs_to :scan, inverse_of: :findings, optional: false belongs_to :scanner, class_name: 'Vulnerabilities::Scanner', inverse_of: :security_findings, optional: false + belongs_to :vulnerability_read, + class_name: 'Vulnerabilities::Read', + primary_key: :uuid, + foreign_key: :uuid, + inverse_of: :security_findings has_one :build, through: :scan, disable_joins: true + has_one :vulnerability, through: :vulnerability_read + has_many :feedbacks, class_name: 'Vulnerabilities::Feedback', inverse_of: :security_finding, @@ -62,15 +72,17 @@ class Finding < ApplicationRecord .where('vulnerability_feedback.finding_uuid = security_findings.uuid')) end scope :latest, -> { joins(:scan).merge(Security::Scan.latest_successful) } - scope :ordered, -> { order(severity: :desc, confidence: :desc, id: :asc) } + scope :ordered, -> { order(severity: :desc, id: :asc) } scope :with_pipeline_entities, -> { preload(build: [:job_artifacts, :pipeline]) } - scope :with_scan, -> { includes(:scan) } + scope :with_scan, -> { preload(:scan) } scope :with_scanner, -> { includes(:scanner) } + scope :with_feedbacks, -> { includes(:feedbacks) } + scope :with_vulnerability, -> { includes(:vulnerability) } scope :deduplicated, -> { where(deduplicated: true) } scope :grouped_by_scan_type, -> { joins(:scan).group('security_scans.scan_type') } - delegate :scan_type, :pipeline, to: :scan, allow_nil: true - delegate :project, to: :pipeline + delegate :scan_type, :pipeline, :remediations_proxy, to: :scan, allow_nil: true + delegate :project, :sha, to: :pipeline class << self def count_by_scan_type @@ -103,5 +115,56 @@ def last_finding_in_partition(partition_number) where(partition_number: partition_number).last end end + + # Following alias attributes as used by `Vulnerabilities::FindingEntity` + alias_attribute :raw_metadata, :finding_data + alias_attribute :report_type, :scan_type + + def dismissal_feedback + feedbacks.find(&:for_dismissal?) + end + + def issue_feedback + feedbacks.find(&:for_issue?) + end + + def merge_request_feedback + feedbacks.find(&:for_merge_request?) + end + + def state + return vulnerability.state if vulnerability + + dismissal_feedback ? 'dismissed' : 'detected' + end + + # Symbolizing the hash keys is important as Grape entity + # works with symbolized keys only. + # See https://github.com/ruby-grape/grape-entity/issues/223 + def symbolized_finding_data + @symbolized_finding_data ||= finding_data.deep_symbolize_keys + end + + def finding_data=(value) + super + ensure + @symbolized_finding_data = nil + end + + # Defines methods for the keys exist in `finding_data` to support the same + # interface with `Vulnerabilities::Finding` model as these methods are used + # by `Vulnerabilities::FindingEntity`. + ATTRIBUTES_DELEGATED_TO_FINDING_DATA.each do |delegated_attribute| + define_method(delegated_attribute) do + symbolized_finding_data.fetch(delegated_attribute) + end + end + + def remediations + return [] unless symbolized_finding_data[:remediation_byte_offsets] + + symbolized_finding_data[:remediation_byte_offsets].map { |offset| offset.values_at(:start_byte, :end_byte) } + .then { remediations_proxy.by_byte_offsets(_1) } + end end end diff --git a/ee/app/models/security/remediations_proxy.rb b/ee/app/models/security/remediations_proxy.rb new file mode 100644 index 000000000000..0c5f12726401 --- /dev/null +++ b/ee/app/models/security/remediations_proxy.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Security + class RemediationsProxy + attr_reader :file + + def initialize(file) + @file = file + end + + def by_byte_offsets(byte_offsets) + byte_offsets.map do |offsets| + batch_load(offsets) + end + end + + private + + def batch_load(offsets) + BatchLoader.for(offsets).batch(key: object_id) do |byte_offsets, loader| + byte_offsets.uniq + .sort + .then { |offsets| file.multi_read(offsets) } + .map { |data| Gitlab::Json.parse(data) } + .zip(byte_offsets) + .each { |remediation, offsets| loader.call(offsets, remediation) } + end + end + end +end diff --git a/ee/app/models/security/scan.rb b/ee/app/models/security/scan.rb index 88a9417c6db9..756573f56a2f 100644 --- a/ee/app/models/security/scan.rb +++ b/ee/app/models/security/scan.rb @@ -107,6 +107,10 @@ def report_primary_identifiers @report_primary_identifiers ||= security_report&.primary_identifiers end + def remediations_proxy + @remediations_proxy ||= RemediationsProxy.new(job_artifact.file) + end + private def security_report diff --git a/ee/app/models/vulnerabilities/finding.rb b/ee/app/models/vulnerabilities/finding.rb index 9f97eaff4a53..fccbbf8bc516 100644 --- a/ee/app/models/vulnerabilities/finding.rb +++ b/ee/app/models/vulnerabilities/finding.rb @@ -380,7 +380,7 @@ def pipeline_branch end def false_positive? - vulnerability_flags.false_positive.any? + vulnerability_flags.any?(&:false_positive?) end def first_finding_pipeline diff --git a/ee/app/models/vulnerabilities/read.rb b/ee/app/models/vulnerabilities/read.rb index 5a244bde35b4..f4299fcb56d4 100644 --- a/ee/app/models/vulnerabilities/read.rb +++ b/ee/app/models/vulnerabilities/read.rb @@ -12,6 +12,12 @@ class Read < ApplicationRecord belongs_to :project belongs_to :scanner, class_name: 'Vulnerabilities::Scanner' + has_many :security_findings, + class_name: 'Security::Finding', + primary_key: :uuid, + foreign_key: :uuid, + inverse_of: :vulnerability_read + validates :vulnerability_id, uniqueness: true, presence: true validates :project_id, presence: true validates :scanner_id, presence: true diff --git a/ee/app/serializers/vulnerabilities/finding_entity.rb b/ee/app/serializers/vulnerabilities/finding_entity.rb index f878020ed1e9..ae5ec53908ef 100644 --- a/ee/app/serializers/vulnerabilities/finding_entity.rb +++ b/ee/app/serializers/vulnerabilities/finding_entity.rb @@ -13,9 +13,7 @@ class Vulnerabilities::FindingEntity < Grape::Entity expose :create_jira_issue_url do |occurrence| create_jira_issue_url_for(occurrence) end - expose :false_positive, if: -> (_, _) { expose_false_positive? } do |occurrence| - occurrence.vulnerability_flags.any?(&:false_positive?) - end + expose :false_positive?, as: :false_positive, if: -> (_, _) { expose_false_positive? } expose :create_vulnerability_feedback_issue_path do |occurrence| create_vulnerability_feedback_issue_path(occurrence.project) end @@ -53,7 +51,7 @@ class Vulnerabilities::FindingEntity < Grape::Entity expose :scan expose :blob_path do |occurrence| - occurrence.present.blob_path + occurrence.present(presenter_class: Vulnerabilities::FindingPresenter).blob_path end alias_method :occurrence, :object diff --git a/ee/config/feature_flags/development/utilize_finding_data.yml b/ee/config/feature_flags/development/utilize_finding_data.yml new file mode 100644 index 000000000000..336bdce5ad09 --- /dev/null +++ b/ee/config/feature_flags/development/utilize_finding_data.yml @@ -0,0 +1,8 @@ +--- +name: utilize_finding_data +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/103652 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/383430 +milestone: '15.7' +type: development +group: group::threat insights +default_enabled: false diff --git a/ee/lib/api/vulnerability_findings.rb b/ee/lib/api/vulnerability_findings.rb index 38f49165c42b..67d998917437 100644 --- a/ee/lib/api/vulnerability_findings.rb +++ b/ee/lib/api/vulnerability_findings.rb @@ -23,31 +23,26 @@ def vulnerability_findings @vulnerability_findings ||= begin return paginate(Kaminari.paginate_array([])) unless pipeline - with_adaptive_finder || with_vulnerabilities_finder + with_pure_finder || with_adaptive_finder end end - # We are using this fallback to provide an early response to the users - # until the `security_findings` are stored. - # We will remove this fallback by https://gitlab.com/gitlab-org/gitlab/-/issues/334488 - def with_vulnerabilities_finder - aggregated_report = Security::PipelineVulnerabilitiesFinder.new(pipeline: pipeline, params: declared_params).execute - - # We might have to add rubocop:disable annotation here in case - # https://gitlab.com/gitlab-org/gitlab/issues/32763 happens, using - # Kaminari.paginate_array here is correct - # See https://gitlab.com/gitlab-org/gitlab/issues/33588#note_291849433 - # for discussion - paginate(Kaminari.paginate_array(aggregated_report.findings)) - rescue Security::PipelineVulnerabilitiesFinder::ParseError - paginate(Kaminari.paginate_array([])) + def with_pure_finder + pure_finder = Security::PureFindingsFinder.new(pipeline, params: declared_params) + return unless Feature.enabled?(:utilize_finding_data, pipeline.project) && pure_finder.available? + + paginate(pure_finder.execute) end def with_adaptive_finder result = Security::FindingsFinder.new(pipeline, params: declared_params).execute - return unless result + paginate(result) + + if Feature.disabled?(:deprecate_vulnerabilities_feedback, user_project) + Gitlab::Vulnerabilities::FindingsPreloader.preload_feedback!(result.findings) + end - paginate(result).findings + result.findings end end @@ -101,10 +96,6 @@ def with_adaptive_finder get ':id/vulnerability_findings' do authorize! :read_security_resource, user_project - if Feature.disabled?(:deprecate_vulnerabilities_feedback, user_project) - Gitlab::Vulnerabilities::FindingsPreloader.preload_feedback!(vulnerability_findings) - end - present vulnerability_findings, with: ::Vulnerabilities::FindingEntity, request: GrapeRequestProxy.new(request, current_user) diff --git a/ee/spec/factories/security_scans.rb b/ee/spec/factories/security_scans.rb index 08d5a741a330..2362ca73ec36 100644 --- a/ee/spec/factories/security_scans.rb +++ b/ee/spec/factories/security_scans.rb @@ -23,5 +23,26 @@ trait :purged do status { :purged } end + + trait :with_findings do + # rubocop:disable RSpec/FactoryBot/StrategyInCallback + after(:create) do |scan| + artifact = create(:ee_ci_job_artifact, scan.scan_type, job: scan.build, project: scan.project) + report = create(:ci_reports_security_report, pipeline: scan.pipeline, type: scan.scan_type) + + Gitlab::Ci::Parsers.fabricate!(scan.scan_type, artifact.file.read, report).parse! + report = Security::MergeReportsService.new(report).execute + + report.findings.each do |finding| + create(:security_finding, + severity: finding.severity, + confidence: finding.confidence, + uuid: finding.uuid, + deduplicated: true, + scan: scan) + end + end + # rubocop:enable RSpec/FactoryBot/StrategyInCallback + end end end diff --git a/ee/spec/finders/security/findings_finder_spec.rb b/ee/spec/finders/security/findings_finder_spec.rb index 2310652e48de..dbf94aeb4c1f 100644 --- a/ee/spec/finders/security/findings_finder_spec.rb +++ b/ee/spec/finders/security/findings_finder_spec.rb @@ -2,377 +2,56 @@ require 'spec_helper' -RSpec.describe Security::FindingsFinder do - let_it_be(:pipeline) { create(:ci_pipeline) } - let_it_be(:build_1) { create(:ci_build, :success, name: 'dependency_scanning', pipeline: pipeline) } - let_it_be(:build_2) { create(:ci_build, :success, name: 'sast', pipeline: pipeline) } - let_it_be(:artifact_ds) { create(:ee_ci_job_artifact, :dependency_scanning, job: build_1) } - let_it_be(:artifact_sast) { create(:ee_ci_job_artifact, :sast, job: build_2) } - let_it_be(:report_ds) { create(:ci_reports_security_report, pipeline: pipeline, type: :dependency_scanning) } - let_it_be(:report_sast) { create(:ci_reports_security_report, pipeline: pipeline, type: :sast) } +RSpec.describe Security::FindingsFinder, feature_category: :vulnerability_management do + it_behaves_like 'security findings finder' do + let(:findings) { service_object.execute.findings } + let(:query_limit) { 11 } - let(:severity_levels) { nil } - let(:confidence_levels) { nil } - let(:report_types) { nil } - let(:scope) { nil } - let(:page) { nil } - let(:per_page) { nil } - let(:service_object) { described_class.new(pipeline, params: params) } - let(:params) do - { - severity: severity_levels, - confidence: confidence_levels, - report_type: report_types, - scope: scope, - page: page, - per_page: per_page - } - end - - describe '#execute' do - context 'when the pipeline does not have security findings' do - subject { service_object.execute } - - it { is_expected.to be_nil } - end - - context 'when the pipeline has security findings' do - let(:finder_result) { service_object.execute } - - before(:all) do - ds_content = File.read(artifact_ds.file.path) - Gitlab::Ci::Parsers::Security::DependencyScanning.parse!(ds_content, report_ds) - report_ds.merge!(report_ds) - sast_content = File.read(artifact_sast.file.path) - Gitlab::Ci::Parsers::Security::Sast.parse!(sast_content, report_sast) - report_sast.merge!(report_sast) - - findings = { artifact_ds => report_ds, artifact_sast => report_sast }.collect do |artifact, report| - scan = create(:security_scan, :latest_successful, scan_type: artifact.job.name, build: artifact.job) - - report.findings.collect do |finding, index| - create(:security_finding, - severity: finding.severity, - confidence: finding.confidence, - uuid: finding.uuid, - deduplicated: true, - scan: scan) - end - end.flatten - - findings.second.update!(deduplicated: false) - end - - before do - stub_licensed_features(sast: true, dependency_scanning: true) - end - - context 'N+1 queries' do - it 'does not cause N+1 queries' do - expect { finder_result }.not_to exceed_query_limit(11) - end + context 'when the `security_findings` records have `overridden_uuid`s' do + let(:security_findings) { Security::Finding.by_build_ids(build_1) } + let(:expected_uuids) do + Security::Finding.where(overridden_uuid: nil).pluck(:uuid) + .concat(Security::Finding.where.not(overridden_uuid: nil).pluck(:overridden_uuid)) - + [Security::Finding.second[:overridden_uuid]] end - describe '#current_page' do - subject { finder_result.current_page } - - context 'when the page is not provided' do - it { is_expected.to be(1) } - end - - context 'when the page is provided' do - let(:page) { 2 } - - it { is_expected.to be(2) } - end - end - - describe '#limit_value' do - subject { finder_result.limit_value } - - context 'when the per_page is not provided' do - it { is_expected.to be(20) } - end - - context 'when the per_page is provided' do - let(:per_page) { 100 } - - it { is_expected.to be(100) } - end - end - - describe '#total_pages' do - subject { finder_result.total_pages } - - context 'when the per_page is not provided' do - it { is_expected.to be(1) } - end + subject { findings.map(&:uuid) } - context 'when the per_page is provided' do - let(:per_page) { 3 } - - it { is_expected.to be(3) } - end - end - - describe '#total_count' do - subject { finder_result.total_count } - - context 'when the scope is not provided' do - let(:finding_to_dismiss) { Security::Finding.first } - - before do - stub_feature_flags(deprecate_vulnerabilities_feedback: deprecate_vulnerabilities_feedback?) - end - - context 'when the `deprecate_vulnerabilities_feedback` feature is enabled' do - let(:deprecate_vulnerabilities_feedback?) { true } - - before do - create(:vulnerabilities_finding, :dismissed, uuid: finding_to_dismiss.uuid) - end - - it { is_expected.to be(7) } - end - - context 'when the `deprecate_vulnerabilities_feedback` feature is disabled' do - let(:deprecate_vulnerabilities_feedback?) { false } - - before do - create(:vulnerability_feedback, - :dismissal, - project: pipeline.project, - category: :dependency_scanning, - finding_uuid: finding_to_dismiss.uuid) - end - - it { is_expected.to be(7) } - end - end - - context 'when the scope is provided as `all`' do - let(:scope) { 'all' } - - it { is_expected.to be(8) } + before do + security_findings.each do |security_finding| + security_finding.update!(overridden_uuid: security_finding.uuid, uuid: SecureRandom.uuid) end end - describe '#next_page' do - subject { finder_result.next_page } - - context 'when the page is not provided' do - # Limit per_page to force pagination on smaller dataset - let(:per_page) { 2 } - - it { is_expected.to be(2) } - end - - context 'when the page is provided' do - let(:page) { 2 } + it { is_expected.to match_array(expected_uuids) } + end - it { is_expected.to be_nil } - end + describe '#vulnerability_flags' do + before do + stub_licensed_features(sast_fp_reduction: true) end - describe '#prev_page' do - subject { finder_result.prev_page } - - context 'when the page is not provided' do - it { is_expected.to be_nil } - end - - context 'when the page is provided' do - let(:page) { 2 } - # Limit per_page to force pagination on smaller dataset - let(:per_page) { 2 } - - it { is_expected.to be(1) } + context 'with no vulnerability flags present' do + it 'does not have any vulnerability flag' do + expect(findings).to all(have_attributes(vulnerability_flags: be_empty)) end end - describe '#vulnerability_flags' do + context 'with some vulnerability flags present' do before do - stub_licensed_features(sast_fp_reduction: true) - end - - context 'with no vulnerability flags present' do - it 'does not have any vulnerability flag' do - expect(finder_result.findings).to all(have_attributes(vulnerability_flags: be_empty)) - end - end - - context 'with some vulnerability flags present' do - before do - allow_next_instance_of(Gitlab::Ci::Reports::Security::Finding) do |finding| - allow(finding).to receive(:flags).and_return([create(:ci_reports_security_flag)]) if finding.report_type == 'sast' - end - end - - it 'has some vulnerability_findings with vulnerability flag' do - expect(finder_result.findings).to include(have_attributes(vulnerability_flags: be_present)) - end - - it 'does not have any vulnerability_flag if license is not available' do - stub_licensed_features(sast_fp_reduction: false) - - expect(finder_result.findings).to all(have_attributes(vulnerability_flags: be_empty)) - end - end - end - - describe '#findings' do - subject { finder_result.findings.map(&:uuid) } - - context 'with the default parameters' do - let(:expected_uuids) { Security::Finding.pluck(:uuid) - [Security::Finding.second[:uuid]] } - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the uuid is provided' do - let(:uuid) { Security::Finding.pick(:uuid) } - let(:params) do - { - uuid: uuid - } - end - - it { is_expected.to match_array([uuid]) } - end - - context 'when the page is provided' do - let(:page) { 2 } - # Limit per_page to force pagination on smaller dataset - let(:per_page) { 2 } - let(:expected_uuids) { Security::Finding.pluck(:uuid)[4..5] } - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the per_page is provided' do - let(:per_page) { 1 } - let(:expected_uuids) { [Security::Finding.pick(:uuid)] } - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the `severity_levels` is provided' do - let(:severity_levels) { [:medium] } - let(:expected_uuids) { Security::Finding.where(severity: 'medium').pluck(:uuid) } - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the `confidence_levels` is provided' do - let(:confidence_levels) { [:low] } - let(:expected_uuids) { Security::Finding.where(confidence: 'low' ).pluck(:uuid) } - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the `report_types` is provided' do - let(:report_types) { :dependency_scanning } - let(:expected_uuids) do - Security::Finding.by_scan(Security::Scan.find_by(scan_type: 'dependency_scanning')).pluck(:uuid) - - [Security::Finding.second[:uuid]] + allow_next_instance_of(Gitlab::Ci::Reports::Security::Finding) do |finding| + allow(finding).to receive(:flags).and_return([create(:ci_reports_security_flag)]) if finding.report_type == 'sast' end - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when the `scope` is provided as `all`' do - let(:scope) { 'all' } - - let(:expected_uuids) { Security::Finding.pluck(:uuid) - [Security::Finding.second[:uuid]] } - - it { is_expected.to match_array(expected_uuids) } end - context 'when there is a retried build' do - let(:retried_build) { create(:ci_build, :success, :retried, name: 'dependency_scanning', pipeline: pipeline) } - let(:artifact) { create(:ee_ci_job_artifact, :dependency_scanning, job: retried_build) } - let(:report) { create(:ci_reports_security_report, pipeline: pipeline, type: :dependency_scanning) } - let(:report_types) { :dependency_scanning } - let(:expected_uuids) do - Security::Finding.by_scan(Security::Scan.find_by(scan_type: 'dependency_scanning')).pluck(:uuid) - - [Security::Finding.second[:uuid]] - end - - before do - retried_content = File.read(artifact.file.path) - Gitlab::Ci::Parsers::Security::DependencyScanning.parse!(retried_content, report) - report.merge!(report) - - scan = create(:security_scan, scan_type: retried_build.name, build: retried_build, latest: false) - - report.findings.each_with_index do |finding, index| - create(:security_finding, - severity: finding.severity, - confidence: finding.confidence, - uuid: finding.uuid, - deduplicated: true, - scan: scan) - end - end - - it { is_expected.to match_array(expected_uuids) } + it 'has some vulnerability_findings with vulnerability flag' do + expect(findings).to include(have_attributes(vulnerability_flags: be_present)) end - context 'when the `security_findings` records have `overridden_uuid`s' do - let(:security_findings) { Security::Finding.by_build_ids(build_1) } - - let(:expected_uuids) do - Security::Finding.where(overridden_uuid: nil).pluck(:uuid) - .concat(Security::Finding.where.not(overridden_uuid: nil).pluck(:overridden_uuid)) - - [Security::Finding.second[:overridden_uuid]] - end + it 'does not have any vulnerability_flag if license is not available' do + stub_licensed_features(sast_fp_reduction: false) - before do - security_findings.each do |security_finding| - security_finding.update!(overridden_uuid: security_finding.uuid, uuid: SecureRandom.uuid) - end - end - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when a build has more than one security report artifacts' do - let(:report_types) { :secret_detection } - let(:secret_detection_report) { create(:ci_reports_security_report, pipeline: pipeline, type: :secret_detection) } - let(:expected_uuids) { secret_detection_report.findings.map(&:uuid) } - - before do - scan = create(:security_scan, :latest_successful, scan_type: :secret_detection, build: build_2) - artifact = create(:ee_ci_job_artifact, :secret_detection, job: build_2) - report_content = File.read(artifact.file.path) - - Gitlab::Ci::Parsers::Security::SecretDetection.parse!(report_content, secret_detection_report) - - secret_detection_report.findings.each_with_index do |finding, index| - create(:security_finding, - severity: finding.severity, - confidence: finding.confidence, - uuid: finding.uuid, - deduplicated: true, - scan: scan) - end - end - - it { is_expected.to match_array(expected_uuids) } - end - - context 'when a vulnerability already exist for a security finding' do - let(:vulnerability) { create(:vulnerability, :with_finding, project: pipeline.project) } - - subject { finder_result.findings.map(&:vulnerability).first } - - before do - vulnerability.finding.update_attribute(:uuid, Security::Finding.first.uuid) - end - - describe 'the vulnerability is included in results' do - it { is_expected.to eq(vulnerability) } - end + expect(findings).to all(have_attributes(vulnerability_flags: be_empty)) end end end diff --git a/ee/spec/finders/security/pure_findings_finder_spec.rb b/ee/spec/finders/security/pure_findings_finder_spec.rb new file mode 100644 index 000000000000..c0228596584d --- /dev/null +++ b/ee/spec/finders/security/pure_findings_finder_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::PureFindingsFinder, feature_category: :vulnerability_management do + it_behaves_like 'security findings finder' do + let(:findings) { finder_result.to_a } + let(:query_limit) { 8 } + + describe 'parsing artifacts' do + before do + allow(::Gitlab::Ci::Parsers).to receive(:fabricate!) + end + + it 'does not parse artifacts' do + service_object.execute + + expect(::Gitlab::Ci::Parsers).not_to have_received(:fabricate!) + end + end + end +end diff --git a/ee/spec/models/security/finding_spec.rb b/ee/spec/models/security/finding_spec.rb index 90fbe64923df..60a12d287582 100644 --- a/ee/spec/models/security/finding_spec.rb +++ b/ee/spec/models/security/finding_spec.rb @@ -2,16 +2,18 @@ require 'spec_helper' -RSpec.describe Security::Finding do +RSpec.describe Security::Finding, feature_category: :vulnerability_management do let_it_be(:scan_1) { create(:security_scan, :latest_successful, scan_type: :sast) } let_it_be(:scan_2) { create(:security_scan, :latest_successful, scan_type: :dast) } - let_it_be(:finding_1) { create(:security_finding, scan: scan_1) } - let_it_be(:finding_2) { create(:security_finding, scan: scan_2) } + let_it_be(:finding_1, refind: true) { create(:security_finding, scan: scan_1) } + let_it_be(:finding_2, refind: true) { create(:security_finding, scan: scan_2) } describe 'associations' do it { is_expected.to belong_to(:scan).required } it { is_expected.to belong_to(:scanner).required } + it { is_expected.to belong_to(:vulnerability_read).class_name('Vulnerabilities::Read') } it { is_expected.to have_one(:build).through(:scan) } + it { is_expected.to have_one(:vulnerability).through(:vulnerability_read) } it { is_expected.to have_many(:feedbacks) @@ -148,16 +150,16 @@ end describe '.ordered' do - let_it_be(:finding_3) { create(:security_finding, severity: :critical, confidence: :confirmed) } - let_it_be(:finding_4) { create(:security_finding, severity: :critical, confidence: :high) } + let_it_be(:finding_3) { create(:security_finding, severity: :critical) } + let_it_be(:finding_4) { create(:security_finding, severity: :critical) } let(:expected_findings) { [finding_3, finding_4, finding_1, finding_2] } subject { described_class.ordered } before do - finding_1.update!(severity: :high, confidence: :unknown) - finding_2.update!(severity: :low, confidence: :confirmed) + finding_1.update!(severity: :high) + finding_2.update!(severity: :low) end it { is_expected.to eq(expected_findings) } @@ -287,4 +289,126 @@ it { is_expected.to match(1) } end end + + describe '#state' do + subject { finding_1.state } + + context 'when there is no associated vulnerability' do + context 'when there is no associated dismissal feedback' do + it { is_expected.to eq('detected') } + end + + context 'when there is an associated dismissal feedback' do + before do + create(:vulnerability_feedback, :dismissal, finding_uuid: finding_1.uuid) + end + + it { is_expected.to eq('dismissed') } + end + end + + context 'when there is an associated vulnerability' do + where(:state) { %i[detected confirmed dismissed resolved] } + + before do + create(:vulnerabilities_finding, state, uuid: finding_1.uuid) + end + + with_them do + it { is_expected.to eq(state.to_s) } + end + end + end + + describe 'feedback accessors' do + shared_examples_for 'has feedback method for' do |type| + context 'when there is no associated dismissal feedback' do + it { is_expected.to be_nil } + end + + context 'when there is an associated dismissal feedback' do + let!(:feedback) { create(:vulnerability_feedback, type, finding_uuid: finding_1.uuid) } + + it { is_expected.to eq(feedback) } + end + end + + describe '#dismissal_feedback' do + it_behaves_like 'has feedback method for', :dismissal do + subject { finding_1.dismissal_feedback } + end + end + + describe '#issue_feedback' do + it_behaves_like 'has feedback method for', :issue do + subject { finding_1.issue_feedback } + end + end + + describe '#merge_request_feedback' do + it_behaves_like 'has feedback method for', :merge_request do + subject { finding_1.merge_request_feedback } + end + end + end + + describe 'attributes delegated to `finding_data`' do + using RSpec::Parameterized::TableSyntax + + where(:attribute, :expected_value) do + :name | 'Test finding' + :description | 'Test description' + :solution | 'Test solution' + :location | 'Test location' + :identifiers | ['Test identifier'] + :links | ['Test link'] + :false_positive? | false + :assets | ['Test asset'] + :evidence | {} + :details | [] + :remediation_byte_offsets | { start_byte: 0, end_byte: 1 } + end + + with_them do + let(:finding) { build(:security_finding) } + + subject { finding.send(attribute) } + + before do + finding.finding_data[attribute] = expected_value + end + + it { is_expected.to eq(expected_value) } + end + end + + describe '#remediations', :aggregate_failures do + let(:finding) { create(:security_finding, finding_data: finding_data) } + let(:mock_remediations) { [Object.new] } + let(:mock_proxy) { instance_double(Security::RemediationsProxy, by_byte_offsets: mock_remediations) } + + subject(:remediations) { finding.remediations } + + before do + allow(finding.scan).to receive(:remediations_proxy).and_return(mock_proxy) + end + + context 'when the remediation byte offsets do not exist' do + let(:finding_data) { {} } + + it 'does not call the proxy and returns an empty array' do + expect(remediations).to be_empty + expect(mock_proxy).not_to have_received(:by_byte_offsets) + end + end + + context 'when the remediation byte offsets exist' do + let(:finding_data) { { remediation_byte_offsets: [{ start_byte: 0, end_byte: 10 }] } } + + it 'delegates the call to the proxy' do + expect(remediations).to eq(mock_remediations) + expect(mock_proxy).to have_received(:by_byte_offsets) + end + end + end end diff --git a/ee/spec/models/security/remediations_proxy_spec.rb b/ee/spec/models/security/remediations_proxy_spec.rb new file mode 100644 index 000000000000..97984c661f7b --- /dev/null +++ b/ee/spec/models/security/remediations_proxy_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::RemediationsProxy, feature_category: :vulnerability_management do + let(:dast_artifact) { create(:ci_job_artifact, :common_security_report) } + let(:file) { dast_artifact.file } + let(:model) { described_class.new(file) } + + describe '#by_byte_offsets' do + # The following byte offsets are collected by parsing the related artifact with Oj::Introspect. + # If the specs fail due to a change in the related artifact, you can collect them again by parsing + # the artifact again and checking the `:__oj_introspect` keys for remediations. + let(:remediation_1_byte_offsets) { [11842, 12008] } + let(:remediation_2_byte_offsets) { [12015, 12181] } + let(:remediation_3_byte_offsets) { remediation_2_byte_offsets } + + subject(:data_fragments) do + model.by_byte_offsets([remediation_1_byte_offsets, remediation_2_byte_offsets, remediation_2_byte_offsets]) + .map(&:deep_symbolize_keys) + end + + before do + allow(file).to receive(:multi_read).and_call_original + end + + it 'returns remediations by given byte offsets' do + expect(data_fragments).to eq( + [ + { + diff: 'dG90YWxseSBsZWdpdCBkaWZm', + summary: 'this remediates CVE-2137', + fixes: [{ cve: 'CVE-2137' }] + }, + { + diff: 'dG90YWxseSBsZWdpdCBkaWZm', + summary: 'this remediates CVE-2138', + fixes: [{ cve: 'CVE-2138' }] + }, + { + diff: 'dG90YWxseSBsZWdpdCBkaWZm', + summary: 'this remediates CVE-2138', + fixes: [{ cve: 'CVE-2138' }] + } + ] + ) + end + + it 'delegates the call to GitlabUploader#multi_read with unique offsets' do + data_fragments + + expect(file).to have_received(:multi_read).once.with([remediation_1_byte_offsets, remediation_2_byte_offsets]) + end + end +end diff --git a/ee/spec/models/security/scan_spec.rb b/ee/spec/models/security/scan_spec.rb index 46fc5ff80129..ed8688698be6 100644 --- a/ee/spec/models/security/scan_spec.rb +++ b/ee/spec/models/security/scan_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Security::Scan do +RSpec.describe Security::Scan, feature_category: :vulnerability_management do it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:security_scan) } let(:parent) { model.build } @@ -399,6 +399,21 @@ end end + describe '#remediations_proxy' do + let(:mock_file) { instance_double(JobArtifactUploader) } + let(:scan) { create(:security_scan, :with_findings) } + + subject { scan.remediations_proxy } + + before do + allow_next_found_instance_of(Ci::JobArtifact) do |artifact| + allow(artifact).to receive(:file).and_return(mock_file) + end + end + + it { is_expected.to be_an_instance_of(Security::RemediationsProxy).and have_attributes(file: mock_file) } + end + it_behaves_like 'having unique enum values' it 'sets `project_id` and `pipeline_id` before save' do diff --git a/ee/spec/models/vulnerabilities/read_spec.rb b/ee/spec/models/vulnerabilities/read_spec.rb index 4817b082b0cd..55c400b3fa7a 100644 --- a/ee/spec/models/vulnerabilities/read_spec.rb +++ b/ee/spec/models/vulnerabilities/read_spec.rb @@ -2,13 +2,14 @@ require 'spec_helper' -RSpec.describe Vulnerabilities::Read, type: :model do +RSpec.describe Vulnerabilities::Read, type: :model, feature_category: :vulnerability_management do let_it_be(:project) { create(:project) } describe 'associations' do it { is_expected.to belong_to(:vulnerability) } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:scanner).class_name('Vulnerabilities::Scanner') } + it { is_expected.to have_many(:security_findings).class_name('Security::Finding') } end describe 'validations' do diff --git a/ee/spec/requests/api/vulnerability_findings_spec.rb b/ee/spec/requests/api/vulnerability_findings_spec.rb index 1fc73e7889dc..8c377633f898 100644 --- a/ee/spec/requests/api/vulnerability_findings_spec.rb +++ b/ee/spec/requests/api/vulnerability_findings_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::VulnerabilityFindings do +RSpec.describe API::VulnerabilityFindings, feature_category: :vulnerability_management do include AccessMatchersForRequest let_it_be(:project) { create(:project, :public) } @@ -11,24 +11,23 @@ describe 'GET /projects/:id/vulnerability_findings' do let(:project_vulnerability_findings_path) { "/projects/#{project.id}/vulnerability_findings" } - 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, status: :created, project: project) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, status: :created, project: project) } + let_it_be(:pipeline_without_vulnerabilities) { create(:ci_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_it_be(:build_ds) { create(:ci_build, :success, name: 'ds_job', pipeline: pipeline, project: project) } + let_it_be(: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 + before(:all) do + create(:security_scan, :latest_successful, :with_findings, scan_type: :dependency_scanning, build: build_ds) + create(:security_scan, :latest_successful, :with_findings, scan_type: :sast, build: build_sast) + end + + before do + stub_licensed_features(security_dashboard: true, sast: true, dependency_scanning: true, container_scanning: true) + create(:vulnerability_feedback, :dismissal, :sast, project: project, pipeline: pipeline, @@ -36,6 +35,8 @@ vulnerability_data: sast_report.findings.first.raw_metadata, finding_uuid: sast_report.findings.first.uuid ) + + create(:vulnerabilities_finding, :dismissed, uuid: sast_report.findings.first.uuid) end context 'with an authorized user with proper permissions' do @@ -80,45 +81,58 @@ end describe 'using different finders' do + let(:utilize_finding_data?) { true } + let(:mock_findings_finder) { instance_double(Security::FindingsFinder, execute: []) } + let(:mock_pure_findings_finder) do + instance_double(Security::PureFindingsFinder, + execute: [], available?: pure_finder_available?) + end + before do - allow(Security::PipelineVulnerabilitiesFinder).to receive(:new).and_call_original + stub_feature_flags(utilize_finding_data: utilize_finding_data?) - allow_next_instance_of(Security::FindingsFinder) do |finder| - allow(finder).to receive(:execute).and_return(mock_result) - end - end + allow(Security::FindingsFinder).to receive(:new).and_return(mock_findings_finder) + allow(Security::PureFindingsFinder).to receive(:new).and_return(mock_pure_findings_finder) - context 'when the project uses `security_findings`' do - let(:finding) { create(:vulnerability_finding) } - let(:mock_result) { double(findings: [finding]) } + get api(project_vulnerability_findings_path, user), params: pagination + end - it 'does not use `Security::PipelineVulnerabilitiesFinder`' do - get api(project_vulnerability_findings_path, user), params: pagination + context 'when the `Security::PureFindingsFinder` is not available' do + let(:pure_finder_available?) { false } - expect(Security::PipelineVulnerabilitiesFinder).not_to have_received(:new) + it 'uses the `Security::FindingsFinder`' do + expect(mock_pure_findings_finder).not_to have_received(:execute) + expect(mock_findings_finder).to have_received(:execute) end end - context 'when the project does not use `security_findings`' do - let(:mock_result) { nil } + context 'when the `Security::PureFindingsFinder` is available' do + let(:pure_finder_available?) { true } - it 'fallsback to `Security::PipelineVulnerabilitiesFinder`' do - get api(project_vulnerability_findings_path, user), params: pagination - - expect(Security::PipelineVulnerabilitiesFinder).to have_received(:new) + it 'uses the `Security::FindingsFinder`' do + expect(mock_pure_findings_finder).to have_received(:execute) + expect(mock_findings_finder).not_to have_received(:execute) end + end + + context 'when the `utilize_finding_data` feature flag is turned off' do + let(:utilize_finding_data?) { false } - context 'when the `Security::PipelineVulnerabilitiesFinder` raises exception' do - before do - allow_next_instance_of(Security::PipelineVulnerabilitiesFinder) do |finder| - allow(finder).to receive(:execute).and_raise(Security::PipelineVulnerabilitiesFinder::ParseError.new('failed')) - end + context 'when the `Security::PureFindingsFinder` is not available' do + let(:pure_finder_available?) { false } + + it 'uses the `Security::FindingsFinder`' do + expect(mock_pure_findings_finder).not_to have_received(:execute) + expect(mock_findings_finder).to have_received(:execute) end + end - it 'does not propagate the error to the client' do - get api(project_vulnerability_findings_path, user), params: pagination + context 'when the `Security::PureFindingsFinder` is available' do + let(:pure_finder_available?) { true } - expect(response).to have_gitlab_http_status(:ok) + it 'uses the `Security::FindingsFinder`' do + expect(mock_pure_findings_finder).not_to have_received(:execute) + expect(mock_findings_finder).to have_received(:execute) end end end diff --git a/ee/spec/support/shared_examples/finders/security/findings_finder_shared_examples.rb b/ee/spec/support/shared_examples/finders/security/findings_finder_shared_examples.rb new file mode 100644 index 000000000000..c126c5748e7e --- /dev/null +++ b/ee/spec/support/shared_examples/finders/security/findings_finder_shared_examples.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'security findings finder' do + let_it_be(:pipeline) { create(:ci_pipeline) } + let_it_be(:build_1) { create(:ci_build, :success, name: 'dependency_scanning', pipeline: pipeline) } + let_it_be(:build_2) { create(:ci_build, :success, name: 'sast', pipeline: pipeline) } + let_it_be(:artifact_ds) { create(:ee_ci_job_artifact, :dependency_scanning, job: build_1) } + let_it_be(:artifact_sast) { create(:ee_ci_job_artifact, :sast, job: build_2) } + let_it_be(:report_ds) { create(:ci_reports_security_report, pipeline: pipeline, type: :dependency_scanning) } + let_it_be(:report_sast) { create(:ci_reports_security_report, pipeline: pipeline, type: :sast) } + + let(:severity_levels) { nil } + let(:confidence_levels) { nil } + let(:report_types) { nil } + let(:scope) { nil } + let(:page) { nil } + let(:per_page) { nil } + let(:service_object) { described_class.new(pipeline, params: params) } + let(:params) do + { + severity: severity_levels, + confidence: confidence_levels, + report_type: report_types, + scope: scope, + page: page, + per_page: per_page + } + end + + describe '#execute' do + context 'when the pipeline does not have security findings' do + subject { service_object.execute } + + it { is_expected.to be_empty } + end + + context 'when the pipeline has security findings' do + let(:finder_result) { service_object.execute } + + before(:all) do + ds_content = File.read(artifact_ds.file.path) + Gitlab::Ci::Parsers::Security::DependencyScanning.parse!(ds_content, report_ds) + report_ds.merge!(report_ds) + sast_content = File.read(artifact_sast.file.path) + Gitlab::Ci::Parsers::Security::Sast.parse!(sast_content, report_sast) + report_sast.merge!(report_sast) + + findings = { artifact_ds => report_ds, artifact_sast => report_sast }.collect do |artifact, report| + scan = create(:security_scan, :latest_successful, scan_type: artifact.job.name, build: artifact.job) + + report.findings.collect do |finding, index| + create(:security_finding, + severity: finding.severity, + confidence: finding.confidence, + uuid: finding.uuid, + deduplicated: true, + scan: scan) + end + end.flatten + + findings.second.update!(deduplicated: false) + + create(:vulnerability_feedback, + :dismissal, + project: pipeline.project, + category: :sast, + finding_uuid: findings.first.uuid) + end + + before do + stub_licensed_features(sast: true, dependency_scanning: true) + end + + describe 'N+1 queries' do + it 'does not cause N+1 queries' do + expect { finder_result }.not_to exceed_query_limit(query_limit) + end + end + + describe '#current_page' do + subject { finder_result.current_page } + + context 'when the page is not provided' do + it { is_expected.to be(1) } + end + + context 'when the page is provided' do + let(:page) { 2 } + + it { is_expected.to be(2) } + end + end + + describe '#limit_value' do + subject { finder_result.limit_value } + + context 'when the per_page is not provided' do + it { is_expected.to be(20) } + end + + context 'when the per_page is provided' do + let(:per_page) { 100 } + + it { is_expected.to be(100) } + end + end + + describe '#total_pages' do + subject { finder_result.total_pages } + + context 'when the per_page is not provided' do + it { is_expected.to be(1) } + end + + context 'when the per_page is provided' do + let(:per_page) { 3 } + + it { is_expected.to be(3) } + end + end + + describe '#total_count' do + subject { finder_result.total_count } + + context 'when the scope is not provided' do + let(:finding_to_dismiss) { Security::Finding.first } + + before do + stub_feature_flags(deprecate_vulnerabilities_feedback: deprecate_vulnerabilities_feedback?) + end + + context 'when the `deprecate_vulnerabilities_feedback` feature is enabled' do + let(:deprecate_vulnerabilities_feedback?) { true } + + before do + create(:vulnerabilities_finding, :dismissed, uuid: finding_to_dismiss.uuid) + end + + it { is_expected.to be(7) } + end + + context 'when the `deprecate_vulnerabilities_feedback` feature is disabled' do + let(:deprecate_vulnerabilities_feedback?) { false } + + before do + create(:vulnerability_feedback, + :dismissal, + project: pipeline.project, + category: :dependency_scanning, + finding_uuid: finding_to_dismiss.uuid) + end + + it { is_expected.to be(7) } + end + end + + context 'when the scope is provided as `all`' do + let(:scope) { 'all' } + + it { is_expected.to be(8) } + end + end + + describe '#next_page' do + subject { finder_result.next_page } + + context 'when the page is not provided' do + # Limit per_page to force pagination on smaller dataset + let(:per_page) { 2 } + + it { is_expected.to be(2) } + end + + context 'when the page is provided' do + let(:page) { 2 } + + it { is_expected.to be_nil } + end + end + + describe '#prev_page' do + subject { finder_result.prev_page } + + context 'when the page is not provided' do + it { is_expected.to be_nil } + end + + context 'when the page is provided' do + let(:page) { 2 } + # Limit per_page to force pagination on smaller dataset + let(:per_page) { 2 } + + it { is_expected.to be(1) } + end + end + + describe '#findings' do + subject { findings.map(&:uuid) } + + context 'with the default parameters' do + let(:expected_uuids) { Security::Finding.pluck(:uuid) - [Security::Finding.second[:uuid]] } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the uuid is provided' do + let(:uuid) { Security::Finding.pick(:uuid) } + let(:params) do + { + uuid: uuid + } + end + + it { is_expected.to match_array([uuid]) } + end + + context 'when the page is provided' do + let(:page) { 2 } + # Limit per_page to force pagination on smaller dataset + let(:per_page) { 2 } + let(:expected_uuids) { Security::Finding.pluck(:uuid)[4..5] } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the per_page is provided' do + let(:per_page) { 1 } + let(:expected_uuids) { [Security::Finding.pick(:uuid)] } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the `severity_levels` is provided' do + let(:severity_levels) { [:medium] } + let(:expected_uuids) { Security::Finding.where(severity: 'medium').pluck(:uuid) } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the `confidence_levels` is provided' do + let(:confidence_levels) { [:low] } + let(:expected_uuids) { Security::Finding.where(confidence: 'low').pluck(:uuid) } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the `report_types` is provided' do + let(:report_types) { :dependency_scanning } + let(:expected_uuids) do + Security::Finding.by_scan(Security::Scan.find_by(scan_type: 'dependency_scanning')).pluck(:uuid) - + [Security::Finding.second[:uuid]] + end + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when the `scope` is provided as `all`' do + let(:scope) { 'all' } + + let(:expected_uuids) { Security::Finding.pluck(:uuid) - [Security::Finding.second[:uuid]] } + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when there is a retried build' do + let(:retried_build) { create(:ci_build, :success, :retried, name: 'dependency_scanning', pipeline: pipeline) } + let(:artifact) { create(:ee_ci_job_artifact, :dependency_scanning, job: retried_build) } + let(:report) { create(:ci_reports_security_report, pipeline: pipeline, type: :dependency_scanning) } + let(:report_types) { :dependency_scanning } + let(:expected_uuids) do + Security::Finding.by_scan(Security::Scan.find_by(scan_type: 'dependency_scanning')).pluck(:uuid) - + [Security::Finding.second[:uuid]] + end + + before do + retried_content = File.read(artifact.file.path) + Gitlab::Ci::Parsers::Security::DependencyScanning.parse!(retried_content, report) + report.merge!(report) + + scan = create(:security_scan, scan_type: retried_build.name, build: retried_build, latest: false) + + report.findings.each_with_index do |finding, index| + create(:security_finding, + severity: finding.severity, + confidence: finding.confidence, + uuid: finding.uuid, + deduplicated: true, + scan: scan) + end + end + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when a build has more than one security report artifacts' do + let(:report_types) { :secret_detection } + let(:expected_uuids) { secret_detection_report.findings.map(&:uuid) } + let(:secret_detection_report) do + create(:ci_reports_security_report, + pipeline: pipeline, + type: :secret_detection) + end + + before do + scan = create(:security_scan, :latest_successful, scan_type: :secret_detection, build: build_2) + artifact = create(:ee_ci_job_artifact, :secret_detection, job: build_2) + report_content = File.read(artifact.file.path) + + Gitlab::Ci::Parsers::Security::SecretDetection.parse!(report_content, secret_detection_report) + + secret_detection_report.findings.each_with_index do |finding, index| + create(:security_finding, + severity: finding.severity, + confidence: finding.confidence, + uuid: finding.uuid, + deduplicated: true, + scan: scan) + end + end + + it { is_expected.to match_array(expected_uuids) } + end + + context 'when a vulnerability already exist for a security finding' do + let!(:vulnerability_finding) do + create(:vulnerabilities_finding, + :detected, + uuid: Security::Finding.first.uuid, + project: pipeline.project) + end + + subject { findings.map(&:vulnerability).first } + + describe 'the vulnerability is included in results' do + it { is_expected.to eq(vulnerability_finding.vulnerability) } + end + end + end + end + end +end diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb index db70441aaf56..f62ab7266319 100644 --- a/spec/uploaders/gitlab_uploader_spec.rb +++ b/spec/uploaders/gitlab_uploader_spec.rb @@ -6,7 +6,7 @@ RSpec.describe GitlabUploader do let(:uploader_class) { Class.new(described_class) } - subject { uploader_class.new(double) } + subject(:uploader) { uploader_class.new(double) } describe '#file_storage?' do context 'when file storage is used' do @@ -161,6 +161,19 @@ end end + describe '#multi_read' do + let(:file) { fixture_file_upload('spec/fixtures/trace/sample_trace', 'text/plain') } + let(:byte_offsets) { [[4, 10], [17, 29]] } + + subject { uploader.multi_read(byte_offsets) } + + before do + uploader.store!(file) + end + + it { is_expected.to eq(%w[Running gitlab-runner]) } + end + describe '.version' do subject { uploader_class.version } -- GitLab