diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index faa6bc7a37429063a2cb3c82d2cc0f56d740289f..6e934c52454fd0267bd7e60e2b057c953524d15b 100644 --- a/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -96,6 +96,7 @@ export default class MergeRequestStore extends CEMergeRequestStore { this.apiFuzzingComparisonPathV2 = data.new_api_fuzzing_comparison_path; this.dastComparisonPathV2 = data.new_dast_comparison_path; this.dependencyScanningComparisonPathV2 = data.new_dependency_scanning_comparison_path; + this.cyclonedxComparisonPath = data.new_cyclonedx_comparison_path; } initGeo(data) { diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/widgets/security_reports/mr_widget_security_reports.vue b/ee/app/assets/javascripts/vue_merge_request_widget/widgets/security_reports/mr_widget_security_reports.vue index bbfdf645e0927ffe35a4e7979cc357a3815c1b74..3a400f241ffb2fc7a5b17c2438b4070e51559a82 100644 --- a/ee/app/assets/javascripts/vue_merge_request_widget/widgets/security_reports/mr_widget_security_reports.vue +++ b/ee/app/assets/javascripts/vue_merge_request_widget/widgets/security_reports/mr_widget_security_reports.vue @@ -171,6 +171,7 @@ export default { [this.mr.coverageFuzzingComparisonPathV2, 'COVERAGE_FUZZING'], [this.mr.dependencyScanningComparisonPathV2, 'DEPENDENCY_SCANNING'], [this.mr.containerScanningComparisonPathV2, 'CONTAINER_SCANNING'], + [this.mr.cyclonedxComparisonPath, 'CYCLONEDX'], ].filter(([endpoint, reportType]) => { const enabledReportsKeyName = convertToCamelCase(reportType.toLowerCase()); return Boolean(endpoint) && this.mr.enabledReports[enabledReportsKeyName]; diff --git a/ee/app/finders/security/pipeline_vulnerabilities_finder.rb b/ee/app/finders/security/pipeline_vulnerabilities_finder.rb index 0f8022041bf7f35fc26f456f23760d2564cc8cb1..2ba22df253e5408da2b72eb91db18a84fe0866dd 100644 --- a/ee/app/finders/security/pipeline_vulnerabilities_finder.rb +++ b/ee/app/finders/security/pipeline_vulnerabilities_finder.rb @@ -59,8 +59,14 @@ def sort_findings(findings) end def requested_reports - @requested_reports ||= pipeline&.security_reports(report_types: report_types)&.reports&.values || [] + reports = pipeline&.security_reports(report_types: report_types)&.reports&.values || [] + if report_types.include?('cyclonedx') + reports.concat(Gitlab::VulnerabilityScanning::SecurityReportBuilder.new(pipeline: pipeline).execute) + end + + reports end + strong_memoize_attr :requested_reports def existing_vulnerabilities_for(findings) findings.map(&:uuid) diff --git a/ee/app/models/ee/ci/build.rb b/ee/app/models/ee/ci/build.rb index f2c76fd404a512e4a6cbd16f7ae04f00abe7b3ea..dc19e040f84b63426b758afa82c644be04273de3 100644 --- a/ee/app/models/ee/ci/build.rb +++ b/ee/app/models/ee/ci/build.rb @@ -167,8 +167,9 @@ def collect_requirements_reports!(requirements_report, legacy: false) end def collect_sbom_reports!(sbom_reports_list) - each_report(::Ci::JobArtifact.file_types_for_report(:sbom)) do |file_type, blob| + each_report(::Ci::JobArtifact.file_types_for_report(:sbom)) do |file_type, blob, report_artifact| report = ::Gitlab::Ci::Reports::Sbom::Report.new + report.created_at = report_artifact.created_at ::Gitlab::Ci::Parsers.fabricate!(file_type, project: project).parse!(blob, report) sbom_reports_list.add_report(report) end diff --git a/ee/app/models/ee/ci/pipeline.rb b/ee/app/models/ee/ci/pipeline.rb index 0ee4a7dc28cd15409c4c8f73c00bb1ab7151eba9..7779f4f53838dbff700015020a71c59f0f63ea46 100644 --- a/ee/app/models/ee/ci/pipeline.rb +++ b/ee/app/models/ee/ci/pipeline.rb @@ -51,7 +51,8 @@ module Pipeline requirements: %i[requirements], requirements_v2: %i[requirements], coverage_fuzzing: %i[coverage_fuzzing], - api_fuzzing: %i[api_fuzzing] + api_fuzzing: %i[api_fuzzing], + cyclonedx: %i[cyclonedx] }.freeze state_machine :status do diff --git a/ee/app/models/ee/merge_request.rb b/ee/app/models/ee/merge_request.rb index c46c7cfe67d87fd104b4fec2886024f626964beb..0be4cab870efa2b33f52e94dd988f6aa819c43f9 100644 --- a/ee/app/models/ee/merge_request.rb +++ b/ee/app/models/ee/merge_request.rb @@ -293,7 +293,8 @@ def enabled_reports license_scanning: report_type_enabled?(:license_scanning), coverage_fuzzing: report_type_enabled?(:coverage_fuzzing), secret_detection: report_type_enabled?(:secret_detection), - api_fuzzing: report_type_enabled?(:api_fuzzing) + api_fuzzing: report_type_enabled?(:api_fuzzing), + cyclonedx: report_type_enabled?(:cyclonedx) } end @@ -325,6 +326,10 @@ def has_dast_reports? !!diff_head_pipeline&.complete_or_manual_and_has_reports?(::Ci::JobArtifact.of_report_type(:dast)) end + def has_sbom_reports? + !!diff_head_pipeline&.complete_or_manual_and_has_reports?(::Ci::JobArtifact.of_report_type(:sbom)) + end + def compare_dast_reports(current_user) return missing_report_error("DAST") unless has_dast_reports? @@ -352,6 +357,12 @@ def compare_license_scanning_reports_collapsed(current_user) ) end + def compare_cyclonedx_reports(current_user) + return missing_report_error("cyclonedx") unless has_sbom_reports? + + compare_reports(::Ci::CompareSecurityReportsService, current_user, 'cyclonedx') + end + def has_metrics_reports? !!diff_head_pipeline&.complete_and_has_reports?(::Ci::JobArtifact.of_report_type(:metrics)) end diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index 22e454cbc742af4dbaaa583fcd7c37fa4ee4e526..f36be3938a7e48b35c5e5f634535b560d6298524 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -195,6 +195,7 @@ class Features container_scanning credentials_inventory custom_roles + cyclonedx dast dependency_scanning dora4_analytics diff --git a/ee/app/services/sbom/create_vulnerabilities_service.rb b/ee/app/services/sbom/create_vulnerabilities_service.rb index b811f66e48139e44238875591cdab09660530db4..517696367d600ed715287a471fe07d0557c4b3b4 100644 --- a/ee/app/services/sbom/create_vulnerabilities_service.rb +++ b/ee/app/services/sbom/create_vulnerabilities_service.rb @@ -50,7 +50,8 @@ def execute source: sbom_report.source, pipeline: pipeline, project: project, - purl_type: affected_occurrence.purl.type) + purl_type: affected_occurrence.purl.type, + scanner: scanner) end create_vulnerabilities(finding_maps) @@ -112,5 +113,10 @@ def project pipeline.project end strong_memoize_attr :project + + def scanner + ::Gitlab::VulnerabilityScanning::SecurityScanner.fabricate + end + strong_memoize_attr :scanner end end diff --git a/ee/app/services/security/merge_request_security_report_generation_service.rb b/ee/app/services/security/merge_request_security_report_generation_service.rb index a029ef2192ef387a54130ecf698295cf8f2080da..48b97403c51bfa7084dbfc6fdc54c397b54b86e3 100644 --- a/ee/app/services/security/merge_request_security_report_generation_service.rb +++ b/ee/app/services/security/merge_request_security_report_generation_service.rb @@ -6,7 +6,7 @@ class MergeRequestSecurityReportGenerationService DEFAULT_FINDING_STATE = 'detected' ALLOWED_REPORT_TYPES = %w[sast secret_detection container_scanning - dependency_scanning dast coverage_fuzzing api_fuzzing].freeze + dependency_scanning dast coverage_fuzzing api_fuzzing cyclonedx].freeze InvalidReportTypeError = Class.new(ArgumentError) @@ -80,6 +80,8 @@ def fixed_findings merge_request.compare_coverage_fuzzing_reports(nil) when 'api_fuzzing' merge_request.compare_api_fuzzing_reports(nil) + when 'cyclonedx' + merge_request.compare_cyclonedx_reports(nil) end end diff --git a/ee/app/services/security/vulnerability_scanning/build_finding_map_service.rb b/ee/app/services/security/vulnerability_scanning/build_finding_map_service.rb index 2d58ff16486d63cc4e637bea5050e42fdca453dc..994f4b5e20361b84c2c2d4e0bf1b9a4a33c77c7a 100644 --- a/ee/app/services/security/vulnerability_scanning/build_finding_map_service.rb +++ b/ee/app/services/security/vulnerability_scanning/build_finding_map_service.rb @@ -9,13 +9,14 @@ def self.execute(...) new(...).execute end - def initialize(advisory:, affected_component:, source:, pipeline:, project:, purl_type:) + def initialize(advisory:, affected_component:, source:, pipeline:, project:, purl_type:, scanner:) @advisory = advisory @affected_component = affected_component @source = source @pipeline = pipeline @project = project @purl_type = purl_type + @scanner = scanner end def execute @@ -29,18 +30,14 @@ def execute private - attr_reader :advisory, :affected_component, :source, :pipeline, :project, :purl_type - - def report_scanner - ::Gitlab::VulnerabilityScanning::SecurityScanner.fabricate - end + attr_reader :advisory, :affected_component, :source, :pipeline, :project, :purl_type, :scanner def finding builder = ::Gitlab::VulnerabilityScanning::FindingBuilder.for_purl_type!(purl_type) builder.new( project: project, pipeline: pipeline, - scanner: report_scanner, + scanner: scanner, sbom_source: source, advisory: advisory, affected_component: affected_component diff --git a/ee/app/views/projects/merge_requests/show.html.haml b/ee/app/views/projects/merge_requests/show.html.haml index 20e68b53eff7a3aaa8b830c1c93d6737cdd9792a..ee6f928722c6def55a0ba54704e440ae73dd7204 100644 --- a/ee/app/views/projects/merge_requests/show.html.haml +++ b/ee/app/views/projects/merge_requests/show.html.haml @@ -29,6 +29,7 @@ window.gl.mrWidgetData.new_secret_detection_comparison_path = '#{security_reports_project_merge_request_path(@project, @merge_request, type: :secret_detection) if @project.feature_available?(:secret_detection)}' window.gl.mrWidgetData.new_coverage_fuzzing_comparison_path = '#{security_reports_project_merge_request_path(@project, @merge_request, type: :coverage_fuzzing) if @project.feature_available?(:coverage_fuzzing)}' window.gl.mrWidgetData.new_api_fuzzing_comparison_path = '#{security_reports_project_merge_request_path(@project, @merge_request, type: :api_fuzzing) if @project.feature_available?(:api_fuzzing)}' + window.gl.mrWidgetData.new_cyclonedx_comparison_path = '#{security_reports_project_merge_request_path(@project, @merge_request, type: :cyclonedx) if @project.feature_available?(:cyclonedx)}' window.gl.mrWidgetData.aiCommitMessageEnabled = #{::Llm::GenerateCommitMessageService.new(current_user, @merge_request).valid?.to_s} window.gl.mrWidgetData.dismissal_descriptions = '#{escape_javascript(dismissal_descriptions.to_json)}'; window.gl.mrWidgetData.commit_path_template = '#{commit_path_template(@project)}'; diff --git a/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb b/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb index c5f1c7dc32ab9d7f70d975a4910f137b40052137..e6275c11ef1a825d202512c07c6990d1b494863c 100644 --- a/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb +++ b/ee/lib/gitlab/vulnerability_scanning/advisory_scanner.rb @@ -100,7 +100,8 @@ def bulk_vulnerability_ingestion(affected_package, advisory_data_object, occurre source: affected_component.source, pipeline: affected_component.pipeline, project: affected_component.project, - purl_type: affected_component.purl_type) + purl_type: affected_component.purl_type, + scanner: scanner) end.compact return if finding_maps.empty? @@ -125,6 +126,11 @@ def possibly_affected_projects_count def known_affected_projects_count @known_affected_projects.keys.size end + + def scanner + ::Gitlab::VulnerabilityScanning::SecurityScanner.fabricate + end + strong_memoize_attr :scanner end end end diff --git a/ee/lib/gitlab/vulnerability_scanning/security_report_builder.rb b/ee/lib/gitlab/vulnerability_scanning/security_report_builder.rb index 26c1811bdcdace8f0fdc5a76d7337be7d8aa650a..b17e40809be272ee3323864f38518553e2cf762e 100644 --- a/ee/lib/gitlab/vulnerability_scanning/security_report_builder.rb +++ b/ee/lib/gitlab/vulnerability_scanning/security_report_builder.rb @@ -4,57 +4,96 @@ module Gitlab module VulnerabilityScanning class SecurityReportBuilder include Gitlab::Utils::StrongMemoize + include Gitlab::VulnerabilityScanning::AdvisoryUtils - # We don't have a schema version set because there is no JSON to validate. - SECURITY_REPORT_VERSION = "0.0.0" + attr_reader :pipeline - attr_reader :report - - def initialize(report_type:, project:, pipeline:, sbom:) - @report_type = report_type - @project = project + def initialize(pipeline:) @pipeline = pipeline - @sbom = sbom - @report = ::Gitlab::Ci::Reports::Security::Report.new(report_type, pipeline, Time.zone.now) - report.version = SECURITY_REPORT_VERSION - report.add_scanner(scanner) - end - - # Add advisories affecting a component to the security report. - # - # @param advisories [Array<[Gitlab::VulnerabilityScanning::Advisory, - # Gitlab::VulnerabilityScanning::AffectedComponent]>] - def add_affections(affections) - affections.each do |advisory, affected_component| - add_affection(advisory, affected_component) + end + + def execute + valid_sbom_reports.map do |sbom_report| + next unless sbom_report.source.present? + + next if sbom_report.source.source_type != :dependency_scanning && Feature.disabled?( + :cvs_for_container_scanning, project) + + report = ::Gitlab::Ci::Reports::Security::Report.new('cyclonedx', pipeline, sbom_report.created_at) + report.add_scanner(scanner) + + sbom_report.components.each_slice(::Security::IngestionConstants::COMPONENTS_BATCH_SIZE) do |occurrence_batch| + affected_packages(occurrence_batch).each_batch do |affected_package_batch| + affected_package_batch.filter_map do |affected_package| + # We need to match every affected package to one occurrence + affected_occurrence = occurrence_batch.find do |occurrence| + next unless affected_package.package_name == occurrence.name + + affected_occurrence?(occurrence, sbom_report.source, affected_package) + end + + next unless affected_occurrence.present? + + advisory_data_object = Gitlab::VulnerabilityScanning::Advisory.from_affected_package( + affected_package: affected_package, advisory: affected_package.advisory) + + finding = ::Security::VulnerabilityScanning::BuildFindingMapService.execute( + advisory: advisory_data_object, + affected_component: affected_occurrence, + source: sbom_report.source, + pipeline: pipeline, + project: project, + purl_type: affected_occurrence.purl.type, + scanner: scanner) + + report.add_finding(finding.report_finding) + end + end + end + + report end end private - attr_reader :report_type, :project, :pipeline, :sbom + def affected_occurrence?(occurrence, source, affected_package) + advisory = affected_package.advisory - def scanner - VulnerabilityScanning::SecurityScanner.fabricate + occurrence_is_affected?( + xid: advisory.advisory_xid, + purl_type: affected_package.purl_type, + range: affected_package.affected_range, + version: occurrence.version, + distro: affected_package.distro_version, + source: source, + project_id: pipeline.project_id, + source_xid: advisory.source_xid + ) end - strong_memoize_attr :scanner - def add_affection(advisory, affected_component) - builder_class = Gitlab::VulnerabilityScanning::FindingBuilder.for_report_type(report_type) + def affected_packages(occurrence_batch) + ::PackageMetadata::AffectedPackage.for_occurrences(occurrence_batch).with_advisory + end - return unless builder_class + def all_sbom_reports + pipeline.sbom_reports(self_and_project_descendants: true).reports + end - begin - builder = builder_class.new(project: project, pipeline: pipeline, sbom_source: sbom.source, scanner: scanner, - advisory: advisory, affected_component: affected_component) - finding = builder.finding - report.add_finding(finding) - finding.identifiers.each { |ident| report.add_identifier(ident) } - rescue DependencyScanning::FindingBuilder::MissingPropertiesError, - ContainerScanning::FindingBuilder::MissingPropertiesError => error - report.add_error('MissingPropertiesError', error.message) - end + def valid_sbom_reports + all_sbom_reports.select(&:valid?) end + strong_memoize_attr :valid_sbom_reports + + def project + pipeline.project + end + strong_memoize_attr :project + + def scanner + ::Gitlab::VulnerabilityScanning::SecurityScanner.fabricate + end + strong_memoize_attr :scanner end end end diff --git a/lib/gitlab/ci/reports/sbom/report.rb b/lib/gitlab/ci/reports/sbom/report.rb index e9364e551e7a57a37cefbb28eec40073bf680351..df43ab1a814450d8c55cebd5b0d26ae57173b2a2 100644 --- a/lib/gitlab/ci/reports/sbom/report.rb +++ b/lib/gitlab/ci/reports/sbom/report.rb @@ -12,7 +12,7 @@ class Report VERSION = 1 attr_reader :source, :errors - attr_accessor :sbom_attributes, :metadata, :components + attr_accessor :sbom_attributes, :metadata, :components, :created_at def initialize @sbom_attributes = {