# frozen_string_literal: true module Gitlab module Ci module Reports module Security class VulnerabilityReportsComparer include Gitlab::Utils::StrongMemoize attr_reader :base_report, :head_report ACCEPTABLE_REPORT_AGE = 1.week def initialize(project, base_report, head_report) @base_report = base_report @head_report = head_report @signatures_enabled = project.licensed_feature_available?(:vulnerability_finding_signatures) if @signatures_enabled @added_findings = [] @fixed_findings = [] calculate_changes end end def base_report_created_at @base_report.created_at end def head_report_created_at @head_report.created_at end def base_report_out_of_date return false unless @base_report.created_at ACCEPTABLE_REPORT_AGE.ago > @base_report.created_at end def added strong_memoize(:added) do if @signatures_enabled @added_findings else head_report.findings - base_report.findings end end end def fixed strong_memoize(:fixed) do if @signatures_enabled @fixed_findings else base_report.findings - head_report.findings end end end private def calculate_changes # This is a deconstructed version of the eql? method on # Ci::Reports::Security::Finding. It: # # * precomputes for the head_findings (using FindingMatcher): # * sets of signature shas grouped by priority # * mappings of signature shas to the head finding object # # These are then used when iterating the base findings to perform # fast(er) prioritized, signature-based comparisons between each base finding # and the head findings. # # Both the head_findings and base_findings arrays are iterated once base_findings = base_report.findings head_findings = head_report.findings matcher = FindingMatcher.new(head_findings) base_findings.each do |base_finding| next if base_finding.requires_manual_resolution? matched_head_finding = matcher.find_and_remove_match!(base_finding) @fixed_findings << base_finding if matched_head_finding.nil? end @added_findings = matcher.unmatched_head_findings.values end end class FindingMatcher attr_reader :unmatched_head_findings, :head_findings include Gitlab::Utils::StrongMemoize def initialize(head_findings) @head_findings = head_findings @unmatched_head_findings = @head_findings.index_by(&:object_id) end def find_and_remove_match!(base_finding) matched_head_finding = find_matched_head_finding_for(base_finding) # no signatures matched, so check the normal uuids of the base and head findings # for a match matched_head_finding = head_signatures_shas[base_finding.uuid] if matched_head_finding.nil? @unmatched_head_findings.delete(matched_head_finding.object_id) unless matched_head_finding.nil? matched_head_finding end private def find_matched_head_finding_for(base_finding) base_signature = sorted_signatures_for(base_finding).find do |signature| # at this point a head_finding exists that has a signature with a # matching priority, and a matching sha --> lookup the actual finding # object from head_signatures_shas head_signatures_shas[signature.signature_sha].eql?(base_finding) end base_signature.present? ? head_signatures_shas[base_signature.signature_sha] : nil end def sorted_signatures_for(base_finding) base_finding.signatures.select { |signature| head_finding_signature?(signature) } .sort_by { |sig| -sig.priority } end def head_finding_signature?(signature) head_signatures_priorities[signature.priority].include?(signature.signature_sha) end def head_signatures_priorities strong_memoize(:head_signatures_priorities) do signatures_priorities = Hash.new { |hash, key| hash[key] = Set.new } head_findings.each_with_object(signatures_priorities) do |head_finding, memo| head_finding.signatures.each do |signature| memo[signature.priority].add(signature.signature_sha) end end end end def head_signatures_shas strong_memoize(:head_signatures_shas) do head_findings.each_with_object({}) do |head_finding, memo| head_finding.signatures.each do |signature| memo[signature.signature_sha] = head_finding end # for the final uuid check when no signatures have matched memo[head_finding.uuid] = head_finding end end end end end end end end