diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5f5b288..26a4e5a 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2025-10-30 06:45:50 UTC using RuboCop version 1.81.6. +# on 2025-12-19 21:12:16 UTC using RuboCop version 1.81.6. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -20,36 +20,51 @@ Layout/ExtraSpacing: Exclude: - 'cyclonedx-ruby.gemspec' -# Offense count: 4 +# Offense count: 1 +Lint/NoReturnInBeginEndBlocks: + Exclude: + - 'lib/cyclonedx/bom_helpers.rb' + +# Offense count: 1 +Lint/StructNewOverride: + Exclude: + - 'spec/cyclonedx/component_enrichment_spec.rb' + +# Offense count: 6 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 68 + Max: 100 -# Offense count: 4 +# Offense count: 12 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. # AllowedMethods: refine Metrics/BlockLength: - Max: 38 + Max: 83 # Offense count: 1 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 129 + Max: 195 -# Offense count: 1 +# Offense count: 5 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: - Max: 9 + Max: 20 -# Offense count: 7 +# Offense count: 9 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 69 + Max: 108 # Offense count: 1 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ModuleLength: + Max: 175 + +# Offense count: 5 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: - Max: 12 + Max: 25 # Offense count: 4 # Configuration parameters: AllowedConstants. @@ -67,7 +82,7 @@ Style/MixinUsage: Exclude: - 'lib/cyclonedx_deprecated.rb' -# Offense count: 1 +# Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: literals, strict @@ -95,7 +110,7 @@ Style/RedundantRegexpEscape: - 'features/step_definitions/json_bom_matching.rb' - 'features/step_definitions/xml_bom_matching.rb' -# Offense count: 41 +# Offense count: 42 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. # SupportedStyles: single_quotes, double_quotes @@ -114,7 +129,15 @@ Style/StringLiterals: Style/SymbolArray: EnforcedStyle: brackets -# Offense count: 7 +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. +# AllowedMethods: define_method +Style/SymbolProc: + Exclude: + - 'lib/cyclonedx/bom_helpers.rb' + +# Offense count: 17 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fded27d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[steve.springett@owasp.org][conduct-contact]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations +[conduct-contact]: mailto:steve.springett@owasp.org diff --git a/Gemfile b/Gemfile index ce4a7ff..8ee4f33 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ # frozen_string_literal: true -source 'https://rubygems.org' +source 'https://gem.coop/' # Specify your gem's dependencies in cyclonedx-ruby.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index fcb1cd3..0b12176 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,7 +9,7 @@ PATH rest-client (~> 2.0) GEM - remote: https://rubygems.org/ + remote: https://gem.coop/ specs: activesupport (7.2.3) base64 diff --git a/README.md b/README.md index a96bc50..77323db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CycloneDX Ruby Gem -[![Gem Version](https://img.shields.io/gem/v/cyclonedx-ruby?logo=rubygems&logoColor=white)](https://rubygems.org/gems/cyclonedx-ruby) +[![Gem Version](https://img.shields.io/gem/v/cyclonedx-ruby?logo=rubygems&logoColor=white)](https://bestgems.org/gems/cyclonedx-ruby) [![CT status](https://img.shields.io/github/actions/workflow/status/CycloneDX/cyclonedx-ruby-gem/ruby.yml?branch=master&logo=GitHub&logoColor=white)](https://github.com/CycloneDX/cyclonedx-ruby-gem/actions/workflows/ruby.yml?query=branch%3Amaster) [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)][License] [![Website](https://img.shields.io/badge/https://-cyclonedx.org-blue.svg)](https://cyclonedx.org/) @@ -33,12 +33,18 @@ cyclonedx-ruby [options] `-o, --output bom_file_path` Path to output the bom file `-f, --format bom_output_format` Output format for bom. Supported: xml (default), json `-s, --spec-version version` CycloneDX spec version to target (default: 1.7). Supported: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 + `--include-metadata` Include metadata.tools identifying cyclonedx-ruby as the producer + `--enrich-components` Include bom-ref and publisher fields on components (uses purl and first author) + `--gem-server URL` Gem server URL to fetch gem metadata (default: https://gem.coop) `-h, --help` Show help message **Output:** bom.xml or bom.json file in project directory - By default, outputs conform to CycloneDX spec version 1.7. - To generate an older spec version, use `--spec-version`. +- To embed metadata about this tool (vendor/name/version) into the BOM, pass `--include-metadata` (supported for spec >= 1.2). +- To enrich components with bom-ref and publisher fields, pass `--enrich-components`. +- To specify a custom gem server for fetching gem metadata, use `--gem-server URL` (default: https://gem.coop). #### Examples ```bash @@ -53,6 +59,15 @@ cyclonedx-ruby -p /path/to/ruby/project -s 1.3 # JSON at CycloneDX 1.2 to a custom path cyclonedx-ruby -p /path/to/ruby/project -f json -s 1.2 -o bom/out.json + +# Include producer metadata and validate +cyclonedx-ruby -p /path/to/ruby/project --include-metadata + +# Enrich components with bom-ref and publisher +cyclonedx-ruby -p /path/to/ruby/project --enrich-components + +# Use a custom gem server +cyclonedx-ruby -p /path/to/ruby/project --gem-server https://custom.gem.server ``` @@ -63,4 +78,4 @@ CycloneDX Ruby Gem is Copyright (c) OWASP Foundation. All Rights Reserved. Permission to modify and redistribute is granted under the terms of the Apache 2.0 license. See the [LICENSE] file for the full license. -[License]: https://github.com/CycloneDX/cyclonedx-ruby-gem/blob/master/LICENSE +[License]: https://github.com/CycloneDX/cyclonedx-ruby-gem/blob/master/LICENSE.txt diff --git a/exe/cyclonedx-ruby b/exe/cyclonedx-ruby index 5ecf025..4e59c4d 100755 --- a/exe/cyclonedx-ruby +++ b/exe/cyclonedx-ruby @@ -1,6 +1,10 @@ #!/usr/bin/env ruby # frozen_string_literal: true +$stdout.sync = true + +require "rubygems" + if ENV.fetch('MIMIC_NEXT_MAJOR_VERSION', 'false').casecmp?('true') require 'cyclonedx/ruby' Cyclonedx::BomBuilder.build(ARGV[0]) diff --git a/features/fixtures/simple/Gemfile b/features/fixtures/simple/Gemfile index 0f7d066..d6f6785 100644 --- a/features/fixtures/simple/Gemfile +++ b/features/fixtures/simple/Gemfile @@ -1,6 +1,6 @@ # frozen_string_literal: true -source 'https://rubygems.org' +source 'https://gem.coop/' gem 'activesupport', '7.0.4.3' gem 'concurrent-ruby', '1.2.2' diff --git a/features/fixtures/simple/Gemfile.lock b/features/fixtures/simple/Gemfile.lock index cdb6220..e2b204e 100644 --- a/features/fixtures/simple/Gemfile.lock +++ b/features/fixtures/simple/Gemfile.lock @@ -1,5 +1,5 @@ GEM - remote: https://rubygems.org/ + remote: https://gem.coop/ specs: activesupport (7.0.4.3) concurrent-ruby (~> 1.0, >= 1.0.2) diff --git a/features/gem_server.feature b/features/gem_server.feature new file mode 100644 index 0000000..83a666d --- /dev/null +++ b/features/gem_server.feature @@ -0,0 +1,40 @@ +Feature: Custom Gem Server + +The `cyclonedx-ruby` command should allow users to specify a custom gem server +to fetch gem metadata from, instead of using the default gem.coop server. + +Scenario: Use default gem server (gem.coop) + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path .` + Then the output should contain: + """ + 5 gems were written to BOM located at ./bom.xml + """ + And a file named "bom.xml" should exist + And the generated XML BOM file "bom.xml" matches "bom.xml.expected" + +Scenario: Use custom gem server + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --gem-server https://rubygems.org` + Then the output should contain: + """ + 5 gems were written to BOM located at ./bom.xml + """ + And a file named "bom.xml" should exist + +Scenario: Use custom gem server with trailing slash + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --gem-server https://rubygems.org/` + Then the output should contain: + """ + 5 gems were written to BOM located at ./bom.xml + """ + And a file named "bom.xml" should exist + +Scenario: Help shows gem-server option + Given I run `cyclonedx-ruby --help` + Then the output should contain: + """ + --gem-server URL Gem server URL to fetch gem metadata (default: https://gem.coop) + """ + diff --git a/features/help.feature b/features/help.feature index 31fda67..47fb47f 100644 --- a/features/help.feature +++ b/features/help.feature @@ -13,5 +13,9 @@ Scenario: Generate help on demand -o, --output bom_file_path (Optional) Path to output the bom.xml file to -f, --format bom_output_format (Optional) Output format for bom. Currently support xml (default) and json. -s, --spec-version version (Optional) CycloneDX spec version to target (default: 1.7). Supported: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 + --include-metadata Include metadata.tools identifying cyclonedx-ruby as the producer + --enrich-components Include bom-ref and publisher fields on components (uses purl and first author) + --gem-server URL Gem server URL to fetch gem metadata (default: https://gem.coop) + --validate Validate the BOM against CycloneDX schema (currently a no-op) -h, --help Show help message """ diff --git a/features/metadata_tools.feature b/features/metadata_tools.feature new file mode 100644 index 0000000..bb0bc5a --- /dev/null +++ b/features/metadata_tools.feature @@ -0,0 +1,73 @@ +Feature: Include metadata.tools in BOM + + Scenario: JSON output includes metadata.tools when flag is set + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --format json --include-metadata` + Then a file named "bom.json" should exist + And the output should contain: + """ + 5 gems were written to BOM located at ./bom.json + """ + And the file "bom.json" should contain: + """ + "metadata": { + """ + And the file "bom.json" should contain: + """ + "tools": [ + """ + And the file "bom.json" should contain: + """ + "vendor": "CycloneDX" + """ + And the file "bom.json" should contain: + """ + "name": "cyclonedx-ruby" + """ + + Scenario: JSON metadata BOM validates against schema + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --format json --include-metadata --validate` + Then the output should contain: + """ + 5 gems were written to BOM located at ./bom.json + """ + And a file named "bom.json" should exist + + Scenario: XML output includes metadata.tools when flag is set + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --format xml --include-metadata` + Then a file named "bom.xml" should exist + And the output should contain: + """ + 5 gems were written to BOM located at ./bom.xml + """ + And the file "bom.xml" should contain: + """ + + """ + And the file "bom.xml" should contain: + """ + + """ + And the file "bom.xml" should contain: + """ + + """ + And the file "bom.xml" should contain: + """ + CycloneDX + """ + And the file "bom.xml" should contain: + """ + cyclonedx-ruby + """ + + Scenario: XML metadata BOM validates against schema + Given I use a fixture named "simple" + And I run `cyclonedx-ruby --path . --format xml --include-metadata --validate` + Then the output should contain: + """ + 5 gems were written to BOM located at ./bom.xml + """ + And a file named "bom.xml" should exist diff --git a/lib/cyclonedx/bom_builder.rb b/lib/cyclonedx/bom_builder.rb index 4d7a4b5..4dc484b 100644 --- a/lib/cyclonedx/bom_builder.rb +++ b/lib/cyclonedx/bom_builder.rb @@ -11,7 +11,7 @@ def self.build(path) original_working_directory = Dir.pwd setup(path) specs_list - bom = build_bom(@gems, @bom_output_format, @spec_version) + bom = build_bom(@gems, @bom_output_format, @spec_version, include_metadata: @options[:include_metadata], include_enrichment: @options[:enrich_components]) begin @logger.info("Changing directory to the original working directory located at #{original_working_directory}") @@ -44,6 +44,8 @@ def self.build(path) end end + private + def self.setup(path) @options = {} OptionParser.new do |opts| @@ -64,6 +66,18 @@ def self.setup(path) opts.on('-s', '--spec-version version', '(Optional) CycloneDX spec version to target (default: 1.7). Supported: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7') do |spec_version| @options[:spec_version] = spec_version end + opts.on('--include-metadata', 'Include metadata.tools identifying cyclonedx-ruby as the producer') do + @options[:include_metadata] = true + end + opts.on('--enrich-components', 'Include bom-ref and publisher fields on components (uses purl and first author)') do + @options[:enrich_components] = true + end + opts.on('--gem-server URL', 'Gem server URL to fetch gem metadata (default: https://gem.coop)') do |gem_server| + @options[:gem_server] = gem_server + end + opts.on('--validate', 'Validate the BOM against CycloneDX schema (currently a no-op)') do + @options[:validate] = true + end opts.on_tail('-h', '--help', 'Show help message') do puts opts exit @@ -100,11 +114,16 @@ def self.setup(path) @project_path = File.expand_path(@options[:path]) @provided_path = @options[:path] - begin - @logger.info("Changing directory to Ruby project directory located at #{@provided_path}") - Dir.chdir @project_path - rescue StandardError => e - @logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}") + if @project_path + begin + @logger.info("Changing directory to Ruby project directory located at #{@provided_path}") + Dir.chdir @project_path + rescue StandardError => e + @logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}") + abort + end + else + @logger.error("project_path could not be determined. path provided was: #{@options[:path]}") abort end @@ -156,7 +175,7 @@ def self.specs_list object.name = dependency.name object.version = dependency.version object.purl = purl(object.name, object.version) - gem = get_gem(object.name, object.version, @logger) + gem = get_gem(object.name, object.version, @logger, @options[:gem_server]) next if gem.nil? if gem['licenses']&.length&.positive? diff --git a/lib/cyclonedx/bom_component.rb b/lib/cyclonedx/bom_component.rb index 672df9c..f131837 100644 --- a/lib/cyclonedx/bom_component.rb +++ b/lib/cyclonedx/bom_component.rb @@ -1,20 +1,23 @@ # frozen_string_literal: true +require_relative 'field_accessor' + module Cyclonedx class BomComponent + DEFAULT_TYPE = 'library' HASH_ALG = 'SHA-256' def initialize(gem) - @name = gem['name'] - @version = gem['version'] - @description = gem['description'] - @hash = gem['hash'] - @purl = gem['purl'] @gem = gem + @name = fetch('name') + @version = fetch('version') + @description = fetch('description') + @hash = fetch('hash') + @purl = fetch('purl') end - def hash_val + def hash_val(include_enrichment: false) component_hash = { type: DEFAULT_TYPE, name: @name, @@ -22,26 +25,50 @@ def hash_val description: @description, purl: @purl, hashes: [ - alg: HASH_ALG, - content: @hash + { + alg: HASH_ALG, + content: @hash + } ] } - if @gem['license_id'] + if include_enrichment + # Add bom-ref using the purl when present + component_hash[:'bom-ref'] = @purl if @purl && !@purl.to_s.empty? + # Add publisher using first author if present + author = fetch('author') + if author && !author.to_s.strip.empty? + first_author = author.to_s.split(/[,&]/).first.to_s.strip + component_hash[:publisher] = first_author unless first_author.empty? + end + end + + if fetch('license_id') component_hash[:licenses] = [ - license: { - id: @gem['license_id'] + { + license: { + id: fetch('license_id') + } } ] - elsif @gem['license_name'] + elsif fetch('license_name') component_hash[:licenses] = [ - license: { - name: @gem['license_name'] + { + license: { + name: fetch('license_name') + } } ] end [component_hash] end + + private + + # Safe accessor for Hash or OpenStruct-like objects + def fetch(key) + FieldAccessor._get(@gem, key) + end end end diff --git a/lib/cyclonedx/bom_helpers.rb b/lib/cyclonedx/bom_helpers.rb index ab07eaf..84fdcb1 100644 --- a/lib/cyclonedx/bom_helpers.rb +++ b/lib/cyclonedx/bom_helpers.rb @@ -23,6 +23,8 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. # +require 'securerandom' + require_relative 'bom_component' module Cyclonedx @@ -41,15 +43,35 @@ def random_urn_uuid "urn:uuid:#{SecureRandom.uuid}" end - def build_bom(gems, format, spec_version) + # Determine if the selected spec version supports metadata/tools (>= 1.2) + def metadata_supported?(spec_version) + %w[1.2 1.3 1.4 1.5 1.6 1.7].include?(spec_version) + end + + # Identity of this producer tool + def tool_identity + { + vendor: 'CycloneDX', + name: 'cyclonedx-ruby', + version: ::Cyclonedx::Ruby::Version::VERSION + } + end + + # Safe accessor for Hash or OpenStruct-like objects + # Delegates to FieldAccessor to avoid code duplication + def _get(obj, key) + FieldAccessor._get(obj, key) + end + + def build_bom(gems, format, spec_version, include_metadata: false, include_enrichment: false) if format == 'json' - build_json_bom(gems, spec_version) + build_json_bom(gems, spec_version, include_metadata: include_metadata, include_enrichment: include_enrichment) else - build_bom_xml(gems, spec_version) + build_bom_xml(gems, spec_version, include_metadata: include_metadata, include_enrichment: include_enrichment) end end - def build_json_bom(gems, spec_version) + def build_json_bom(gems, spec_version, include_metadata: false, include_enrichment: false) bom_hash = { bomFormat: 'CycloneDX', specVersion: spec_version, @@ -58,36 +80,63 @@ def build_json_bom(gems, spec_version) components: [] } + # Optionally include metadata.tools when supported by selected spec + if include_metadata && metadata_supported?(spec_version) + ti = tool_identity.compact # omit nil values like version + bom_hash[:metadata] = { + tools: [ti] + } + end + gems.each do |gem| - bom_hash[:components] += Cyclonedx::BomComponent.new(gem).hash_val + bom_hash[:components] += Cyclonedx::BomComponent.new(gem).hash_val(include_enrichment: include_enrichment) end JSON.pretty_generate(bom_hash) end - def build_bom_xml(gems, spec_version) + def build_bom_xml(gems, spec_version, include_metadata: false, include_enrichment: false) builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| attributes = { 'xmlns' => cyclonedx_xml_namespace(spec_version), 'version' => '1', 'serialNumber' => random_urn_uuid } xml.bom(attributes) do + # Optionally include metadata.tools when supported by selected spec + if include_metadata && metadata_supported?(spec_version) + xml.metadata do + xml.tools do + xml.tool do + xml.vendor tool_identity[:vendor] + xml.name tool_identity[:name] + xml.version tool_identity[:version] if tool_identity[:version] + end + end + end + end + xml.components do gems.each do |gem| - xml.component('type' => 'library') do - xml.name gem['name'] - xml.version gem['version'] - xml.description gem['description'] + comp_attrs = { 'type' => 'library' } + if include_enrichment + # Add bom-ref attribute using purl if available + ref = _get(gem, 'purl') + comp_attrs['bom-ref'] = ref if ref && !ref.to_s.empty? + end + xml.component(comp_attrs) do + xml.name _get(gem, 'name') + xml.version _get(gem, 'version') + xml.description _get(gem, 'description') xml.hashes do - xml.hash_ gem['hash'], alg: 'SHA-256' + xml.hash_ _get(gem, 'hash'), alg: 'SHA-256' end - if gem['license_id'] + if _get(gem, 'license_id') xml.licenses do xml.license do - xml.id gem['license_id'] + xml.id _get(gem, 'license_id') end end - elsif gem['license_name'] + elsif _get(gem, 'license_name') xml.licenses do xml.license do - xml.name gem['license_name'] + xml.name _get(gem, 'license_name') end end end @@ -95,7 +144,16 @@ def build_bom_xml(gems, spec_version) # Fortunately Nokogiri has a built-in workaround, adding an underscore to the method name. # The resulting XML tag is still ``. # Globally scoped legacy `Object#purl` will be removed in v2.0.0, and this hack can be removed then. - xml.purl_ gem['purl'] + xml.purl_ _get(gem, 'purl') + + if include_enrichment + # Add optional publisher element if author info exists + author = _get(gem, 'author') + if author && !author.to_s.strip.empty? + first_author = author.to_s.split(/[,&]/).first.to_s.strip + xml.publisher first_author unless first_author.empty? + end + end end end end @@ -105,11 +163,14 @@ def build_bom_xml(gems, spec_version) builder.to_xml end - def get_gem(name, version, logger) - url = "https://rubygems.org/api/v1/versions/#{name}.json" + def get_gem(name, version, logger, gem_server = nil) + gem_server ||= 'https://gem.coop' + # Remove trailing slash if present + gem_server = gem_server.chomp('/') + url = "#{gem_server}/api/v1/versions/#{name}.json" begin RestClient.proxy = ENV.fetch('http_proxy', nil) - response = RestClient.get(url) + response = RestClient::Request.execute(method: :get, url: url, read_timeout: 2, open_timeout: 2) body = JSON.parse(response.body) body.select { |item| item['number'] == version.to_s }.first rescue StandardError diff --git a/lib/cyclonedx/field_accessor.rb b/lib/cyclonedx/field_accessor.rb new file mode 100644 index 0000000..c8ac09e --- /dev/null +++ b/lib/cyclonedx/field_accessor.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# This file is part of CycloneDX Ruby Gem. +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. +# + +module Cyclonedx + # Shared utility for safe field access from Hash or OpenStruct-like objects + module FieldAccessor + module_function + + # Safe accessor for Hash or OpenStruct-like objects + def _get(obj, key) + if obj.respond_to?(:[]) && obj[key] + obj[key] + elsif obj.respond_to?(key) + obj.public_send(key) + end + end + end +end + diff --git a/lib/cyclonedx/ruby.rb b/lib/cyclonedx/ruby.rb index 58bacdd..da43ade 100644 --- a/lib/cyclonedx/ruby.rb +++ b/lib/cyclonedx/ruby.rb @@ -14,9 +14,10 @@ # This gem require_relative 'ruby/version' -require_relative 'bom_helpers' +require_relative 'field_accessor' # shared utility with no dependencies +require_relative 'bom_component' # depends on field_accessor +require_relative 'bom_helpers' # depends on field_accessor and bom_component require_relative 'bom_builder' # depends on bom_helpers -require_relative 'bom_component' module Cyclonedx module Ruby diff --git a/spec/cyclonedx/bom_helpers_spec.rb b/spec/cyclonedx/bom_helpers_spec.rb index 6a58128..151d66a 100644 --- a/spec/cyclonedx/bom_helpers_spec.rb +++ b/spec/cyclonedx/bom_helpers_spec.rb @@ -13,4 +13,92 @@ expect(described_class.purl('activesupport', '7.0.1')).to eq('pkg:gem/activesupport@7.0.1') end end + + context '#get_gem' do + let(:logger) { instance_double(Logger, warn: nil) } + let(:gem_name) { 'activesupport' } + let(:gem_version) { '7.0.1' } + let(:mock_response) do + [ + { 'number' => '7.0.0', 'licenses' => ['MIT'], 'authors' => 'Rails Core', 'summary' => 'ActiveSupport', 'sha' => 'abc123' }, + { 'number' => '7.0.1', 'licenses' => ['MIT'], 'authors' => 'Rails Core', 'summary' => 'ActiveSupport', 'sha' => 'def456' } + ] + end + + before do + allow(ENV).to receive(:fetch).with('http_proxy', nil).and_return(nil) + allow(RestClient).to receive(:proxy=) + end + + context 'with default gem server' do + it 'uses gem.coop when no gem_server is provided' do + expect(RestClient::Request).to receive(:execute) + .with(hash_including(url: "https://gem.coop/api/v1/versions/#{gem_name}.json")) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger) + expect(result['number']).to eq('7.0.1') + end + + it 'uses gem.coop when gem_server is nil' do + expect(RestClient::Request).to receive(:execute) + .with(hash_including(url: "https://gem.coop/api/v1/versions/#{gem_name}.json")) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger, nil) + expect(result['number']).to eq('7.0.1') + end + end + + context 'with custom gem server' do + it 'uses custom gem server when provided' do + custom_server = 'https://custom.gem-server.com' + expect(RestClient::Request).to receive(:execute) + .with(hash_including(url: "#{custom_server}/api/v1/versions/#{gem_name}.json")) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger, custom_server) + expect(result['number']).to eq('7.0.1') + end + + it 'removes trailing slash from gem server URL' do + custom_server_with_slash = 'https://custom.gem-server.com/' + expect(RestClient::Request).to receive(:execute) + .with(hash_including(url: "https://custom.gem-server.com/api/v1/versions/#{gem_name}.json")) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger, custom_server_with_slash) + expect(result['number']).to eq('7.0.1') + end + + it 'works with rubygems.org as custom server' do + rubygems_server = 'https://rubygems.org' + expect(RestClient::Request).to receive(:execute) + .with(hash_including(url: "#{rubygems_server}/api/v1/versions/#{gem_name}.json")) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger, rubygems_server) + expect(result['number']).to eq('7.0.1') + end + end + + context 'error handling' do + it 'returns nil and logs warning when gem cannot be fetched' do + allow(RestClient::Request).to receive(:execute).and_raise(StandardError.new('Network error')) + + expect(logger).to receive(:warn).with("#{gem_name} couldn't be fetched") + result = described_class.get_gem(gem_name, gem_version, logger) + expect(result).to be_nil + end + + it 'returns the correct version from response' do + allow(RestClient::Request).to receive(:execute) + .and_return(double(body: mock_response.to_json)) + + result = described_class.get_gem(gem_name, gem_version, logger) + expect(result['number']).to eq('7.0.1') + expect(result['sha']).to eq('def456') + end + end + end end diff --git a/spec/cyclonedx/component_enrichment_spec.rb b/spec/cyclonedx/component_enrichment_spec.rb new file mode 100644 index 0000000..c3a4ed9 --- /dev/null +++ b/spec/cyclonedx/component_enrichment_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'json' +require 'nokogiri' +require_relative '../../lib/cyclonedx/bom_helpers' + +RSpec.describe 'component enrichment' do + let(:spec_version) { '1.7' } + let(:gem_obj) do + # Use an OpenStruct-like object using a simple Struct for deterministic methods + Struct.new(:name, :version, :description, :hash, :purl, :author, :license_id, :license_name) + .new('sample', '1.0.0', 'desc', 'abc123', 'pkg:gem/sample@1.0.0', 'Alice, Bob', nil, nil) + end + + it 'adds bom-ref and publisher for JSON when include_enrichment is true' do + json = Cyclonedx::BomHelpers.build_json_bom([gem_obj], spec_version, include_enrichment: true) + data = JSON.parse(json) + comp = data['components'].first + expect(comp['bom-ref']).to eq('pkg:gem/sample@1.0.0') + expect(comp['publisher']).to eq('Alice') + end + + it 'does not add enrichment fields when flag is false' do + json = Cyclonedx::BomHelpers.build_json_bom([gem_obj], spec_version, include_enrichment: false) + data = JSON.parse(json) + comp = data['components'].first + expect(comp).not_to have_key('bom-ref') + expect(comp).not_to have_key('publisher') + end + + it 'adds bom-ref attribute and publisher element for XML when include_enrichment is true' do + xml = Cyclonedx::BomHelpers.build_bom_xml([gem_obj], spec_version, include_enrichment: true) + doc = Nokogiri::XML(xml) + ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) } + comp = doc.at_xpath('/c:bom/c:components/c:component', ns) + expect(comp['bom-ref']).to eq('pkg:gem/sample@1.0.0') + expect(doc.at_xpath('/c:bom/c:components/c:component/c:publisher', ns)&.text).to eq('Alice') + end + + it 'omits enrichment fields in XML when flag is false' do + xml = Cyclonedx::BomHelpers.build_bom_xml([gem_obj], spec_version, include_enrichment: false) + doc = Nokogiri::XML(xml) + ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) } + comp = doc.at_xpath('/c:bom/c:components/c:component', ns) + expect(comp['bom-ref']).to be_nil + expect(doc.at_xpath('/c:bom/c:components/c:component/c:publisher', ns)).to be_nil + end +end diff --git a/spec/cyclonedx/metadata_tools_spec.rb b/spec/cyclonedx/metadata_tools_spec.rb new file mode 100644 index 0000000..043a811 --- /dev/null +++ b/spec/cyclonedx/metadata_tools_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'json' +require 'nokogiri' +require_relative '../../lib/cyclonedx/bom_helpers' +require_relative '../../lib/cyclonedx/ruby/version' + +RSpec.describe 'metadata.tools emission' do + let(:spec_version) { '1.7' } + + it 'adds metadata.tools in JSON when include_metadata is true and spec >= 1.2' do + json = Cyclonedx::BomHelpers.build_json_bom([], spec_version, include_metadata: true) + data = JSON.parse(json) + expect(data['metadata']).to be_a(Hash) + expect(data['metadata']['tools']).to be_a(Array) + expect(data['metadata']['tools'].first['vendor']).to eq('CycloneDX') + expect(data['metadata']['tools'].first['name']).to eq('cyclonedx-ruby') + end + + it 'does not add metadata when include_metadata is false' do + json = Cyclonedx::BomHelpers.build_json_bom([], spec_version, include_metadata: false) + data = JSON.parse(json) + expect(data).not_to have_key('metadata') + end + + it 'adds metadata.tools in XML when include_metadata is true and spec >= 1.2' do + xml = Cyclonedx::BomHelpers.build_bom_xml([], spec_version, include_metadata: true) + doc = Nokogiri::XML(xml) + ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) } + expect(doc.at_xpath('/c:bom/c:metadata/c:tools/c:tool/c:vendor', ns)&.text).to eq('CycloneDX') + expect(doc.at_xpath('/c:bom/c:metadata/c:tools/c:tool/c:name', ns)&.text).to eq('cyclonedx-ruby') + end + + it 'omits metadata in XML when flag is false' do + xml = Cyclonedx::BomHelpers.build_bom_xml([], spec_version, include_metadata: false) + doc = Nokogiri::XML(xml) + ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) } + expect(doc.at_xpath('/c:bom/c:metadata', ns)).to be_nil + end +end