Skip to content
Closed
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
36 changes: 8 additions & 28 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ PATH
cyclonedx-ruby (1.2.0)
activesupport (~> 7.0)
json (~> 2.6)
json_schemer (~> 2.2)
nokogiri (~> 1.15)
ostruct (~> 0.5.5)
rest-client (~> 2.0)
Expand Down Expand Up @@ -67,20 +68,19 @@ GEM
domain_name (0.6.20240107)
drb (2.2.3)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
hana (1.3.7)
http-accept (1.7.0)
http-cookie (1.1.0)
domain_name (~> 0.5)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.15.2)
json_schemer (2.4.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
Expand All @@ -93,22 +93,8 @@ GEM
minitest (5.26.0)
multi_test (1.1.0)
netrc (0.11.0)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.5.5)
parallel (1.27.0)
parser (3.3.10.0)
Expand Down Expand Up @@ -159,6 +145,7 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
simpleidn (0.2.3)
stone_checksums (1.0.3)
version_gem (~> 1.1, >= 1.1.9)
sys-uname (1.4.1)
Expand All @@ -173,14 +160,7 @@ GEM
version_gem (1.1.9)

PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl

DEPENDENCIES
aruba (~> 2.2)
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ 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
`--validate PATH` Validate an existing BOM file instead of generating one
`-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`.
- Generated BOMs are automatically validated against the CycloneDX schema.

#### Examples
```bash
Expand All @@ -53,6 +55,9 @@ 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

# Validate an existing BOM file
cyclonedx-ruby --validate bom.xml --spec-version 1.7
```


Expand Down
3 changes: 2 additions & 1 deletion cucumber.yml
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
default: --publish-quiet
default: --publish-quiet --format progress features

3 changes: 3 additions & 0 deletions cyclonedx-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Gem::Specification.new do |spec|
# Code / tasks / data (NOTE: exe/ is specified via spec.bindir and spec.executables below)
"lib/**/*.rb",
"lib/licenses.json",
# Schemas used at runtime
"schema/**/*",
# Signatures
"sig/**/*.rbs"
]
Expand Down Expand Up @@ -59,6 +61,7 @@ Gem::Specification.new do |spec|
spec.add_dependency('ostruct', '~> 0.5.5')
spec.add_dependency('rest-client', '~> 2.0')
spec.add_dependency('activesupport', '~> 7.0')
spec.add_dependency('json_schemer', '~> 2.2')
spec.add_development_dependency 'rake', '~> 13'
spec.add_development_dependency 'rspec', '~> 3.12'
spec.add_development_dependency 'cucumber', '~> 10.1', '>= 10.1.1'
Expand Down
8 changes: 6 additions & 2 deletions exe/cyclonedx-ruby
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@

if ENV.fetch('MIMIC_NEXT_MAJOR_VERSION', 'false').casecmp?('true')
require 'cyclonedx/ruby'
Cyclonedx::BomBuilder.build(ARGV[0])
path_arg = ARGV[0]
path_arg = nil if path_arg&.start_with?('-')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

wait. what?
so if i want to save a file to a file '-my-sbom.json', the what ?

