# frozen_string_literal: true # Security::PipelineVulnerabilitiesFinder # # Used to retrieve security vulnerabilities from an associated Pipeline, # This involves normalizing Report::Occurrence POROs to Vulnerabilities::Finding # # Arguments: # pipeline - object to filter vulnerabilities # params: # report_type: Array<String> module Security class PipelineVulnerabilitiesFinder include Gitlab::Utils::StrongMemoize ParseError = Class.new(Gitlab::Ci::Parsers::ParserError) attr_accessor :params attr_reader :pipeline def initialize(pipeline:, params: {}) @pipeline = pipeline @params = params end def execute findings = requested_reports.each_with_object([]) do |(type, report), findings| raise ParseError, 'JSON parsing failed' if report.error.is_a?(Gitlab::Ci::Parsers::Security::Common::SecurityReportParserError) normalized_findings = normalize_report_findings( report.findings, vulnerabilities_by_finding_fingerprint(type, report)) filtered_findings = filter(normalized_findings) findings.concat(filtered_findings) end Gitlab::Ci::Reports::Security::AggregatedReport.new(requested_reports.values, sort_findings(findings)) end private def sort_findings(findings) # This sort is stable (see https://en.wikipedia.org/wiki/Sorting_algorithm#Stability) contrary to the bare # Ruby sort_by method. Using just sort_by leads to instability across different platforms (e.g., x86_64-linux and # x86_64-darwin18) which in turn leads to different sorting results for the equal elements across these platforms. # This is important because it breaks test suite results consistency between local and CI # environment. # This is easier to address from within the class rather than from tests because this leads to bad class design # and exposing too much of its implementation details to the test suite. # See also https://stackoverflow.com/questions/15442298/is-sort-in-ruby-stable. Gitlab::Utils.stable_sort_by(findings) { |x| [-x.severity_value, -x.confidence_value] } end def requested_reports @requested_reports ||= pipeline&.security_reports(report_types: report_types)&.reports || {} end def vulnerabilities_by_finding_fingerprint(report_type, report) Vulnerabilities::Finding .by_project_fingerprints(report.findings.map(&:project_fingerprint)) .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 # 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 # coerce the POROs into unpersisted AR records to give them a common object. # See https://gitlab.com/gitlab-org/gitlab/issues/33588#note_291849433 for more context # on why this happens. def normalize_report_findings(report_findings, vulnerabilities) report_findings.map do |report_finding| finding_hash = report_finding.to_hash .except(:compare_key, :identifiers, :location, :scanner, :links, :signatures) finding = Vulnerabilities::Finding.new(finding_hash) # assigning Vulnerabilities to Findings to enable the computed state finding.location_fingerprint = report_finding.location.fingerprint finding.vulnerability_id = vulnerabilities[finding.project_fingerprint] finding.project = pipeline.project finding.sha = pipeline.sha finding.build_scanner(report_finding.scanner&.to_hash) finding.finding_links = report_finding.links.map do |link| Vulnerabilities::FindingLink.new(link.to_hash) end finding.identifiers = report_finding.identifiers.map do |identifier| Vulnerabilities::Identifier.new(identifier.to_hash) end finding.signatures = report_finding.signatures.map do |signature| Vulnerabilities::FindingSignature.new(signature.to_hash) end finding end end def filter(findings) findings.select do |finding| next if !include_dismissed? && dismissal_feedback?(finding) next unless confidence_levels.include?(finding.confidence) next unless severity_levels.include?(finding.severity) next if scanners.present? && !scanners.include?(finding.scanner.external_id) finding end end def include_dismissed? params[:scope] == 'all' end def dismissal_feedback?(finding) if ::Feature.enabled?(:vulnerability_finding_signatures, pipeline.project) && !finding.signatures.empty? dismissal_feedback_by_finding_signatures(finding) else dismissal_feedback_by_project_fingerprint(finding) end end def all_dismissal_feedbacks strong_memoize(:all_dismissal_feedbacks) do pipeline.project .vulnerability_feedback .for_dismissal end end def dismissal_feedback_by_finding_signatures(finding) potential_uuids = Set.new([*finding.signature_uuids, finding.uuid].compact) all_dismissal_feedbacks.any? { |dismissal| potential_uuids.include?(dismissal.finding_uuid) } end def dismissal_feedback_by_fingerprint strong_memoize(:dismissal_feedback_by_fingerprint) do all_dismissal_feedbacks.group_by(&:project_fingerprint) end end def dismissal_feedback_by_project_fingerprint(finding) dismissal_feedback_by_fingerprint[finding.project_fingerprint] end def confidence_levels Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys)) end def report_types Array(params.fetch(:report_type, Vulnerabilities::Finding.report_types.keys)) end def severity_levels Array(params.fetch(:severity, Vulnerabilities::Finding.severities.keys)) end def scanners Array(params.fetch(:scanner, [])) end end end