diff --git a/ee/app/models/package_metadata/affected_package.rb b/ee/app/models/package_metadata/affected_package.rb index 23e3ba0aed74b054a50337ab3be779252ed90b20..5cd7dd51c66d4d1dc60fb1f408c69cb06a3dd715 100644 --- a/ee/app/models/package_metadata/affected_package.rb +++ b/ee/app/models/package_metadata/affected_package.rb @@ -40,5 +40,15 @@ def self.occurrence_cte_join 'INNER JOIN occurrences_cte ON occurrences_cte.purl_type = pm_affected_packages.purl_type ' \ 'AND occurrences_cte.name = pm_affected_packages.package_name' end + + def solution_text + return solution if solution.present? + + # This is a Container Scanning affected package, check for presence of fixed_versions. + explicit_fixed_version = fixed_versions.delete_if { |v| v == '*' } + return 'Unfortunately, there is no solution available yet.' if explicit_fixed_version.empty? + + "Upgrade to version #{explicit_fixed_version.join(', ')} or above" + end end end diff --git a/ee/lib/gitlab/vulnerability_scanning/advisory.rb b/ee/lib/gitlab/vulnerability_scanning/advisory.rb index f7f493a3e3950cc3c51c33fe6b5a3f51242098fe..7a60217817425cccb73c3b20bf40811c42a30f2e 100644 --- a/ee/lib/gitlab/vulnerability_scanning/advisory.rb +++ b/ee/lib/gitlab/vulnerability_scanning/advisory.rb @@ -5,6 +5,20 @@ module VulnerabilityScanning class Advisory attr_reader :xid, :title, :description, :solution, :identifiers, :urls, :cvss_v2, :cvss_v3, :source_xid + def self.from_affected_package(affected_package:, advisory:) + new( + xid: advisory.advisory_xid, + title: advisory.title, + description: advisory.description, + identifiers: advisory.identifiers, + urls: advisory.urls, + cvss_v2: advisory.cvss_v2, + cvss_v3: advisory.cvss_v3, + solution: affected_package.solution_text, + source_xid: advisory.source_xid + ) + end + # rubocop:disable Metrics/ParameterLists # Creates a new advisory object that can be used to create findings # via the Gitlab::VulnerabilityScanning::FindingBuilder classes. diff --git a/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb b/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb index bd4be948a8de5ae107c6025d4ed75e0c6d7ee469..f44ca7fdb5c50ca3dd69e057b8baa2dc90054663 100644 --- a/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb +++ b/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb @@ -4,6 +4,7 @@ module Gitlab module VulnerabilityScanning class AdvisoryScanner include Gitlab::Utils::StrongMemoize + include Gitlab::VulnerabilityScanning::AdvisoryUtils # Scans eligible projects that contain software components affected # by an advisory. If affected, it creates new vulnerabilities in the @@ -31,7 +32,8 @@ def execute start_time = Time.current.iso8601 affected_packages.each do |affected_package| - advisory_data_object = vulnerability_scanning_advisory(solution: solution(affected_package)) + advisory_data_object = Gitlab::VulnerabilityScanning::Advisory.from_affected_package( + affected_package: affected_package, advisory: advisory) purl_type = affected_package.purl_type package_name = affected_package.package_name ::Sbom::PossiblyAffectedOccurrencesFinder.new( @@ -72,66 +74,6 @@ def affected_packages end strong_memoize_attr :affected_packages - def occurrence_is_affected?(purl_type:, range:, version:, distro:, source:, project_id:) - matcher = build_matcher(purl_type: purl_type, range: range) - if Enums::Sbom.container_scanning_purl_type?(purl_type) - matcher.affected?(distro, source, version) - else - matcher.affected?(version) - end - rescue SemverDialects::InvalidVersionError, SemverDialects::UnsupportedVersionError => error - log_cannot_determine_if_occurence_is_affected(error: error, purl_type: purl_type, version: version, - project_id: project_id) - false - end - - def build_matcher(purl_type:, range:) - strong_memoize_with(:build_matcher, purl_type, range) do - if Enums::Sbom.container_scanning_purl_type?(purl_type) - Gitlab::VulnerabilityScanning::ContainerScanning::AffectedVersionRangeMatcher.new( - purl_type: purl_type, range: range) - else - Gitlab::VulnerabilityScanning::DependencyScanning::AffectedVersionRangeMatcher.new( - purl_type: purl_type, range: range) - end - end - end - - def log_cannot_determine_if_occurence_is_affected(error:, purl_type:, version:, project_id:) - ::Gitlab::ErrorTracking.track_exception( - error, - message: 'Cannot determine if component is affected', - purl_type: purl_type, - version: version, - project_id: project_id, - advisory_xid: advisory.advisory_xid, - source_xid: advisory.source_xid) - end - - def vulnerability_scanning_advisory(solution:) - Gitlab::VulnerabilityScanning::Advisory.new( - xid: advisory.advisory_xid, - title: advisory.title, - description: advisory.description, - identifiers: advisory.identifiers, - urls: advisory.urls, - cvss_v2: advisory.cvss_v2, - cvss_v3: advisory.cvss_v3, - solution: solution, - source_xid: advisory.source_xid - ) - end - - def solution(affected_package) - return affected_package.solution if affected_package.solution.present? - - # This is a Container Scanning affected package, check for presence of fixed_versions. - fixed_versions = affected_package.fixed_versions.delete_if { |v| v == '*' } - return 'Unfortunately, there is no solution available yet.' if fixed_versions.empty? - - "Upgrade to version #{fixed_versions.join(', ')} or above" - end - def bulk_vulnerability_ingestion(affected_package, advisory_data_object, occurrences_batch) affected_components = occurrences_batch.filter_map do |occurrence| count_possibly_affected_sbom_occurrence(occurrence) @@ -153,21 +95,7 @@ def bulk_vulnerability_ingestion(affected_package, advisory_data_object, occurre return if affected_components.empty? - create_vulnerabilities(advisory_data_object, affected_components) - end - - def create_vulnerabilities(advisory, affected_components) - response = ::Security::VulnerabilityScanning::CreateVulnerabilityService.execute( - advisory: advisory, affected_components: affected_components) - - project_ids_with_upsert = response.payload[:project_ids_with_upsert] - project_ids_with_error = response.payload[:project_ids_with_error] - if response.success? - log_success(project_ids_with_upsert: project_ids_with_upsert, project_ids_with_error: project_ids_with_error) - else - log_error(response.payload[:error], project_ids_with_upsert: project_ids_with_upsert, - project_ids_with_error: project_ids_with_error) - end + create_vulnerabilities(advisory: advisory_data_object, affected_components: affected_components) end def count_possibly_affected_sbom_occurrence(occurrence) @@ -187,18 +115,6 @@ def possibly_affected_projects_count def known_affected_projects_count @known_affected_projects.keys.size end - - def log_success(project_ids_with_upsert:, project_ids_with_error:) - Gitlab::AppJsonLogger.debug(message: "Successfully created vulnerabilities on advisory ingestion", - project_ids_with_upsert: project_ids_with_upsert, project_ids_with_error: project_ids_with_error, - source_xid: advisory.source_xid, advisory_xid: advisory.advisory_xid) - end - - def log_error(error, project_ids_with_upsert:, project_ids_with_error:) - Gitlab::AppJsonLogger.error(message: "Failed to create vulnerabilities on advisory ingestion", error: error, - project_ids_with_upsert: project_ids_with_upsert, project_ids_with_error: project_ids_with_error, - source_xid: advisory.source_xid, advisory_xid: advisory.advisory_xid) - end end end end diff --git a/ee/lib/gitlab/vulnerability_scanning/advisory_utils.rb b/ee/lib/gitlab/vulnerability_scanning/advisory_utils.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b7274dfae0bef939db7bea51fde4125e85fd30c --- /dev/null +++ b/ee/lib/gitlab/vulnerability_scanning/advisory_utils.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + module VulnerabilityScanning + module AdvisoryUtils + include Gitlab::Utils::StrongMemoize + + def occurrence_is_affected?(purl_type:, range:, version:, distro:, source:, project_id:) + matcher = build_matcher(purl_type: purl_type, range: range) + if Enums::Sbom.container_scanning_purl_type?(purl_type) + matcher.affected?(distro, source, version) + else + matcher.affected?(version) + end + rescue SemverDialects::InvalidVersionError, SemverDialects::UnsupportedVersionError => error + track_indetermined_if_occurence_is_affected(error: error, purl_type: purl_type, version: version, + project_id: project_id) + false + end + + def build_matcher(purl_type:, range:) + strong_memoize_with(:build_matcher, purl_type, range) do + if Enums::Sbom.container_scanning_purl_type?(purl_type) + Gitlab::VulnerabilityScanning::ContainerScanning::AffectedVersionRangeMatcher.new( + purl_type: purl_type, range: range) + else + Gitlab::VulnerabilityScanning::DependencyScanning::AffectedVersionRangeMatcher.new( + purl_type: purl_type, range: range) + end + end + end + + def track_indetermined_if_occurence_is_affected(error:, purl_type:, version:, project_id:) + ::Gitlab::ErrorTracking.track_exception( + error, + message: 'Cannot determine if component is affected', + purl_type: purl_type, + version: version, + project_id: project_id) + end + + def create_vulnerabilities(advisory:, affected_components:) + response = ::Security::VulnerabilityScanning::CreateVulnerabilityService.execute( + advisory: advisory, affected_components: affected_components) + + project_ids_with_upsert = response.payload[:project_ids_with_upsert] + project_ids_with_error = response.payload[:project_ids_with_error] + if response.success? + log_success(advisory: advisory, project_ids_with_upsert: project_ids_with_upsert, + project_ids_with_error: project_ids_with_error) + else + log_error(response.payload[:error], advisory: advisory, project_ids_with_upsert: project_ids_with_upsert, + project_ids_with_error: project_ids_with_error) + end + end + + def log_success(advisory:, project_ids_with_upsert:, project_ids_with_error:) + Gitlab::AppJsonLogger.debug(message: "Successfully created vulnerabilities on advisory ingestion", + project_ids_with_upsert: project_ids_with_upsert, project_ids_with_error: project_ids_with_error, + source_xid: advisory.source_xid, advisory_xid: advisory.xid) + end + + def log_error(error, advisory:, project_ids_with_upsert:, project_ids_with_error:) + Gitlab::AppJsonLogger.error(message: "Failed to create vulnerabilities on advisory ingestion", error: error, + project_ids_with_upsert: project_ids_with_upsert, project_ids_with_error: project_ids_with_error, + source_xid: advisory.source_xid, advisory_xid: advisory.xid) + end + end + end +end diff --git a/ee/spec/lib/gitlab/vulnerability_scanning/advisory_scanner_spec.rb b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_scanner_spec.rb index 116731debd92381e191d1e9fdf417ebc63581de2..f1fbf3670934b1fed22f39295f7a69f22e8eb56d 100644 --- a/ee/spec/lib/gitlab/vulnerability_scanning/advisory_scanner_spec.rb +++ b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_scanner_spec.rb @@ -120,16 +120,13 @@ end it "captures and tracks the invalid version error" do - advisory = affected_package.advisory pipeline = affected_pipeline expect(Gitlab::ErrorTracking).to have_received(:track_exception) .with(a_kind_of(::SemverDialects::InvalidVersionError), message: 'Cannot determine if component is affected', purl_type: 'npm', version: 'invalid-version', - project_id: pipeline.project.id, - advisory_xid: advisory.advisory_xid, - source_xid: advisory.source_xid) + project_id: pipeline.project.id) end it "tracks an event for the scan" do @@ -410,16 +407,13 @@ it "captures and tracks the unsupported version error" do # APK package versions containing leading zeros eg 1.2.03 are currently unsupported. https://gitlab.com/gitlab-org/gitlab/-/issues/471509 - advisory = affected_package.advisory pipeline = affected_pipeline expect(Gitlab::ErrorTracking).to have_received(:track_exception) .with(a_kind_of(::SemverDialects::UnsupportedVersionError), message: 'Cannot determine if component is affected', purl_type: 'apk', version: '1.2.03', - project_id: pipeline.project.id, - advisory_xid: advisory.advisory_xid, - source_xid: advisory.source_xid) + project_id: pipeline.project.id) end end end diff --git a/ee/spec/lib/gitlab/vulnerability_scanning/advisory_spec.rb b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_spec.rb index c7ef74b127caf7564f5b21728e754670d2384d20..654e3f2013bf2279e609c7800a41657b50981221 100644 --- a/ee/spec/lib/gitlab/vulnerability_scanning/advisory_spec.rb +++ b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_spec.rb @@ -64,4 +64,22 @@ it { expect(advisory.cvss_v2).to be(cvss_v2) } end end + + describe '.from_affected_package' do + let(:advisory) { affected_package.advisory } + let(:affected_package) { build(:pm_affected_package) } + + subject do + described_class.from_affected_package(affected_package: affected_package, + advisory: advisory) + end + + it 'returns data with attributes related to advisory and affected_package' do + is_expected.to have_attributes( + xid: advisory.advisory_xid, title: advisory.title, + description: advisory.description, identifiers: advisory.identifiers, + urls: advisory.urls, cvss_v2: advisory.cvss_v2, cvss_v3: advisory.cvss_v3, + source_xid: advisory.source_xid, solution: affected_package.solution) + end + end end diff --git a/ee/spec/lib/gitlab/vulnerability_scanning/advisory_utils_spec.rb b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_utils_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5fc182bb44c861bf55ed9640335762fa72e58575 --- /dev/null +++ b/ee/spec/lib/gitlab/vulnerability_scanning/advisory_utils_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Gitlab::VulnerabilityScanning::AdvisoryUtils, feature_category: :software_composition_analysis do + let(:advisory_utils_test_class) do + Class.new do + include Gitlab::VulnerabilityScanning::AdvisoryUtils + end + end + + let(:affected_package) do + build(:pm_affected_package) + end + + let(:advisory) { affected_package.advisory } + let_it_be(:user) { create(:user) } + let_it_be(:pipeline) { create(:ci_pipeline, user: user) } + let_it_be(:occurrence) { create(:sbom_occurrence, pipeline: pipeline) } + + describe '.occurrence_is_affected?' do + let(:source) { occurrence.source } + let(:version) { "5.2" } + + subject(:occurrence_is_affected) do + advisory_utils_test_class.new.occurrence_is_affected?(purl_type: affected_package.purl_type, + range: affected_package.affected_range, version: version, distro: affected_package.distro_version, + source: source, project_id: occurrence.project_id) + end + + context 'when the occurrence is not affected' do + let(:version) { "1.0" } + + it { is_expected.to be false } + end + + context 'with container scanning' do + let(:affected_package) { build(:pm_affected_package, :os_advisory, affected_range: "<5:5.2") } + let(:source) do + create(:sbom_source, source_type: :container_scanning, packager_name: 'apk', + source: { + 'category' => 'development', + 'image' => { 'name' => 'image-1', 'tag' => 'v1' }, + 'operating_system' => { 'name' => 'debian', 'version' => '9' } + }) + end + + it { is_expected.to be true } + + context 'when affected package version is not supported' do + # APK package versions containing leading zeros eg 1.2.03 are currently unsupported. https://gitlab.com/gitlab-org/gitlab/-/issues/471509 + let(:affected_package) { build(:pm_affected_package, purl_type: "apk", distro_version: 'alpine 3.14') } + let(:version) { "5.03" } + + let(:source) do + create(:sbom_source, source_type: :container_scanning, packager_name: 'apk', + source: { + 'category' => 'development', + 'image' => { 'name' => 'image-1', 'tag' => 'v1' }, + 'operating_system' => { 'name' => 'alpine', 'version' => '3.14' } + }) + end + + it "captures and tracks the unsupported version error" do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(an_instance_of(SemverDialects::UnsupportedVersionError), + message: 'Cannot determine if component is affected', + purl_type: 'apk', + version: version, + project_id: occurrence.project_id).once + + occurrence_is_affected + end + end + end + + context 'with dependency scanning' do + it { is_expected.to be true } + end + + context 'when version is invalid' do + let(:affected_package) { build(:pm_affected_package, purl_type: "pypi") } + let(:version) { " 5.0" } + + it 'tracks an exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with( + an_instance_of(SemverDialects::InvalidVersionError), + message: 'Cannot determine if component is affected', + purl_type: affected_package.purl_type, + version: version, + project_id: occurrence.project_id + ) + .once + + occurrence_is_affected + end + end + end + + describe '.create_vulnerabilities' do + subject(:create_vulnerabilities) do + advisory_utils_test_class.new.create_vulnerabilities(advisory: advisory_data, affected_components: [occurrence]) + end + + let(:advisory_data) do + Gitlab::VulnerabilityScanning::Advisory.from_affected_package(affected_package: affected_package, + advisory: advisory) + end + + it 'creates new vulnerabilities' do + expect(Gitlab::AppJsonLogger).to receive(:debug) + .with( + message: "Successfully created vulnerabilities on advisory ingestion", + project_ids_with_upsert: [pipeline.project.id], project_ids_with_error: [], + source_xid: advisory_data.source_xid, advisory_xid: advisory_data.xid) + .once + + expect { create_vulnerabilities }.to change { Vulnerability.count }.by(1) + end + + context 'when exception is raised' do + before do + allow(::Security::Ingestion::IngestCvsSliceService).to receive(:execute).and_raise(StandardError) + end + + it 'does not create vulnerabilities' do + expect(Gitlab::AppJsonLogger).to receive(:error) + .with( + message: "Failed to create vulnerabilities on advisory ingestion", + error: an_instance_of(StandardError), + project_ids_with_upsert: [pipeline.project.id], project_ids_with_error: [], + source_xid: advisory_data.source_xid, advisory_xid: advisory_data.xid) + .once + + expect { create_vulnerabilities }.not_to change { Vulnerability.count } + end + end + end +end diff --git a/ee/spec/models/package_metadata/affected_package_spec.rb b/ee/spec/models/package_metadata/affected_package_spec.rb index 753b6ba6c100fc31d4db0dc800f9066a8040be2d..4507d678ba7a9ecdf201ca3d651077abd7f5e3ef 100644 --- a/ee/spec/models/package_metadata/affected_package_spec.rb +++ b/ee/spec/models/package_metadata/affected_package_spec.rb @@ -138,4 +138,33 @@ expect(described_class.for_occurrences(occurrences)).not_to include(unrelated_affected_package) end end + + describe '#solution_text' do + subject { affected_package.solution_text } + + let(:affected_package) do + build(:pm_affected_package, solution: 'Update version') + end + + context 'with solution present' do + it { is_expected.to eq('Update version') } + end + + context 'without fixed versions' do + let(:affected_package) do + build(:pm_affected_package, solution: nil, fixed_versions: []) + end + + it { is_expected.to eq('Unfortunately, there is no solution available yet.') } + end + + context 'with fixed versions' do + let(:fixed_version) { '1.2.3' } + let(:affected_package) do + build(:pm_affected_package, solution: nil, fixed_versions: [fixed_version]) + end + + it { is_expected.to match(/Upgrade to version #{fixed_version} or above/) } + end + end end