Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Appraisal.*.gemfile.lock

# Editors
*~
/.idea/

# vendor
/vendor/
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ PLATFORMS

DEPENDENCIES
aruba (~> 2.2)
cucumber (~> 10.0)
cucumber (~> 10.1, >= 10.1.1)
cyclonedx-ruby!
rake (~> 13)
rspec (~> 3.12)
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,29 @@ cyclonedx-ruby [options]

`-v, --[no-]verbose` Run verbosely
`-p, --path path` Path to Ruby project directory
`-f, --format` Bom output format
`-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
`-h, --help` Show help message

**Output:** bom.xml or bom.json file in project directory

#### Example
- By default, outputs conform to CycloneDX spec version 1.7.
- To generate an older spec version, use `--spec-version`.

#### Examples
```bash
# Default (XML, CycloneDX 1.7)
cyclonedx-ruby -p /path/to/ruby/project

# JSON at CycloneDX 1.7
cyclonedx-ruby -p /path/to/ruby/project -f json

# XML at CycloneDX 1.3
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
```


Expand All @@ -49,4 +64,3 @@ 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

2 changes: 1 addition & 1 deletion cyclonedx-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Gem::Specification.new do |spec|
spec.add_dependency('activesupport', '~> 7.0')
spec.add_development_dependency 'rake', '~> 13'
spec.add_development_dependency 'rspec', '~> 3.12'
spec.add_development_dependency 'cucumber', '~> 10.0'
spec.add_development_dependency 'cucumber', '~> 10.1', '>= 10.1.1'
spec.add_development_dependency 'aruba', '~> 2.2'
spec.add_development_dependency 'simplecov', '~> 0.22.0'
spec.add_development_dependency 'rubocop', '~> 1.54'
Expand Down
4 changes: 2 additions & 2 deletions features/fixtures/simple/bom.json.expected
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.1",
"specVersion": "1.7",
"serialNumber": "urn:uuid:d498cdc2-5494-4031-b37d-ff3d10d336bf",
"version": 1,
"components": [
Expand Down Expand Up @@ -105,4 +105,4 @@
]
}
]
}
}
2 changes: 1 addition & 1 deletion features/fixtures/simple/bom.xml.expected
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" version="1" serialNumber="urn:uuid:ffc51349-2d7d-408e-b2c1-3e3f220e6d2f">
<bom xmlns="http://cyclonedx.org/schema/bom/1.7" version="1" serialNumber="urn:uuid:ffc51349-2d7d-408e-b2c1-3e3f220e6d2f">
<components>
<component type="library">
<name>activesupport</name>
Expand Down
1 change: 1 addition & 0 deletions features/help.feature
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ Scenario: Generate help on demand
-p, --path path (Required) Path to Ruby project directory
-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
-h, --help Show help message
"""
1 change: 0 additions & 1 deletion features/json_format.feature
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,3 @@ Feature: Creating BOM using Json format
"""
And a file named "bom.json" should exist
And the generated Json BOM file "bom.json" matches "bom.json.expected"

4 changes: 2 additions & 2 deletions features/step_definitions/json_bom_matching.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

serial_number_matcher = /\"serialNumber\": \"urn:uuid:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"/
normalized_serial_number = '"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000"'
normalized_generated_file_contents = generated_file_contents.gsub(serial_number_matcher, normalized_serial_number)
normalized_expected_file_contents = expected_file_contents.gsub(serial_number_matcher, normalized_serial_number)
normalized_generated_file_contents = generated_file_contents.gsub(serial_number_matcher, normalized_serial_number).rstrip
normalized_expected_file_contents = expected_file_contents.gsub(serial_number_matcher, normalized_serial_number).rstrip

expect(normalized_expected_file_contents).to eq(normalized_generated_file_contents)
end
49 changes: 36 additions & 13 deletions lib/cyclonedx/bom_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@
module Cyclonedx
class BomBuilder
SUPPORTED_BOM_FORMATS = %w[xml json]
SUPPORTED_SPEC_VERSIONS = %w[1.1 1.2 1.3 1.4 1.5 1.6 1.7]

extend Cyclonedx::BomHelpers

def self.build(path)
original_working_directory = Dir.pwd
setup(path)
specs_list
bom = build_bom(@gems, @bom_output_format)
bom = build_bom(@gems, @bom_output_format, @spec_version)

begin
@logger.info("Changing directory to the original working directory located at #{original_working_directory}")
Dir.chdir original_working_directory
rescue StandardError => e
@logger.error("Unable to change directory the original working directory located at #{original_working_directory}. #{e.message}: #{e.backtrace.join('\n')}")
@logger.error("Unable to change to the original working directory located at #{original_working_directory}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end

bom_directory = File.dirname(@bom_file_path)
begin
FileUtils.mkdir_p(bom_directory) unless File.directory?(bom_directory)
rescue StandardError => e
@logger.error("Unable to create the directory to hold the BOM output at #{bom_directory}. #{e.message}: #{e.backtrace.join('\n')}")
@logger.error("Unable to create the directory to hold the BOM output at #{bom_directory}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end

Expand All @@ -38,34 +39,40 @@ def self.build(path)
puts "#{@gems.size} gems were written to BOM located at #{@bom_file_path}"
end
rescue StandardError => e
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{e.backtrace.join('\n')}")
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end
end

def self.setup(_path)
def self.setup(path)
@options = {}
OptionParser.new do |opts|
opts.banner = 'Usage: cyclonedx-ruby [options]'

opts.on('-v', '--[no-]verbose', 'Run verbosely') do |v|
@options[:verbose] = v
end
opts.on('-p', '--path path', '(Required) Path to Ruby project directory') do |path|
@options[:path] = path
opts.on('-p', '--path path', '(Required) Path to Ruby project directory') do |proj_path_opt|
@options[:path] = proj_path_opt
end
opts.on('-o', '--output bom_file_path', '(Optional) Path to output the bom.xml file to') do |bom_file_path|
@options[:bom_file_path] = bom_file_path
end
opts.on('-f', '--format bom_output_format', '(Optional) Output format for bom. Currently support xml (default) and json.') do |bom_output_format|
@options[:bom_output_format] = bom_output_format
end
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_tail('-h', '--help', 'Show help message') do
puts opts
exit
end
end.parse!

# Allow passing the path as a positional arg via exe wrapper
@options[:path] ||= path

@logger = Logger.new($stdout)
@logger.level = if @options[:verbose]
Logger::INFO
Expand All @@ -89,11 +96,15 @@ def self.setup(_path)
abort
end

# Normalize to an absolute project path to avoid relative path issues later
@project_path = File.expand_path(@options[:path])
@provided_path = @options[:path]

begin
@logger.info("Changing directory to Ruby project directory located at #{@options[:path]}")
Dir.chdir @options[:path]
@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 #{@options[:path]}. #{e.message}: #{e.backtrace.join('\n')}")
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end

Expand All @@ -106,6 +117,15 @@ def self.setup(_path)
abort
end

# Spec version selection
requested_spec = @options[:spec_version] || '1.7'
if SUPPORTED_SPEC_VERSIONS.include?(requested_spec)
@spec_version = requested_spec
else
@logger.error("Unrecognized CycloneDX spec version '#{requested_spec}'. Please choose one of #{SUPPORTED_SPEC_VERSIONS}")
abort
end
Comment on lines +120 to +127

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new spec version validation logic lacks test coverage. While other features like format validation have integration tests in the features directory (e.g., xml_format.feature, json_format.feature), there are no tests verifying the spec version flag behavior, such as testing that an invalid version is rejected or that a valid version like 1.3 produces the correct output with the appropriate namespace/version.

Copilot uses AI. Check for mistakes.

@bom_file_path = if @options[:bom_file_path].nil?
"./bom.#{@bom_output_format}"
else
Expand All @@ -115,13 +135,16 @@ def self.setup(_path)
@logger.info("BOM will be written to #{@bom_file_path}")

begin
gemfile_path = "#{@options[:path]}/Gemfile.lock"
@logger.info("Parsing specs from #{gemfile_path}...")
# Use absolute path so it's correct regardless of current working directory
gemfile_path = File.join(@project_path, 'Gemfile.lock')
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
@logger.info("Parsing specs from #{display_gemfile_path}...")
gemfile_contents = File.read(gemfile_path)
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
@logger.info('Specs successfully parsed!')
rescue StandardError => e
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{e.backtrace.join('\n')}")
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end
end
Expand Down
18 changes: 11 additions & 7 deletions lib/cyclonedx/bom_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ module Cyclonedx
module BomHelpers
module_function

def cyclonedx_xml_namespace(spec_version)
"http://cyclonedx.org/schema/bom/#{spec_version}"
end
Comment on lines +32 to +34

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cyclonedx_xml_namespace helper method lacks test coverage. Similar to how the purl method has unit tests in bom_helpers_spec.rb, this new method should have tests verifying it correctly constructs namespace URLs for different spec versions.

Copilot uses AI. Check for mistakes.

def purl(name, version)
"pkg:gem/#{name}@#{version}"
end
Expand All @@ -37,18 +41,18 @@ def random_urn_uuid
"urn:uuid:#{SecureRandom.uuid}"
end

def build_bom(gems, format)
def build_bom(gems, format, spec_version)
if format == 'json'
build_json_bom(gems)
build_json_bom(gems, spec_version)
else
build_bom_xml(gems)
build_bom_xml(gems, spec_version)
end
end

def build_json_bom(gems)
def build_json_bom(gems, spec_version)
bom_hash = {
bomFormat: 'CycloneDX',
specVersion: '1.1',
specVersion: spec_version,
serialNumber: random_urn_uuid,
version: 1,
components: []
Expand All @@ -61,9 +65,9 @@ def build_json_bom(gems)
JSON.pretty_generate(bom_hash)
end

def build_bom_xml(gems)
def build_bom_xml(gems, spec_version)
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
attributes = { 'xmlns' => 'http://cyclonedx.org/schema/bom/1.1', 'version' => '1', 'serialNumber' => random_urn_uuid }
attributes = { 'xmlns' => cyclonedx_xml_namespace(spec_version), 'version' => '1', 'serialNumber' => random_urn_uuid }
xml.bom(attributes) do
xml.components do
gems.each do |gem|
Expand Down