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
report_finding = report_finding_for(security_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|
Vulnerabilities::Identifier.new(identifier.to_hash)
end
signatures = report_finding.signatures.map do |signature|
Vulnerabilities::FindingSignature.new(signature.to_hash)
end
Vulnerabilities::Finding.new(finding_data).tap do |finding|
finding.location_fingerprint = report_finding.location.fingerprint
......@@ -59,6 +62,7 @@ module Security
finding.sha = pipeline.sha
finding.scanner = security_finding.scanner
finding.identifiers = identifiers
finding.signatures = signatures
end
end
......
......@@ -75,7 +75,7 @@ module Security
def normalize_report_findings(report_findings, vulnerabilities)
report_findings.map do |report_finding|
finding_hash = report_finding.to_hash
.except(:compare_key, :identifiers, :location, :scanner, :links)
.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures)
finding = Vulnerabilities::Finding.new(finding_hash)
# assigning Vulnerabilities to Findings to enable the computed state
......@@ -90,6 +90,9 @@ module Security
finding.identifiers = report_finding.identifiers.map do |identifier|
Vulnerabilities::Identifier.new(identifier.to_hash)
end
finding.signatures = report_finding.signatures.map do |signature|
Vulnerabilities::FindingSignature.new(signature.to_hash)
end
finding
end
......@@ -111,18 +114,36 @@ module Security
end
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 dismissal_feedback_by_fingerprint
strong_memoize(:dismissal_feedback_by_fingerprint) do
def all_dismissal_feedbacks
strong_memoize(:all_dismissal_feedbacks) do
pipeline.project
.vulnerability_feedback
.for_dismissal
.group_by(&:project_fingerprint)
end
end
def dismissal_feedback_by_finding_signatures(finding)
potential_uuids = Set.new([*finding.signature_uuids, finding.uuid].compact)
all_dismissal_feedbacks.any? { |dismissal| potential_uuids.include?(dismissal.finding_uuid) }
end
def dismissal_feedback_by_fingerprint
strong_memoize(:dismissal_feedback_by_fingerprint) do
all_dismissal_feedbacks.group_by(&:project_fingerprint)
end
end
def dismissal_feedback_by_project_fingerprint(finding)
dismissal_feedback_by_fingerprint[finding.project_fingerprint]
end
def confidence_levels
Array(params.fetch(:confidence, Vulnerabilities::Finding.confidences.keys))
end
......
# 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
subepics
threat_monitoring
vulnerability_auto_fix
vulnerability_finding_signatures
]
EEU_FEATURES.freeze
......
......@@ -5,6 +5,7 @@ module Vulnerabilities
include ShaAttribute
include ::Gitlab::Utils::StrongMemoize
include Presentable
include ::VulnerabilityFindingHelpers
# 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
......@@ -332,12 +333,16 @@ module Vulnerabilities
end
end
alias_method :==, :eql? # eql? is necessary in some cases like array intersection
alias_method :==, :eql?
def eql?(other)
other.report_type == report_type &&
other.location_fingerprint == location_fingerprint &&
other.first_fingerprint == first_fingerprint
return false unless other.report_type == report_type && other.primary_identifier_fingerprint == primary_identifier_fingerprint
if ::Feature.enabled?(:vulnerability_finding_signatures, project)
matches_signatures(other.signatures, other.uuid)
else
other.location_fingerprint == location_fingerprint
end
end
# Array.difference (-) method uses hash and eql? methods to do comparison
......@@ -348,7 +353,7 @@ module Vulnerabilities
# when Finding is persisted and identifiers are not preloaded.
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
def severity_value
......@@ -380,7 +385,7 @@ module Vulnerabilities
protected
def first_fingerprint
def primary_identifier_fingerprint
identifiers.first&.fingerprint
end
......
......@@ -5,11 +5,23 @@ module Vulnerabilities
self.table_name = 'vulnerability_finding_signatures'
include BulkInsertSafe
include VulnerabilityFindingSignatureHelpers
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
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
......@@ -61,17 +61,21 @@ module Security
return
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')
# Vulnerabilities::Finding (`vulnerability_occurrences`)
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_finding(vulnerability_finding, vulnerability_params)
reset_remediations_for(vulnerability_finding, 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
# we just want to ignore the rest if a finding has more than that.
......@@ -86,8 +90,16 @@ module Security
create_vulnerability(vulnerability_finding, pipeline)
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
def create_new_vulnerability_finding(finding, create_params)
def find_or_create_vulnerability_finding_with_location(finding, create_params)
find_params = {
scanner: scanners_objects[finding.scanner.key],
primary_identifier: identifiers_objects[finding.primary_identifier.key],
......@@ -120,6 +132,56 @@ module Security
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)
scanner = scanners_objects[finding.scanner.key]
scanner.update!(finding.scanner.to_hash)
......@@ -223,6 +285,14 @@ module Security
end
# 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)
to_update = {}
to_create = []
......@@ -240,12 +310,12 @@ module Security
next if poro_signature.nil?
poro_signatures.delete(signature.algorithm_type)
to_update[signature.id] = poro_signature.to_h
to_update[signature.id] = poro_signature.to_hash
end
# any remaining poro signatures left are new
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)
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
class Common
SecurityReportParserError = Class.new(Gitlab::Ci::Parsers::ParserError)
def self.parse!(json_data, report)
new(json_data, report).parse!
def self.parse!(json_data, report, vulnerability_finding_signatures_enabled = false)
new(json_data, report, vulnerability_finding_signatures_enabled).parse!
end
def initialize(json_data, report)
def initialize(json_data, report, vulnerability_finding_signatures_enabled = false)
@json_data = json_data
@report = report
@vulnerability_finding_signatures_enabled = vulnerability_finding_signatures_enabled
end
def parse!
......@@ -80,11 +81,20 @@ module Gitlab
links = create_links(data['links'])
location = create_location(data['location'] || {})
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(
::Gitlab::Ci::Reports::Security::Finding.new(
uuid: calculate_uuid_v5(identifiers.first, location),
uuid: uuid,
report_type: report.type,
name: finding_name(data, identifiers, location),
compare_key: data['cve'] || '',
......@@ -99,14 +109,17 @@ module Gitlab
raw_metadata: data.to_json,
metadata_version: report_version,
details: data['details'] || {},
signatures: signatures))
signatures: signatures,
project_id: report.project_id,
vulnerability_finding_signatures_enabled: @vulnerability_finding_signatures_enabled))
end
def create_signatures(data)
return [] if data.nil? || data['items'].nil?
def create_signatures(location, tracking)
tracking ||= { 'items' => [] }
signature_algorithms = Hash.new { |hash, key| hash[key] = [] }
data['items'].each do |item|
tracking['items'].each do |item|
next unless item.key?('signatures')
item['signatures'].each do |signature|
......@@ -117,14 +130,16 @@ module Gitlab
signature_algorithms.map do |algorithm, values|
value = values.join('|')
begin
signature = ::Gitlab::Ci::Reports::Security::FindingSignature.new(
algorithm_type: algorithm,
signature_value: value
)
signature.valid? ? signature : nil
rescue ArgumentError => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
if signature.valid?
signature
else
e = SecurityReportParserError.new("Vulnerability tracking signature is not valid: #{signature}")
Gitlab::ErrorTracking.track_exception(e)
nil
end
end.compact
......@@ -201,11 +216,11 @@ module Gitlab
"#{identifier.name} in #{location&.fingerprint_path}"
end
def calculate_uuid_v5(primary_identifier, location)
def calculate_uuid_v5(primary_identifier, location_fingerprint)
uuid_v5_name_components = {
report_type: report.type,
primary_identifier_fingerprint: primary_identifier&.fingerprint,
location_fingerprint: location&.fingerprint,
location_fingerprint: location_fingerprint,
project_id: report.project_id
}
......
......@@ -5,6 +5,8 @@ module Gitlab
module Reports
module Security
class Finding
include ::VulnerabilityFindingHelpers
UNSAFE_SEVERITIES = %w[unknown high critical].freeze
attr_reader :compare_key
......@@ -25,10 +27,11 @@ module Gitlab
attr_reader :remediations
attr_reader :details
attr_reader :signatures
attr_reader :project_id
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
@confidence = confidence
@identifiers = identifiers
......@@ -45,6 +48,8 @@ module Gitlab
@remediations = remediations
@details = details
@signatures = signatures
@project_id = project_id
@vulnerability_finding_signatures_enabled = vulnerability_finding_signatures_enabled
@project_fingerprint = generate_project_fingerprint
end
......@@ -66,6 +71,7 @@ module Gitlab
severity
uuid
details
signatures
].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end
......@@ -85,13 +91,22 @@ module Gitlab
end
def eql?(other)
report_type == other.report_type &&
location.fingerprint == other.location.fingerprint &&
primary_fingerprint == other.primary_fingerprint
return false unless report_type == other.report_type && primary_identifier_fingerprint == other.primary_identifier_fingerprint
if @vulnerability_finding_signatures_enabled
matches_signatures(other.signatures, other.uuid)
else
location.fingerprint == other.location.fingerprint
end
end
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
def valid?
......@@ -104,7 +119,7 @@ module Gitlab
end
end
def primary_fingerprint
def primary_identifier_fingerprint
primary_identifier&.fingerprint
end
......
......@@ -5,6 +5,8 @@ module Gitlab
module Reports
module Security
class FindingSignature
include VulnerabilityFindingSignatureHelpers
attr_accessor :algorithm_type, :signature_value
def initialize(params = {})
......@@ -12,11 +14,19 @@ module Gitlab
@signature_value = params.dig(:signature_value)
end
def priority
::Vulnerabilities::FindingSignature.priority(algorithm_type)
end
def signature_sha
Digest::SHA1.digest(signature_value)
end
def to_h
def signature_hex
signature_sha.unpack1("H*")
end
def to_hash
{
algorithm_type: algorithm_type,
signature_sha: signature_sha
......@@ -26,6 +36,13 @@ module Gitlab
def valid?
::Vulnerabilities::FindingSignature.algorithm_types.key?(algorithm_type)
end
def eql?(other)
other.algorithm_type == algorithm_type &&
other.signature_sha == signature_sha
end
alias_method :==, :eql?
end
end
end
......
......@@ -18,12 +18,12 @@ module Gitlab
@package_version = package_version
end
private
def fingerprint_data
"#{docker_image_name_without_tag}:#{package_name}"
end
private
def docker_image_name_without_tag
base_name, version = image.split(':')
......
......@@ -16,8 +16,6 @@ module Gitlab
@crash_state = crash_state
end
private
def fingerprint_data
"#{crash_type}:#{crash_state}"
end
......
......@@ -20,8 +20,6 @@ module Gitlab
alias_method :fingerprint_path, :path
private
def fingerprint_data
"#{path}:#{method_name}:#{param}"
end
......
......@@ -18,8 +18,6 @@ module Gitlab
@package_version = package_version
end
private
def fingerprint_data
"#{file_path}:#{package_name}"
end
......
......@@ -22,8 +22,6 @@ module Gitlab
@start_line = start_line
end
private
def fingerprint_data
"#{file_path}:#{start_line}:#{end_line}"
end
......
......@@ -22,8 +22,6 @@ module Gitlab
@start_line = start_line
end
private
def fingerprint_data
"#{file_path}:#{start_line}:#{end_line}"
end
......
......@@ -7,13 +7,17 @@ module Gitlab
class VulnerabilityReportsComparer
include Gitlab::Utils::StrongMemoize
attr_reader :base_report, :head_report
attr_reader :base_report, :head_report, :added, :fixed
ACCEPTABLE_REPORT_AGE = 1.week
def initialize(base_report, head_report)
@base_report = base_report
@head_report = head_report
@added = []
@fixed = []
calculate_changes
end
def base_report_created_at
......@@ -30,16 +34,30 @@ module Gitlab
ACCEPTABLE_REPORT_AGE.ago > @base_report.created_at
end
def added
strong_memoize(:added) do
head_report.findings - base_report.findings
end
def calculate_changes
base_findings = base_report.findings
head_findings = head_report.findings
head_findings_hash = head_findings.index_by(&:object_id)
# This is slow - O(N^2). If we didn't need to worry about one high
# priority fingerprint that doesn't match overruling a lower
# 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
def fixed
strong_memoize(:fixed) do
base_report.findings - head_report.findings
@fixed << base_finding unless still_exists
end
@added = head_findings_hash.values
end
end
end
......
......@@ -39,6 +39,7 @@ FactoryBot.define do
project_id: n
)
end
vulnerability_finding_signatures_enabled { false }
skip_create
......
......@@ -17,6 +17,7 @@ FactoryBot.define do
category { 'sast' }
project_fingerprint { generate(:project_fingerprint) }
vulnerability_data { { category: 'sast' } }
finding_uuid { Gitlab::UUID.v5(SecureRandom.hex) }
trait :dismissal do
feedback_type { 'dismissal' }
......
......@@ -83,12 +83,12 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
# use the same number of queries, regardless of the number of findings
# 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
# this test is invalid if we don't have more sast findings than dep findings
expect(sast_findings.count).to be > dep_findings.count
# this test is invalid if we don't have more container_scanning findings than dep findings
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
# (the vulnerability is created with the :confirmed trait)
create(:vulnerabilities_finding,
......@@ -103,7 +103,7 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
# should be the same number of queries between different report types
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 {
described_class.new(pipeline: pipeline, params: { report_type: %w[dependency_scanning] }).execute
}
......@@ -117,11 +117,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
orig_security_reports = pipeline.security_reports
new_finding = create(:ci_reports_security_finding)
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 {
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)
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
......@@ -130,9 +130,11 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
context 'when sast' do
let(:params) { { report_type: %w[sast] } }
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
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)
end
end
......@@ -172,6 +174,7 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
let(:ds_finding) { pipeline.security_reports.reports["dependency_scanning"].findings.first }
let(:sast_finding) { pipeline.security_reports.reports["sast"].findings.first }
context 'when vulnerability_finding_signatures feature flag is disabled' do
let!(:feedback) do
[
create(
......@@ -181,7 +184,8 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
project: project,
pipeline: pipeline,
project_fingerprint: ds_finding.project_fingerprint,
vulnerability_data: ds_finding.raw_metadata
vulnerability_data: ds_finding.raw_metadata,
finding_uuid: ds_finding.uuid
),
create(
:vulnerability_feedback,
......@@ -190,11 +194,16 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
project: project,
pipeline: pipeline,
project_fingerprint: sast_finding.project_fingerprint,
vulnerability_data: sast_finding.raw_metadata
vulnerability_data: sast_finding.raw_metadata,
finding_uuid: sast_finding.uuid
)
]
end
before do
stub_feature_flags(vulnerability_finding_signatures: false)
end
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
......@@ -222,6 +231,54 @@ RSpec.describe Security::PipelineVulnerabilitiesFinder do
end
end
context 'when vulnerability_finding_signatures feature flag is enabled' do
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
before do
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
context 'by severity' do
context 'when unscoped' do
subject { described_class.new(pipeline: pipeline).execute }
......
......@@ -4,30 +4,22 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Common do
describe '#parse!' do
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
let_it_be(:pipeline) { create(:ci_pipeline) }
let(:artifact) { build(:ee_ci_job_artifact, :common_security_report) }
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(:tracking_data) do
{
'type' => 'source',
'items' => [
'signatures' => [
{ 'algorithm' => 'hash', 'value' => 'hash_value' },
{ 'algorithm' => 'location', 'value' => 'location_value' },
{ 'algorithm' => 'scope_offset', 'value' => 'scope_offset_value' }
]
]
}
end
let(:tracking_data) { nil }
before do
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
artifact.each_blob { |blob| described_class.parse!(blob, report) }
artifact.each_blob { |blob| described_class.parse!(blob, report, vulnerability_finding_signatures_enabled) }
end
describe 'parsing finding.name' do
......@@ -200,8 +192,21 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
end
end
describe 'parsing signature' do
context 'with valid signature information' do
describe 'parsing tracking' do
let(:tracking_data) do
{
'type' => 'source',
'items' => [
'signatures' => [
{ 'algorithm' => 'hash', 'value' => 'hash_value' },
{ 'algorithm' => 'location', 'value' => 'location_value' },
{ 'algorithm' => 'scope_offset', 'value' => 'scope_offset_value' }
]
]
}
end
context 'with valid tracking information' do
it 'creates signatures for each algorithm' do
finding = report.findings.first
expect(finding.signatures.size).to eq(3)
......@@ -209,7 +214,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
end
end
context 'with invalid signature information' do
context 'with invalid tracking information' do
let(:tracking_data) do
{
'type' => 'source',
......@@ -229,6 +234,34 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
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
......@@ -18,15 +18,12 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do
expect(subject.algorithm_type).to eq(params[:algorithm_type])
expect(subject.signature_value).to eq(params[:signature_value])
end
describe '#valid?' do
it 'returns true' do
expect(subject.valid?).to eq(true)
end
end
describe '#to_h' do
it 'returns a hash representation of the signature' do
expect(subject.to_h).to eq(
algorithm_type: params[:algorithm_type],
signature_sha: Digest::SHA1.digest(params[:signature_value])
)
end
end
......@@ -50,4 +47,13 @@ RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do
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
......@@ -137,7 +137,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scan: occurrence.scan,
severity: occurrence.severity,
uuid: occurrence.uuid,
details: occurrence.details
details: occurrence.details,
signatures: []
})
end
end
......@@ -197,9 +198,11 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
end
describe '#eql?' do
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
let(:identifier) { build(:ci_reports_security_identifier) }
let(:location) { build(:ci_reports_security_locations_sast) }
let(:finding) { build(:ci_reports_security_finding, severity: 'low', report_type: :sast, identifiers: [identifier], location: location) }
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(:report_type) { :secret_detection }
let(:identifier_external_id) { 'foo' }
......@@ -211,9 +214,12 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
severity: 'low',
report_type: report_type,
identifiers: [other_identifier],
location: other_location)
location: other_location,
vulnerability_finding_signatures_enabled: vulnerability_finding_signatures_enabled)
end
let(:signature) { ::Gitlab::Ci::Reports::Security::FindingSignature.new(algorithm_type: 'location', signature_value: 'value1') }
subject { finding.eql?(other_finding) }
context 'when the primary_identifier is nil' do
......@@ -230,25 +236,30 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
context 'when the other finding has same primary identifier fingerprint' do
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
before do
finding.signatures << signature
other_finding.signatures << signature
end
let(:location_start_line) { location.start_line }
it { is_expected.to be(true) }
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) }
end
end
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 }
it { is_expected.to be(false) }
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) }
end
end
......@@ -258,30 +269,31 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
context 'when the other finding has same primary identifier fingerprint' do
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 }
it { is_expected.to be(false) }
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) }
end
end
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 }
it { is_expected.to be(false) }
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) }
end
end
end
end
end
describe '#valid?' do
let(:scanner) { build(:ci_reports_security_scanner) }
......@@ -345,4 +357,55 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
it { is_expected.to match_array(expected_keys) }
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
......@@ -8,16 +8,20 @@ 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_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])}
subject { described_class.new(base_report, head_report) }
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
before do
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
subject { described_class.new(base_report, head_report) }
describe '#base_report_out_of_date' do
context 'no base report' do
let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: [])}
......@@ -79,7 +83,7 @@ RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do
describe '#fixed' do
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
let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln])}
......@@ -132,4 +136,5 @@ RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do
expect(comparer.added).to eq([])
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,6 +7,12 @@ RSpec.describe Vulnerabilities::Finding do
it { is_expected.to define_enum_for(:report_type) }
it { is_expected.to define_enum_for(:severity) }
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
before do
stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
end
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:primary_identifier).class_name('Vulnerabilities::Identifier') }
......@@ -63,7 +69,7 @@ RSpec.describe Vulnerabilities::Finding do
context 'database uniqueness' do
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
expect { new_finding.save! }.to raise_error(ActiveRecord::RecordNotUnique)
......@@ -81,7 +87,7 @@ RSpec.describe Vulnerabilities::Finding do
with_them 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
......@@ -673,7 +679,6 @@ RSpec.describe Vulnerabilities::Finding do
let(:confirmed_finding) { create(:vulnerabilities_finding, :confirmed) }
let(:resolved_finding) { create(:vulnerabilities_finding, :resolved) }
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
......@@ -692,10 +697,6 @@ RSpec.describe Vulnerabilities::Finding do
expect(dismissed_finding.state).to eq 'dismissed'
end
it 'returns the expected state for a detected finding' do
expect(detected_finding.state).to eq 'detected'
end
context 'when a vulnerability present for a dismissed finding' do
before do
create(:vulnerability, project: dismissed_finding.project, findings: [dismissed_finding])
......@@ -1010,4 +1011,117 @@ RSpec.describe Vulnerabilities::Finding do
end
end
end
describe '#eql?' do
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 'matches the finding based on enabled tracking methods (if feature flag enabled)' do
signature1 = create(
: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
......@@ -33,7 +33,8 @@ RSpec.describe API::VulnerabilityFindings do
project: project,
pipeline: pipeline,
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
......
......@@ -164,7 +164,7 @@ RSpec.describe Vulnerabilities::FeedbackEntity do
end
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
expect(subject[:finding_uuid]).to be_nil
......
......@@ -11,6 +11,12 @@ RSpec.describe Ci::CompareSecurityReportsService do
collection.map { |t| t['identifiers'].first['external_id'] }
end
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
before do
stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
end
describe '#execute DS' do
before do
stub_licensed_features(dependency_scanning: true)
......@@ -273,4 +279,5 @@ RSpec.describe Ci::CompareSecurityReportsService do
end
end
end
end
end
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Security::StoreReportService, '#execute' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
let(:artifact) { create(:ee_ci_job_artifact, trait) }
......@@ -11,13 +13,16 @@ RSpec.describe Security::StoreReportService, '#execute' do
let(:pipeline) { artifact.job.pipeline }
let(:report) { pipeline.security_reports.get_report(report_type.to_s, artifact) }
subject { described_class.new(pipeline, report).execute }
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
before do
stub_feature_flags(vulnerability_finding_signatures: vulnerability_finding_signatures_enabled)
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 }
context 'without existing data' do
before(:all) do
checksum = 'f00bc6261fa512f0960b7fc3bfcce7fb31997cf32b96fa647bed5668b2c77fee'
......@@ -30,24 +35,18 @@ RSpec.describe Security::StoreReportService, '#execute' do
end
context 'for different security reports' do
using RSpec::Parameterized::TableSyntax
where(:case_name, :trait, :scanners, :identifiers, :findings, :finding_identifiers, :finding_pipelines, :remediations, :signatures, :optimize_sql_query_for_security_report_ff) do
'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
with_them do
before do
stub_feature_flags(optimize_sql_query_for_security_report: optimize_sql_query_for_security_report_ff)
end
where(:case_name, :trait, :scanners, :identifiers, :findings, :finding_identifiers, :finding_pipelines, :remediations, :signatures) do
'with SAST report' | :sast | 1 | 6 | 5 | 7 | 5 | 0 | 2
'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 scanners' do
expect { subject }.to change { Vulnerabilities::Scanner.count }.by(scanners)
end
......@@ -164,11 +163,6 @@ RSpec.describe Security::StoreReportService, '#execute' do
let(:new_pipeline) { create(:ci_pipeline, project: project) }
let(:new_report) { new_pipeline.security_reports.get_report(report_type.to_s, artifact) }
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 }
......@@ -182,7 +176,7 @@ RSpec.describe Security::StoreReportService, '#execute' do
end
let!(:finding) do
create(:vulnerabilities_finding,
created_finding = create(:vulnerabilities_finding,
pipelines: [pipeline],
identifiers: [identifier],
primary_identifier: identifier,
......@@ -190,6 +184,15 @@ RSpec.describe Security::StoreReportService, '#execute' do
project: project,
uuid: "e5388f40-18f5-566d-95c6-d64c6f46a00a",
location_fingerprint: finding_location_fingerprint)
existing_finding = report.findings.find { |f| f.location.fingerprint == created_finding.location_fingerprint }
create(:vulnerabilities_finding_signature,
finding: created_finding,
algorithm_type: existing_finding.signatures.first.algorithm_type,
signature_sha: existing_finding.signatures.first.signature_sha)
created_finding
end
let!(:vulnerability) { create(:vulnerability, findings: [finding], project: project) }
......@@ -227,6 +230,14 @@ RSpec.describe Security::StoreReportService, '#execute' do
end
it 'updates UUIDv4 to UUIDv5' do
finding.uuid = '00000000-1111-2222-3333-444444444444'
finding.save!
# this report_finding should be used to update the finding's uuid
report_finding = new_report.findings.find { |f| f.location.fingerprint == '0e7d0291d912f56880e39d4fbd80d99dd5d327ba' }
allow(report_finding).to receive(:uuid).and_return(desired_uuid)
report_finding.signatures.pop
subject
expect(finding.reload.uuid).to eq(desired_uuid)
......@@ -259,28 +270,24 @@ RSpec.describe Security::StoreReportService, '#execute' do
end
it 'updates signatures to match new values' do
existing_signature
unsupported_signature
next unless vulnerability_finding_signatures_enabled
expect(finding.signatures.count).to eq(2)
signature_algs = finding.signatures.map(&:algorithm_type).sort
expect(signature_algs).to eq(%w[hash location])
expect(finding.signatures.count).to eq(1)
expect(finding.signatures.first.algorithm_type).to eq('hash')
existing_signature = finding.signatures.first
subject
finding.reload
existing_signature.reload
# check that unsupported algorithm is not deleted
expect(finding.signatures.count).to eq(3)
signature_algs = finding.signatures.sort.map(&:algorithm_type)
expect(signature_algs).to eq(%w[hash location scope_offset])
expect(finding.signatures.count).to eq(2)
signature_algs = finding.signatures.sort_by(&:priority).map(&:algorithm_type)
expect(signature_algs).to eq(%w[hash scope_offset])
# check that the existing hash signature was updated/reused
expect(existing_signature.id).to eq(finding.signatures.min.id)
# check that the unsupported signature was not deleted
expect(::Vulnerabilities::FindingSignature.exists?(unsupported_signature.id)).to eq(true)
expect(existing_signature.id).to eq(finding.signatures.find(&:algorithm_hash?).id)
end
it 'updates existing vulnerability with new data' do
......@@ -299,7 +306,7 @@ RSpec.describe Security::StoreReportService, '#execute' do
context 'when the existing resolved vulnerability is discovered again on the latest report' do
before do
vulnerability.update!(resolved_on_default_branch: true)
vulnerability.update_column(:resolved_on_default_branch, true)
end
it 'marks the vulnerability as not resolved on default branch' do
......@@ -331,7 +338,7 @@ RSpec.describe Security::StoreReportService, '#execute' do
end
context 'vulnerability issue link' do
context 'when there is no associated issue feedback with finding' do
context 'when there is no assoiciated issue feedback with finding' do
it 'does not insert issue links from the new pipeline' do
expect { subject }.to change { Vulnerabilities::IssueLink.count }.by(0)
end
......@@ -414,7 +421,7 @@ RSpec.describe Security::StoreReportService, '#execute' do
context 'when auto fix feature is disabled' do
before do
project.security_setting.update!(auto_fix_dependency_scanning: false)
project.security_setting.update_column(:auto_fix_dependency_scanning, false)
end
it 'does not start auto fix worker' do
......@@ -464,4 +471,5 @@ RSpec.describe Security::StoreReportService, '#execute' do
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