diff --git a/lib/cyclonedx/base.rb b/lib/cyclonedx/base.rb index 21891145..8c7ea08c 100644 --- a/lib/cyclonedx/base.rb +++ b/lib/cyclonedx/base.rb @@ -1,6 +1,7 @@ module Cyclonedx class Base DEFAULT_COMPONENT_TYPE = "application".freeze + DEFAULT_DEP_COMPONENT_TYPE = "library".freeze def initialize(scan_report, config = {}) @scan_report = scan_report @@ -24,20 +25,32 @@ def build_metadata # Returns the 'components' object for a supported/unsupported scanner's report def build_components_object components = [] - @scan_report.info[:dependencies].each do |dependency| - component = { - "bom-ref": "", - "type": DEFAULT_COMPONENT_TYPE, - "group": "", - "name": dependency[:name], - "version": "", - "purl": "" - } - - # TODO: Add specific component parsing for individual scanners - components << component + info = @scan_report.to_h.fetch(:info) + info[:dependencies].each do |dependency| + components << parse_dependency(dependency) end components end + + def parse_dependency(dependency) + { + "bom-ref": package_url(dependency), + "type": DEFAULT_DEP_COMPONENT_TYPE, + "group": "", # TODO: add group or domain name of the publisher + "name": dependency[:name], + "version": version_string(dependency), + "purl": package_url(dependency), + "properties": [ + { + "key": "source", + "value": dependency[:source] + }, + { + "key": "dependency_file", + "value": dependency[:dependency_file] + } + ] + } + end end end diff --git a/lib/cyclonedx/report.rb b/lib/cyclonedx/report.rb index b0f3321b..2074eb07 100644 --- a/lib/cyclonedx/report.rb +++ b/lib/cyclonedx/report.rb @@ -14,7 +14,7 @@ def initialize(scan_reports, config = {}) @config = config end - CYCLONEDX_SPEC_VERSION = "1.2.0".freeze + CYCLONEDX_SPEC_VERSION = "1.3".freeze CYCLONEDX_VERSION = "1".freeze CYCLONEDX_FORMAT = "CycloneDX".freeze diff --git a/lib/cyclonedx/report_ruby_gems_cyclonedx.rb b/lib/cyclonedx/report_ruby_gems_cyclonedx.rb index 840f7ee2..353a669c 100644 --- a/lib/cyclonedx/report_ruby_gems_cyclonedx.rb +++ b/lib/cyclonedx/report_ruby_gems_cyclonedx.rb @@ -3,5 +3,19 @@ class ReportRubyGems < Base def initialize(scan_report) super(scan_report) end + + def package_url(dependency) + "pkg:#{dependency[:type]}/#{dependency[:name]}#{version_string(dependency, true)}" + end + + # Return version string to be used in purl or component + def version_string(dependency, is_purl_version = false) + # If the dependency is specified in the Gemfile and an absolute version is needed for + # the purl return empty + return "" if dependency[:dependency_file] == 'Gemfile' && is_purl_version + + prefix = is_purl_version ? "@" : "" + "#{prefix}#{dependency[:version]}" + end end end diff --git a/lib/salus.rb b/lib/salus.rb index 3420dc7b..47b69531 100644 --- a/lib/salus.rb +++ b/lib/salus.rb @@ -8,6 +8,7 @@ require 'salus/processor' require 'salus/plugin_manager' require 'sarif/sarif_report' +require 'cyclonedx/report' module Salus VERSION = '2.11.13'.freeze diff --git a/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.lock b/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.lock new file mode 100644 index 00000000..dce6ce83 --- /dev/null +++ b/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.lock @@ -0,0 +1,91 @@ +GEM + remote: https://cool_rubygems.org/ + specs: + dep1 (0.0.47) + activesupport + dep2 (0.15.3) + activesupport + google-protobuf (~> 3.14) + googleapis-common-protos-types (~> 1.0) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.1.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + aws-eventstream (1.1.1) + aws-partitions (1.472.0) + aws-sdk-core (3.115.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-sns (1.41.0) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sigv4 (~> 1.1) + aws-sdk-sqs (1.39.0) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sigv4 (~> 1.1) + aws-sdk-xray (1.4.0) + aws-sdk-core (~> 3) + aws-sigv4 (~> 1.0) + aws-sigv4 (1.2.3) + aws-eventstream (~> 1, >= 1.0.2) + aws-xray-sdk (0.11.5) + aws-sdk-xray (~> 1.4.0) + multi_json (~> 1) + bugsnag (6.21.0) + concurrent-ruby (~> 1.0) + concurrent-ruby (1.1.9) + datadog-lambda (1.12.0) + aws-xray-sdk (~> 0.11.3) + ddtrace (0.50.0) + ffi (~> 1.0) + msgpack + faraday (1.4.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.1.0) + ffi (1.15.3) + google-protobuf (3.17.3-universal-darwin) + googleapis-common-protos-types (1.0.6) + google-protobuf (~> 3.14) + grpc-tools (1.38.0) + i18n (1.8.10) + concurrent-ruby (~> 1.0) + jmespath (1.4.0) + jwt (2.2.3) + minitest (5.14.4) + msgpack (1.4.2) + multi_json (1.15.0) + multipart-post (2.1.1) + oj (3.11.7) + rack (2.2.3) + ruby2_keywords (0.0.4) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + zeitwerk (2.4.2) + +PLATFORMS + universal-darwin-20 + x86_64-darwin-19 + +DEPENDENCIES + dep1! + dep2! + +BUNDLED WITH + 2.2.17 \ No newline at end of file diff --git a/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.rb b/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.rb new file mode 100644 index 00000000..e0a73a95 --- /dev/null +++ b/spec/fixtures/report_ruby_gems/lockfile_multiple_sources/Gemfile.rb @@ -0,0 +1,4 @@ +source 'https://rubygems.org'.freeze + +gem 'dep1', source: 'https://cool_rubygems.org' +gem 'dep2', source: 'https://cool_rubygems.org' diff --git a/spec/lib/cyclonedx/report_ruby_gems_cyclonedx_spec.rb b/spec/lib/cyclonedx/report_ruby_gems_cyclonedx_spec.rb new file mode 100644 index 00000000..05a8cd02 --- /dev/null +++ b/spec/lib/cyclonedx/report_ruby_gems_cyclonedx_spec.rb @@ -0,0 +1,256 @@ +require_relative '../../spec_helper' +require 'json' + +describe Cyclonedx::ReportRubyGems do + describe "#run" do + it 'should report all the deps in the Gemfile if Gemfile.lock is absent in cyclonedx' do + repo = Salus::Repo.new('spec/fixtures/report_ruby_gems/gemfile_only') + scanner = Salus::Scanners::ReportRubyGems.new(repository: repo, config: {}) + scanner.run + + ruby_cyclonedx = Cyclonedx::ReportRubyGems.new(scanner.report) + expect(ruby_cyclonedx.build_components_object).to match_array( + [ + { + "bom-ref": "pkg:gem/kibana_url", + "type": "library", + "group": "", + "name": "kibana_url", + "version": "~> 1.0", + "purl": "pkg:gem/kibana_url", + "properties": [ + { + "key": "source", + "value": "https://rubygems.org/" + }, + { + "key": "dependency_file", + "value": "Gemfile" + } + ] + }, + { + "bom-ref": "pkg:gem/rails", + "type": "library", + "group": "", + "name": "rails", + "version": ">= 0", + "purl": "pkg:gem/rails", + "properties": [ + { + "key": "source", + "value": "https://rubygems.org/" + }, + { + "key": "dependency_file", + "value": "Gemfile" + } + ] + }, + { + "bom-ref": "pkg:gem/master_lock", + "type": "library", + "group": "", + "name": "master_lock", + "version": ">= 0", + "purl": "pkg:gem/master_lock", + "properties": [ + { + "key": "source", + "value": "git@github.com:coinbase/master_lock.git" + }, + { + "key": "dependency_file", + "value": "Gemfile" + } + ] + } + ] + ) + end + + it 'should report all deps in Gemfile.lock in cyclonedx' do + repo = Salus::Repo.new('spec/fixtures/report_ruby_gems/lockfile') + scanner = Salus::Scanners::ReportRubyGems.new(repository: repo, config: {}) + scanner.run + + ruby_cyclonedx = Cyclonedx::ReportRubyGems.new(scanner.report) + expected = [ + { + "bom-ref": "pkg:gem/actioncable@5.1.2", + "type": "library", + "group": "", + "name": "actioncable", + "version": "5.1.2", + "purl": "pkg:gem/actioncable@5.1.2", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/actionmailer@5.1.2", + "type": "library", + "group": "", + "name": "actionmailer", + "version": "5.1.2", + "purl": "pkg:gem/actionmailer@5.1.2", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/actionpack@5.1.2", + "type": "library", + "group": "", + "name": "actionpack", + "version": "5.1.2", + "purl": "pkg:gem/actionpack@5.1.2", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/nio4r@2.1.0", + "type": "library", + "group": "", + "name": "nio4r", + "version": "2.1.0", + "purl": "pkg:gem/nio4r@2.1.0", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/kibana_url@1.0.1", + "type": "library", + "group": "", + "name": "kibana_url", + "version": "1.0.1", + "purl": "pkg:gem/kibana_url@1.0.1", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/master_lock@0.9.1", + "type": "library", + "group": "", + "name": "master_lock", + "version": "0.9.1", + "purl": "pkg:gem/master_lock@0.9.1", + "properties": [ + { + "key": "source", + "value": "git@github.com:coinbase/master_lock.git" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + } + ] + expect(ruby_cyclonedx.build_components_object).to include(*expected) + end + + it 'should report all deps from multiple sources in Gemfile.lock in cyclonedx' do + repo = Salus::Repo.new('spec/fixtures/report_ruby_gems/lockfile_multiple_sources') + scanner = Salus::Scanners::ReportRubyGems.new(repository: repo, config: {}) + scanner.run + + ruby_cyclonedx = Cyclonedx::ReportRubyGems.new(scanner.report) + expected = [ + { + "bom-ref": "pkg:gem/dep1@0.0.47", + "type": "library", + "group": "", + "name": "dep1", + "version": "0.0.47", + "purl": "pkg:gem/dep1@0.0.47", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://cool_rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/dep2@0.15.3", + "type": "library", + "group": "", + "name": "dep2", + "version": "0.15.3", + "purl": "pkg:gem/dep2@0.15.3", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://cool_rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + }, + { + "bom-ref": "pkg:gem/minitest@5.14.4", + "type": "library", + "group": "", + "name": "minitest", + "version": "5.14.4", + "purl": "pkg:gem/minitest@5.14.4", + "properties": [ + { + "key": "source", + "value": "rubygems repository https://rubygems.org/ or installed locally" + }, + { + "key": "dependency_file", + "value": "Gemfile.lock" + } + ] + } + ] + expect(ruby_cyclonedx.build_components_object).to include(*expected) + end + end +end