Skip to content

Commit 86a8d79

Browse files
committed
✨ --validate
# CLI and validation - Added --validate and --validate-file flags in Cyclonedx::BomBuilder. - After writing the BOM, if --validate is set, validate JSON via JSON Schema and XML via XSD with local files under spec/specification-1.7/schema. - Added logic to validate an existing file with --validate --validate-file <path>, inferring format from extension unless --format is provided.</path> - In validate-only mode, project path isn’t required. # Validation helpers - Added Cyclonedx::BomHelpers.validate_bom_content(content, format, spec_version) which: - For JSON: uses json_schemer to validate against bom-<ver>.schema.json.</ver> - For XML: uses Nokogiri::XML::Schema with bom-<ver>.xsd.</ver> - Uses local schemas at spec/specification-1.7/schema and surfaces compact error messages; returns non-zero exit on failure. # Dependencies - Added json_schemer (~> 2.2) to cyclonedx-ruby.gemspec. - Required json_schemer in lib/cyclonedx/ruby.rb. # Cucumber tests - Updated features/help.feature to show the new flags. - Added features/validate.feature: - Validate XML BOM succeeds. - Validate JSON BOM succeeds. - Validate fails for invalid XML BOM (corrupts namespace and expects exit 1). # Small extras - Infer format from file extension when using --validate-file and no --format provided. Signed-off-by: Peter H. Boling <[email protected]>
1 parent 81179a5 commit 86a8d79

File tree

9 files changed

+199
-36
lines changed

9 files changed

+199
-36
lines changed

Gemfile.lock

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ PATH
44
cyclonedx-ruby (1.2.0)
55
activesupport (~> 7.0)
66
json (~> 2.6)
7+
json_schemer (~> 2.2)
78
nokogiri (~> 1.15)
89
ostruct (~> 0.5.5)
910
rest-client (~> 2.0)
@@ -75,12 +76,18 @@ GEM
7576
ffi (1.17.2-x86_64-darwin)
7677
ffi (1.17.2-x86_64-linux-gnu)
7778
ffi (1.17.2-x86_64-linux-musl)
79+
hana (1.3.7)
7880
http-accept (1.7.0)
7981
http-cookie (1.1.0)
8082
domain_name (~> 0.5)
8183
i18n (1.14.7)
8284
concurrent-ruby (~> 1.0)
8385
json (2.15.2)
86+
json_schemer (2.4.0)
87+
bigdecimal
88+
hana (~> 1.3)
89+
regexp_parser (~> 2.0)
90+
simpleidn (~> 0.2)
8491
language_server-protocol (3.17.0.5)
8592
lint_roller (1.1.0)
8693
logger (1.7.0)
@@ -90,13 +97,9 @@ GEM
9097
mime-types-data (~> 3.2025, >= 3.2025.0507)
9198
mime-types-data (3.2025.0924)
9299
mini_mime (1.1.5)
93-
mini_portile2 (2.8.9)
94100
minitest (5.26.0)
95101
multi_test (1.1.0)
96102
netrc (0.11.0)
97-
nokogiri (1.18.10)
98-
mini_portile2 (~> 2.8.2)
99-
racc (~> 1.4)
100103
nokogiri (1.18.10-aarch64-linux-gnu)
101104
racc (~> 1.4)
102105
nokogiri (1.18.10-aarch64-linux-musl)
@@ -163,6 +166,7 @@ GEM
163166
simplecov_json_formatter (~> 0.1)
164167
simplecov-html (0.13.2)
165168
simplecov_json_formatter (0.1.4)
169+
simpleidn (0.2.3)
166170
sys-uname (1.4.1)
167171
ffi (~> 1.1)
168172
memoist3 (~> 1.0.0)
@@ -179,7 +183,6 @@ PLATFORMS
179183
arm-linux-gnu
180184
arm-linux-musl
181185
arm64-darwin
182-
ruby
183186
x86_64-darwin
184187
x86_64-linux-gnu
185188
x86_64-linux-musl

cucumber.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
default: --publish-quiet
1+
default: --publish-quiet --format progress features
2+

cyclonedx-ruby.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Gem::Specification.new do |spec|
6464
spec.add_dependency('ostruct', '~> 0.5.5')
6565
spec.add_dependency('rest-client', '~> 2.0')
6666
spec.add_dependency('activesupport', '~> 7.0')
67+
spec.add_dependency('json_schemer', '~> 2.2')
6768
spec.add_development_dependency 'rake', '~> 13'
6869
spec.add_development_dependency 'rspec', '~> 3.12'
6970
spec.add_development_dependency 'cucumber', '~> 10.1', '>= 10.1.1'

exe/cyclonedx-ruby

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
if ENV.fetch("MIMIC_NEXT_MAJOR_VERSION", "false").casecmp?("true")
55
require 'cyclonedx/ruby'
6-
Cyclonedx::BomBuilder.build(ARGV[0])
6+
path_arg = ARGV[0]
7+
path_arg = nil if path_arg&.start_with?('-')
8+
Cyclonedx::BomBuilder.build(path_arg)
79
else
810
require 'bom_builder'
9-
Bombuilder.build(ARGV[0])
11+
path_arg = ARGV[0]
12+
path_arg = nil if path_arg&.start_with?('-')
13+
Bombuilder.build(path_arg)
1014
end

features/help.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ Scenario: Generate help on demand
1313
-o, --output bom_file_path (Optional) Path to output the bom.xml file to
1414
-f, --format bom_output_format (Optional) Output format for bom. Currently support xml (default) and json.
1515
-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
16+
--validate Validate the produced BOM against the selected CycloneDX schema
17+
--validate-file PATH Validate an existing BOM file instead of generating one
1618
-h, --help Show help message
1719
"""

features/validate.feature

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Feature: Validate generated BOM against CycloneDX schema
2+
3+
Scenario: Validate XML BOM succeeds
4+
Given I use a fixture named "simple"
5+
And I run `cyclonedx-ruby --path . --format xml --validate`
6+
Then the output should contain:
7+
"""
8+
5 gems were written to BOM located at ./bom.xml
9+
"""
10+
And a file named "bom.xml" should exist
11+
12+
Scenario: Validate JSON BOM succeeds
13+
Given I use a fixture named "simple"
14+
And I run `cyclonedx-ruby --path . --format json --validate`
15+
Then the output should contain:
16+
"""
17+
5 gems were written to BOM located at ./bom.json
18+
"""
19+
And a file named "bom.json" should exist
20+
21+
Scenario: Validate fails for invalid XML BOM
22+
Given I use a fixture named "simple"
23+
And I run `cyclonedx-ruby --path . --format xml`
24+
Then a file named "bom.xml" should exist
25+
When I run `sh -lc "sed -i 's|http://cyclonedx.org/schema/bom/1.7|http://cyclonedx.org/schema/bom/9.9|' bom.xml"`
26+
And I run `cyclonedx-ruby --validate --validate-file bom.xml --spec-version 1.7`
27+
Then the exit status should be 1
28+
29+
Scenario: Validate existing XML BOM succeeds
30+
Given I use a fixture named "simple"
31+
And I run `cyclonedx-ruby --path . --format xml`
32+
Then a file named "bom.xml" should exist
33+
When I run `cyclonedx-ruby --validate --validate-file bom.xml --spec-version 1.7`
34+
Then the output should contain:
35+
"""
36+
Validation succeeded for bom.xml (spec 1.7)
37+
"""
38+
39+
Scenario: Validate existing JSON BOM succeeds
40+
Given I use a fixture named "simple"
41+
And I run `cyclonedx-ruby --path . --format json`
42+
Then a file named "bom.json" should exist
43+
When I run `cyclonedx-ruby --validate --validate-file bom.json --spec-version 1.7`
44+
Then the output should contain:
45+
"""
46+
Validation succeeded for bom.json (spec 1.7)
47+
"""

lib/cyclonedx/bom_builder.rb

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ class BomBuilder
88
def self.build(path)
99
original_working_directory = Dir.pwd
1010
setup(path)
11+
12+
# If asked to validate an existing file, do not generate a new one
13+
if @options[:validate] && @options[:validate_file]
14+
content = begin
15+
File.read(@options[:validate_file])
16+
rescue StandardError => e
17+
@logger.error("Unable to read file for validation: #{@options[:validate_file]}. #{e.message}")
18+
exit(1)
19+
end
20+
# Use explicitly provided format if set, otherwise infer from file extension
21+
format = @options[:bom_output_format] || infer_format_from_path(@options[:validate_file])
22+
success, message = validate_bom_content(content, format, @spec_version)
23+
unless success
24+
@logger.error(message)
25+
exit(1)
26+
end
27+
puts "Validation succeeded for #{@options[:validate_file]} (spec #{@spec_version})" unless @options[:verbose]
28+
return
29+
end
30+
1131
specs_list
1232
bom = build_bom(@gems, @bom_output_format, @spec_version)
1333

@@ -40,7 +60,22 @@ def self.build(path)
4060
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
4161
abort
4262
end
63+
64+
if @options[:validate]
65+
success, message = validate_bom_content(bom, @bom_output_format, @spec_version)
66+
unless success
67+
@logger.error(message)
68+
exit(1)
69+
end
70+
@logger.info("BOM validation succeeded for spec #{@spec_version}") if @options[:verbose]
71+
end
72+
end
73+
74+
# Infer format from file extension when not explicitly provided
75+
def self.infer_format_from_path(path)
76+
File.extname(path).downcase == '.json' ? 'json' : 'xml'
4377
end
78+
4479
private
4580
def self.setup(path)
4681
@options = {}
@@ -62,6 +97,12 @@ def self.setup(path)
6297
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|
6398
@options[:spec_version] = spec_version
6499
end
100+
opts.on('--validate', 'Validate the produced BOM against the selected CycloneDX schema') do
101+
@options[:validate] = true
102+
end
103+
opts.on('--validate-file PATH', 'Validate an existing BOM file instead of generating one') do |path|
104+
@options[:validate_file] = path
105+
end
65106
opts.on_tail('-h', '--help', 'Show help message') do
66107
puts opts
67108
exit
@@ -84,26 +125,31 @@ def self.setup(path)
84125
licenses_file = File.read(licenses_path)
85126
@licenses_list = JSON.parse(licenses_file)
86127

87-
if @options[:path].nil?
88-
@logger.error('missing path to project directory')
89-
abort
90-
end
128+
# If only validating a file, project path is optional; otherwise require
129+
if @options[:validate_file].nil? || !@options[:validate]
130+
if @options[:path].nil?
131+
@logger.error('missing path to project directory')
132+
abort
133+
end
91134

92-
unless File.directory?(@options[:path])
93-
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
94-
abort
135+
unless File.directory?(@options[:path])
136+
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
137+
abort
138+
end
95139
end
96140

97141
# Normalize to an absolute project path to avoid relative path issues later
98-
@project_path = File.expand_path(@options[:path])
142+
@project_path = File.expand_path(@options[:path]) if @options[:path]
99143
@provided_path = @options[:path]
100144

101-
begin
102-
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
103-
Dir.chdir @project_path
104-
rescue StandardError => e
105-
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
106-
abort
145+
if @project_path
146+
begin
147+
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
148+
Dir.chdir @project_path
149+
rescue StandardError => e
150+
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
151+
abort
152+
end
107153
end
108154

109155
if @options[:bom_output_format].nil?
@@ -130,20 +176,22 @@ def self.setup(path)
130176
@options[:bom_file_path]
131177
end
132178

133-
@logger.info("BOM will be written to #{@bom_file_path}")
134-
135-
begin
136-
# Use absolute path so it's correct regardless of current working directory
137-
gemfile_path = File.join(@project_path, 'Gemfile.lock')
138-
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
139-
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
140-
@logger.info("Parsing specs from #{display_gemfile_path}...")
141-
gemfile_contents = File.read(gemfile_path)
142-
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
143-
@logger.info('Specs successfully parsed!')
144-
rescue StandardError => e
145-
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
146-
abort
179+
@logger.info("BOM will be written to #{@bom_file_path}") if @project_path
180+
181+
if @project_path
182+
begin
183+
# Use absolute path so it's correct regardless of current working directory
184+
gemfile_path = File.join(@project_path, 'Gemfile.lock')
185+
# Compute display path for logs: './Gemfile.lock' when provided path is '.', else '<provided>/Gemfile.lock'
186+
display_gemfile_path = (@provided_path == '.' ? './Gemfile.lock' : File.join(@provided_path, 'Gemfile.lock'))
187+
@logger.info("Parsing specs from #{display_gemfile_path}...")
188+
gemfile_contents = File.read(gemfile_path)
189+
@specs = Bundler::LockfileParser.new(gemfile_contents).specs
190+
@logger.info('Specs successfully parsed!')
191+
rescue StandardError => e
192+
@logger.error("Unable to parse specs from #{gemfile_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
193+
abort
194+
end
147195
end
148196
end
149197

lib/cyclonedx/bom_helpers.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,62 @@ def build_bom_xml(gems, spec_version)
104104
builder.to_xml
105105
end
106106

107+
# Validate content against the selected CycloneDX schema (local files, offline)
108+
# Returns [true, nil] on success; [false, "message"] on failure
109+
def validate_bom_content(content, format, spec_version)
110+
schema_dir = File.expand_path("../../schema", __dir__)
111+
case format
112+
when 'json'
113+
schema_path = File.join(schema_dir, "bom-#{spec_version}.schema.json")
114+
begin
115+
schema = JSON.parse(File.read(schema_path))
116+
resolver = lambda do |uri|
117+
begin
118+
u = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
119+
basename = File.basename(u.path.to_s)
120+
local_path = File.join(schema_dir, basename)
121+
return JSON.parse(File.read(local_path)) if File.exist?(local_path)
122+
rescue StandardError
123+
# fall through to unknown ref handling in schemer
124+
end
125+
nil
126+
end
127+
schemer = JSONSchemer.schema(schema, ref_resolver: resolver)
128+
data = JSON.parse(content)
129+
errors = schemer.validate(data).to_a
130+
return [true, nil] if errors.empty?
131+
# Build a compact error message
132+
msgs = errors.first(5).map do |e|
133+
path = Array(e['data_pointer']).join
134+
"#{e['type']}: #{e['message']} at #{path}"
135+
end
136+
[false, "JSON schema validation failed (#{errors.size} errors). First: #{msgs.join('; ')}"]
137+
rescue Errno::ENOENT
138+
[false, "JSON schema not found at #{schema_path}"]
139+
rescue StandardError => e
140+
[false, "JSON schema validation error: #{e.class}: #{e.message}"]
141+
end
142+
else
143+
schema_path = File.join(schema_dir, "bom-#{spec_version}.xsd")
144+
begin
145+
# Use local XML catalog to resolve imports like http://cyclonedx.org/schema/spdx
146+
previous_catalog = ENV['XML_CATALOG_FILES']
147+
ENV['XML_CATALOG_FILES'] = File.join(schema_dir, 'xmlcatalog.xml')
148+
xsd = Nokogiri::XML::Schema(File.read(schema_path))
149+
doc = Nokogiri::XML(content) { |cfg| cfg.nonet }
150+
errors = xsd.validate(doc)
151+
return [true, nil] if errors.empty?
152+
[false, "XML schema validation failed: #{errors.first.message}"]
153+
rescue Errno::ENOENT
154+
[false, "XML schema not found at #{schema_path}"]
155+
rescue StandardError => e
156+
[false, "XML schema validation error: #{e.class}: #{e.message}"]
157+
ensure
158+
ENV['XML_CATALOG_FILES'] = previous_catalog
159+
end
160+
end
161+
end
162+
107163
def get_gem(name, version)
108164
url = "https://rubygems.org/api/v1/versions/#{name}.json"
109165
begin

lib/cyclonedx/ruby.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require 'rest_client'
1212
require 'securerandom'
1313
require 'active_support/core_ext/hash'
14+
require 'json_schemer'
1415

1516
# This gem
1617
require_relative "ruby/version"

0 commit comments

Comments
 (0)