Commit 12418f92 authored by Dmitry Gruzd's avatar Dmitry Gruzd

Merge branch 'improve_vuln_tracking-backend_use_fingerprints' into 'master'

Improve Vulnerability Tracking: Use Signatures [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!54608
parents 49af0f6e 56fcaba4
# frozen_string_literal: true
module VulnerabilityFindingHelpers
extend ActiveSupport::Concern
end
VulnerabilityFindingHelpers.prepend_if_ee('EE::VulnerabilityFindingHelpers')
# frozen_string_literal: true
module VulnerabilityFindingSignatureHelpers
extend ActiveSupport::Concern
end
VulnerabilityFindingSignatureHelpers.prepend_if_ee('EE::VulnerabilityFindingSignatureHelpers')
...@@ -47,10 +47,13 @@ module Security ...@@ -47,10 +47,13 @@ module Security
report_finding = report_finding_for(security_finding) report_finding = report_finding_for(security_finding)
return Vulnerabilities::Finding.new unless report_finding return Vulnerabilities::Finding.new unless report_finding
finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links) finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures)
identifiers = report_finding.identifiers.map do |identifier| identifiers = report_finding.identifiers.map do |identifier|
Vulnerabilities::Identifier.new(identifier.to_hash) Vulnerabilities::Identifier.new(identifier.to_hash)
end end
signatures = report_finding.signatures.map do |signature|
Vulnerabilities::FindingSignature.new(signature.to_hash)
end
Vulnerabilities::Finding.new(finding_data).tap do |finding| Vulnerabilities::Finding.new(finding_data).tap do |finding|
finding.location_fingerprint = report_finding.location.fingerprint finding.location_fingerprint = report_finding.location.fingerprint
...@@ -59,6 +62,7 @@ module Security ...@@ -59,6 +62,7 @@ module Security
finding.sha = pipeline.sha finding.sha = pipeline.sha
finding.scanner = security_finding.scanner finding.scanner = security_finding.scanner
finding.identifiers = identifiers finding.identifiers = identifiers
finding.signatures = signatures
end end
end end
......
...@@ -75,7 +75,7 @@ module Security ...@@ -75,7 +75,7 @@ module Security
def normalize_report_findings(report_findings, vulnerabilities) def normalize_report_findings(report_findings, vulnerabilities)
report_findings.map do |report_finding| report_findings.map do |report_finding|
finding_hash = report_finding.to_hash finding_hash = report_finding.to_hash
.except(:compare_key, :identifiers, :location, :scanner, :links) .except(:compare_key, :identifiers, :location, :scanner, :links, :signatures)
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
...@@ -90,6 +90,9 @@ module Security ...@@ -90,6 +90,9 @@ module Security
finding.identifiers = report_finding.identifiers.map do |identifier| finding.identifiers = report_finding.identifiers.map do |identifier|
Vulnerabilities::Identifier.new(identifier.to_hash) Vulnerabilities::Identifier.new(identifier.to_hash)
end end
finding.signatures = report_finding.signatures.map do |signature|
Vulnerabilities::FindingSignature.new(signature.to_hash)
end
finding finding
end end
...@@ -111,18 +114,36 @@ module Security ...@@ -111,18 +114,36 @@ module Security
end end
def dismissal_feedback?(finding) def dismissal_feedback?(finding)
dismissal_feedback_by_fingerprint[finding.project_fingerprint] 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 end
def dismissal_feedback_by_fingerprint def dismissal_feedback_by_fingerprint
strong_memoize(:dismissal_feedback_by_fingerprint) do strong_memoize(:dismissal_feedback_by_fingerprint) do
pipeline.project all_dismissal_feedbacks.group_by(&:project_fingerprint)
.vulnerability_feedback
.for_dismissal
.group_by(&:project_fingerprint)
end end
end end
def dismissal_feedback_by_project_fingerprint(finding)
dismissal_feedback_by_fingerprint[finding.project_fingerprint]
end
def confidence_levels def confidence_levels
Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys)) Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys))
end end
......
# frozen_string_literal: true
module EE
module VulnerabilityFindingHelpers
extend ActiveSupport::Concern
def matches_signatures(other_signatures, other_uuid)
other_signature_types = other_signatures.index_by(&:algorithm_type)
# highest first
match_result = nil
signatures.sort_by(&:priority).reverse_each do |signature|
matching_other_signature = other_signature_types[signature.algorithm_type]
next if matching_other_signature.nil?
match_result = matching_other_signature == signature
break
end
if match_result.nil?
[uuid, *signature_uuids].include?(other_uuid)
else
match_result
end
end
def signature_uuids
signatures.map do |signature|
hex_sha = signature.signature_hex
::Security::VulnerabilityUUID.generate(
report_type: report_type,
location_fingerprint: hex_sha,
primary_identifier_fingerprint: primary_identifier&.fingerprint,
project_id: project_id
)
end
end
end
end
# frozen_string_literal: true
module EE
module VulnerabilityFindingSignatureHelpers
extend ActiveSupport::Concern
# If the location object describes a physical location within a file
# (filename + line numbers), the 'location' algorithm_type should be used
#
# If the location object describes arbitrary data, then the 'hash'
# algorithm_type should be used.
PRIORITIES = {
scope_offset: 3,
location: 2,
hash: 1
}.with_indifferent_access.freeze
class_methods do
def priority(algorithm_type)
raise ArgumentError.new("No priority for #{algorithm_type.inspect}") unless PRIORITIES.key?(algorithm_type)
PRIORITIES[algorithm_type]
end
end
def priority
self.class.priority(algorithm_type)
end
end
end
...@@ -180,6 +180,7 @@ class License < ApplicationRecord ...@@ -180,6 +180,7 @@ class License < ApplicationRecord
subepics subepics
threat_monitoring threat_monitoring
vulnerability_auto_fix vulnerability_auto_fix
vulnerability_finding_signatures
] ]
EEU_FEATURES.freeze EEU_FEATURES.freeze
......
...@@ -5,6 +5,7 @@ module Vulnerabilities ...@@ -5,6 +5,7 @@ module Vulnerabilities
include ShaAttribute include ShaAttribute
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
include Presentable include Presentable
include ::VulnerabilityFindingHelpers
# https://gitlab.com/groups/gitlab-org/-/epics/3148 # https://gitlab.com/groups/gitlab-org/-/epics/3148
# https://gitlab.com/gitlab-org/gitlab/-/issues/214563#note_370782508 is why the table names are not renamed # https://gitlab.com/gitlab-org/gitlab/-/issues/214563#note_370782508 is why the table names are not renamed
...@@ -332,12 +333,16 @@ module Vulnerabilities ...@@ -332,12 +333,16 @@ module Vulnerabilities
end end
end end
alias_method :==, :eql? # eql? is necessary in some cases like array intersection alias_method :==, :eql?
def eql?(other) def eql?(other)
other.report_type == report_type && return false unless other.report_type == report_type && other.primary_identifier_fingerprint == primary_identifier_fingerprint
other.location_fingerprint == location_fingerprint &&
other.first_fingerprint == first_fingerprint if ::Feature.enabled?(:vulnerability_finding_signatures, project)
matches_signatures(other.signatures, other.uuid)
else
other.location_fingerprint == location_fingerprint
end
end end
# Array.difference (-) method uses hash and eql? methods to do comparison # Array.difference (-) method uses hash and eql? methods to do comparison
...@@ -348,7 +353,7 @@ module Vulnerabilities ...@@ -348,7 +353,7 @@ module Vulnerabilities
# when Finding is persisted and identifiers are not preloaded. # when Finding is persisted and identifiers are not preloaded.
return super if persisted? && !identifiers.loaded? return super if persisted? && !identifiers.loaded?
report_type.hash ^ location_fingerprint.hash ^ first_fingerprint.hash report_type.hash ^ location_fingerprint.hash ^ primary_identifier_fingerprint.hash
end end
def severity_value def severity_value
...@@ -380,7 +385,7 @@ module Vulnerabilities ...@@ -380,7 +385,7 @@ module Vulnerabilities
protected protected
def first_fingerprint def primary_identifier_fingerprint
identifiers.first&.fingerprint identifiers.first&.fingerprint
end end
......
...@@ -5,11 +5,23 @@ module Vulnerabilities ...@@ -5,11 +5,23 @@ module Vulnerabilities
self.table_name = 'vulnerability_finding_signatures' self.table_name = 'vulnerability_finding_signatures'
include BulkInsertSafe include BulkInsertSafe
include VulnerabilityFindingSignatureHelpers
belongs_to :finding, foreign_key: 'finding_id', inverse_of: :signatures, class_name: 'Vulnerabilities::Finding' belongs_to :finding, foreign_key: 'finding_id', inverse_of: :signatures, class_name: 'Vulnerabilities::Finding'
enum algorithm_type: { hash: 1, location: 2, scope_offset: 3 }, _prefix: :algorithm enum algorithm_type: { hash: 1, location: 2, scope_offset: 3 }, _prefix: :algorithm
validates :finding, presence: true validates :finding, presence: true
def signature_hex
signature_sha.unpack1("H*")
end
def eql?(other)
other.algorithm_type == algorithm_type &&
other.signature_sha == signature_sha
end
alias_method :==, :eql?
end end
end end
...@@ -61,17 +61,21 @@ module Security ...@@ -61,17 +61,21 @@ module Security
return return
end end
vulnerability_params = finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :scan, :links) vulnerability_params = finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :scan, :links, :signatures)
entity_params = Gitlab::Json.parse(vulnerability_params&.dig(:raw_metadata)).slice('description', 'message', 'solution', 'cve', 'location') entity_params = Gitlab::Json.parse(vulnerability_params&.dig(:raw_metadata)).slice('description', 'message', 'solution', 'cve', 'location')
# Vulnerabilities::Finding (`vulnerability_occurrences`) # Vulnerabilities::Finding (`vulnerability_occurrences`)
vulnerability_finding = vulnerability_findings_by_uuid[finding.uuid] || vulnerability_finding = vulnerability_findings_by_uuid[finding.uuid] ||
create_new_vulnerability_finding(finding, vulnerability_params.merge(entity_params)) find_or_create_vulnerability_finding(finding, vulnerability_params.merge(entity_params))
update_vulnerability_scanner(finding) unless Feature.enabled?(:optimize_sql_query_for_security_report, project) update_vulnerability_scanner(finding) unless Feature.enabled?(:optimize_sql_query_for_security_report, project)
update_vulnerability_finding(vulnerability_finding, vulnerability_params) update_vulnerability_finding(vulnerability_finding, vulnerability_params)
reset_remediations_for(vulnerability_finding, finding) reset_remediations_for(vulnerability_finding, finding)
update_finding_signatures(finding, vulnerability_finding)
if ::Feature.enabled?(:vulnerability_finding_signatures, project)
update_feedbacks(vulnerability_finding, vulnerability_params[:uuid])
update_finding_signatures(finding, vulnerability_finding)
end
# The maximum number of identifiers is not used in validation # The maximum number of identifiers is not used in validation
# we just want to ignore the rest if a finding has more than that. # we just want to ignore the rest if a finding has more than that.
...@@ -86,8 +90,16 @@ module Security ...@@ -86,8 +90,16 @@ module Security
create_vulnerability(vulnerability_finding, pipeline) create_vulnerability(vulnerability_finding, pipeline)
end end
def find_or_create_vulnerability_finding(finding, create_params)
if ::Feature.enabled?(:vulnerability_finding_signatures, project)
find_or_create_vulnerability_finding_with_signatures(finding, create_params)
else
find_or_create_vulnerability_finding_with_location(finding, create_params)
end
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def create_new_vulnerability_finding(finding, create_params) def find_or_create_vulnerability_finding_with_location(finding, create_params)
find_params = { find_params = {
scanner: scanners_objects[finding.scanner.key], scanner: scanners_objects[finding.scanner.key],
primary_identifier: identifiers_objects[finding.primary_identifier.key], primary_identifier: identifiers_objects[finding.primary_identifier.key],
...@@ -120,6 +132,56 @@ module Security ...@@ -120,6 +132,56 @@ module Security
end end
end end
def get_matched_findings(finding, normalized_signatures, find_params)
project.vulnerability_findings.where(**find_params).filter do |vf|
vf.matches_signatures(normalized_signatures, finding.uuid)
end
end
def find_or_create_vulnerability_finding_with_signatures(finding, create_params)
find_params = {
# this isn't taking prioritization into account (happens in the filter
# block below), but it *does* limit the number of findings we have to sift through
location_fingerprint: [finding.location.fingerprint, *finding.signatures.map(&:signature_hex)],
scanner: scanners_objects[finding.scanner.key],
primary_identifier: identifiers_objects[finding.primary_identifier.key]
}
normalized_signatures = finding.signatures.map do |signature|
::Vulnerabilities::FindingSignature.new(signature.to_hash)
end
matched_findings = get_matched_findings(finding, normalized_signatures, find_params)
begin
vulnerability_finding = matched_findings.first
if vulnerability_finding.nil?
find_params[:uuid] = finding.uuid
vulnerability_finding = project
.vulnerability_findings
.create_with(create_params)
.find_or_initialize_by(find_params)
end
vulnerability_finding.uuid = finding.uuid
vulnerability_finding.location_fingerprint = if finding.signatures.empty?
finding.location.fingerprint
else
finding.signatures.max_by(&:priority).signature_hex
end
vulnerability_finding.location = create_params.dig(:location)
vulnerability_finding.save!
vulnerability_finding
rescue ActiveRecord::RecordNotUnique
get_matched_findings(finding, normalized_signatures, find_params).first
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_and_raise_exception(e, create_params: create_params&.dig(:raw_metadata))
end
end
def update_vulnerability_scanner(finding) def update_vulnerability_scanner(finding)
scanner = scanners_objects[finding.scanner.key] scanner = scanners_objects[finding.scanner.key]
scanner.update!(finding.scanner.to_hash) scanner.update!(finding.scanner.to_hash)
...@@ -223,6 +285,14 @@ module Security ...@@ -223,6 +285,14 @@ module Security
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def update_feedbacks(vulnerability_finding, new_uuid)
vulnerability_finding.load_feedback.each do |feedback|
feedback.finding_uuid = new_uuid
feedback.vulnerability_data = vulnerability_finding.raw_metadata
feedback.save!
end
end
def update_finding_signatures(finding, vulnerability_finding) def update_finding_signatures(finding, vulnerability_finding)
to_update = {} to_update = {}
to_create = [] to_create = []
...@@ -240,12 +310,12 @@ module Security ...@@ -240,12 +310,12 @@ module Security
next if poro_signature.nil? next if poro_signature.nil?
poro_signatures.delete(signature.algorithm_type) poro_signatures.delete(signature.algorithm_type)
to_update[signature.id] = poro_signature.to_h to_update[signature.id] = poro_signature.to_hash
end end
# any remaining poro signatures left are new # any remaining poro signatures left are new
poro_signatures.values.each do |poro_signature| poro_signatures.values.each do |poro_signature|
attributes = poro_signature.to_h.merge(finding_id: vulnerability_finding.id) attributes = poro_signature.to_hash.merge(finding_id: vulnerability_finding.id)
to_create << ::Vulnerabilities::FindingSignature.new(attributes: attributes, created_at: Time.zone.now, updated_at: Time.zone.now) to_create << ::Vulnerabilities::FindingSignature.new(attributes: attributes, created_at: Time.zone.now, updated_at: Time.zone.now)
end end
......
---
name: vulnerability_finding_signatures
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54608
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/322044
milestone: '13.11'
type: development
group: group::vulnerability research
default_enabled: false
...@@ -7,13 +7,14 @@ module Gitlab ...@@ -7,13 +7,14 @@ module Gitlab
class Common class Common
SecurityReportParserError = Class.new(Gitlab::Ci::Parsers::ParserError) SecurityReportParserError = Class.new(Gitlab::Ci::Parsers::ParserError)
def self.parse!(json_data, report) def self.parse!(json_data, report, vulnerability_finding_signatures_enabled = false)
new(json_data, report).parse! new(json_data, report, vulnerability_finding_signatures_enabled).parse!
end end
def initialize(json_data, report) def initialize(json_data, report, vulnerability_finding_signatures_enabled = false)
@json_data = json_data @json_data = json_data
@report = report @report = report
@vulnerability_finding_signatures_enabled = vulnerability_finding_signatures_enabled
end end
def parse! def parse!
...@@ -80,11 +81,20 @@ module Gitlab ...@@ -80,11 +81,20 @@ module Gitlab
links = create_links(data['links']) links = create_links(data['links'])
location = create_location(data['location'] || {}) location = create_location(data['location'] || {})
remediations = create_remediations(data['remediations']) remediations = create_remediations(data['remediations'])
signatures = create_signatures(tracking_data(data)) signatures = create_signatures(location, tracking_data(data))
if @vulnerability_finding_signatures_enabled && !signatures.empty?
# NOT the signature_sha - the compare key is hashed
# to create the project_fingerprint
highest_priority_signature = signatures.max_by(&:priority)
uuid = calculate_uuid_v5(identifiers.first, highest_priority_signature.signature_hex)
else
uuid = calculate_uuid_v5(identifiers.first, location&.fingerprint)
end
report.add_finding( report.add_finding(
::Gitlab::Ci::Reports::Security::Finding.new( ::Gitlab::Ci::Reports::Security::Finding.new(
uuid: calculate_uuid_v5(identifiers.first, location), uuid: uuid,
report_type: report.type, report_type: report.type,
name: finding_name(data, identifiers, location), name: finding_name(data, identifiers, location),
compare_key: data['cve'] || '', compare_key: data['cve'] || '',
...@@ -99,14 +109,17 @@ module Gitlab ...@@ -99,14 +109,17 @@ module Gitlab
raw_metadata: data.to_json, raw_metadata: data.to_json,
metadata_version: report_version, metadata_version: report_version,
details: data['details'] || {}, details: data['details'] || {},
signatures: signatures)) signatures: signatures,
project_id: report.project_id,
vulnerability_finding_signatures_enabled: @vulnerability_finding_signatures_enabled))
end end
def create_signatures(data) def create_signatures(location, tracking)
return [] if data.nil? || data['items'].nil? tracking ||= { 'items' => [] }
signature_algorithms = Hash.new { |hash, key| hash[key] = [] } signature_algorithms = Hash.new { |hash, key| hash[key] = [] }
data['items'].each do |item|
tracking['items'].each do |item|
next unless item.key?('signatures') next unless item.key?('signatures')
item['signatures'].each do |signature| item['signatures'].each do |signature|
...@@ -117,14 +130,16 @@ module Gitlab ...@@ -117,14 +130,16 @@ module Gitlab
signature_algorithms.map do |algorithm, values| signature_algorithms.map do |algorithm, values|
value = values.join('|') value = values.join('|')
begin signature = ::Gitlab::Ci::Reports::Security::FindingSignature.new(
signature = ::Gitlab::Ci::Reports::Security::FindingSignature.new( algorithm_type: algorithm,
algorithm_type: algorithm, signature_value: value
signature_value: value )
)
signature.valid? ? signature : nil if signature.valid?
rescue ArgumentError => e signature
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) else
e = SecurityReportParserError.new("Vulnerability tracking signature is not valid: #{signature}")
Gitlab::ErrorTracking.track_exception(e)
nil nil
end end
end.compact end.compact
...@@ -201,11 +216,11 @@ module Gitlab ...@@ -201,11 +216,11 @@ module Gitlab
"#{identifier.name} in #{location&.fingerprint_path}" "#{identifier.name} in #{location&.fingerprint_path}"
end end
def calculate_uuid_v5(primary_identifier, location) def calculate_uuid_v5(primary_identifier, location_fingerprint)
uuid_v5_name_components = { uuid_v5_name_components = {
report_type: report.type, report_type: report.type,
primary_identifier_fingerprint: primary_identifier&.fingerprint, primary_identifier_fingerprint: primary_identifier&.fingerprint,
location_fingerprint: location&.fingerprint, location_fingerprint: location_fingerprint,
project_id: report.project_id project_id: report.project_id
} }
......
...@@ -5,6 +5,8 @@ module Gitlab ...@@ -5,6 +5,8 @@ module Gitlab
module Reports module Reports
module Security module Security
class Finding class Finding
include ::VulnerabilityFindingHelpers
UNSAFE_SEVERITIES = %w[unknown high critical].freeze UNSAFE_SEVERITIES = %w[unknown high critical].freeze
attr_reader :compare_key attr_reader :compare_key
...@@ -25,10 +27,11 @@ module Gitlab ...@@ -25,10 +27,11 @@ module Gitlab
attr_reader :remediations attr_reader :remediations
attr_reader :details attr_reader :details
attr_reader :signatures attr_reader :signatures
attr_reader :project_id
delegate :file_path, :start_line, :end_line, to: :location delegate :file_path, :start_line, :end_line, to: :location
def initialize(compare_key:, identifiers:, links: [], remediations: [], location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, scan:, uuid:, confidence: nil, severity: nil, details: {}, signatures: []) # rubocop:disable Metrics/ParameterLists def initialize(compare_key:, identifiers:, links: [], remediations: [], location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, scan:, uuid:, confidence: nil, severity: nil, details: {}, signatures: [], project_id: nil, vulnerability_finding_signatures_enabled: false) # rubocop:disable Metrics/ParameterLists
@compare_key = compare_key @compare_key = compare_key
@confidence = confidence @confidence = confidence
@identifiers = identifiers @identifiers = identifiers
...@@ -45,6 +48,8 @@ module Gitlab ...@@ -45,6 +48,8 @@ module Gitlab
@remediations = remediations @remediations = remediations
@details = details @details = details
@signatures = signatures @signatures = signatures
@project_id = project_id
@vulnerability_finding_signatures_enabled = vulnerability_finding_signatures_enabled
@project_fingerprint = generate_project_fingerprint @project_fingerprint = generate_project_fingerprint
end end
...@@ -66,6 +71,7 @@ module Gitlab ...@@ -66,6 +71,7 @@ module Gitlab
severity severity
uuid uuid
details details
signatures
].each_with_object({}) do |key, hash| ].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end end
...@@ -85,13 +91,22 @@ module Gitlab ...@@ -85,13 +91,22 @@ module Gitlab
end end
def eql?(other) def eql?(other)
report_type == other.report_type && return false unless report_type == other.report_type && primary_identifier_fingerprint == other.primary_identifier_fingerprint
location.fingerprint == other.location.fingerprint &&
primary_fingerprint == other.primary_fingerprint if @vulnerability_finding_signatures_enabled
matches_signatures(other.signatures, other.uuid)
else
location.fingerprint == other.location.fingerprint
end
end end
def hash def hash
report_type.hash ^ location.fingerprint.hash ^ primary_fingerprint.hash if @vulnerability_finding_signatures_enabled && !signatures.empty?
highest_signature = signatures.max_by(&:priority)
report_type.hash ^ highest_signature.signature_hex.hash ^ primary_identifier_fingerprint.hash
else
report_type.hash ^ location.fingerprint.hash ^ primary_identifier_fingerprint.hash
end
end end
def valid? def valid?
...@@ -104,7 +119,7 @@ module Gitlab ...@@ -104,7 +119,7 @@ module Gitlab
end end
end end
def primary_fingerprint def primary_identifier_fingerprint
primary_identifier&.fingerprint primary_identifier&.fingerprint
end end
......
...@@ -5,6 +5,8 @@ module Gitlab ...@@ -5,6 +5,8 @@ module Gitlab
module Reports module Reports
module Security module Security
class FindingSignature class FindingSignature
include VulnerabilityFindingSignatureHelpers
attr_accessor :algorithm_type, :signature_value attr_accessor :algorithm_type, :signature_value
def initialize(params = {}) def initialize(params = {})
...@@ -12,11 +14,19 @@ module Gitlab ...@@ -12,11 +14,19 @@ module Gitlab
@signature_value = params.dig(:signature_value) @signature_value = params.dig(:signature_value)
end end
def priority
::Vulnerabilities::FindingSignature.priority(algorithm_type)
end
def signature_sha def signature_sha
Digest::SHA1.digest(signature_value) Digest::SHA1.digest(signature_value)
end end
def to_h def signature_hex
signature_sha.unpack1("H*")
end
def to_hash
{ {
algorithm_type: algorithm_type, algorithm_type: algorithm_type,
signature_sha: signature_sha signature_sha: signature_sha
...@@ -26,6 +36,13 @@ module Gitlab ...@@ -26,6 +36,13 @@ module Gitlab
def valid? def valid?
::Vulnerabilities::FindingSignature.algorithm_types.key?(algorithm_type) ::Vulnerabilities::FindingSignature.algorithm_types.key?(algorithm_type)
end end
def eql?(other)
other.algorithm_type == algorithm_type &&
other.signature_sha == signature_sha
end
alias_method :==, :eql?
end end
end end
end end
......
...@@ -18,12 +18,12 @@ module Gitlab ...@@ -18,12 +18,12 @@ module Gitlab
@package_version = package_version @package_version = package_version
end end
private
def fingerprint_data def fingerprint_data
"#{docker_image_name_without_tag}:#{package_name}" "#{docker_image_name_without_tag}:#{package_name}"
end end
private
def docker_image_name_without_tag def docker_image_name_without_tag
base_name, version = image.split(':') base_name, version = image.split(':')
......
...@@ -16,8 +16,6 @@ module Gitlab ...@@ -16,8 +16,6 @@ module Gitlab
@crash_state = crash_state @crash_state = crash_state
end end
private
def fingerprint_data def fingerprint_data
"#{crash_type}:#{crash_state}" "#{crash_type}:#{crash_state}"
end end
......
...@@ -20,8 +20,6 @@ module Gitlab ...@@ -20,8 +20,6 @@ module Gitlab
alias_method :fingerprint_path, :path alias_method :fingerprint_path, :path
private
def fingerprint_data def fingerprint_data
"#{path}:#{method_name}:#{param}" "#{path}:#{method_name}:#{param}"
end end
......
...@@ -18,8 +18,6 @@ module Gitlab ...@@ -18,8 +18,6 @@ module Gitlab
@package_version = package_version @package_version = package_version
end end
private
def fingerprint_data def fingerprint_data
"#{file_path}:#{package_name}" "#{file_path}:#{package_name}"
end end
......
...@@ -22,8 +22,6 @@ module Gitlab ...@@ -22,8 +22,6 @@ module Gitlab
@start_line = start_line @start_line = start_line
end end
private
def fingerprint_data def fingerprint_data
"#{file_path}:#{start_line}:#{end_line}" "#{file_path}:#{start_line}:#{end_line}"
end end
......
...@@ -22,8 +22,6 @@ module Gitlab ...@@ -22,8 +22,6 @@ module Gitlab
@start_line = start_line @start_line = start_line
end end
private
def fingerprint_data def fingerprint_data
"#{file_path}:#{start_line}:#{end_line}" "#{file_path}:#{start_line}:#{end_line}"
end end
......
...@@ -7,13 +7,17 @@ module Gitlab ...@@ -7,13 +7,17 @@ module Gitlab
class VulnerabilityReportsComparer class VulnerabilityReportsComparer
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
attr_reader :base_report, :head_report attr_reader :base_report, :head_report, :added, :fixed
ACCEPTABLE_REPORT_AGE = 1.week ACCEPTABLE_REPORT_AGE = 1.week
def initialize(base_report, head_report) def initialize(base_report, head_report)
@base_report = base_report @base_report = base_report
@head_report = head_report @head_report = head_report
@added = []
@fixed = []
calculate_changes
end end
def base_report_created_at def base_report_created_at
...@@ -30,16 +34,30 @@ module Gitlab ...@@ -30,16 +34,30 @@ module Gitlab
ACCEPTABLE_REPORT_AGE.ago > @base_report.created_at ACCEPTABLE_REPORT_AGE.ago > @base_report.created_at
end end
def added def calculate_changes
strong_memoize(:added) do base_findings = base_report.findings
head_report.findings - base_report.findings head_findings = head_report.findings
end
end head_findings_hash = head_findings.index_by(&:object_id)
def fixed # This is slow - O(N^2). If we didn't need to worry about one high
strong_memoize(:fixed) do # priority fingerprint that doesn't match overruling a lower
base_report.findings - head_report.findings # priority fingerprint that does match, we'd be able to do some
# set operations here
base_findings.each do |base_finding|
still_exists = false
head_findings.each do |head_finding|
next unless base_finding.eql?(head_finding)
still_exists = true
head_findings_hash.delete(head_finding.object_id)
break
end
@fixed << base_finding unless still_exists
end end
@added = head_findings_hash.values
end end
end end
end end
......
...@@ -39,6 +39,7 @@ FactoryBot.define do ...@@ -39,6 +39,7 @@ FactoryBot.define do
project_id: n project_id: n
) )
end end
vulnerability_finding_signatures_enabled { false }
skip_create skip_create
......
...@@ -17,6 +17,7 @@ FactoryBot.define do ...@@ -17,6 +17,7 @@ FactoryBot.define do
category { 'sast' } category { 'sast' }
project_fingerprint { generate(:project_fingerprint) } project_fingerprint { generate(:project_fingerprint) }
vulnerability_data { { category: 'sast' } } vulnerability_data { { category: 'sast' } }
finding_uuid { Gitlab::UUID.v5(SecureRandom.hex) }
trait :dismissal do trait :dismissal do
feedback_type { 'dismissal' } feedback_type { 'dismissal' }
......
...@@ -83,12 +83,12 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -83,12 +83,12 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
# use the same number of queries, regardless of the number of findings # use the same number of queries, regardless of the number of findings
# contained in the pipeline report. # contained in the pipeline report.
sast_findings = pipeline.security_reports.reports['sast'].findings container_scanning_findings = pipeline.security_reports.reports['container_scanning'].findings
dep_findings = pipeline.security_reports.reports['dependency_scanning'].findings dep_findings = pipeline.security_reports.reports['dependency_scanning'].findings
# this test is invalid if we don't have more sast findings than dep findings # this test is invalid if we don't have more container_scanning findings than dep findings
expect(sast_findings.count).to be > dep_findings.count expect(container_scanning_findings.count).to be > dep_findings.count
(sast_findings + dep_findings).each do |report_finding| (container_scanning_findings + dep_findings).each do |report_finding|
# create a finding and a vulnerability for each report finding # create a finding and a vulnerability for each report finding
# (the vulnerability is created with the :confirmed trait) # (the vulnerability is created with the :confirmed trait)
create(:vulnerabilities_finding, create(:vulnerabilities_finding,
...@@ -103,7 +103,7 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -103,7 +103,7 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
# should be the same number of queries between different report types # should be the same number of queries between different report types
expect do expect do
described_class.new(pipeline: pipeline, params: { report_type: %w[sast] }).execute described_class.new(pipeline: pipeline, params: { report_type: %w[container_scanning] }).execute
end.to issue_same_number_of_queries_as { end.to issue_same_number_of_queries_as {
described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning] }).execute described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning] }).execute
} }
...@@ -117,11 +117,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -117,11 +117,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
orig_security_reports = pipeline.security_reports orig_security_reports = pipeline.security_reports
new_finding = create(:ci_reports_security_finding) new_finding = create(:ci_reports_security_finding)
expect do expect do
described_class.new(pipeline: pipeline, params: { report_type: %w[sast] }).execute described_class.new(pipeline: pipeline, params: { report_type: %w[container_scanning] }).execute
end.to issue_same_number_of_queries_as { end.to issue_same_number_of_queries_as {
orig_security_reports.reports['sast'].add_finding(new_finding) orig_security_reports.reports['container_scanning'].add_finding(new_finding)
allow(pipeline).to receive(:security_reports).and_return(orig_security_reports) allow(pipeline).to receive(:security_reports).and_return(orig_security_reports)
described_class.new(pipeline: pipeline, params: { report_type: %w[sast] }).execute described_class.new(pipeline: pipeline, params: { report_type: %w[container_scanning] }).execute
} }
end end
end end
...@@ -130,9 +130,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -130,9 +130,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
context 'when sast' do context 'when sast' do
let(:params) { { report_type: %w[sast] } } let(:params) { { report_type: %w[sast] } }
let(:sast_report_fingerprints) {pipeline.security_reports.reports['sast'].findings.map(&:location).map(&:fingerprint) } let(:sast_report_fingerprints) {pipeline.security_reports.reports['sast'].findings.map(&:location).map(&:fingerprint) }
let(:sast_report_uuids) {pipeline.security_reports.reports['sast'].findings.map(&:uuid) }
it 'includes only sast' do it 'includes only sast' do
expect(subject.findings.map(&:location_fingerprint)).to match_array(sast_report_fingerprints) expect(subject.findings.map(&:location_fingerprint)).to match_array(sast_report_fingerprints)
expect(subject.findings.map(&:uuid)).to match_array(sast_report_uuids)
expect(subject.findings.count).to eq(sast_count) expect(subject.findings.count).to eq(sast_count)
end end
end end
...@@ -172,52 +174,107 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do ...@@ -172,52 +174,107 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
let(:ds_finding) { pipeline.security_reports.reports["dependency_scanning"].findings.first } let(:ds_finding) { pipeline.security_reports.reports["dependency_scanning"].findings.first }
let(:sast_finding) { pipeline.security_reports.reports["sast"].findings.first } let(:sast_finding) { pipeline.security_reports.reports["sast"].findings.first }
let!(:feedback) do context 'when vulnerability_finding_signatures feature flag is disabled' do
[ let!(:feedback) do
create( [
:vulnerability_feedback, create(
:dismissal, :vulnerability_feedback,
:dependency_scanning, :dismissal,
project: project, :dependency_scanning,
pipeline: pipeline, project: project,
project_fingerprint: ds_finding.project_fingerprint, pipeline: pipeline,
vulnerability_data: ds_finding.raw_metadata project_fingerprint: ds_finding.project_fingerprint,
), vulnerability_data: ds_finding.raw_metadata,
create( finding_uuid: ds_finding.uuid
:vulnerability_feedback, ),
:dismissal, create(
:sast, :vulnerability_feedback,
project: project, :dismissal,
pipeline: pipeline, :sast,
project_fingerprint: sast_finding.project_fingerprint, project: project,
vulnerability_data: sast_finding.raw_metadata pipeline: pipeline,
) project_fingerprint: sast_finding.project_fingerprint,
] vulnerability_data: sast_finding.raw_metadata,
end finding_uuid: sast_finding.uuid
)
]
end
context 'when unscoped' do before do
subject { described_class.new(pipeline: pipeline).execute } stub_feature_flags(vulnerability_finding_signatures: false)
end
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
it 'returns non-dismissed vulnerabilities' do it 'returns non-dismissed vulnerabilities' do
expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count - feedback.count) expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count - feedback.count)
expect(subject.findings.map(&:project_fingerprint)).not_to include(*feedback.map(&:project_fingerprint)) expect(subject.findings.map(&:project_fingerprint)).not_to include(*feedback.map(&:project_fingerprint))
end
end end
end
context 'when `dismissed`' do context 'when `dismissed`' do
subject { described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning], scope: 'dismissed' } ).execute } subject { described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning], scope: 'dismissed' } ).execute }
it 'returns non-dismissed vulnerabilities' do it 'returns non-dismissed vulnerabilities' do
expect(subject.findings.count).to eq(ds_count - 1) expect(subject.findings.count).to eq(ds_count - 1)
expect(subject.findings.map(&:project_fingerprint)).not_to include(ds_finding.project_fingerprint) expect(subject.findings.map(&:project_fingerprint)).not_to include(ds_finding.project_fingerprint)
end
end
context 'when `all`' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scope: 'all' } }
it 'returns all vulnerabilities' do
expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count)
end
end end
end end
context 'when `all`' do context 'when vulnerability_finding_signatures feature flag is enabled' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scope: 'all' } } let!(:feedback) do
[
create(
:vulnerability_feedback,
:dismissal,
:sast,
project: project,
pipeline: pipeline,
project_fingerprint: sast_finding.project_fingerprint,
vulnerability_data: sast_finding.raw_metadata,
finding_uuid: sast_finding.uuid
)
]
end
it 'returns all vulnerabilities' do before do
expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count) stub_feature_flags(vulnerability_finding_signatures: true)
end
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
it 'returns non-dismissed vulnerabilities' do
expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count - feedback.count)
expect(subject.findings.map(&:project_fingerprint)).not_to include(*feedback.map(&:project_fingerprint))
end
end
context 'when `dismissed`' do
subject { described_class.new(pipeline: pipeline, params: { report_type: %w[sast], scope: 'dismissed' } ).execute }
it 'returns non-dismissed vulnerabilities' do
expect(subject.findings.count).to eq(sast_count - 1)
expect(subject.findings.map(&:project_fingerprint)).not_to include(sast_finding.project_fingerprint)
end
end
context 'when `all`' do
let(:params) { { report_type: %w[sast dast container_scanning dependency_scanning], scope: 'all' } }
it 'returns all vulnerabilities' do
expect(subject.findings.count).to eq(cs_count + dast_count + ds_count + sast_count)
end
end end
end end
end end
......
...@@ -4,212 +4,195 @@ require 'spec_helper' ...@@ -4,212 +4,195 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Common do RSpec.describe Gitlab::Ci::Parsers::Security::Common do
describe '#parse!' do describe '#parse!' do
let_it_be(:pipeline) { create(:ci_pipeline) } where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
let(:artifact) { build(:ee_ci_job_artifact, :common_security_report) } let_it_be(:pipeline) { create(:ci_pipeline) }
let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline, 2.weeks.ago) }
let(:location) { ::Gitlab::Ci::Reports::Security::Locations::DependencyScanning.new(file_path: 'yarn/yarn.lock', package_version: 'v2', package_name: 'saml2') } let(:artifact) { build(:ee_ci_job_artifact, :common_security_report) }
let(:tracking_data) do let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline, 2.weeks.ago) }
{ let(:location) { ::Gitlab::Ci::Reports::Security::Locations::DependencyScanning.new(file_path: 'yarn/yarn.lock', package_version: 'v2', package_name: 'saml2') }
'type' => 'source', let(:tracking_data) { nil }
'items' => [
'signatures' => [ before do
{ 'algorithm' => 'hash', 'value' => 'hash_value' }, allow_next_instance_of(described_class) do |parser|
{ 'algorithm' => 'location', 'value' => 'location_value' }, allow(parser).to receive(:create_location).and_return(location)
{ 'algorithm' => 'scope_offset', 'value' => 'scope_offset_value' } allow(parser).to receive(:tracking_data).and_return(tracking_data)
] end
]
}
end
before do artifact.each_blob { |blob| described_class.parse!(blob, report, vulnerability_finding_signatures_enabled) }
allow_next_instance_of(described_class) do |parser|
allow(parser).to receive(:create_location).and_return(location)
allow(parser).to receive(:tracking_data).and_return(tracking_data)
end end
artifact.each_blob { |blob| described_class.parse!(blob, report) }
end
describe 'parsing finding.name' do
let(:artifact) { build(:ee_ci_job_artifact, :common_security_report_with_blank_names) }
context 'when message is provided' do describe 'parsing finding.name' do
it 'sets message from the report as a finding name' do let(:artifact) { build(:ee_ci_job_artifact, :common_security_report_with_blank_names) }
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1020' }
expected_name = Gitlab::Json.parse(vulnerability.raw_metadata)['message']
expect(vulnerability.name).to eq(expected_name) context 'when message is provided' do
end it 'sets message from the report as a finding name' do
end vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1020' }
expected_name = Gitlab::Json.parse(vulnerability.raw_metadata)['message']
context 'when message is not provided' do
context 'and name is provided' do
it 'sets name from the report as a name' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1030' }
expected_name = Gitlab::Json.parse(vulnerability.raw_metadata)['name']
expect(vulnerability.name).to eq(expected_name) expect(vulnerability.name).to eq(expected_name)
end end
end end
context 'and name is not provided' do context 'when message is not provided' do
context 'when CVE identifier exists' do context 'and name is provided' do
it 'combines identifier with location to create name' do it 'sets name from the report as a name' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-2017-11429' } vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1030' }
expect(vulnerability.name).to eq("CVE-2017-11429 in yarn.lock") expected_name = Gitlab::Json.parse(vulnerability.raw_metadata)['name']
expect(vulnerability.name).to eq(expected_name)
end end
end end
context 'when CWE identifier exists' do context 'and name is not provided' do
it 'combines identifier with location to create name' do context 'when CVE identifier exists' do
vulnerability = report.findings.find { |x| x.compare_key == 'CWE-2017-11429' } it 'combines identifier with location to create name' do
expect(vulnerability.name).to eq("CWE-2017-11429 in yarn.lock") vulnerability = report.findings.find { |x| x.compare_key == 'CVE-2017-11429' }
expect(vulnerability.name).to eq("CVE-2017-11429 in yarn.lock")
end
end
context 'when CWE identifier exists' do
it 'combines identifier with location to create name' do
vulnerability = report.findings.find { |x| x.compare_key == 'CWE-2017-11429' }
expect(vulnerability.name).to eq("CWE-2017-11429 in yarn.lock")
end
end end
end
context 'when neither CVE nor CWE identifier exist' do context 'when neither CVE nor CWE identifier exist' do
it 'combines identifier with location to create name' do it 'combines identifier with location to create name' do
vulnerability = report.findings.find { |x| x.compare_key == 'OTHER-2017-11429' } vulnerability = report.findings.find { |x| x.compare_key == 'OTHER-2017-11429' }
expect(vulnerability.name).to eq("other-2017-11429 in yarn.lock") expect(vulnerability.name).to eq("other-2017-11429 in yarn.lock")
end
end end
end end
end end
end end
end
describe 'parsing finding.details' do describe 'parsing finding.details' do
context 'when details are provided' do context 'when details are provided' do
it 'sets details from the report' do it 'sets details from the report' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1020' } vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1020' }
expected_details = Gitlab::Json.parse(vulnerability.raw_metadata)['details'] expected_details = Gitlab::Json.parse(vulnerability.raw_metadata)['details']
expect(vulnerability.details).to eq(expected_details) expect(vulnerability.details).to eq(expected_details)
end
end end
end
context 'when details are not provided' do context 'when details are not provided' do
it 'sets empty hash' do it 'sets empty hash' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1030' } vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1030' }
expect(vulnerability.details).to eq({}) expect(vulnerability.details).to eq({})
end
end end
end end
end
describe 'parsing remediations' do describe 'parsing remediations' do
let(:expected_remediation) { create(:ci_reports_security_remediation, diff: '') } let(:expected_remediation) { create(:ci_reports_security_remediation, diff: '') }
it 'finds remediation with same cve' do it 'finds remediation with same cve' do
vulnerability = report.findings.find { |x| x.compare_key == "CVE-1020" } vulnerability = report.findings.find { |x| x.compare_key == "CVE-1020" }
remediation = { 'fixes' => [{ 'cve' => 'CVE-1020' }], 'summary' => '', 'diff' => '' } remediation = { 'fixes' => [{ 'cve' => 'CVE-1020' }], 'summary' => '', 'diff' => '' }
expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations').first).to include remediation expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations').first).to include remediation
expect(vulnerability.remediations.first.checksum).to eq(expected_remediation.checksum) expect(vulnerability.remediations.first.checksum).to eq(expected_remediation.checksum)
end end
it 'finds remediation with same id' do it 'finds remediation with same id' do
vulnerability = report.findings.find { |x| x.compare_key == "CVE-1030" } vulnerability = report.findings.find { |x| x.compare_key == "CVE-1030" }
remediation = { 'fixes' => [{ 'cve' => 'CVE', 'id' => 'bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3' }], 'summary' => '', 'diff' => '' } remediation = { 'fixes' => [{ 'cve' => 'CVE', 'id' => 'bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3' }], 'summary' => '', 'diff' => '' }
expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations').first).to include remediation expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations').first).to include remediation
expect(vulnerability.remediations.first.checksum).to eq(expected_remediation.checksum) expect(vulnerability.remediations.first.checksum).to eq(expected_remediation.checksum)
end end
it 'does not find remediation with different id' do it 'does not find remediation with different id' do
fix_with_id = { fix_with_id = {
"fixes": [ "fixes": [
{ {
"id": "2134", "id": "2134",
"cve": "CVE-1" "cve": "CVE-1"
} }
], ],
"summary": "", "summary": "",
"diff": "" "diff": ""
} }
report.findings.map do |vulnerability| report.findings.map do |vulnerability|
expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations')).not_to include(fix_with_id) expect(Gitlab::Json.parse(vulnerability.raw_metadata).dig('remediations')).not_to include(fix_with_id)
end
end end
end end
end
describe 'parsing scanners' do describe 'parsing scanners' do
subject(:scanner) { report.findings.first.scanner } subject(:scanner) { report.findings.first.scanner }
context 'when vendor is not missing in scanner' do context 'when vendor is not missing in scanner' do
it 'returns scanner with parsed vendor value' do it 'returns scanner with parsed vendor value' do
expect(scanner.vendor).to eq('GitLab') expect(scanner.vendor).to eq('GitLab')
end
end end
end end
end
describe 'parsing scan' do describe 'parsing scan' do
it 'returns scan object for each finding' do it 'returns scan object for each finding' do
scans = report.findings.map(&:scan) scans = report.findings.map(&:scan)
expect(scans.map(&:status).all?('success')).to be(true) expect(scans.map(&:status).all?('success')).to be(true)
expect(scans.map(&:type).all?('dependency_scanning')).to be(true) expect(scans.map(&:type).all?('dependency_scanning')).to be(true)
expect(scans.map(&:start_time).all?('placeholder-value')).to be(true) expect(scans.map(&:start_time).all?('placeholder-value')).to be(true)
expect(scans.map(&:end_time).all?('placeholder-value')).to be(true) expect(scans.map(&:end_time).all?('placeholder-value')).to be(true)
expect(scans.size).to eq(3) expect(scans.size).to eq(3)
expect(scans.first).to be_a(::Gitlab::Ci::Reports::Security::Scan) expect(scans.first).to be_a(::Gitlab::Ci::Reports::Security::Scan)
end end
it 'returns nil when scan is not a hash' do it 'returns nil when scan is not a hash' do
empty_report = Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline, 2.weeks.ago) empty_report = Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline, 2.weeks.ago)
described_class.parse!({}.to_json, empty_report) described_class.parse!({}.to_json, empty_report)
expect(empty_report.scan).to be(nil) expect(empty_report.scan).to be(nil)
end
end end
end
describe 'parsing links' do describe 'parsing links' do
it 'returns links object for each finding', :aggregate_failures do it 'returns links object for each finding', :aggregate_failures do
links = report.findings.flat_map(&:links) links = report.findings.flat_map(&:links)
expect(links.map(&:url)).to match_array(['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020', 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030']) expect(links.map(&:url)).to match_array(['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020', 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030'])
expect(links.map(&:name)).to match_array([nil, 'CVE-1030']) expect(links.map(&:name)).to match_array([nil, 'CVE-1030'])
expect(links.size).to eq(2) expect(links.size).to eq(2)
expect(links.first).to be_a(::Gitlab::Ci::Reports::Security::Link) expect(links.first).to be_a(::Gitlab::Ci::Reports::Security::Link)
end end
end
describe 'setting the uuid' do
let(:finding_uuids) { report.findings.map(&:uuid) }
let(:uuid_1) do
Security::VulnerabilityUUID.generate(
report_type: "dependency_scanning",
primary_identifier_fingerprint: "4ff8184cd18485b6e85d5b101e341b12eacd1b3b",
location_fingerprint: "33dc9f32c77dde16d39c69d3f78f27ca3114a7c5",
project_id: pipeline.project_id
)
end end
let(:uuid_2) do describe 'setting the uuid' do
Security::VulnerabilityUUID.generate( let(:finding_uuids) { report.findings.map(&:uuid) }
report_type: "dependency_scanning", let(:uuid_1) do
primary_identifier_fingerprint: "d55f9e66e79882ae63af9fd55cc822ab75307e31", Security::VulnerabilityUUID.generate(
location_fingerprint: "33dc9f32c77dde16d39c69d3f78f27ca3114a7c5", report_type: "dependency_scanning",
project_id: pipeline.project_id primary_identifier_fingerprint: "4ff8184cd18485b6e85d5b101e341b12eacd1b3b",
) location_fingerprint: "33dc9f32c77dde16d39c69d3f78f27ca3114a7c5",
end project_id: pipeline.project_id
)
end
let(:expected_uuids) { [uuid_1, uuid_2, nil] } let(:uuid_2) do
Security::VulnerabilityUUID.generate(
report_type: "dependency_scanning",
primary_identifier_fingerprint: "d55f9e66e79882ae63af9fd55cc822ab75307e31",
location_fingerprint: "33dc9f32c77dde16d39c69d3f78f27ca3114a7c5",
project_id: pipeline.project_id
)
end
it 'sets the UUIDv5 for findings', :aggregate_failures do let(:expected_uuids) { [uuid_1, uuid_2, nil] }
expect(finding_uuids).to match_array(expected_uuids)
end
end
describe 'parsing signature' do it 'sets the UUIDv5 for findings', :aggregate_failures do
context 'with valid signature information' do expect(finding_uuids).to match_array(expected_uuids)
it 'creates signatures for each algorithm' do
finding = report.findings.first
expect(finding.signatures.size).to eq(3)
expect(finding.signatures.map(&:algorithm_type).to_set).to eq(Set['hash', 'location', 'scope_offset'])
end end
end end
context 'with invalid signature information' do describe 'parsing tracking' do
let(:tracking_data) do let(:tracking_data) do
{ {
'type' => 'source', 'type' => 'source',
...@@ -217,16 +200,66 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do ...@@ -217,16 +200,66 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
'signatures' => [ 'signatures' => [
{ 'algorithm' => 'hash', 'value' => 'hash_value' }, { 'algorithm' => 'hash', 'value' => 'hash_value' },
{ 'algorithm' => 'location', 'value' => 'location_value' }, { 'algorithm' => 'location', 'value' => 'location_value' },
{ 'algorithm' => 'INVALID', 'value' => 'scope_offset_value' } { 'algorithm' => 'scope_offset', 'value' => 'scope_offset_value' }
] ]
] ]
} }
end end
it 'ignores invalid algorithm types' do context 'with valid tracking information' do
finding = report.findings.first it 'creates signatures for each algorithm' do
expect(finding.signatures.size).to eq(2) finding = report.findings.first
expect(finding.signatures.map(&:algorithm_type).to_set).to eq(Set['hash', 'location']) expect(finding.signatures.size).to eq(3)
expect(finding.signatures.map(&:algorithm_type).to_set).to eq(Set['hash', 'location', 'scope_offset'])
end
end
context 'with invalid tracking information' do
let(:tracking_data) do
{
'type' => 'source',
'items' => [
'signatures' => [
{ 'algorithm' => 'hash', 'value' => 'hash_value' },
{ 'algorithm' => 'location', 'value' => 'location_value' },
{ 'algorithm' => 'INVALID', 'value' => 'scope_offset_value' }
]
]
}
end
it 'ignores invalid algorithm types' do
finding = report.findings.first
expect(finding.signatures.size).to eq(2)
expect(finding.signatures.map(&:algorithm_type).to_set).to eq(Set['hash', 'location'])
end
end
context 'with valid tracking information' do
it 'creates signatures for each signature algorithm' do
finding = report.findings.first
expect(finding.signatures.size).to eq(3)
expect(finding.signatures.map(&:algorithm_type)).to eq(%w[hash location scope_offset])
signatures = finding.signatures.index_by(&:algorithm_type)
expected_values = tracking_data['items'][0]['signatures'].index_by { |x| x['algorithm'] }
expect(signatures['hash'].signature_value).to eq(expected_values['hash']['value'])
expect(signatures['location'].signature_value).to eq(expected_values['location']['value'])
expect(signatures['scope_offset'].signature_value).to eq(expected_values['scope_offset']['value'])
end
it 'sets the uuid according to the higest priority signature' do
finding = report.findings.first
highest_signature = finding.signatures.max_by(&:priority)
identifiers = if vulnerability_finding_signatures_enabled
"#{finding.report_type}-#{finding.primary_identifier.fingerprint}-#{highest_signature.signature_hex}-#{report.project_id}"
else
"#{finding.report_type}-#{finding.primary_identifier.fingerprint}-#{finding.location.fingerprint}-#{report.project_id}"
end
expect(finding.uuid).to eq(Gitlab::UUID.v5(identifiers))
end
end end
end end
end end
......
...@@ -18,15 +18,12 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do ...@@ -18,15 +18,12 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do
expect(subject.algorithm_type).to eq(params[:algorithm_type]) expect(subject.algorithm_type).to eq(params[:algorithm_type])
expect(subject.signature_value).to eq(params[:signature_value]) expect(subject.signature_value).to eq(params[:signature_value])
end end
end
end
describe '#to_h' do describe '#valid?' do
it 'returns a hash representation of the signature' do it 'returns true' do
expect(subject.to_h).to eq( expect(subject.valid?).to eq(true)
algorithm_type: params[:algorithm_type], end
signature_sha: Digest::SHA1.digest(params[:signature_value]) end
)
end end
end end
...@@ -50,4 +47,13 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do ...@@ -50,4 +47,13 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do
end end
end end
end end
describe '#to_hash' do
it 'returns a hash representation of the signature' do
expect(subject.to_hash).to eq(
algorithm_type: params[:algorithm_type],
signature_sha: Digest::SHA1.digest(params[:signature_value])
)
end
end
end end
...@@ -137,7 +137,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -137,7 +137,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scan: occurrence.scan, scan: occurrence.scan,
severity: occurrence.severity, severity: occurrence.severity,
uuid: occurrence.uuid, uuid: occurrence.uuid,
details: occurrence.details details: occurrence.details,
signatures: []
}) })
end end
end end
...@@ -197,87 +198,98 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -197,87 +198,98 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
end end
describe '#eql?' do describe '#eql?' do
let(:identifier) { build(:ci_reports_security_identifier) } where(vulnerability_finding_signatures_enabled: [true, false])
let(:location) { build(:ci_reports_security_locations_sast) } with_them do
let(:finding) { build(:ci_reports_security_finding, severity: 'low', report_type: :sast, identifiers: [identifier], location: location) } let(:identifier) { build(:ci_reports_security_identifier) }
let(:location) { build(:ci_reports_security_locations_sast) }
let(:report_type) { :secret_detection } let(:finding) { build(:ci_reports_security_finding, severity: 'low', report_type: :sast, identifiers: [identifier], location: location, vulnerability_finding_signatures_enabled: vulnerability_finding_signatures_enabled) }
let(:identifier_external_id) { 'foo' }
let(:location_start_line) { 0 } let(:report_type) { :secret_detection }
let(:other_identifier) { build(:ci_reports_security_identifier, external_id: identifier_external_id) } let(:identifier_external_id) { 'foo' }
let(:other_location) { build(:ci_reports_security_locations_sast, start_line: location_start_line) } let(:location_start_line) { 0 }
let(:other_finding) do let(:other_identifier) { build(:ci_reports_security_identifier, external_id: identifier_external_id) }
build(:ci_reports_security_finding, let(:other_location) { build(:ci_reports_security_locations_sast, start_line: location_start_line) }
severity: 'low', let(:other_finding) do
report_type: report_type, build(:ci_reports_security_finding,
identifiers: [other_identifier], severity: 'low',
location: other_location) report_type: report_type,
end identifiers: [other_identifier],
location: other_location,
vulnerability_finding_signatures_enabled: vulnerability_finding_signatures_enabled)
end
subject { finding.eql?(other_finding) } let(:signature) { ::Gitlab::Ci::Reports::Security::FindingSignature.new(algorithm_type: 'location', signature_value: 'value1') }
context 'when the primary_identifier is nil' do subject { finding.eql?(other_finding) }
let(:identifier) { nil }
it 'does not raise an exception' do context 'when the primary_identifier is nil' do
expect { subject }.not_to raise_error let(:identifier) { nil }
it 'does not raise an exception' do
expect { subject }.not_to raise_error
end
end end
end
context 'when the other finding has same `report_type`' do context 'when the other finding has same `report_type`' do
let(:report_type) { :sast } let(:report_type) { :sast }
context 'when the other finding has same primary identifier fingerprint' do context 'when the other finding has same primary identifier fingerprint' do
let(:identifier_external_id) { identifier.external_id } let(:identifier_external_id) { identifier.external_id }
context 'when the other finding has same location fingerprint' do context 'when the other finding has same location signature' do
let(:location_start_line) { location.start_line } before do
finding.signatures << signature
other_finding.signatures << signature
end
it { is_expected.to be(true) } let(:location_start_line) { location.start_line }
end
context 'when the other finding does not have same location fingerprint' do it { is_expected.to be(true) }
it { is_expected.to be(false) } end
context 'when the other finding does not have same location signature' do
it { is_expected.to be(false) }
end
end end
end
context 'when the other finding does not have same primary identifier fingerprint' do context 'when the other finding does not have same primary identifier fingerprint' do
context 'when the other finding has same location fingerprint' do context 'when the other finding has same location signature' do
let(:location_start_line) { location.start_line } let(:location_start_line) { location.start_line }
it { is_expected.to be(false) } it { is_expected.to be(false) }
end end
context 'when the other finding does not have same location fingerprint' do context 'when the other finding does not have same location signature' do
it { is_expected.to be(false) } it { is_expected.to be(false) }
end
end end
end end
end
context 'when the other finding does not have same `report_type`' do context 'when the other finding does not have same `report_type`' do
context 'when the other finding has same primary identifier fingerprint' do context 'when the other finding has same primary identifier fingerprint' do
let(:identifier_external_id) { identifier.external_id } let(:identifier_external_id) { identifier.external_id }
context 'when the other finding has same location fingerprint' do context 'when the other finding has same location signature' do
let(:location_start_line) { location.start_line } let(:location_start_line) { location.start_line }
it { is_expected.to be(false) } it { is_expected.to be(false) }
end end
context 'when the other finding does not have same location fingerprint' do context 'when the other finding does not have same location signature' do
it { is_expected.to be(false) } it { is_expected.to be(false) }
end
end end
end
context 'when the other finding does not have same primary identifier fingerprint' do context 'when the other finding does not have same primary identifier fingerprint' do
context 'when the other finding has same location fingerprint' do context 'when the other finding has same location signature' do
let(:location_start_line) { location.start_line } let(:location_start_line) { location.start_line }
it { is_expected.to be(false) } it { is_expected.to be(false) }
end end
context 'when the other finding does not have same location fingerprint' do context 'when the other finding does not have same location signature' do
it { is_expected.to be(false) } it { is_expected.to be(false) }
end
end end
end end
end end
...@@ -345,4 +357,55 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -345,4 +357,55 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
it { is_expected.to match_array(expected_keys) } it { is_expected.to match_array(expected_keys) }
end end
describe '#hash' do
let(:scanner) { build(:ci_reports_security_scanner) }
let(:identifiers) { [build(:ci_reports_security_identifier)] }
let(:location) { build(:ci_reports_security_locations_sast) }
let(:uuid) { SecureRandom.uuid }
context 'with vulnerability_finding_signatures enabled' do
let(:finding) do
build(:ci_reports_security_finding,
scanner: scanner,
identifiers: identifiers,
location: location,
uuid: uuid,
compare_key: '',
vulnerability_finding_signatures_enabled: true)
end
let(:low_priority_signature) { ::Gitlab::Ci::Reports::Security::FindingSignature.new(algorithm_type: 'location', signature_value: 'value1') }
let(:high_priority_signature) { ::Gitlab::Ci::Reports::Security::FindingSignature.new(algorithm_type: 'scope_offset', signature_value: 'value2') }
it 'returns the expected hash with no signatures' do
expect(finding.signatures.length).to eq(0)
expect(finding.hash).to eq(finding.report_type.hash ^ finding.location.fingerprint.hash ^ finding.primary_identifier_fingerprint.hash)
end
it 'returns the expected hash with signatures' do
finding.signatures << low_priority_signature
finding.signatures << high_priority_signature
expect(finding.signatures.length).to eq(2)
expect(finding.hash).to eq(finding.report_type.hash ^ high_priority_signature.signature_hex.hash ^ finding.primary_identifier_fingerprint.hash)
end
end
context 'without vulnerability_finding_signatures enabled' do
let(:finding) do
build(:ci_reports_security_finding,
scanner: scanner,
identifiers: identifiers,
location: location,
uuid: uuid,
compare_key: '',
vulnerability_finding_signatures_enabled: false)
end
it 'returns the expected hash' do
expect(finding.hash).to eq(finding.report_type.hash ^ finding.location.fingerprint.hash ^ finding.primary_identifier_fingerprint.hash)
end
end
end
end end
...@@ -8,128 +8,133 @@ RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do ...@@ -8,128 +8,133 @@ RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do
let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '123', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical]) } let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '123', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical]) }
let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability])} let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability])}
let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '123', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical]) } let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: base_vulnerability.location_fingerprint, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical], uuid: base_vulnerability.uuid) }
let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability])} let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability])}
before do
allow(base_vulnerability).to receive(:location).and_return({})
allow(head_vulnerability).to receive(:location).and_return({})
end
subject { described_class.new(base_report, head_report) } subject { described_class.new(base_report, head_report) }
describe '#base_report_out_of_date' do where(vulnerability_finding_signatures_enabled: [true, false])
context 'no base report' do
let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: [])}
it 'is not out of date' do with_them do
expect(subject.base_report_out_of_date).to be false before do
end allow(base_vulnerability).to receive(:location).and_return({})
allow(head_vulnerability).to receive(:location).and_return({})
stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
end end
context 'base report older than one week' do describe '#base_report_out_of_date' do
let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago - 60.seconds) } context 'no base report' do
let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report])} let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: [])}
it 'is not out of date' do it 'is not out of date' do
expect(subject.base_report_out_of_date).to be true expect(subject.base_report_out_of_date).to be false
end
end end
end
context 'base report less than one week old' do context 'base report older than one week' do
let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago + 60.seconds) } let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago - 60.seconds) }
let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report])} let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report])}
it 'is not out of date' do
expect(subject.base_report_out_of_date).to be true
end
end
context 'base report less than one week old' do
let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago + 60.seconds) }
let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report])}
it 'is not out of date' do it 'is not out of date' do
expect(subject.base_report_out_of_date).to be false expect(subject.base_report_out_of_date).to be false
end
end end
end end
end
describe '#added' do describe '#added' do
let(:vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:critical]) } let(:vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:critical]) }
let(:low_vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:low]) } let(:low_vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:low]) }
context 'with new vulnerability' do context 'with new vulnerability' do
let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln])} let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln])}
it 'points to source tree' do it 'points to source tree' do
expect(subject.added).to eq([vuln]) expect(subject.added).to eq([vuln])
end
end end
end
context 'when comparing reports with different fingerprints' do context 'when comparing reports with different fingerprints' do
let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'A') } let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'A') }
let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'B') } let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'B') }
let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln])} let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln])}
it 'does not find any overlap' do it 'does not find any overlap' do
expect(subject.added).to eq([head_vulnerability, vuln]) expect(subject.added).to eq([head_vulnerability, vuln])
end
end end
end
context 'order' do context 'order' do
let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln])} let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln])}
it 'does not change' do it 'does not change' do
expect(subject.added).to eq([vuln, low_vuln]) expect(subject.added).to eq([vuln, low_vuln])
end
end end
end end
end
describe '#fixed' do describe '#fixed' do
let(:vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888') } let(:vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888') }
let(:medium_vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:medium]) } let(:medium_vuln) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: '888', confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:medium], uuid: vuln.uuid) }
context 'with fixed vulnerability' do context 'with fixed vulnerability' do
let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln])} let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln])}
it 'points to base tree' do it 'points to base tree' do
expect(subject.fixed).to eq([vuln]) expect(subject.fixed).to eq([vuln])
end
end end
end
context 'when comparing reports with different fingerprints' do context 'when comparing reports with different fingerprints' do
let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'A') } let(:base_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'A') }
let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'B') } let(:head_vulnerability) { build(:vulnerabilities_finding, report_type: :sast, identifiers: [identifier], location_fingerprint: 'B') }
let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln])} let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln])}
it 'does not find any overlap' do it 'does not find any overlap' do
expect(subject.fixed).to eq([base_vulnerability, vuln]) expect(subject.fixed).to eq([base_vulnerability, vuln])
end
end end
end
context 'order' do context 'order' do
let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [vuln, medium_vuln, base_vulnerability])} let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [vuln, medium_vuln, base_vulnerability])}
it 'does not change' do it 'does not change' do
expect(subject.fixed).to eq([vuln, medium_vuln]) expect(subject.fixed).to eq([vuln, medium_vuln])
end
end end
end end
end
describe 'with empty vulnerabilities' do describe 'with empty vulnerabilities' do
let(:empty_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: [])} let(:empty_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: [])}
it 'returns empty array when reports are not present' do it 'returns empty array when reports are not present' do
comparer = described_class.new(empty_report, empty_report) comparer = described_class.new(empty_report, empty_report)
expect(comparer.fixed).to eq([]) expect(comparer.fixed).to eq([])
expect(comparer.added).to eq([]) expect(comparer.added).to eq([])
end end
it 'returns added vulnerability when base is empty and head is not empty' do it 'returns added vulnerability when base is empty and head is not empty' do
comparer = described_class.new(empty_report, head_report) comparer = described_class.new(empty_report, head_report)
expect(comparer.fixed).to eq([]) expect(comparer.fixed).to eq([])
expect(comparer.added).to eq([head_vulnerability]) expect(comparer.added).to eq([head_vulnerability])
end end
it 'returns fixed vulnerability when head is empty and base is not empty' do it 'returns fixed vulnerability when head is empty and base is not empty' do
comparer = described_class.new(base_report, empty_report) comparer = described_class.new(base_report, empty_report)
expect(comparer.fixed).to eq([base_vulnerability]) expect(comparer.fixed).to eq([base_vulnerability])
expect(comparer.added).to eq([]) expect(comparer.added).to eq([])
end
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe VulnerabilityFindingSignatureHelpers do
let(:cls) do
Class.new do
include VulnerabilityFindingSignatureHelpers
attr_accessor :algorithm_type
def initialize(algorithm_type)
@algorithm_type = algorithm_type
end
end
end
describe '#priority' do
it 'returns numeric values of the priority string' do
expect(cls.new('scope_offset').priority).to eq(3)
expect(cls.new('location').priority).to eq(2)
expect(cls.new('hash').priority).to eq(1)
end
end
describe '#self.priority' do
it 'returns the numeric value of the provided string' do
expect(cls.priority('scope_offset')).to eq(3)
expect(cls.priority('location')).to eq(2)
expect(cls.priority('hash')).to eq(1)
end
end
end
...@@ -7,638 +7,619 @@ RSpec.describe Vulnerabilities::Finding do ...@@ -7,638 +7,619 @@ RSpec.describe Vulnerabilities::Finding do
it { is_expected.to define_enum_for(:report_type) } it { is_expected.to define_enum_for(:report_type) }
it { is_expected.to define_enum_for(:severity) } it { is_expected.to define_enum_for(:severity) }
describe 'associations' do where(vulnerability_finding_signatures_enabled: [true, false])
it { is_expected.to belong_to(:project) } with_them do
it { is_expected.to belong_to(:primary_identifier).class_name('Vulnerabilities::Identifier') } before do
it { is_expected.to belong_to(:scanner).class_name('Vulnerabilities::Scanner') } stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
it { is_expected.to belong_to(:vulnerability).inverse_of(:findings) } end
it { is_expected.to have_many(:pipelines).class_name('Ci::Pipeline') }
it { is_expected.to have_many(:finding_pipelines).class_name('Vulnerabilities::FindingPipeline').with_foreign_key('occurrence_id') } describe 'associations' do
it { is_expected.to have_many(:identifiers).class_name('Vulnerabilities::Identifier') } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:finding_identifiers).class_name('Vulnerabilities::FindingIdentifier').with_foreign_key('occurrence_id') } it { is_expected.to belong_to(:primary_identifier).class_name('Vulnerabilities::Identifier') }
it { is_expected.to have_many(:finding_links).class_name('Vulnerabilities::FindingLink').with_foreign_key('vulnerability_occurrence_id') } it { is_expected.to belong_to(:scanner).class_name('Vulnerabilities::Scanner') }
it { is_expected.to have_many(:finding_remediations).class_name('Vulnerabilities::FindingRemediation').with_foreign_key('vulnerability_occurrence_id') } it { is_expected.to belong_to(:vulnerability).inverse_of(:findings) }
it { is_expected.to have_many(:remediations).through(:finding_remediations) } it { is_expected.to have_many(:pipelines).class_name('Ci::Pipeline') }
it { is_expected.to have_many(:finding_evidences).class_name('Vulnerabilities::FindingEvidence').with_foreign_key('vulnerability_occurrence_id') } it { is_expected.to have_many(:finding_pipelines).class_name('Vulnerabilities::FindingPipeline').with_foreign_key('occurrence_id') }
end it { is_expected.to have_many(:identifiers).class_name('Vulnerabilities::Identifier') }
it { is_expected.to have_many(:finding_identifiers).class_name('Vulnerabilities::FindingIdentifier').with_foreign_key('occurrence_id') }
describe 'validations' do it { is_expected.to have_many(:finding_links).class_name('Vulnerabilities::FindingLink').with_foreign_key('vulnerability_occurrence_id') }
let(:finding) { build(:vulnerabilities_finding) } it { is_expected.to have_many(:finding_remediations).class_name('Vulnerabilities::FindingRemediation').with_foreign_key('vulnerability_occurrence_id') }
it { is_expected.to have_many(:remediations).through(:finding_remediations) }
it { is_expected.to validate_presence_of(:scanner) } it { is_expected.to have_many(:finding_evidences).class_name('Vulnerabilities::FindingEvidence').with_foreign_key('vulnerability_occurrence_id') }
it { is_expected.to validate_presence_of(:project) } end
it { is_expected.to validate_presence_of(:uuid) }
it { is_expected.to validate_presence_of(:project_fingerprint) } describe 'validations' do
it { is_expected.to validate_presence_of(:primary_identifier) } let(:finding) { build(:vulnerabilities_finding) }
it { is_expected.to validate_presence_of(:location_fingerprint) }
it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:scanner) }
it { is_expected.to validate_presence_of(:report_type) } it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:metadata_version) } it { is_expected.to validate_presence_of(:uuid) }
it { is_expected.to validate_presence_of(:raw_metadata) } it { is_expected.to validate_presence_of(:project_fingerprint) }
it { is_expected.to validate_presence_of(:severity) } it { is_expected.to validate_presence_of(:primary_identifier) }
it { is_expected.to validate_presence_of(:confidence) } it { is_expected.to validate_presence_of(:location_fingerprint) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:description).is_at_most(15000) } it { is_expected.to validate_presence_of(:report_type) }
it { is_expected.to validate_length_of(:message).is_at_most(3000) } it { is_expected.to validate_presence_of(:metadata_version) }
it { is_expected.to validate_length_of(:solution).is_at_most(7000) } it { is_expected.to validate_presence_of(:raw_metadata) }
it { is_expected.to validate_length_of(:cve).is_at_most(48400) } it { is_expected.to validate_presence_of(:severity) }
it { is_expected.to validate_presence_of(:confidence) }
context 'when value for details field is valid' do
it 'is valid' do it { is_expected.to validate_length_of(:description).is_at_most(15000) }
finding.details = {} it { is_expected.to validate_length_of(:message).is_at_most(3000) }
it { is_expected.to validate_length_of(:solution).is_at_most(7000) }
expect(finding).to be_valid it { is_expected.to validate_length_of(:cve).is_at_most(48400) }
context 'when value for details field is valid' do
it 'is valid' do
finding.details = {}
expect(finding).to be_valid
end
end end
end
context 'when value for details field is invalid' do context 'when value for details field is invalid' do
it 'returns errors' do it 'returns errors' do
finding.details = { invalid: 'data' } finding.details = { invalid: 'data' }
expect(finding).to be_invalid expect(finding).to be_invalid
expect(finding.errors.full_messages).to eq(["Details must be a valid json schema"]) expect(finding.errors.full_messages).to eq(["Details must be a valid json schema"])
end
end end
end end
end
context 'database uniqueness' do context 'database uniqueness' do
let(:finding) { create(:vulnerabilities_finding) } let(:finding) { create(:vulnerabilities_finding) }
let(:new_finding) { finding.dup.tap { |o| o.uuid = SecureRandom.uuid } } let(:new_finding) { finding.dup.tap { |o| o.cve = SecureRandom.uuid } }
it "when all index attributes are identical" do it "when all index attributes are identical" do
expect { new_finding.save! }.to raise_error(ActiveRecord::RecordNotUnique) expect { new_finding.save! }.to raise_error(ActiveRecord::RecordNotUnique)
end end
describe 'when some parameters are changed' do describe 'when some parameters are changed' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
# we use block to delay object creations # we use block to delay object creations
where(:key, :factory_name) do where(:key, :factory_name) do
:primary_identifier | :vulnerabilities_identifier :primary_identifier | :vulnerabilities_identifier
:scanner | :vulnerabilities_scanner :scanner | :vulnerabilities_scanner
:project | :project :project | :project
end end
with_them do with_them do
it "is valid" do it "is valid" do
expect { new_finding.update!({ key => create(factory_name) }) }.not_to raise_error expect { new_finding.update!({ key => create(factory_name), 'uuid' => SecureRandom.uuid }) }.not_to raise_error
end
end end
end end
end end
end
context 'order' do context 'order' do
let!(:finding1) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:high]) } let!(:finding1) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:high]) }
let!(:finding2) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:medium], severity: ::Enums::Vulnerability.severity_levels[:critical]) } let!(:finding2) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:medium], severity: ::Enums::Vulnerability.severity_levels[:critical]) }
let!(:finding3) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical]) } let!(:finding3) { create(:vulnerabilities_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: ::Enums::Vulnerability.severity_levels[:critical]) }
it 'orders by severity and confidence' do it 'orders by severity and confidence' do
expect(described_class.all.ordered).to eq([finding3, finding2, finding1]) expect(described_class.all.ordered).to eq([finding3, finding2, finding1])
end
end end
end
describe '.report_type' do describe '.report_type' do
let(:report_type) { :sast } let(:report_type) { :sast }
subject { described_class.report_type(report_type) } subject { described_class.report_type(report_type) }
context 'when finding has the corresponding report type' do context 'when finding has the corresponding report type' do
let!(:finding) { create(:vulnerabilities_finding, report_type: report_type) } let!(:finding) { create(:vulnerabilities_finding, report_type: report_type) }
it 'selects the finding' do it 'selects the finding' do
is_expected.to eq([finding]) is_expected.to eq([finding])
end
end end
end
context 'when finding does not have security reports' do context 'when finding does not have security reports' do
let!(:finding) { create(:vulnerabilities_finding, report_type: :dependency_scanning) } let!(:finding) { create(:vulnerabilities_finding, report_type: :dependency_scanning) }
it 'does not select the finding' do it 'does not select the finding' do
is_expected.to be_empty is_expected.to be_empty
end
end end
end end
end
describe '.for_pipelines_with_sha' do describe '.for_pipelines_with_sha' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, :success, project: project) } let(:pipeline) { create(:ci_pipeline, :success, project: project) }
before do before do
create(:vulnerabilities_finding, pipelines: [pipeline], project: project) create(:vulnerabilities_finding, pipelines: [pipeline], project: project)
end end
subject(:findings) { described_class.for_pipelines_with_sha([pipeline]) } subject(:findings) { described_class.for_pipelines_with_sha([pipeline]) }
it 'sets the sha' do it 'sets the sha' do
expect(findings.first.sha).to eq(pipeline.sha) expect(findings.first.sha).to eq(pipeline.sha)
end
end end
end
describe '.by_report_types' do describe '.by_report_types' do
let!(:vulnerability_sast) { create(:vulnerabilities_finding, report_type: :sast) } let!(:vulnerability_sast) { create(:vulnerabilities_finding, report_type: :sast) }
let!(:vulnerability_secret_detection) { create(:vulnerabilities_finding, report_type: :secret_detection) } let!(:vulnerability_secret_detection) { create(:vulnerabilities_finding, report_type: :secret_detection) }
let!(:vulnerability_dast) { create(:vulnerabilities_finding, report_type: :dast) } let!(:vulnerability_dast) { create(:vulnerabilities_finding, report_type: :dast) }
let!(:vulnerability_depscan) { create(:vulnerabilities_finding, report_type: :dependency_scanning) } let!(:vulnerability_depscan) { create(:vulnerabilities_finding, report_type: :dependency_scanning) }
let!(:vulnerability_covfuzz) { create(:vulnerabilities_finding, report_type: :coverage_fuzzing) } let!(:vulnerability_covfuzz) { create(:vulnerabilities_finding, report_type: :coverage_fuzzing) }
let!(:vulnerability_apifuzz) { create(:vulnerabilities_finding, report_type: :api_fuzzing) } let!(:vulnerability_apifuzz) { create(:vulnerabilities_finding, report_type: :api_fuzzing) }
subject { described_class.by_report_types(param) } subject { described_class.by_report_types(param) }
context 'with one param' do context 'with one param' do
let(:param) { Vulnerabilities::Finding.report_types['sast'] } let(:param) { Vulnerabilities::Finding.report_types['sast'] }
it 'returns found record' do it 'returns found record' do
is_expected.to contain_exactly(vulnerability_sast) is_expected.to contain_exactly(vulnerability_sast)
end
end end
end
context 'with array of params' do context 'with array of params' do
let(:param) do let(:param) do
[ [
Vulnerabilities::Finding.report_types['dependency_scanning'], Vulnerabilities::Finding.report_types['dependency_scanning'],
Vulnerabilities::Finding.report_types['dast'], Vulnerabilities::Finding.report_types['dast'],
Vulnerabilities::Finding.report_types['secret_detection'], Vulnerabilities::Finding.report_types['secret_detection'],
Vulnerabilities::Finding.report_types['coverage_fuzzing'], Vulnerabilities::Finding.report_types['coverage_fuzzing'],
Vulnerabilities::Finding.report_types['api_fuzzing'] Vulnerabilities::Finding.report_types['api_fuzzing']
] ]
end end
it 'returns found records' do it 'returns found records' do
is_expected.to contain_exactly( is_expected.to contain_exactly(
vulnerability_dast, vulnerability_dast,
vulnerability_depscan, vulnerability_depscan,
vulnerability_secret_detection, vulnerability_secret_detection,
vulnerability_covfuzz, vulnerability_covfuzz,
vulnerability_apifuzz) vulnerability_apifuzz)
end
end end
end
context 'without found record' do context 'without found record' do
let(:param) { ::Enums::Vulnerability.report_types['container_scanning']} let(:param) { ::Enums::Vulnerability.report_types['container_scanning']}
it 'returns empty collection' do it 'returns empty collection' do
is_expected.to be_empty is_expected.to be_empty
end
end end
end end
end
describe '.by_projects' do describe '.by_projects' do
let!(:vulnerability1) { create(:vulnerabilities_finding) } let!(:vulnerability1) { create(:vulnerabilities_finding) }
let!(:vulnerability2) { create(:vulnerabilities_finding) } let!(:vulnerability2) { create(:vulnerabilities_finding) }
subject { described_class.by_projects(param) } subject { described_class.by_projects(param) }
context 'with found record' do context 'with found record' do
let(:param) { vulnerability1.project_id } let(:param) { vulnerability1.project_id }
it 'returns found record' do it 'returns found record' do
is_expected.to contain_exactly(vulnerability1) is_expected.to contain_exactly(vulnerability1)
end
end end
end end
end
describe '.by_scanners' do describe '.by_scanners' do
context 'with found record' do context 'with found record' do
it 'returns found record' do it 'returns found record' do
vulnerability1 = create(:vulnerabilities_finding) vulnerability1 = create(:vulnerabilities_finding)
create(:vulnerabilities_finding) create(:vulnerabilities_finding)
param = vulnerability1.scanner_id param = vulnerability1.scanner_id
result = described_class.by_scanners(param) result = described_class.by_scanners(param)
expect(result).to contain_exactly(vulnerability1) expect(result).to contain_exactly(vulnerability1)
end
end end
end end
end
describe '.by_severities' do describe '.by_severities' do
let!(:vulnerability_high) { create(:vulnerabilities_finding, severity: :high) } let!(:vulnerability_high) { create(:vulnerabilities_finding, severity: :high) }
let!(:vulnerability_low) { create(:vulnerabilities_finding, severity: :low) } let!(:vulnerability_low) { create(:vulnerabilities_finding, severity: :low) }
subject { described_class.by_severities(param) } subject { described_class.by_severities(param) }
context 'with one param' do context 'with one param' do
let(:param) { described_class.severities[:low] } let(:param) { described_class.severities[:low] }
it 'returns found record' do it 'returns found record' do
is_expected.to contain_exactly(vulnerability_low) is_expected.to contain_exactly(vulnerability_low)
end
end end
end
context 'without found record' do context 'without found record' do
let(:param) { described_class.severities[:unknown] } let(:param) { described_class.severities[:unknown] }
it 'returns empty collection' do it 'returns empty collection' do
is_expected.to be_empty is_expected.to be_empty
end
end end
end end
end
describe '.by_confidences' do describe '.by_confidences' do
let!(:vulnerability_high) { create(:vulnerabilities_finding, confidence: :high) } let!(:vulnerability_high) { create(:vulnerabilities_finding, confidence: :high) }
let!(:vulnerability_low) { create(:vulnerabilities_finding, confidence: :low) } let!(:vulnerability_low) { create(:vulnerabilities_finding, confidence: :low) }
subject { described_class.by_confidences(param) } subject { described_class.by_confidences(param) }
context 'with matching param' do context 'with matching param' do
let(:param) { described_class.confidences[:low] } let(:param) { described_class.confidences[:low] }
it 'returns found record' do it 'returns found record' do
is_expected.to contain_exactly(vulnerability_low) is_expected.to contain_exactly(vulnerability_low)
end
end end
end
context 'with non-matching param' do context 'with non-matching param' do
let(:param) { described_class.confidences[:unknown] } let(:param) { described_class.confidences[:unknown] }
it 'returns empty collection' do it 'returns empty collection' do
is_expected.to be_empty is_expected.to be_empty
end
end end
end end
end
describe '.counted_by_severity' do describe '.counted_by_severity' do
let!(:high_vulnerabilities) { create_list(:vulnerabilities_finding, 3, severity: :high) } let!(:high_vulnerabilities) { create_list(:vulnerabilities_finding, 3, severity: :high) }
let!(:medium_vulnerabilities) { create_list(:vulnerabilities_finding, 2, severity: :medium) } let!(:medium_vulnerabilities) { create_list(:vulnerabilities_finding, 2, severity: :medium) }
let!(:low_vulnerabilities) { create_list(:vulnerabilities_finding, 1, severity: :low) } let!(:low_vulnerabilities) { create_list(:vulnerabilities_finding, 1, severity: :low) }
subject { described_class.counted_by_severity } subject { described_class.counted_by_severity }
it 'returns counts' do it 'returns counts' do
is_expected.to eq({ 4 => 1, 5 => 2, 6 => 3 }) is_expected.to eq({ 4 => 1, 5 => 2, 6 => 3 })
end
end end
end
describe '.undismissed' do describe '.undismissed' do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) } let_it_be(:project2) { create(:project) }
let!(:finding1) { create(:vulnerabilities_finding, project: project) } let!(:finding1) { create(:vulnerabilities_finding, project: project) }
let!(:finding2) { create(:vulnerabilities_finding, project: project, report_type: :dast) } let!(:finding2) { create(:vulnerabilities_finding, project: project, report_type: :dast) }
let!(:finding3) { create(:vulnerabilities_finding, project: project2) } let!(:finding3) { create(:vulnerabilities_finding, project: project2) }
before do before do
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dismissal, :dismissal,
project: finding1.project, project: finding1.project,
project_fingerprint: finding1.project_fingerprint project_fingerprint: finding1.project_fingerprint
) )
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dismissal, :dismissal,
project_fingerprint: finding2.project_fingerprint, project_fingerprint: finding2.project_fingerprint,
project: project2 project: project2
) )
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dismissal, :dismissal,
category: :sast, category: :sast,
project_fingerprint: finding2.project_fingerprint, project_fingerprint: finding2.project_fingerprint,
project: finding2.project project: finding2.project
) )
end end
it 'returns all non-dismissed findings' do
expect(described_class.undismissed).to contain_exactly(finding2, finding3)
end
it 'returns non-dismissed findings for project' do
expect(project2.vulnerability_findings.undismissed).to contain_exactly(finding3)
end
end
describe '.dismissed' do
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
let!(:finding1) { create(:vulnerabilities_finding, project: project) } it 'returns all non-dismissed findings' do
let!(:finding2) { create(:vulnerabilities_finding, project: project, report_type: :dast) } expect(described_class.undismissed).to contain_exactly(finding2, finding3)
let!(:finding3) { create(:vulnerabilities_finding, project: project2) } end
before do it 'returns non-dismissed findings for project' do
create( expect(project2.vulnerability_findings.undismissed).to contain_exactly(finding3)
:vulnerability_feedback, end
:dismissal,
project: finding1.project,
project_fingerprint: finding1.project_fingerprint
)
create(
:vulnerability_feedback,
:dismissal,
project_fingerprint: finding2.project_fingerprint,
project: project2
)
create(
:vulnerability_feedback,
:dismissal,
category: :sast,
project_fingerprint: finding2.project_fingerprint,
project: finding2.project
)
end
it 'returns all dismissed findings' do
expect(described_class.dismissed).to contain_exactly(finding1)
end
it 'returns dismissed findings for project' do
expect(project.vulnerability_findings.dismissed).to contain_exactly(finding1)
end end
end
describe '.batch_count_by_project_and_severity' do describe '.dismissed' do
let(:pipeline) { create(:ci_pipeline, :success, project: project) } let_it_be(:project) { create(:project) }
let(:project) { create(:project) } let_it_be(:project2) { create(:project) }
it 'fetches a vulnerability count for the given project and severity' do let!(:finding1) { create(:vulnerabilities_finding, project: project) }
create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high) let!(:finding2) { create(:vulnerabilities_finding, project: project, report_type: :dast) }
let!(:finding3) { create(:vulnerabilities_finding, project: project2) }
count = described_class.batch_count_by_project_and_severity(project.id, 'high') before do
create(
:vulnerability_feedback,
:dismissal,
project: finding1.project,
project_fingerprint: finding1.project_fingerprint
)
create(
:vulnerability_feedback,
:dismissal,
project_fingerprint: finding2.project_fingerprint,
project: project2
)
create(
:vulnerability_feedback,
:dismissal,
category: :sast,
project_fingerprint: finding2.project_fingerprint,
project: finding2.project
)
end
expect(count).to be(1) it 'returns all dismissed findings' do
end expect(described_class.dismissed).to contain_exactly(finding1)
end
it 'only returns vulnerabilities from the latest successful pipeline' do it 'returns dismissed findings for project' do
old_pipeline = create(:ci_pipeline, :success, project: project) expect(project.vulnerability_findings.dismissed).to contain_exactly(finding1)
latest_pipeline = create(:ci_pipeline, :success, project: project) end
latest_failed_pipeline = create(:ci_pipeline, :failed, project: project) end
create(:vulnerabilities_finding, pipelines: [old_pipeline], project: project, severity: :critical)
create(
:vulnerabilities_finding,
pipelines: [latest_failed_pipeline],
project: project,
severity: :critical
)
create_list(
:vulnerabilities_finding, 2,
pipelines: [latest_pipeline],
project: project,
severity: :critical
)
count = described_class.batch_count_by_project_and_severity(project.id, 'critical') describe '.batch_count_by_project_and_severity' do
let(:pipeline) { create(:ci_pipeline, :success, project: project) }
let(:project) { create(:project) }
expect(count).to be(2) it 'fetches a vulnerability count for the given project and severity' do
end create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high)
it 'returns 0 when there are no vulnerabilities for that severity level' do count = described_class.batch_count_by_project_and_severity(project.id, 'high')
count = described_class.batch_count_by_project_and_severity(project.id, 'high')
expect(count).to be(0) expect(count).to be(1)
end end
it 'batch loads the counts' do it 'only returns vulnerabilities from the latest successful pipeline' do
projects = create_list(:project, 2) old_pipeline = create(:ci_pipeline, :success, project: project)
latest_pipeline = create(:ci_pipeline, :success, project: project)
latest_failed_pipeline = create(:ci_pipeline, :failed, project: project)
create(:vulnerabilities_finding, pipelines: [old_pipeline], project: project, severity: :critical)
create(
:vulnerabilities_finding,
pipelines: [latest_failed_pipeline],
project: project,
severity: :critical
)
create_list(
:vulnerabilities_finding, 2,
pipelines: [latest_pipeline],
project: project,
severity: :critical
)
projects.each do |project| count = described_class.batch_count_by_project_and_severity(project.id, 'critical')
pipeline = create(:ci_pipeline, :success, project: project)
create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high) expect(count).to be(2)
create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :low)
end end
projects_and_severities = [ it 'returns 0 when there are no vulnerabilities for that severity level' do
[projects.first, 'high'], count = described_class.batch_count_by_project_and_severity(project.id, 'high')
[projects.first, 'low'],
[projects.second, 'high'],
[projects.second, 'low']
]
counts = projects_and_severities.map do |(project, severity)| expect(count).to be(0)
described_class.batch_count_by_project_and_severity(project.id, severity)
end end
expect { expect(counts).to all(be 1) }.not_to exceed_query_limit(1) it 'batch loads the counts' do
end projects = create_list(:project, 2)
it 'does not include dismissed vulnerabilities in the counts' do projects.each do |project|
create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high) pipeline = create(:ci_pipeline, :success, project: project)
dismissed_vulnerability = create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high)
create(
:vulnerability_feedback,
project: project,
project_fingerprint: dismissed_vulnerability.project_fingerprint,
feedback_type: :dismissal
)
count = described_class.batch_count_by_project_and_severity(project.id, 'high') create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high)
create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :low)
end
expect(count).to be(1) projects_and_severities = [
end [projects.first, 'high'],
[projects.first, 'low'],
[projects.second, 'high'],
[projects.second, 'low']
]
it "does not overwrite one project's counts with another's" do counts = projects_and_severities.map do |(project, severity)|
project1 = create(:project) described_class.batch_count_by_project_and_severity(project.id, severity)
project2 = create(:project) end
pipeline1 = create(:ci_pipeline, :success, project: project1)
pipeline2 = create(:ci_pipeline, :success, project: project2)
create(:vulnerabilities_finding, pipelines: [pipeline1], project: project1, severity: :critical)
create(:vulnerabilities_finding, pipelines: [pipeline2], project: project2, severity: :high)
project1_critical_count = described_class.batch_count_by_project_and_severity(project1.id, 'critical') expect { expect(counts).to all(be 1) }.not_to exceed_query_limit(1)
project1_high_count = described_class.batch_count_by_project_and_severity(project1.id, 'high') end
project2_critical_count = described_class.batch_count_by_project_and_severity(project2.id, 'critical')
project2_high_count = described_class.batch_count_by_project_and_severity(project2.id, 'high')
expect(project1_critical_count).to be(1) it 'does not include dismissed vulnerabilities in the counts' do
expect(project1_high_count).to be(0) create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high)
expect(project2_critical_count).to be(0) dismissed_vulnerability = create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high)
expect(project2_high_count).to be(1) create(
end :vulnerability_feedback,
end project: project,
project_fingerprint: dismissed_vulnerability.project_fingerprint,
feedback_type: :dismissal
)
describe '#links' do count = described_class.batch_count_by_project_and_severity(project.id, 'high')
let_it_be(:finding, reload: true) do
create(
:vulnerabilities_finding,
raw_metadata: {
links: [{ url: 'https://raw.gitlab.com', name: 'raw_metadata_link' }]
}.to_json
)
end
subject(:links) { finding.links } expect(count).to be(1)
end
context 'when there are no finding links' do it "does not overwrite one project's counts with another's" do
it 'returns links from raw_metadata' do project1 = create(:project)
expect(links).to eq([{ 'url' => 'https://raw.gitlab.com', 'name' => 'raw_metadata_link' }]) project2 = create(:project)
pipeline1 = create(:ci_pipeline, :success, project: project1)
pipeline2 = create(:ci_pipeline, :success, project: project2)
create(:vulnerabilities_finding, pipelines: [pipeline1], project: project1, severity: :critical)
create(:vulnerabilities_finding, pipelines: [pipeline2], project: project2, severity: :high)
project1_critical_count = described_class.batch_count_by_project_and_severity(project1.id, 'critical')
project1_high_count = described_class.batch_count_by_project_and_severity(project1.id, 'high')
project2_critical_count = described_class.batch_count_by_project_and_severity(project2.id, 'critical')
project2_high_count = described_class.batch_count_by_project_and_severity(project2.id, 'high')
expect(project1_critical_count).to be(1)
expect(project1_high_count).to be(0)
expect(project2_critical_count).to be(0)
expect(project2_high_count).to be(1)
end end
end end
context 'when there are finding links assigned to given finding' do describe '#links' do
let_it_be(:finding_link) { create(:finding_link, name: 'finding_link', url: 'https://link.gitlab.com', finding: finding) } let_it_be(:finding, reload: true) do
create(
it 'returns links from finding link' do :vulnerabilities_finding,
expect(links).to eq([{ 'url' => 'https://link.gitlab.com', 'name' => 'finding_link' }]) raw_metadata: {
links: [{ url: 'https://raw.gitlab.com', name: 'raw_metadata_link' }]
}.to_json
)
end end
end
end
describe '#remediations' do subject(:links) { finding.links }
let_it_be(:project) { create_default(:project) }
let_it_be(:finding, refind: true) { create(:vulnerabilities_finding) }
subject { finding.remediations } context 'when there are no finding links' do
it 'returns links from raw_metadata' do
expect(links).to eq([{ 'url' => 'https://raw.gitlab.com', 'name' => 'raw_metadata_link' }])
end
end
context 'when the finding has associated remediation records' do context 'when there are finding links assigned to given finding' do
let_it_be(:persisted_remediation) { create(:vulnerabilities_remediation, findings: [finding]) } let_it_be(:finding_link) { create(:finding_link, name: 'finding_link', url: 'https://link.gitlab.com', finding: finding) }
let_it_be(:remediation_hash) { { 'summary' => persisted_remediation.summary, 'diff' => persisted_remediation.diff } }
it { is_expected.to eq([remediation_hash]) } it 'returns links from finding link' do
expect(links).to eq([{ 'url' => 'https://link.gitlab.com', 'name' => 'finding_link' }])
end
end
end end
context 'when the finding does not have associated remediation records' do describe '#remediations' do
context 'when the finding has remediations in `raw_metadata`' do let_it_be(:project) { create_default(:project) }
let(:raw_remediation) { { summary: 'foo', diff: 'bar' }.stringify_keys } let_it_be(:finding, refind: true) { create(:vulnerabilities_finding) }
before do subject { finding.remediations }
raw_metadata = { remediations: [raw_remediation] }.to_json
finding.update!(raw_metadata: raw_metadata) context 'when the finding has associated remediation records' do
end let_it_be(:persisted_remediation) { create(:vulnerabilities_remediation, findings: [finding]) }
let_it_be(:remediation_hash) { { 'summary' => persisted_remediation.summary, 'diff' => persisted_remediation.diff } }
it { is_expected.to eq([raw_remediation]) } it { is_expected.to eq([remediation_hash]) }
end end
context 'when the finding does not have remediations in `raw_metadata`' do context 'when the finding does not have associated remediation records' do
before do context 'when the finding has remediations in `raw_metadata`' do
finding.update!(raw_metadata: {}.to_json) let(:raw_remediation) { { summary: 'foo', diff: 'bar' }.stringify_keys }
before do
raw_metadata = { remediations: [raw_remediation] }.to_json
finding.update!(raw_metadata: raw_metadata)
end
it { is_expected.to eq([raw_remediation]) }
end end
it { is_expected.to be_nil } context 'when the finding does not have remediations in `raw_metadata`' do
before do
finding.update!(raw_metadata: {}.to_json)
end
it { is_expected.to be_nil }
end
end end
end end
end
describe 'feedback' do
let_it_be(:project) { create(:project) }
let(:finding) do describe 'feedback' do
create( let_it_be(:project) { create(:project) }
:vulnerabilities_finding,
report_type: :dependency_scanning,
project: project
)
end
describe '#issue_feedback' do let(:finding) do
let(:issue) { create(:issue, project: project) }
let!(:issue_feedback) do
create( create(
:vulnerability_feedback, :vulnerabilities_finding,
:dependency_scanning, report_type: :dependency_scanning,
:issue, project: project
issue: issue,
project: project,
project_fingerprint: finding.project_fingerprint
) )
end end
let(:vulnerability) { create(:vulnerability, findings: [finding]) } describe '#issue_feedback' do
let!(:issue_link) { create(:vulnerabilities_issue_link, vulnerability: vulnerability, issue: issue)} let(:issue) { create(:issue, project: project) }
let!(:issue_feedback) do
create(
:vulnerability_feedback,
:dependency_scanning,
:issue,
issue: issue,
project: project,
project_fingerprint: finding.project_fingerprint
)
end
it 'returns associated feedback' do let(:vulnerability) { create(:vulnerability, findings: [finding]) }
feedback = finding.issue_feedback let!(:issue_link) { create(:vulnerabilities_issue_link, vulnerability: vulnerability, issue: issue)}
expect(feedback).to be_present it 'returns associated feedback' do
expect(feedback[:project_id]).to eq project.id feedback = finding.issue_feedback
expect(feedback[:feedback_type]).to eq 'issue'
expect(feedback[:issue_id]).to eq issue.id expect(feedback).to be_present
end expect(feedback[:project_id]).to eq project.id
expect(feedback[:feedback_type]).to eq 'issue'
expect(feedback[:issue_id]).to eq issue.id
end
context 'when there is no feedback for the vulnerability' do context 'when there is no feedback for the vulnerability' do
let(:vulnerability_no_feedback) { create(:vulnerability, findings: [finding_no_feedback]) } let(:vulnerability_no_feedback) { create(:vulnerability, findings: [finding_no_feedback]) }
let!(:finding_no_feedback) { create(:vulnerabilities_finding, :dependency_scanning, project: project) } let!(:finding_no_feedback) { create(:vulnerabilities_finding, :dependency_scanning, project: project) }
it 'does not return unassociated feedback' do it 'does not return unassociated feedback' do
feedback = finding_no_feedback.issue_feedback feedback = finding_no_feedback.issue_feedback
expect(feedback).not_to be_present expect(feedback).not_to be_present
end
end end
end
context 'when there is no vulnerability associated with the finding' do context 'when there is no vulnerability associated with the finding' do
let!(:finding_no_vulnerability) { create(:vulnerabilities_finding, :dependency_scanning, project: project) } let!(:finding_no_vulnerability) { create(:vulnerabilities_finding, :dependency_scanning, project: project) }
it 'does not return feedback' do it 'does not return feedback' do
feedback = finding_no_vulnerability.issue_feedback feedback = finding_no_vulnerability.issue_feedback
expect(feedback).not_to be_present expect(feedback).not_to be_present
end
end end
end end
end
describe '#dismissal_feedback' do describe '#dismissal_feedback' do
let!(:dismissal_feedback) do let!(:dismissal_feedback) do
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dependency_scanning, :dependency_scanning,
:dismissal, :dismissal,
project: project, project: project,
project_fingerprint: finding.project_fingerprint project_fingerprint: finding.project_fingerprint
) )
end end
it 'returns associated feedback' do it 'returns associated feedback' do
feedback = finding.dismissal_feedback feedback = finding.dismissal_feedback
expect(feedback).to be_present expect(feedback).to be_present
expect(feedback[:project_id]).to eq project.id expect(feedback[:project_id]).to eq project.id
expect(feedback[:feedback_type]).to eq 'dismissal' expect(feedback[:feedback_type]).to eq 'dismissal'
end
end end
end
describe '#merge_request_feedback' do describe '#merge_request_feedback' do
let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request) { create(:merge_request, source_project: project) }
let!(:merge_request_feedback) do let!(:merge_request_feedback) do
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dependency_scanning, :dependency_scanning,
:merge_request, :merge_request,
merge_request: merge_request, merge_request: merge_request,
project: project, project: project,
project_fingerprint: finding.project_fingerprint project_fingerprint: finding.project_fingerprint
) )
end end
it 'returns associated feedback' do it 'returns associated feedback' do
feedback = finding.merge_request_feedback feedback = finding.merge_request_feedback
expect(feedback).to be_present expect(feedback).to be_present
expect(feedback[:project_id]).to eq project.id expect(feedback[:project_id]).to eq project.id
expect(feedback[:feedback_type]).to eq 'merge_request' expect(feedback[:feedback_type]).to eq 'merge_request'
expect(feedback[:merge_request_id]).to eq merge_request.id expect(feedback[:merge_request_id]).to eq merge_request.id
end
end end
end end
end
describe '#load_feedback' do
let_it_be(:project) { create(:project) }
let_it_be(:finding) do
create(
:vulnerabilities_finding,
report_type: :dependency_scanning,
project: project
)
end
let_it_be(:feedback) do
create(
:vulnerability_feedback,
:dependency_scanning,
:dismissal,
project: project,
project_fingerprint: finding.project_fingerprint
)
end
let(:expected_feedback) { [feedback] }
subject(:load_feedback) { finding.load_feedback.to_a }
it { is_expected.to eq(expected_feedback) } describe '#load_feedback' do
let_it_be(:project) { create(:project) }
context 'when you have multiple findings' do let_it_be(:finding) do
let_it_be(:finding_2) do
create( create(
:vulnerabilities_finding, :vulnerabilities_finding,
report_type: :dependency_scanning, report_type: :dependency_scanning,
...@@ -646,367 +627,500 @@ RSpec.describe Vulnerabilities::Finding do ...@@ -646,367 +627,500 @@ RSpec.describe Vulnerabilities::Finding do
) )
end end
let_it_be(:feedback_2) do let_it_be(:feedback) do
create( create(
:vulnerability_feedback, :vulnerability_feedback,
:dependency_scanning, :dependency_scanning,
:dismissal, :dismissal,
project: project, project: project,
project_fingerprint: finding_2.project_fingerprint project_fingerprint: finding.project_fingerprint
) )
end end
let(:expected_feedback) { [[feedback], [feedback_2]] } let(:expected_feedback) { [feedback] }
subject(:load_feedback) { [finding, finding_2].map(&:load_feedback) } subject(:load_feedback) { finding.load_feedback.to_a }
it { is_expected.to eq(expected_feedback) } it { is_expected.to eq(expected_feedback) }
end
end
describe '#state' do context 'when you have multiple findings' do
before do let_it_be(:finding_2) do
create(:vulnerability, :dismissed, project: finding_with_issue.project, findings: [finding_with_issue]) create(
end :vulnerabilities_finding,
report_type: :dependency_scanning,
let(:unresolved_finding) { create(:vulnerabilities_finding) } project: project
let(:confirmed_finding) { create(:vulnerabilities_finding, :confirmed) } )
let(:resolved_finding) { create(:vulnerabilities_finding, :resolved) } end
let(:dismissed_finding) { create(:vulnerabilities_finding, :dismissed) }
let(:detected_finding) { create(:vulnerabilities_finding, :detected) }
let(:finding_with_issue) { create(:vulnerabilities_finding, :with_issue_feedback) }
it 'returns the expected state for a unresolved finding' do
expect(unresolved_finding.state).to eq 'detected'
end
it 'returns the expected state for a confirmed finding' do let_it_be(:feedback_2) do
expect(confirmed_finding.state).to eq 'confirmed' create(
end :vulnerability_feedback,
:dependency_scanning,
:dismissal,
project: project,
project_fingerprint: finding_2.project_fingerprint
)
end
it 'returns the expected state for a resolved finding' do let(:expected_feedback) { [[feedback], [feedback_2]] }
expect(resolved_finding.state).to eq 'resolved'
end
it 'returns the expected state for a dismissed finding' do subject(:load_feedback) { [finding, finding_2].map(&:load_feedback) }
expect(dismissed_finding.state).to eq 'dismissed'
end
it 'returns the expected state for a detected finding' do it { is_expected.to eq(expected_feedback) }
expect(detected_finding.state).to eq 'detected' end
end end
context 'when a vulnerability present for a dismissed finding' do describe '#state' do
before do before do
create(:vulnerability, project: dismissed_finding.project, findings: [dismissed_finding]) create(:vulnerability, :dismissed, project: finding_with_issue.project, findings: [finding_with_issue])
end end
it 'still reports a dismissed state' do let(:unresolved_finding) { create(:vulnerabilities_finding) }
expect(dismissed_finding.state).to eq 'dismissed' let(:confirmed_finding) { create(:vulnerabilities_finding, :confirmed) }
let(:resolved_finding) { create(:vulnerabilities_finding, :resolved) }
let(:dismissed_finding) { create(:vulnerabilities_finding, :dismissed) }
let(:finding_with_issue) { create(:vulnerabilities_finding, :with_issue_feedback) }
it 'returns the expected state for a unresolved finding' do
expect(unresolved_finding.state).to eq 'detected'
end end
end
context 'when a non-dismissal feedback present for a finding belonging to a closed vulnerability' do it 'returns the expected state for a confirmed finding' do
before do expect(confirmed_finding.state).to eq 'confirmed'
create(:vulnerability_feedback, :issue, project: resolved_finding.project)
end end
it 'reports as resolved' do it 'returns the expected state for a resolved finding' do
expect(resolved_finding.state).to eq 'resolved' expect(resolved_finding.state).to eq 'resolved'
end end
end
end
describe '#scanner_name' do it 'returns the expected state for a dismissed finding' do
let(:vulnerabilities_finding) { create(:vulnerabilities_finding) } expect(dismissed_finding.state).to eq 'dismissed'
end
subject(:scanner_name) { vulnerabilities_finding.scanner_name }
it { is_expected.to eq(vulnerabilities_finding.scanner.name) } context 'when a vulnerability present for a dismissed finding' do
end before do
create(:vulnerability, project: dismissed_finding.project, findings: [dismissed_finding])
end
describe '#description' do it 'still reports a dismissed state' do
let(:finding) { build(:vulnerabilities_finding) } expect(dismissed_finding.state).to eq 'dismissed'
let(:expected_description) { finding.metadata['description'] } end
end
subject { finding.description } context 'when a non-dismissal feedback present for a finding belonging to a closed vulnerability' do
before do
create(:vulnerability_feedback, :issue, project: resolved_finding.project)
end
context 'when description metadata key is present' do it 'reports as resolved' do
it { is_expected.to eql(expected_description) } expect(resolved_finding.state).to eq 'resolved'
end
end
end end
context 'when description data is present' do describe '#scanner_name' do
let(:finding) { build(:vulnerabilities_finding, description: 'Vulnerability description') } let(:vulnerabilities_finding) { create(:vulnerabilities_finding) }
it { is_expected.to eq('Vulnerability description') } subject(:scanner_name) { vulnerabilities_finding.scanner_name }
end
end
describe '#solution' do it { is_expected.to eq(vulnerabilities_finding.scanner.name) }
subject { vulnerabilities_finding.solution } end
context 'when solution metadata key is present' do describe '#description' do
let(:vulnerabilities_finding) { build(:vulnerabilities_finding) } let(:finding) { build(:vulnerabilities_finding) }
let(:expected_description) { finding.metadata['description'] }
it { is_expected.to eq(vulnerabilities_finding.metadata['solution']) } subject { finding.description }
end
context 'when remediations key is present in finding' do context 'when description metadata key is present' do
let(:vulnerabilities_finding) do it { is_expected.to eql(expected_description) }
build(:vulnerabilities_finding_with_remediation, summary: "Test remediation")
end end
it { is_expected.to eq(vulnerabilities_finding.remediations.dig(0, 'summary')) } context 'when description data is present' do
let(:finding) { build(:vulnerabilities_finding, description: 'Vulnerability description') }
it { is_expected.to eq('Vulnerability description') }
end
end end
context 'when solution data is present' do describe '#solution' do
let(:vulnerabilities_finding) { build(:vulnerabilities_finding, solution: 'Vulnerability solution') } subject { vulnerabilities_finding.solution }
it { is_expected.to eq('Vulnerability solution') } context 'when solution metadata key is present' do
end let(:vulnerabilities_finding) { build(:vulnerabilities_finding) }
end
describe '#location' do it { is_expected.to eq(vulnerabilities_finding.metadata['solution']) }
let(:finding) { build(:vulnerabilities_finding) } end
let(:expected_location) { finding.metadata['location'] }
subject { finding.location } context 'when remediations key is present in finding' do
let(:vulnerabilities_finding) do
build(:vulnerabilities_finding_with_remediation, summary: "Test remediation")
end
context 'when location metadata key is present' do it { is_expected.to eq(vulnerabilities_finding.remediations.dig(0, 'summary')) }
it { is_expected.to eql(expected_location) } end
end
context 'when location data is present' do context 'when solution data is present' do
let(:location) { { 'class' => 'class', 'end_line' => 3, 'file' => 'test_file.rb', 'start_line' => 1 } } let(:vulnerabilities_finding) { build(:vulnerabilities_finding, solution: 'Vulnerability solution') }
let(:finding) { build(:vulnerabilities_finding, location: location) }
it { is_expected.to eq(location) } it { is_expected.to eq('Vulnerability solution') }
end
end end
end
describe '#evidence' do describe '#location' do
subject { finding.evidence } let(:finding) { build(:vulnerabilities_finding) }
let(:expected_location) { finding.metadata['location'] }
context 'has an evidence fields' do subject { finding.location }
let(:finding) { create(:vulnerabilities_finding) }
let(:evidence) { finding.metadata['evidence'] }
it do context 'when location metadata key is present' do
is_expected.to match a_hash_including( it { is_expected.to eql(expected_location) }
summary: evidence['summary'], end
request: {
headers: [ context 'when location data is present' do
{ let(:location) { { 'class' => 'class', 'end_line' => 3, 'file' => 'test_file.rb', 'start_line' => 1 } }
name: evidence['request']['headers'][0]['name'], let(:finding) { build(:vulnerabilities_finding, location: location) }
value: evidence['request']['headers'][0]['value']
} it { is_expected.to eq(location) }
], end
url: evidence['request']['url'], end
method: evidence['request']['method'],
body: evidence['request']['body'] describe '#evidence' do
}, subject { finding.evidence }
response: {
headers: [ context 'has an evidence fields' do
{ let(:finding) { create(:vulnerabilities_finding) }
name: evidence['response']['headers'][0]['name'], let(:evidence) { finding.metadata['evidence'] }
value: evidence['response']['headers'][0]['value']
} it do
], is_expected.to match a_hash_including(
reason_phrase: evidence['response']['reason_phrase'], summary: evidence['summary'],
status_code: evidence['response']['status_code'], request: {
body: evidence['request']['body'] headers: [
}, {
source: { name: evidence['request']['headers'][0]['name'],
id: evidence.dig('source', 'id'), value: evidence['request']['headers'][0]['value']
name: evidence.dig('source', 'name'), }
url: evidence.dig('source', 'url') ],
}, url: evidence['request']['url'],
supporting_messages: [ method: evidence['request']['method'],
{ body: evidence['request']['body']
name: evidence.dig('supporting_messages')[0].dig('name'), },
request: { response: {
headers: [ headers: [
{ {
name: evidence.dig('supporting_messages')[0].dig('request', 'headers')[0].dig('name'), name: evidence['response']['headers'][0]['name'],
value: evidence.dig('supporting_messages')[0].dig('request', 'headers')[0].dig('value') value: evidence['response']['headers'][0]['value']
} }
], ],
url: evidence.dig('supporting_messages')[0].dig('request', 'url'), reason_phrase: evidence['response']['reason_phrase'],
method: evidence.dig('supporting_messages')[0].dig('request', 'method'), status_code: evidence['response']['status_code'],
body: evidence.dig('supporting_messages')[0].dig('request', 'body') body: evidence['request']['body']
}, },
response: evidence.dig('supporting_messages')[0].dig('response') source: {
id: evidence.dig('source', 'id'),
name: evidence.dig('source', 'name'),
url: evidence.dig('source', 'url')
}, },
{ supporting_messages: [
name: evidence.dig('supporting_messages')[1].dig('name'), {
request: { name: evidence.dig('supporting_messages')[0].dig('name'),
headers: [ request: {
{ headers: [
name: evidence.dig('supporting_messages')[1].dig('request', 'headers')[0].dig('name'), {
value: evidence.dig('supporting_messages')[1].dig('request', 'headers')[0].dig('value') name: evidence.dig('supporting_messages')[0].dig('request', 'headers')[0].dig('name'),
} value: evidence.dig('supporting_messages')[0].dig('request', 'headers')[0].dig('value')
], }
url: evidence.dig('supporting_messages')[1].dig('request', 'url'), ],
method: evidence.dig('supporting_messages')[1].dig('request', 'method'), url: evidence.dig('supporting_messages')[0].dig('request', 'url'),
body: evidence.dig('supporting_messages')[1].dig('request', 'body') method: evidence.dig('supporting_messages')[0].dig('request', 'method'),
body: evidence.dig('supporting_messages')[0].dig('request', 'body')
},
response: evidence.dig('supporting_messages')[0].dig('response')
}, },
response: { {
headers: [ name: evidence.dig('supporting_messages')[1].dig('name'),
{ request: {
name: evidence.dig('supporting_messages')[1].dig('response', 'headers')[0].dig('name'), headers: [
value: evidence.dig('supporting_messages')[1].dig('response', 'headers')[0].dig('value') {
} name: evidence.dig('supporting_messages')[1].dig('request', 'headers')[0].dig('name'),
], value: evidence.dig('supporting_messages')[1].dig('request', 'headers')[0].dig('value')
reason_phrase: evidence.dig('supporting_messages')[1].dig('response', 'reason_phrase'), }
status_code: evidence.dig('supporting_messages')[1].dig('response', 'status_code'), ],
body: evidence.dig('supporting_messages')[1].dig('response', 'body') url: evidence.dig('supporting_messages')[1].dig('request', 'url'),
method: evidence.dig('supporting_messages')[1].dig('request', 'method'),
body: evidence.dig('supporting_messages')[1].dig('request', 'body')
},
response: {
headers: [
{
name: evidence.dig('supporting_messages')[1].dig('response', 'headers')[0].dig('name'),
value: evidence.dig('supporting_messages')[1].dig('response', 'headers')[0].dig('value')
}
],
reason_phrase: evidence.dig('supporting_messages')[1].dig('response', 'reason_phrase'),
status_code: evidence.dig('supporting_messages')[1].dig('response', 'status_code'),
body: evidence.dig('supporting_messages')[1].dig('response', 'body')
}
} }
} ]
] )
) end
end end
end
context 'has no evidence summary when evidence is present, summary is not' do context 'has no evidence summary when evidence is present, summary is not' do
let(:finding) { create(:vulnerabilities_finding, raw_metadata: { evidence: {} }) } let(:finding) { create(:vulnerabilities_finding, raw_metadata: { evidence: {} }) }
it do it do
is_expected.to match a_hash_including( is_expected.to match a_hash_including(
summary: nil, summary: nil,
source: nil, source: nil,
supporting_messages: [], supporting_messages: [],
request: nil, request: nil,
response: nil) response: nil)
end
end end
end end
end
describe '#message' do describe '#message' do
let(:finding) { build(:vulnerabilities_finding) } let(:finding) { build(:vulnerabilities_finding) }
let(:expected_message) { finding.metadata['message'] } let(:expected_message) { finding.metadata['message'] }
subject { finding.message } subject { finding.message }
context 'when message metadata key is present' do context 'when message metadata key is present' do
it { is_expected.to eql(expected_message) } it { is_expected.to eql(expected_message) }
end end
context 'when message data is present' do context 'when message data is present' do
let(:finding) { build(:vulnerabilities_finding, message: 'Vulnerability message') } let(:finding) { build(:vulnerabilities_finding, message: 'Vulnerability message') }
it { is_expected.to eq('Vulnerability message') } it { is_expected.to eq('Vulnerability message') }
end
end end
end
describe '#cve_value' do describe '#cve_value' do
let(:finding) { build(:vulnerabilities_finding) } let(:finding) { build(:vulnerabilities_finding) }
let(:expected_cve) { 'CVE-2020-0000' } let(:expected_cve) { 'CVE-2020-0000' }
subject { finding.cve_value } subject { finding.cve_value }
before do before do
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'cve', name: expected_cve) finding.identifiers << build(:vulnerabilities_identifier, external_type: 'cve', name: expected_cve)
end end
context 'when cve metadata key is present' do context 'when cve metadata key is present' do
it { is_expected.to eql(expected_cve) } it { is_expected.to eql(expected_cve) }
end end
context 'when cve data is present' do context 'when cve data is present' do
let(:finding) { build(:vulnerabilities_finding, cve: 'Vulnerability cve') } let(:finding) { build(:vulnerabilities_finding, cve: 'Vulnerability cve') }
it { is_expected.to eq('Vulnerability cve') } it { is_expected.to eq('Vulnerability cve') }
end
end end
end
describe '#cwe_value' do describe '#cwe_value' do
let(:finding) { build(:vulnerabilities_finding) } let(:finding) { build(:vulnerabilities_finding) }
let(:expected_cwe) { 'CWE-0000' } let(:expected_cwe) { 'CWE-0000' }
subject { finding.cwe_value } subject { finding.cwe_value }
before do before do
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'cwe', name: expected_cwe) finding.identifiers << build(:vulnerabilities_identifier, external_type: 'cwe', name: expected_cwe)
end
it { is_expected.to eql(expected_cwe) }
end end
it { is_expected.to eql(expected_cwe) } describe '#other_identifier_values' do
end let(:finding) { build(:vulnerabilities_finding) }
let(:expected_values) { ['ID 1', 'ID 2'] }
describe '#other_identifier_values' do subject { finding.other_identifier_values }
let(:finding) { build(:vulnerabilities_finding) }
let(:expected_values) { ['ID 1', 'ID 2'] }
subject { finding.other_identifier_values } before do
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'foo', name: expected_values.first)
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'bar', name: expected_values.second)
end
before do it { is_expected.to match_array(expected_values) }
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'foo', name: expected_values.first)
finding.identifiers << build(:vulnerabilities_identifier, external_type: 'bar', name: expected_values.second)
end end
it { is_expected.to match_array(expected_values) } describe "#metadata" do
end let(:finding) { build(:vulnerabilities_finding) }
describe "#metadata" do subject { finding.metadata }
let(:finding) { build(:vulnerabilities_finding) }
subject { finding.metadata } it "handles bool JSON data" do
allow(finding).to receive(:raw_metadata) { "true" }
it "handles bool JSON data" do expect(subject).to eq({})
allow(finding).to receive(:raw_metadata) { "true" } end
expect(subject).to eq({}) it "handles string JSON data" do
end allow(finding).to receive(:raw_metadata) { '"test"' }
expect(subject).to eq({})
end
it "handles string JSON data" do it "parses JSON data" do
allow(finding).to receive(:raw_metadata) { '"test"' } allow(finding).to receive(:raw_metadata) { '{ "test": true }' }
expect(subject).to eq({}) expect(subject).to eq({ "test" => true })
end
end end
it "parses JSON data" do describe '#uuid_v5' do
allow(finding).to receive(:raw_metadata) { '{ "test": true }' } let(:project) { create(:project) }
let(:report_type) { :sast }
let(:identifier_fingerprint) { 'fooo' }
let(:location_fingerprint) { 'zooo' }
let(:identifier) { build(:vulnerabilities_identifier, fingerprint: identifier_fingerprint) }
let(:expected_uuid) { 'this-is-supposed-to-a-uuid' }
let(:finding) do
build(:vulnerabilities_finding, report_type,
uuid: uuid,
project: project,
primary_identifier: identifier,
location_fingerprint: location_fingerprint)
end
expect(subject).to eq({ "test" => true }) subject(:uuid_v5) { finding.uuid_v5 }
end
end
describe '#uuid_v5' do before do
let(:project) { create(:project) } allow(::Gitlab::UUID).to receive(:v5).and_return(expected_uuid)
let(:report_type) { :sast } end
let(:identifier_fingerprint) { 'fooo' }
let(:location_fingerprint) { 'zooo' }
let(:identifier) { build(:vulnerabilities_identifier, fingerprint: identifier_fingerprint) }
let(:expected_uuid) { 'this-is-supposed-to-a-uuid' }
let(:finding) do
build(:vulnerabilities_finding, report_type,
uuid: uuid,
project: project,
primary_identifier: identifier,
location_fingerprint: location_fingerprint)
end
subject(:uuid_v5) { finding.uuid_v5 } context 'when the finding has a version 4 uuid' do
let(:uuid) { SecureRandom.uuid }
let(:uuid_name_value) { "#{report_type}-#{identifier_fingerprint}-#{location_fingerprint}-#{project.id}" }
before do it 'returns the calculated uuid for the finding' do
allow(::Gitlab::UUID).to receive(:v5).and_return(expected_uuid) expect(uuid_v5).to eq(expected_uuid)
end expect(::Gitlab::UUID).to have_received(:v5).with(uuid_name_value)
end
end
context 'when the finding has a version 4 uuid' do context 'when the finding has a version 5 uuid' do
let(:uuid) { SecureRandom.uuid } let(:uuid) { '6756ebb6-8465-5c33-9af9-c5c8b117aefb' }
let(:uuid_name_value) { "#{report_type}-#{identifier_fingerprint}-#{location_fingerprint}-#{project.id}" }
it 'returns the calculated uuid for the finding' do it 'returns the uuid of the finding' do
expect(uuid_v5).to eq(expected_uuid) expect(uuid_v5).to eq(uuid)
expect(::Gitlab::UUID).to have_received(:v5).with(uuid_name_value) expect(::Gitlab::UUID).not_to have_received(:v5)
end
end end
end end
context 'when the finding has a version 5 uuid' do describe '#eql?' do
let(:uuid) { '6756ebb6-8465-5c33-9af9-c5c8b117aefb' } let(:project) { create(:project) }
let(:report_type) { :sast }
let(:identifier_fingerprint) { 'fooo' }
let(:identifier) { build(:vulnerabilities_identifier, fingerprint: identifier_fingerprint) }
let(:location_fingerprint1) { 'fingerprint1' }
let(:location_fingerprint2) { 'fingerprint2' }
let(:finding1) do
build(:vulnerabilities_finding, report_type,
project: project,
primary_identifier: identifier,
location_fingerprint: location_fingerprint1)
end
let(:finding2) do
build(:vulnerabilities_finding, report_type,
project: project,
primary_identifier: identifier,
location_fingerprint: location_fingerprint2)
end
it 'returns the uuid of the finding' do it 'matches the finding based on enabled tracking methods (if feature flag enabled)' do
expect(uuid_v5).to eq(uuid) signature1 = create(
expect(::Gitlab::UUID).not_to have_received(:v5) :vulnerabilities_finding_signature,
finding: finding1
)
signature2 = create(
:vulnerabilities_finding_signature,
finding: finding2,
signature_sha: signature1.signature_sha
)
# verify that the signatures do exist and that they match
expect(finding1.signatures.size).to eq(1)
expect(finding2.signatures.size).to eq(1)
expect(signature1.eql?(signature2)).to be(true)
# now verify that the correct matching method was used for eql?
expect(finding1.eql?(finding2)).to be(vulnerability_finding_signatures_enabled)
end
context 'short circuits on the highest priority signature match' do
using RSpec::Parameterized::TableSyntax
let(:same_hash) { false }
let(:same_location) { false }
let(:create_scope_offset) { false }
let(:same_scope_offset) { false}
let(:create_signatures) do
signature1_hash = create(
:vulnerabilities_finding_signature,
algorithm_type: 'hash',
finding: finding1
)
sha = same_hash ? signature1_hash.signature_sha : ::Digest::SHA1.digest(SecureRandom.hex(50))
create(
:vulnerabilities_finding_signature,
algorithm_type: 'hash',
finding: finding2,
signature_sha: sha
)
signature1_location = create(
:vulnerabilities_finding_signature,
algorithm_type: 'location',
finding: finding1
)
sha = same_location ? signature1_location.signature_sha : ::Digest::SHA1.digest(SecureRandom.hex(50))
create(
:vulnerabilities_finding_signature,
algorithm_type: 'location',
finding: finding2,
signature_sha: sha
)
signature1_scope_offset = create(
:vulnerabilities_finding_signature,
algorithm_type: 'scope_offset',
finding: finding1
)
if create_scope_offset
sha = same_scope_offset ? signature1_scope_offset.signature_sha : ::Digest::SHA1.digest(SecureRandom.hex(50))
create(
:vulnerabilities_finding_signature,
algorithm_type: 'scope_offset',
finding: finding2,
signature_sha: sha
)
end
end
where(:same_hash, :same_location, :create_scope_offset, :same_scope_offset, :should_match) do
true | true | true | true | true # everything matches
false | false | true | false | false # nothing matches
true | true | true | false | false # highest priority matches alg/priority but not on value
false | false | true | true | true # highest priority matches alg/priority and value
false | true | false | false | true # highest priority is location, matches alg/priority and value
end
with_them do
it 'matches correctly' do
next unless vulnerability_finding_signatures_enabled
create_signatures
expect(finding1.eql?(finding2)).to be(should_match)
end
end
end end
end end
end end
......
...@@ -33,7 +33,8 @@ RSpec.describe API::VulnerabilityFindings do ...@@ -33,7 +33,8 @@ RSpec.describe API::VulnerabilityFindings do
project: project, project: project,
pipeline: pipeline, pipeline: pipeline,
project_fingerprint: sast_report.findings.first.project_fingerprint, project_fingerprint: sast_report.findings.first.project_fingerprint,
vulnerability_data: sast_report.findings.first.raw_metadata vulnerability_data: sast_report.findings.first.raw_metadata,
finding_uuid: sast_report.findings.first.uuid
) )
end end
......
...@@ -164,7 +164,7 @@ RSpec.describe Vulnerabilities::FeedbackEntity do ...@@ -164,7 +164,7 @@ RSpec.describe Vulnerabilities::FeedbackEntity do
end end
context 'when finding_uuid is not present' do context 'when finding_uuid is not present' do
let(:feedback) { build_stubbed(:vulnerability_feedback, :issue, project: project) } let(:feedback) { build_stubbed(:vulnerability_feedback, :issue, project: project, finding_uuid: nil) }
it 'has a nil finding_uuid' do it 'has a nil finding_uuid' do
expect(subject[:finding_uuid]).to be_nil expect(subject[:finding_uuid]).to be_nil
......
...@@ -11,265 +11,272 @@ RSpec.describe Ci::CompareSecurityReportsService do ...@@ -11,265 +11,272 @@ RSpec.describe Ci::CompareSecurityReportsService do
collection.map { |t| t['identifiers'].first['external_id'] } collection.map { |t| t['identifiers'].first['external_id'] }
end end
describe '#execute DS' do where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
before do before do
stub_licensed_features(dependency_scanning: true) stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
end end
let(:service) { described_class.new(project, current_user, report_type: 'dependency_scanning') } describe '#execute DS' do
before do
subject { service.execute(base_pipeline, head_pipeline) } stub_licensed_features(dependency_scanning: true)
context 'when head pipeline has dependency scanning reports' do
let!(:base_pipeline) { create(:ee_ci_pipeline) }
let!(:head_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_report, project: project) }
it 'reports new vulnerabilities' do
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(4)
expect(subject[:data]['fixed'].count).to eq(0)
end end
end
context 'when base and head pipelines have dependency scanning reports' do
let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_report, project: project) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_feature_branch, project: project) }
it 'reports status as parsed' do let(:service) { described_class.new(project, current_user, report_type: 'dependency_scanning') }
expect(subject[:status]).to eq(:parsed)
end
it 'populates fields based on current_user' do subject { service.execute(base_pipeline, head_pipeline) }
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present context 'when head pipeline has dependency scanning reports' do
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present let!(:base_pipeline) { create(:ee_ci_pipeline) }
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present let!(:head_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_report, project: project) }
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports fixed vulnerability' do it 'reports new vulnerabilities' do
expect(subject[:data]['added'].count).to eq(1) expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('external_id' => 'CVE-2017-5946')) expect(subject[:data]['added'].count).to eq(4)
expect(subject[:data]['fixed'].count).to eq(0)
end
end end
it 'reports fixed dependency scanning vulnerabilities' do context 'when base and head pipelines have dependency scanning reports' do
expect(subject[:data]['fixed'].count).to eq(1) let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_report, project: project) }
compare_keys = collect_ids(subject[:data]['fixed']) let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dependency_scanning_feature_branch, project: project) }
expected_keys = %w(06565b64-486d-4326-b906-890d9915804d)
expect(compare_keys).to match_array(expected_keys) it 'reports status as parsed' do
expect(subject[:status]).to eq(:parsed)
end
it 'populates fields based on current_user' do
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports fixed vulnerability' do
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('external_id' => 'CVE-2017-5946'))
end
it 'reports fixed dependency scanning vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(1)
compare_keys = collect_ids(subject[:data]['fixed'])
expected_keys = %w(06565b64-486d-4326-b906-890d9915804d)
expect(compare_keys).to match_array(expected_keys)
end
end end
end
context 'when head pipeline has corrupted dependency scanning vulnerability reports' do context 'when head pipeline has corrupted dependency scanning vulnerability reports' do
let_it_be(:base_pipeline) { build(:ee_ci_pipeline, :with_corrupted_dependency_scanning_report, project: project) } let_it_be(:base_pipeline) { build(:ee_ci_pipeline, :with_corrupted_dependency_scanning_report, project: project) }
let_it_be(:head_pipeline) { build(:ee_ci_pipeline, :with_corrupted_dependency_scanning_report, project: project) } let_it_be(:head_pipeline) { build(:ee_ci_pipeline, :with_corrupted_dependency_scanning_report, project: project) }
it 'returns status and error message' do it 'returns status and error message' do
expect(subject[:status]).to eq(:error) expect(subject[:status]).to eq(:error)
expect(subject[:status_reason]).to include('JSON parsing failed') expect(subject[:status_reason]).to include('JSON parsing failed')
end end
it 'returns status and error message when pipeline is nil' do it 'returns status and error message when pipeline is nil' do
result = service.execute(nil, head_pipeline) result = service.execute(nil, head_pipeline)
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:status_reason]).to include('JSON parsing failed') expect(result[:status_reason]).to include('JSON parsing failed')
end
end end
end end
end
describe '#execute CS' do describe '#execute CS' do
before do before do
stub_licensed_features(container_scanning: true) stub_licensed_features(container_scanning: true)
end end
let(:service) { described_class.new(project, current_user, report_type: 'container_scanning') }
subject { service.execute(base_pipeline, head_pipeline) }
context 'when head pipeline has container scanning reports' do let(:service) { described_class.new(project, current_user, report_type: 'container_scanning') }
let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_report, project: project) }
it 'reports new and fixed vulnerabilities' do subject { service.execute(base_pipeline, head_pipeline) }
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(8)
expect(subject[:data]['fixed'].count).to eq(0)
end
end
context 'when base and head pipelines have container scanning reports' do context 'when head pipeline has container scanning reports' do
let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_report, project: project) } let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_feature_branch, project: project) } let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_report, project: project) }
it 'populates fields based on current_user' do
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do it 'reports new and fixed vulnerabilities' do
expect(subject[:data]['added'].count).to eq(1) expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('external_id' => 'CVE-2017-15650')) expect(subject[:data]['added'].count).to eq(8)
expect(subject[:data]['fixed'].count).to eq(0)
end
end end
it 'reports fixed container scanning vulnerabilities' do context 'when base and head pipelines have container scanning reports' do
expect(subject[:data]['fixed'].count).to eq(8) let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_report, project: project) }
compare_keys = collect_ids(subject[:data]['fixed']) let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_container_scanning_feature_branch, project: project) }
expected_keys = %w(CVE-2017-16997 CVE-2017-18269 CVE-2018-1000001 CVE-2016-10228 CVE-2010-4052 CVE-2018-18520 CVE-2018-16869 CVE-2018-18311)
expect(compare_keys).to match_array(expected_keys) it 'populates fields based on current_user' do
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('external_id' => 'CVE-2017-15650'))
end
it 'reports fixed container scanning vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(8)
compare_keys = collect_ids(subject[:data]['fixed'])
expected_keys = %w(CVE-2017-16997 CVE-2017-18269 CVE-2018-1000001 CVE-2016-10228 CVE-2010-4052 CVE-2018-18520 CVE-2018-16869 CVE-2018-18311)
expect(compare_keys).to match_array(expected_keys)
end
end end
end end
end
describe '#execute DAST' do
before do
stub_licensed_features(dast: true)
end
let(:service) { described_class.new(project, current_user, report_type: 'dast') }
subject { service.execute(base_pipeline, head_pipeline) } describe '#execute DAST' do
before do
context 'when head pipeline has DAST reports containing some vulnerabilities' do stub_licensed_features(dast: true)
let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dast_report, project: project) }
it 'reports the new vulnerabilities, while not changing the counts of fixed vulnerabilities' do
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(20)
expect(subject[:data]['fixed'].count).to eq(0)
end end
end
context 'when base and head pipelines have DAST reports containing vulnerabilities' do let(:service) { described_class.new(project, current_user, report_type: 'dast') }
let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_dast_report, project: project) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dast_feature_branch, project: project) }
it 'populates fields based on current_user' do subject { service.execute(base_pipeline, head_pipeline) }
payload = subject[:data]['fixed'].first
expect(payload).to be_present context 'when head pipeline has DAST reports containing some vulnerabilities' do
expect(payload['create_vulnerability_feedback_issue_path']).to be_present let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dast_report, project: project) }
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do it 'reports the new vulnerabilities, while not changing the counts of fixed vulnerabilities' do
expect(subject[:data]['added'].count).to eq(1) expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].last['identifiers']).to include(a_hash_including('name' => 'CWE-201')) expect(subject[:data]['added'].count).to eq(20)
expect(subject[:data]['fixed'].count).to eq(0)
end
end end
it 'reports fixed DAST vulnerabilities' do context 'when base and head pipelines have DAST reports containing vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(19) let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_dast_report, project: project) }
expect(subject[:data]['fixed']).to include( let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_dast_feature_branch, project: project) }
a_hash_including(
{ it 'populates fields based on current_user' do
'identifiers' => a_collection_including( payload = subject[:data]['fixed'].first
a_hash_including(
"name" => "CWE-352" expect(payload).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['added'].last['identifiers']).to include(a_hash_including('name' => 'CWE-201'))
end
it 'reports fixed DAST vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(19)
expect(subject[:data]['fixed']).to include(
a_hash_including(
{
'identifiers' => a_collection_including(
a_hash_including(
"name" => "CWE-352"
)
) )
) })
}) )
) end
end end
end end
end
describe '#execute SAST' do
before do
stub_licensed_features(sast: true)
end
let(:service) { described_class.new(project, current_user, report_type: 'sast') }
subject { service.execute(base_pipeline, head_pipeline) } describe '#execute SAST' do
before do
context 'when head pipeline has sast reports' do stub_licensed_features(sast: true)
let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_sast_report, project: project) }
it 'reports new vulnerabilities' do
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(5)
expect(subject[:data]['fixed'].count).to eq(0)
end end
end
context 'when base and head pipelines have sast reports' do let(:service) { described_class.new(project, current_user, report_type: 'sast') }
let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_sast_report, project: project) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_sast_feature_branch, project: project) }
it 'populates fields based on current_user' do subject { service.execute(base_pipeline, head_pipeline) }
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present context 'when head pipeline has sast reports' do
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_sast_report, project: project) }
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do it 'reports new vulnerabilities' do
expect(subject[:data]['added'].count).to eq(1) expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('name' => 'CWE-327')) expect(subject[:data]['added'].count).to eq(5)
expect(subject[:data]['fixed'].count).to eq(0)
end
end end
it 'reports fixed sast vulnerabilities' do context 'when base and head pipelines have sast reports' do
expect(subject[:data]['fixed'].count).to eq(1) let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_sast_report, project: project) }
compare_keys = collect_ids(subject[:data]['fixed']) let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_sast_feature_branch, project: project) }
expected_keys = %w(CIPHER_INTEGRITY)
expect(compare_keys - expected_keys).to eq([]) it 'populates fields based on current_user' do
payload = subject[:data]['added'].first
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(payload['create_vulnerability_feedback_merge_request_path']).to be_present
expect(payload['create_vulnerability_feedback_dismissal_path']).to be_present
expect(payload['create_vulnerability_feedback_issue_path']).to be_present
expect(service.current_user).to eq(current_user)
end
it 'reports new vulnerability' do
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['added'].first['identifiers']).to include(a_hash_including('name' => 'CWE-327'))
end
it 'reports fixed sast vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(1)
compare_keys = collect_ids(subject[:data]['fixed'])
expected_keys = %w(CIPHER_INTEGRITY)
expect(compare_keys - expected_keys).to eq([])
end
end end
end end
end
describe '#execute SECRET DETECTION' do
before do
stub_licensed_features(secret_detection: true)
end
let(:service) { described_class.new(project, current_user, report_type: 'secret_detection') }
subject { service.execute(base_pipeline, head_pipeline) } describe '#execute SECRET DETECTION' do
before do
context 'when head pipeline has secret_detection reports' do stub_licensed_features(secret_detection: true)
let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_report, project: project) }
it 'reports new vulnerabilities' do
expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['fixed'].count).to eq(0)
end end
end
context 'when base and head pipelines have secret_detection reports' do let(:service) { described_class.new(project, current_user, report_type: 'secret_detection') }
let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_report, project: project) }
let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_feature_branch, project: project) }
it 'populates fields based on current_user' do subject { service.execute(base_pipeline, head_pipeline) }
payload = subject[:data]['added'].first
expect(payload).to be_nil context 'when head pipeline has secret_detection reports' do
expect(service.current_user).to eq(current_user) let_it_be(:base_pipeline) { create(:ee_ci_pipeline) }
end let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_report, project: project) }
it 'does not report any new vulnerability' do it 'reports new vulnerabilities' do
expect(subject[:data]['added'].count).to eq(0) expect(subject[:status]).to eq(:parsed)
expect(subject[:data]['added'].count).to eq(1)
expect(subject[:data]['fixed'].count).to eq(0)
end
end end
it 'reports fixed secret_detection vulnerabilities' do context 'when base and head pipelines have secret_detection reports' do
expect(subject[:data]['fixed'].count).to eq(1) let_it_be(:base_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_report, project: project) }
compare_keys = collect_ids(subject[:data]['fixed']) let_it_be(:head_pipeline) { create(:ee_ci_pipeline, :with_secret_detection_feature_branch, project: project) }
expected_keys = %w(AWS)
expect(compare_keys).to match_array(expected_keys) it 'populates fields based on current_user' do
payload = subject[:data]['added'].first
expect(payload).to be_nil
expect(service.current_user).to eq(current_user)
end
it 'does not report any new vulnerability' do
expect(subject[:data]['added'].count).to eq(0)
end
it 'reports fixed secret_detection vulnerabilities' do
expect(subject[:data]['fixed'].count).to eq(1)
compare_keys = collect_ids(subject[:data]['fixed'])
expected_keys = %w(AWS)
expect(compare_keys).to match_array(expected_keys)
end
end end
end end
end end
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Security::StoreReportService, '#execute' do RSpec.describe Security::StoreReportService, '#execute' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:artifact) { create(:ee_ci_job_artifact, trait) } let(:artifact) { create(:ee_ci_job_artifact, trait) }
...@@ -11,456 +13,462 @@ RSpec.describe Security::StoreReportService, '#execute' do ...@@ -11,456 +13,462 @@ RSpec.describe Security::StoreReportService, '#execute' do
let(:pipeline) { artifact.job.pipeline } let(:pipeline) { artifact.job.pipeline }
let(:report) { pipeline.security_reports.get_report(report_type.to_s, artifact) } let(:report) { pipeline.security_reports.get_report(report_type.to_s, artifact) }
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, security_dashboard: true)
allow(Security::AutoFixWorker).to receive(:perform_async)
end
subject { described_class.new(pipeline, report).execute } subject { described_class.new(pipeline, report).execute }
context 'without existing data' do where(vulnerability_finding_signatures_enabled: [true, false])
before(:all) do with_them do
checksum = 'f00bc6261fa512f0960b7fc3bfcce7fb31997cf32b96fa647bed5668b2c77fee'
create(:vulnerabilities_remediation, checksum: checksum)
end
before do before do
project.add_developer(user) stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
allow(pipeline).to receive(:user).and_return(user) stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, security_dashboard: true)
allow(Security::AutoFixWorker).to receive(:perform_async)
end end
context 'for different security reports' do context 'without existing data' do
using RSpec::Parameterized::TableSyntax before(:all) do
checksum = 'f00bc6261fa512f0960b7fc3bfcce7fb31997cf32b96fa647bed5668b2c77fee'
where(:case_name, :trait, :scanners, :identifiers, :findings, :finding_identifiers, :finding_pipelines, :remediations, :signatures, :optimize_sql_query_for_security_report_ff) do create(:vulnerabilities_remediation, checksum: checksum)
'with SAST report' | :sast | 1 | 6 | 5 | 7 | 5 | 0 | 2 | false
'with exceeding identifiers' | :with_exceeding_identifiers | 1 | 20 | 1 | 20 | 1 | 0 | 0 | false
'with Dependency Scanning report' | :dependency_scanning_remediation | 1 | 3 | 2 | 3 | 2 | 1 | 0 | false
'with Container Scanning report' | :container_scanning | 1 | 8 | 8 | 8 | 8 | 0 | 0 | false
'with SAST report' | :sast | 1 | 6 | 5 | 7 | 5 | 0 | 2 | true
'with exceeding identifiers' | :with_exceeding_identifiers | 1 | 20 | 1 | 20 | 1 | 0 | 0 | true
'with Dependency Scanning report' | :dependency_scanning_remediation | 1 | 3 | 2 | 3 | 2 | 1 | 0 | true
'with Container Scanning report' | :container_scanning | 1 | 8 | 8 | 8 | 8 | 0 | 0 | true
end end
with_them do before do
before do project.add_developer(user)
stub_feature_flags(optimize_sql_query_for_security_report: optimize_sql_query_for_security_report_ff) allow(pipeline).to receive(:user).and_return(user)
end end
it 'inserts all scanners' do context 'for different security reports' do
expect { subject }.to change { Vulnerabilities::Scanner.count }.by(scanners) with_them do
end before do
stub_feature_flags(optimize_sql_query_for_security_report: optimize_sql_query_for_security_report_ff)
end
it 'inserts all identifiers' do where(:case_name, :trait, :scanners, :identifiers, :findings, :finding_identifiers, :finding_pipelines, :remediations, :signatures) do
expect { subject }.to change { Vulnerabilities::Identifier.count }.by(identifiers) 'with SAST report' | :sast | 1 | 6 | 5 | 7 | 5 | 0 | 2
end 'with exceeding identifiers' | :with_exceeding_identifiers | 1 | 20 | 1 | 20 | 1 | 0 | 1
'with Dependency Scanning report' | :dependency_scanning_remediation | 1 | 3 | 2 | 3 | 2 | 1 | 2
'with Container Scanning report' | :container_scanning | 1 | 8 | 8 | 8 | 8 | 0 | 8
end
it 'inserts all findings' do it 'inserts all scanners' do
expect { subject }.to change { Vulnerabilities::Finding.count }.by(findings) expect { subject }.to change { Vulnerabilities::Scanner.count }.by(scanners)
end end
it 'inserts all finding identifiers (join model)' do it 'inserts all identifiers' do
expect { subject }.to change { Vulnerabilities::FindingIdentifier.count }.by(finding_identifiers) expect { subject }.to change { Vulnerabilities::Identifier.count }.by(identifiers)
end end
it 'inserts all finding pipelines (join model)' do it 'inserts all findings' do
expect { subject }.to change { Vulnerabilities::FindingPipeline.count }.by(finding_pipelines) expect { subject }.to change { Vulnerabilities::Finding.count }.by(findings)
end end
it 'inserts all remediations' do it 'inserts all finding identifiers (join model)' do
expect { subject }.to change { project.vulnerability_remediations.count }.by(remediations) expect { subject }.to change { Vulnerabilities::FindingIdentifier.count }.by(finding_identifiers)
end end
it 'inserts all vulnerabilities' do it 'inserts all finding pipelines (join model)' do
expect { subject }.to change { Vulnerability.count }.by(findings) expect { subject }.to change { Vulnerabilities::FindingPipeline.count }.by(finding_pipelines)
end end
it 'inserts all signatures' do it 'inserts all remediations' do
expect { subject }.to change { Vulnerabilities::FindingSignature.count }.by(signatures) expect { subject }.to change { project.vulnerability_remediations.count }.by(remediations)
end
it 'inserts all vulnerabilities' do
expect { subject }.to change { Vulnerability.count }.by(findings)
end
it 'inserts all signatures' do
expect { subject }.to change { Vulnerabilities::FindingSignature.count }.by(signatures)
end
end end
end end
end
context 'when there is an exception' do context 'when there is an exception' do
let(:trait) { :sast } let(:trait) { :sast }
subject { described_class.new(pipeline, report) } subject { described_class.new(pipeline, report) }
it 'does not insert any scanner' do it 'does not insert any scanner' do
allow(Vulnerabilities::Scanner).to receive(:insert_all).with(anything).and_raise(StandardError) allow(Vulnerabilities::Scanner).to receive(:insert_all).with(anything).and_raise(StandardError)
expect { subject.send(:update_vulnerability_scanners!, report.findings) }.to change { Vulnerabilities::Scanner.count }.by(0) expect { subject.send(:update_vulnerability_scanners!, report.findings) }.to change { Vulnerabilities::Scanner.count }.by(0)
end
end end
end
context 'when N+1 database queries have been removed' do context 'when N+1 database queries have been removed' do
let(:trait) { :sast } let(:trait) { :sast }
let(:bandit_scanner) { build(:ci_reports_security_scanner, external_id: 'bandit', name: 'Bandit') } let(:bandit_scanner) { build(:ci_reports_security_scanner, external_id: 'bandit', name: 'Bandit') }
subject { described_class.new(pipeline, report) } subject { described_class.new(pipeline, report) }
it "avoids N+1 database queries for updating vulnerability scanners", :use_sql_query_cache do it "avoids N+1 database queries for updating vulnerability scanners", :use_sql_query_cache do
report.add_scanner(bandit_scanner) report.add_scanner(bandit_scanner)
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { subject.send(:update_vulnerability_scanners!, report.findings) }.count control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { subject.send(:update_vulnerability_scanners!, report.findings) }.count
5.times { report.add_finding(build(:ci_reports_security_finding, scanner: bandit_scanner)) } 5.times { report.add_finding(build(:ci_reports_security_finding, scanner: bandit_scanner)) }
expect { described_class.new(pipeline, report).send(:update_vulnerability_scanners!, report.findings) }.not_to exceed_query_limit(control_count) expect { described_class.new(pipeline, report).send(:update_vulnerability_scanners!, report.findings) }.not_to exceed_query_limit(control_count)
end
end end
end
context 'when report data includes all raw_metadata' do context 'when report data includes all raw_metadata' do
let(:trait) { :dependency_scanning_remediation } let(:trait) { :dependency_scanning_remediation }
it 'inserts top level finding data', :aggregate_failures do it 'inserts top level finding data', :aggregate_failures do
subject subject
finding = Vulnerabilities::Finding.last finding = Vulnerabilities::Finding.last
finding.raw_metadata = nil finding.raw_metadata = nil
expect(finding.metadata).to be_blank expect(finding.metadata).to be_blank
expect(finding.cve).not_to be_nil expect(finding.cve).not_to be_nil
expect(finding.description).not_to be_nil expect(finding.description).not_to be_nil
expect(finding.location).not_to be_nil expect(finding.location).not_to be_nil
expect(finding.message).not_to be_nil expect(finding.message).not_to be_nil
expect(finding.solution).not_to be_nil expect(finding.solution).not_to be_nil
end
end end
end
context 'invalid data' do context 'invalid data' do
let(:artifact) { create(:ee_ci_job_artifact, :sast) } let(:artifact) { create(:ee_ci_job_artifact, :sast) }
let(:finding_without_name) { build(:ci_reports_security_finding, name: nil) } let(:finding_without_name) { build(:ci_reports_security_finding, name: nil) }
let(:report) { Gitlab::Ci::Reports::Security::Report.new('container_scanning', nil, nil) } let(:report) { Gitlab::Ci::Reports::Security::Report.new('container_scanning', nil, nil) }
before do before do
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception).and_call_original allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception).and_call_original
report.add_finding(finding_without_name) report.add_finding(finding_without_name)
end end
it 'raises invalid record error' do it 'raises invalid record error' do
expect { subject.execute }.to raise_error(ActiveRecord::RecordInvalid) expect { subject.execute }.to raise_error(ActiveRecord::RecordInvalid)
end end
it 'reports the error correctly' do it 'reports the error correctly' do
expected_params = finding_without_name.to_hash.dig(:raw_metadata) expected_params = finding_without_name.to_hash.dig(:raw_metadata)
expect { subject.execute }.to raise_error { |error| expect { subject.execute }.to raise_error { |error|
expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(error, create_params: expected_params) expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(error, create_params: expected_params)
} }
end
end end
end end
end
context 'with existing data from previous pipeline' do context 'with existing data from previous pipeline' do
let(:finding_identifier_fingerprint) do let(:finding_identifier_fingerprint) do
build(:ci_reports_security_identifier, external_id: "CIPHER_INTEGRITY").fingerprint build(:ci_reports_security_identifier, external_id: "CIPHER_INTEGRITY").fingerprint
end end
let(:scanner) { build(:vulnerabilities_scanner, project: project, external_id: 'find_sec_bugs', name: 'Find Security Bugs') } let(:scanner) { build(:vulnerabilities_scanner, project: project, external_id: 'find_sec_bugs', name: 'Find Security Bugs') }
let(:identifier) { build(:vulnerabilities_identifier, project: project, fingerprint: finding_identifier_fingerprint) } let(:identifier) { build(:vulnerabilities_identifier, project: project, fingerprint: finding_identifier_fingerprint) }
let(:different_identifier) { build(:vulnerabilities_identifier, project: project) } let(:different_identifier) { build(:vulnerabilities_identifier, project: project) }
let!(:new_artifact) { create(:ee_ci_job_artifact, :sast, job: new_build) } let!(:new_artifact) { create(:ee_ci_job_artifact, :sast, job: new_build) }
let(:new_build) { create(:ci_build, pipeline: new_pipeline) } let(:new_build) { create(:ci_build, pipeline: new_pipeline) }
let(:new_pipeline) { create(:ci_pipeline, project: project) } let(:new_pipeline) { create(:ci_pipeline, project: project) }
let(:new_report) { new_pipeline.security_reports.get_report(report_type.to_s, artifact) } let(:new_report) { new_pipeline.security_reports.get_report(report_type.to_s, artifact) }
let(:existing_signature) { create(:vulnerabilities_finding_signature, finding: finding) } let(:existing_signature) { create(:vulnerabilities_finding_signature, finding: finding) }
let(:unsupported_signature) do
create(:vulnerabilities_finding_signature,
finding: finding,
algorithm_type: ::Vulnerabilities::FindingSignature.algorithm_types[:location])
end
let(:trait) { :sast } let(:trait) { :sast }
let(:finding_location_fingerprint) do let(:finding_location_fingerprint) do
build( build(
:ci_reports_security_locations_sast, :ci_reports_security_locations_sast,
file_path: "groovy/src/main/java/com/gitlab/security_products/tests/App.groovy", file_path: "groovy/src/main/java/com/gitlab/security_products/tests/App.groovy",
start_line: "29", start_line: "29",
end_line: "29" end_line: "29"
).fingerprint ).fingerprint
end end
let!(:finding) do let!(:finding) do
create(:vulnerabilities_finding, created_finding = create(:vulnerabilities_finding,
pipelines: [pipeline], pipelines: [pipeline],
identifiers: [identifier], identifiers: [identifier],
primary_identifier: identifier, primary_identifier: identifier,
scanner: scanner, scanner: scanner,
project: project, project: project,
uuid: "e5388f40-18f5-566d-95c6-d64c6f46a00a", uuid: "e5388f40-18f5-566d-95c6-d64c6f46a00a",
location_fingerprint: finding_location_fingerprint) location_fingerprint: finding_location_fingerprint)
end
let!(:vulnerability) { create(:vulnerability, findings: [finding], project: project) } existing_finding = report.findings.find { |f| f.location.fingerprint == created_finding.location_fingerprint }
let(:desired_uuid) do create(:vulnerabilities_finding_signature,
Security::VulnerabilityUUID.generate( finding: created_finding,
report_type: finding.report_type, algorithm_type: existing_finding.signatures.first.algorithm_type,
primary_identifier_fingerprint: finding.primary_identifier.fingerprint, signature_sha: existing_finding.signatures.first.signature_sha)
location_fingerprint: finding.location_fingerprint,
project_id: finding.project_id
)
end
let!(:finding_with_uuidv5) do created_finding
create(:vulnerabilities_finding, end
pipelines: [pipeline],
identifiers: [different_identifier],
primary_identifier: different_identifier,
scanner: scanner,
project: project,
location_fingerprint: '34661e23abcf78ff80dfcc89d0700437612e3f88')
end
let!(:vulnerability_with_uuid5) { create(:vulnerability, findings: [finding_with_uuidv5], project: project) } let!(:vulnerability) { create(:vulnerability, findings: [finding], project: project) }
before do let(:desired_uuid) do
project.add_developer(user) Security::VulnerabilityUUID.generate(
allow(new_pipeline).to receive(:user).and_return(user) report_type: finding.report_type,
end primary_identifier_fingerprint: finding.primary_identifier.fingerprint,
location_fingerprint: finding.location_fingerprint,
project_id: finding.project_id
)
end
subject { described_class.new(new_pipeline, new_report).execute } let!(:finding_with_uuidv5) do
create(:vulnerabilities_finding,
pipelines: [pipeline],
identifiers: [different_identifier],
primary_identifier: different_identifier,
scanner: scanner,
project: project,
location_fingerprint: '34661e23abcf78ff80dfcc89d0700437612e3f88')
end
it 'does not change existing UUIDv5' do let!(:vulnerability_with_uuid5) { create(:vulnerability, findings: [finding_with_uuidv5], project: project) }
expect { subject }.not_to change(finding_with_uuidv5, :uuid)
end
it 'updates UUIDv4 to UUIDv5' do before do
subject project.add_developer(user)
allow(new_pipeline).to receive(:user).and_return(user)
end
expect(finding.reload.uuid).to eq(desired_uuid) subject { described_class.new(new_pipeline, new_report).execute }
end
it 'reuses existing scanner' do it 'does not change existing UUIDv5' do
expect { subject }.not_to change { Vulnerabilities::Scanner.count } expect { subject }.not_to change(finding_with_uuidv5, :uuid)
end end
it 'inserts only new identifiers and reuse existing ones' do it 'updates UUIDv4 to UUIDv5' do
expect { subject }.to change { Vulnerabilities::Identifier.count }.by(5) finding.uuid = '00000000-1111-2222-3333-444444444444'
end finding.save!
it 'inserts only new findings and reuse existing ones' do # this report_finding should be used to update the finding's uuid
expect { subject }.to change { Vulnerabilities::Finding.count }.by(4) report_finding = new_report.findings.find { |f| f.location.fingerprint == '0e7d0291d912f56880e39d4fbd80d99dd5d327ba' }
end allow(report_finding).to receive(:uuid).and_return(desired_uuid)
report_finding.signatures.pop
it 'inserts all finding pipelines (join model) for this new pipeline' do subject
expect { subject }.to change { Vulnerabilities::FindingPipeline.where(pipeline: new_pipeline).count }.by(5)
end
it 'inserts new vulnerabilities with data from findings from this new pipeline' do expect(finding.reload.uuid).to eq(desired_uuid)
expect { subject }.to change { Vulnerability.count }.by(4) end
end
it 'updates existing findings with new data' do it 'reuses existing scanner' do
subject expect { subject }.not_to change { Vulnerabilities::Scanner.count }
end
expect(finding.reload).to have_attributes(severity: 'medium', name: 'Cipher with no integrity') it 'inserts only new identifiers and reuse existing ones' do
end expect { subject }.to change { Vulnerabilities::Identifier.count }.by(5)
end
it 'updates signatures to match new values' do it 'inserts only new findings and reuse existing ones' do
existing_signature expect { subject }.to change { Vulnerabilities::Finding.count }.by(4)
unsupported_signature end
expect(finding.signatures.count).to eq(2) it 'inserts all finding pipelines (join model) for this new pipeline' do
signature_algs = finding.signatures.map(&:algorithm_type).sort expect { subject }.to change { Vulnerabilities::FindingPipeline.where(pipeline: new_pipeline).count }.by(5)
expect(signature_algs).to eq(%w[hash location]) end
subject it 'inserts new vulnerabilities with data from findings from this new pipeline' do
expect { subject }.to change { Vulnerability.count }.by(4)
end
finding.reload it 'updates existing findings with new data' do
existing_signature.reload subject
# check that unsupported algorithm is not deleted expect(finding.reload).to have_attributes(severity: 'medium', name: 'Cipher with no integrity')
expect(finding.signatures.count).to eq(3) end
signature_algs = finding.signatures.sort.map(&:algorithm_type)
expect(signature_algs).to eq(%w[hash location scope_offset])
# check that the existing hash signature was updated/reused it 'updates signatures to match new values' do
expect(existing_signature.id).to eq(finding.signatures.min.id) next unless vulnerability_finding_signatures_enabled
# check that the unsupported signature was not deleted expect(finding.signatures.count).to eq(1)
expect(::Vulnerabilities::FindingSignature.exists?(unsupported_signature.id)).to eq(true) expect(finding.signatures.first.algorithm_type).to eq('hash')
end
it 'updates existing vulnerability with new data' do existing_signature = finding.signatures.first
subject
expect(vulnerability.reload).to have_attributes(severity: 'medium', title: 'Cipher with no integrity', title_html: 'Cipher with no integrity') subject
end
context 'when the existing vulnerability is resolved with the latest report' do finding.reload
let!(:existing_vulnerability) { create(:vulnerability, report_type: report_type, project: project) } existing_signature.reload
it 'marks the vulnerability as resolved on default branch' do expect(finding.signatures.count).to eq(2)
expect { subject }.to change { existing_vulnerability.reload.resolved_on_default_branch }.from(false).to(true) signature_algs = finding.signatures.sort_by(&:priority).map(&:algorithm_type)
end expect(signature_algs).to eq(%w[hash scope_offset])
end
context 'when the existing resolved vulnerability is discovered again on the latest report' do # check that the existing hash signature was updated/reused
before do expect(existing_signature.id).to eq(finding.signatures.find(&:algorithm_hash?).id)
vulnerability.update!(resolved_on_default_branch: true)
end end
it 'marks the vulnerability as not resolved on default branch' do it 'updates existing vulnerability with new data' do
expect { subject }.to change { vulnerability.reload.resolved_on_default_branch }.from(true).to(false) subject
end
end
context 'when the finding is not valid' do expect(vulnerability.reload).to have_attributes(severity: 'medium', title: 'Cipher with no integrity', title_html: 'Cipher with no integrity')
before do
allow(Gitlab::AppLogger).to receive(:warn)
allow_next_instance_of(::Gitlab::Ci::Reports::Security::Finding) do |finding|
allow(finding).to receive(:valid?).and_return(false)
end
end end
it 'does not create a new finding' do context 'when the existing vulnerability is resolved with the latest report' do
expect { subject }.not_to change { Vulnerabilities::Finding.count } let!(:existing_vulnerability) { create(:vulnerability, report_type: report_type, project: project) }
end
it 'does not raise an error' do it 'marks the vulnerability as resolved on default branch' do
expect { subject }.not_to raise_error expect { subject }.to change { existing_vulnerability.reload.resolved_on_default_branch }.from(false).to(true)
end
end end
it 'puts a warning log' do context 'when the existing resolved vulnerability is discovered again on the latest report' do
subject before do
vulnerability.update_column(:resolved_on_default_branch, true)
end
expect(Gitlab::AppLogger).to have_received(:warn).exactly(new_report.findings.length).times it 'marks the vulnerability as not resolved on default branch' do
expect { subject }.to change { vulnerability.reload.resolved_on_default_branch }.from(true).to(false)
end
end end
end
context 'vulnerability issue link' do context 'when the finding is not valid' do
context 'when there is no associated issue feedback with finding' do before do
it 'does not insert issue links from the new pipeline' do allow(Gitlab::AppLogger).to receive(:warn)
expect { subject }.to change { Vulnerabilities::IssueLink.count }.by(0) allow_next_instance_of(::Gitlab::Ci::Reports::Security::Finding) do |finding|
allow(finding).to receive(:valid?).and_return(false)
end
end end
end
context 'when there is an associated issue feedback with finding' do it 'does not create a new finding' do
let(:issue) { create(:issue, project: project) } expect { subject }.not_to change { Vulnerabilities::Finding.count }
let!(:issue_feedback) do
create(
:vulnerability_feedback,
:sast,
:issue,
issue: issue,
project: project,
project_fingerprint: new_report.findings.first.project_fingerprint
)
end end
it 'inserts issue links from the new pipeline' do it 'does not raise an error' do
expect { subject }.to change { Vulnerabilities::IssueLink.count }.by(1) expect { subject }.not_to raise_error
end end
it 'the issue link is valid' do it 'puts a warning log' do
subject subject
finding = Vulnerabilities::Finding.find_by(uuid: new_report.findings.first.uuid) expect(Gitlab::AppLogger).to have_received(:warn).exactly(new_report.findings.length).times
vulnerability_id = finding.vulnerability_id
issue_id = issue.id
issue_link = Vulnerabilities::IssueLink.find_by(
vulnerability_id: vulnerability_id,
issue_id: issue_id
)
expect(issue_link).not_to be_nil
end end
end end
end
end
context 'with existing data from same pipeline' do context 'vulnerability issue link' do
let!(:finding) { create(:vulnerabilities_finding, project: project, pipelines: [pipeline]) } context 'when there is no assoiciated issue feedback with finding' do
let(:trait) { :sast } it 'does not insert issue links from the new pipeline' do
expect { subject }.to change { Vulnerabilities::IssueLink.count }.by(0)
end
end
it 'skips report' do context 'when there is an associated issue feedback with finding' do
expect(subject).to eq({ let(:issue) { create(:issue, project: project) }
status: :error, let!(:issue_feedback) do
message: "sast report already stored for this pipeline, skipping..." create(
}) :vulnerability_feedback,
end :sast,
end :issue,
issue: issue,
project: project,
project_fingerprint: new_report.findings.first.project_fingerprint
)
end
context 'start auto_fix' do it 'inserts issue links from the new pipeline' do
before do expect { subject }.to change { Vulnerabilities::IssueLink.count }.by(1)
stub_licensed_features(vulnerability_auto_fix: true) end
end
context 'with auto fix supported report type' do it 'the issue link is valid' do
let(:trait) { :dependency_scanning } subject
context 'when auto fix enabled' do finding = Vulnerabilities::Finding.find_by(uuid: new_report.findings.first.uuid)
it 'start auto fix worker' do vulnerability_id = finding.vulnerability_id
expect(Security::AutoFixWorker).to receive(:perform_async).with(pipeline.id) issue_id = issue.id
issue_link = Vulnerabilities::IssueLink.find_by(
vulnerability_id: vulnerability_id,
issue_id: issue_id
)
subject expect(issue_link).not_to be_nil
end
end end
end end
end
context 'when auto fix disabled' do context 'with existing data from same pipeline' do
context 'when feature flag is disabled' do let!(:finding) { create(:vulnerabilities_finding, project: project, pipelines: [pipeline]) }
before do let(:trait) { :sast }
stub_feature_flags(security_auto_fix: false)
end
it 'does not start auto fix worker' do it 'skips report' do
expect(Security::AutoFixWorker).not_to receive(:perform_async) expect(subject).to eq({
status: :error,
message: "sast report already stored for this pipeline, skipping..."
})
end
end
subject context 'start auto_fix' do
end before do
end stub_licensed_features(vulnerability_auto_fix: true)
end
context 'when auto fix feature is disabled' do context 'with auto fix supported report type' do
before do let(:trait) { :dependency_scanning }
project.security_setting.update!(auto_fix_dependency_scanning: false)
end
it 'does not start auto fix worker' do context 'when auto fix enabled' do
expect(Security::AutoFixWorker).not_to receive(:perform_async) it 'start auto fix worker' do
expect(Security::AutoFixWorker).to receive(:perform_async).with(pipeline.id)
subject subject
end end
end end
context 'when licensed feature is unavailable' do context 'when auto fix disabled' do
before do context 'when feature flag is disabled' do
stub_licensed_features(vulnerability_auto_fix: false) before do
stub_feature_flags(security_auto_fix: false)
end
it 'does not start auto fix worker' do
expect(Security::AutoFixWorker).not_to receive(:perform_async)
subject
end
end end
it 'does not start auto fix worker' do context 'when auto fix feature is disabled' do
expect(Security::AutoFixWorker).not_to receive(:perform_async) before do
project.security_setting.update_column(:auto_fix_dependency_scanning, false)
end
subject it 'does not start auto fix worker' do
expect(Security::AutoFixWorker).not_to receive(:perform_async)
subject
end
end end
end
context 'when security setting is not created' do context 'when licensed feature is unavailable' do
before do before do
project.security_setting.destroy! stub_licensed_features(vulnerability_auto_fix: false)
project.reload end
it 'does not start auto fix worker' do
expect(Security::AutoFixWorker).not_to receive(:perform_async)
subject
end
end end
it 'does not start auto fix worker' do context 'when security setting is not created' do
expect(Security::AutoFixWorker).not_to receive(:perform_async) before do
expect(subject[:status]).to eq(:success) project.security_setting.destroy!
project.reload
end
it 'does not start auto fix worker' do
expect(Security::AutoFixWorker).not_to receive(:perform_async)
expect(subject[:status]).to eq(:success)
end
end end
end end
end end
end
context 'with auto fix not supported report type' do context 'with auto fix not supported report type' do
let(:trait) { :sast } let(:trait) { :sast }
before do before do
stub_licensed_features(vulnerability_auto_fix: true) stub_licensed_features(vulnerability_auto_fix: true)
end end
it 'does not start auto fix worker' do it 'does not start auto fix worker' do
expect(Security::AutoFixWorker).not_to receive(:perform_async) expect(Security::AutoFixWorker).not_to receive(:perform_async)
subject subject
end
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