Cyclonedx::BomBuilder.build(path_arg)
else
require 'bom_builder'
Bombuilder.build(ARGV[0])
path_arg = ARGV[0]
path_arg = nil if path_arg&.start_with?('-')
Bombuilder.build(path_arg)
end
1 change: 1 addition & 0 deletions features/help.feature
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ 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
--validate PATH Validate an existing BOM file instead of generating one
-h, --help Show help message
"""
47 changes: 47 additions & 0 deletions features/validate.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
Feature: Validate generated BOM against CycloneDX schema

Scenario: Validate XML BOM succeeds
Given I use a fixture named "simple"
And I run `cyclonedx-ruby --path . --format xml`
Then the output should contain:
"""
5 gems were written to BOM located at ./bom.xml
"""
And a file named "bom.xml" should exist

Scenario: Validate JSON BOM succeeds
Given I use a fixture named "simple"
And I run `cyclonedx-ruby --path . --format json`
Then the output should contain:
"""
5 gems were written to BOM located at ./bom.json
"""
And a file named "bom.json" should exist

Scenario: Validate fails for invalid XML BOM
Given I use a fixture named "simple"
And I run `cyclonedx-ruby --path . --format xml`
Then a file named "bom.xml" should exist
When I run `sh -lc "sed -i 's|http://cyclonedx.org/schema/bom/1.7|http://cyclonedx.org/schema/bom/9.9|' bom.xml"`
And I run `cyclonedx-ruby --validate bom.xml --spec-version 1.7`
Then the exit status should be 1

Scenario: Validate existing XML BOM succeeds
Given I use a fixture named "simple"
And I run `cyclonedx-ruby --path . --format xml`
Then a file named "bom.xml" should exist
When I run `cyclonedx-ruby --validate bom.xml --spec-version 1.7`
Then the output should contain:
"""
Validation succeeded for bom.xml (spec 1.7)
"""

Scenario: Validate existing JSON BOM succeeds
Given I use a fixture named "simple"
And I run `cyclonedx-ruby --path . --format json`
Then a file named "bom.json" should exist
When I run `cyclonedx-ruby --validate bom.json --spec-version 1.7`
Then the output should contain:
"""
Validation succeeded for bom.json (spec 1.7)
"""
101 changes: 73 additions & 28 deletions lib/cyclonedx/bom_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ class BomBuilder
def self.build(path)
original_working_directory = Dir.pwd
setup(path)

# If asked to validate an existing file, do not generate a new one
if @options[:validate]
content = begin
File.read(@options[:validate])
rescue StandardError => e
@logger.error("Unable to read file for validation: #{@options[:validate]}. #{e.message}")
exit(1)
end
# Use explicitly provided format if set, otherwise infer from file extension
format = @options[:bom_output_format] || infer_format_from_path(@options[:validate])
success, message = validate_bom_content(content, format, @spec_version)
unless success
@logger.error(message)
exit(1)
end
puts "Validation succeeded for #{@options[:validate]} (spec #{@spec_version})" unless @options[:verbose]
return
end

specs_list
bom = build_bom(@gems, @bom_output_format, @spec_version)

Expand Down Expand Up @@ -42,8 +62,23 @@ def self.build(path)
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
abort
end

# Always validate generated BOMs against the schema
success, message = validate_bom_content(bom, @bom_output_format, @spec_version)
unless success
@logger.error(message)
exit(1)
end
@logger.info("BOM validation succeeded for spec #{@spec_version}") if @options[:verbose]
end

# Infer format from file extension when not explicitly provided
def self.infer_format_from_path(path)
File.extname(path).downcase == '.json' ? 'json' : 'xml'
end

private

def self.setup(path)
@options = {}
OptionParser.new do |opts|
Expand All @@ -64,6 +99,9 @@ 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('--validate PATH', 'Validate an existing BOM file instead of generating one') do |file_path|
@options[:validate] = file_path
end
opts.on_tail('-h', '--help', 'Show help message') do
puts opts
exit
Expand All @@ -86,26 +124,31 @@ def self.setup(path)
licenses_file = File.read(licenses_path)
@licenses_list = JSON.parse(licenses_file)

if @options[:path].nil?
@logger.error('missing path to project directory')
abort
end
# If only validating a file, project path is optional; otherwise require
if @options[:validate].nil?
if @options[:path].nil?
@logger.error('missing path to project directory')
abort
end

unless File.directory?(@options[:path])
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
abort
unless File.directory?(@options[:path])
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
abort
end
end

# Normalize to an absolute project path to avoid relative path issues later
@project_path = File.expand_path(@options[:path])
@project_path = File.expand_path(@options[:path]) if @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")}")
abort
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
end

if @options[:bom_output_format].nil?
Expand All @@ -132,20 +175,22 @@ def self.setup(path)
@options[:bom_file_path]
end

@logger.info("BOM will be written to #{@bom_file_path}")

begin
# 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}: #{Array(e.backtrace).join("\n")}")
abort
@logger.info("BOM will be written to #{@bom_file_path}") if @project_path

if @project_path
begin
# 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}: #{Array(e.backtrace).join("\n")}")
abort
end
end
end

Expand Down
Loading