Commit f0c1975b authored by Steve Abrams's avatar Steve Abrams

Merge branch...

Merge branch '331724_add_filtering_by_state_to_pipeline_security_report_findings_resolver' into 'master'

Add filtering by state to PipelineSecurityReportFindingsResolver

See merge request gitlab-org/gitlab!64762
parents 526e99ae d550e65a
...@@ -11180,6 +11180,7 @@ four standard [pagination arguments](#connection-pagination-arguments): ...@@ -11180,6 +11180,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="pipelinesecurityreportfindingsreporttype"></a>`reportType` | [`[String!]`](#string) | Filter vulnerability findings by report type. | | <a id="pipelinesecurityreportfindingsreporttype"></a>`reportType` | [`[String!]`](#string) | Filter vulnerability findings by report type. |
| <a id="pipelinesecurityreportfindingsscanner"></a>`scanner` | [`[String!]`](#string) | Filter vulnerability findings by Scanner.externalId. | | <a id="pipelinesecurityreportfindingsscanner"></a>`scanner` | [`[String!]`](#string) | Filter vulnerability findings by Scanner.externalId. |
| <a id="pipelinesecurityreportfindingsseverity"></a>`severity` | [`[String!]`](#string) | Filter vulnerability findings by severity. | | <a id="pipelinesecurityreportfindingsseverity"></a>`severity` | [`[String!]`](#string) | Filter vulnerability findings by severity. |
| <a id="pipelinesecurityreportfindingsstate"></a>`state` | [`[VulnerabilityState!]`](#vulnerabilitystate) | Filter vulnerability findings by state. |
##### `Pipeline.testSuite` ##### `Pipeline.testSuite`
......
...@@ -10,6 +10,10 @@ ...@@ -10,6 +10,10 @@
# params: # params:
# report_type: Array<String> # report_type: Array<String>
# DEPRECATED: This finder class is deprecated and will be removed
# by https://gitlab.com/gitlab-org/gitlab/-/issues/334488.
# Please inform us by adding a comment to aforementioned issue,
# if you need to add an extra feature to this class.
module Security module Security
class PipelineVulnerabilitiesFinder class PipelineVulnerabilitiesFinder
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
...@@ -24,18 +28,18 @@ module Security ...@@ -24,18 +28,18 @@ module Security
end end
def execute def execute
findings = requested_reports.each_with_object([]) do |(type, report), findings| findings = requested_reports.each_with_object([]) do |report, findings|
raise ParseError, 'JSON parsing failed' if report.errored? raise ParseError, 'JSON parsing failed' if report.errored?
normalized_findings = normalize_report_findings( normalized_findings = normalize_report_findings(
report.findings, report.findings,
vulnerabilities_by_finding_fingerprint(type, report)) vulnerabilities_by_finding_fingerprint(report))
filtered_findings = filter(normalized_findings) filtered_findings = filter(normalized_findings)
findings.concat(filtered_findings) findings.concat(filtered_findings)
end end
Gitlab::Ci::Reports::Security::AggregatedReport.new(requested_reports.values, sort_findings(findings)) Gitlab::Ci::Reports::Security::AggregatedReport.new(requested_reports, sort_findings(findings))
end end
private private
...@@ -53,20 +57,23 @@ module Security ...@@ -53,20 +57,23 @@ module Security
end end
def requested_reports def requested_reports
@requested_reports ||= pipeline&.security_reports(report_types: report_types)&.reports || {} @requested_reports ||= pipeline&.security_reports(report_types: report_types)&.reports&.values || []
end end
def vulnerabilities_by_finding_fingerprint(report_type, report) def vulnerabilities_by_finding_fingerprint(report)
Vulnerabilities::Finding existing_findings_for(report).each_with_object({}) do |finding, memo|
.by_project_fingerprints(report.findings.map(&:project_fingerprint)) memo[finding.project_fingerprint] = finding.vulnerability
.by_projects(pipeline.project)
.by_report_types(report_type)
.select(:vulnerability_id, :project_fingerprint)
.each_with_object({}) do |finding, hash|
hash[finding.project_fingerprint] = finding.vulnerability_id
end end
end end
def existing_findings_for(report)
Vulnerabilities::Finding.by_project_fingerprints(report.findings.map(&:project_fingerprint))
.by_projects(pipeline.project)
.by_report_types(report.type)
.includes(:vulnerability) # rubocop:disable CodeReuse/ActiveRecord (We will remove this class)
.select(:vulnerability_id, :project_fingerprint)
end
# This finder is used for fetching vulnerabilities for any pipeline, if we used it to fetch # This finder is used for fetching vulnerabilities for any pipeline, if we used it to fetch
# vulnerabilities for a non-default-branch, the findings will be unpersisted, so we # vulnerabilities for a non-default-branch, the findings will be unpersisted, so we
# coerce the POROs into unpersisted AR records to give them a common object. # coerce the POROs into unpersisted AR records to give them a common object.
...@@ -80,7 +87,7 @@ module Security ...@@ -80,7 +87,7 @@ module Security
finding = Vulnerabilities::Finding.new(finding_hash) finding = Vulnerabilities::Finding.new(finding_hash)
# assigning Vulnerabilities to Findings to enable the computed state # assigning Vulnerabilities to Findings to enable the computed state
finding.location_fingerprint = report_finding.location.fingerprint finding.location_fingerprint = report_finding.location.fingerprint
finding.vulnerability_id = vulnerabilities[finding.project_fingerprint] finding.vulnerability = vulnerabilities[finding.project_fingerprint]
finding.project = pipeline.project finding.project = pipeline.project
finding.sha = pipeline.sha finding.sha = pipeline.sha
finding.build_scanner(report_finding.scanner&.to_hash) finding.build_scanner(report_finding.scanner&.to_hash)
...@@ -100,6 +107,7 @@ module Security ...@@ -100,6 +107,7 @@ module Security
def filter(findings) def filter(findings)
findings.select do |finding| findings.select do |finding|
next unless in_selected_state?(finding)
next if !include_dismissed? && dismissal_feedback?(finding) next if !include_dismissed? && dismissal_feedback?(finding)
next unless confidence_levels.include?(finding.confidence) next unless confidence_levels.include?(finding.confidence)
next unless severity_levels.include?(finding.severity) next unless severity_levels.include?(finding.severity)
...@@ -109,8 +117,26 @@ module Security ...@@ -109,8 +117,26 @@ module Security
end end
end end
def in_selected_state?(finding)
params[:state].blank? || states.include?(computed_finding_state(finding))
end
# Here we are checking the state of the `vulnerability` and preloaded `feedback` records
# instead of checking the `finding.state` as the `state` method of the `finding` fires
# an additional database query to load the `feedback` record for each `finding`.
def computed_finding_state(finding)
finding.vulnerability&.state ||
(dismissal_feedback?(finding) ? 'dismissed' : 'detected')
end
def include_dismissed? def include_dismissed?
params[:scope] == 'all' skip_scope_parameter? || params[:scope] == 'all'
end
# If the client explicitly asks for the dismissed findings, we shouldn't
# filter by the `scope` parameter as it's `skip_dismissed` by default.
def skip_scope_parameter?
params[:state].present? && states.include?('dismissed')
end end
def dismissal_feedback?(finding) def dismissal_feedback?(finding)
...@@ -145,19 +171,23 @@ module Security ...@@ -145,19 +171,23 @@ module Security
end end
def confidence_levels def confidence_levels
Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys)) @confidence_levels ||= Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys))
end end
def report_types def report_types
Array(params.fetch(:report_type, Vulnerabilities::Finding.report_types.keys)) @report_types ||= Array(params.fetch(:report_type, Vulnerabilities::Finding.report_types.keys))
end end
def severity_levels def severity_levels
Array(params.fetch(:severity, Vulnerabilities::Finding.severities.keys)) @severity_levels ||= Array(params.fetch(:severity, Vulnerabilities::Finding.severities.keys))
end end
def scanners def scanners
Array(params.fetch(:scanner, [])) @scanners ||= Array(params.fetch(:scanner, []))
end
def states
@state ||= Array(params.fetch(:state, Vulnerability.states.keys))
end end
end end
end end
...@@ -18,6 +18,10 @@ module Resolvers ...@@ -18,6 +18,10 @@ module Resolvers
required: false, required: false,
description: 'Filter vulnerability findings by Scanner.externalId.' description: 'Filter vulnerability findings by Scanner.externalId.'
argument :state, [Types::VulnerabilityStateEnum],
required: false,
description: 'Filter vulnerability findings by state.'
def resolve(**args) def resolve(**args)
Security::PipelineVulnerabilitiesFinder.new(pipeline: pipeline, params: args).execute.findings Security::PipelineVulnerabilitiesFinder.new(pipeline: pipeline, params: args).execute.findings
end end
......
...@@ -333,6 +333,74 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -333,6 +333,74 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
end end
end end
context 'by state' do
let(:params) { {} }
let(:aggregated_report) { described_class.new(pipeline: pipeline, params: params).execute }
subject(:finding_uuids) { aggregated_report.findings.map(&:uuid) }
let(:finding_with_feedback) { pipeline.security_reports.reports['sast'].findings.first }
before do
create(:vulnerability_feedback, :dismissal,
:sast,
project: project,
pipeline: pipeline,
category: finding_with_feedback.report_type,
project_fingerprint: finding_with_feedback.project_fingerprint,
vulnerability_data: finding_with_feedback.raw_metadata,
finding_uuid: finding_with_feedback.uuid)
end
context 'when the state parameter is not given' do
it 'returns all findings' do
expect(finding_uuids.length).to be(40)
end
end
context 'when the state parameter is given' do
let(:params) { { state: state } }
let(:finding_with_associated_vulnerability) { pipeline.security_reports.reports['dependency_scanning'].findings.first }
before do
vulnerability = create(:vulnerability, state, project: project)
create(:vulnerabilities_finding, :identifier,
vulnerability: vulnerability,
report_type: finding_with_associated_vulnerability.report_type,
project: project,
project_fingerprint: finding_with_associated_vulnerability.project_fingerprint,
uuid: finding_with_associated_vulnerability.uuid)
end
context 'when the given state is `dismissed`' do
let(:state) { 'dismissed' }
it { is_expected.to match_array([finding_with_associated_vulnerability.uuid, finding_with_feedback.uuid]) }
end
context 'when the given state is `detected`' do
let(:state) { 'detected' }
it 'returns all detected findings' do
expect(finding_uuids.length).to be(40)
end
end
context 'when the given state is `confirmed`' do
let(:state) { 'confirmed' }
it { is_expected.to match_array([finding_with_associated_vulnerability.uuid]) }
end
context 'when the given state is `resolved`' do
let(:state) { 'resolved' }
it { is_expected.to match_array([finding_with_associated_vulnerability.uuid]) }
end
end
end
context 'by all filters' do context 'by all filters' do
context 'with found entity' do context 'with found entity' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scanner: %w[bundler_audit find_sec_bugs gemnasium trivy zaproxy], scope: 'all' } } let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scanner: %w[bundler_audit find_sec_bugs gemnasium trivy zaproxy], scope: 'all' } }
......
...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::PipelineSecurityReportFindingsResolver do ...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::PipelineSecurityReportFindingsResolver do
let_it_be(:pipeline, reload: true) { create(:ci_pipeline, :success, project: project) } let_it_be(:pipeline, reload: true) { create(:ci_pipeline, :success, project: project) }
describe '#resolve' do describe '#resolve' do
subject { resolve(described_class, obj: pipeline, args: params) } subject(:resolve_query) { resolve(described_class, obj: pipeline, args: params) }
let_it_be(:low_vulnerability_finding) { build(:vulnerabilities_finding, severity: :low, report_type: :dast, project: project) } let_it_be(:low_vulnerability_finding) { build(:vulnerabilities_finding, severity: :low, report_type: :dast, project: project) }
let_it_be(:critical_vulnerability_finding) { build(:vulnerabilities_finding, severity: :critical, report_type: :sast, project: project) } let_it_be(:critical_vulnerability_finding) { build(:vulnerabilities_finding, severity: :critical, report_type: :sast, project: project) }
...@@ -49,5 +49,19 @@ RSpec.describe Resolvers::PipelineSecurityReportFindingsResolver do ...@@ -49,5 +49,19 @@ RSpec.describe Resolvers::PipelineSecurityReportFindingsResolver do
is_expected.to contain_exactly(critical_vulnerability_finding, low_vulnerability_finding) is_expected.to contain_exactly(critical_vulnerability_finding, low_vulnerability_finding)
end end
end end
context 'when given states' do
let(:params) { { state: %w(detected confirmed) } }
before do
allow(Security::PipelineVulnerabilitiesFinder).to receive(:new).and_call_original
end
it 'calls the finder class with given parameters' do
resolve_query
expect(Security::PipelineVulnerabilitiesFinder).to have_received(:new).with(pipeline: pipeline, params: params)
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment