Skip to content

Commit c156a77

Browse files
committed
✨ --validate
- 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 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. - 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 schema/ and surfaces compact error messages; returns non-zero exit on failure. - Added json_schemer (~> 2.2) to cyclonedx-ruby.gemspec. - Required json_schemer in lib/cyclonedx/ruby.rb. - 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). - Infer format from file extension when using --validate-file and no --format provided. Signed-off-by: Peter H. Boling <[email protected]>
1 parent 21398eb commit c156a77

File tree

9 files changed

+200
-59
lines changed

9 files changed

+200
-59
lines changed

Gemfile.lock

Lines changed: 8 additions & 28 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)
@@ -67,20 +68,19 @@ GEM
6768
domain_name (0.6.20240107)
6869
drb (2.2.3)
6970
ffi (1.17.2)
70-
ffi (1.17.2-aarch64-linux-gnu)
71-
ffi (1.17.2-aarch64-linux-musl)
72-
ffi (1.17.2-arm-linux-gnu)
73-
ffi (1.17.2-arm-linux-musl)
74-
ffi (1.17.2-arm64-darwin)
75-
ffi (1.17.2-x86_64-darwin)
7671
ffi (1.17.2-x86_64-linux-gnu)
77-
ffi (1.17.2-x86_64-linux-musl)
72+
hana (1.3.7)
7873
http-accept (1.7.0)
7974
http-cookie (1.1.0)
8075
domain_name (~> 0.5)
8176
i18n (1.14.7)
8277
concurrent-ruby (~> 1.0)
8378
json (2.15.2)
79+
json_schemer (2.4.0)
80+
bigdecimal
81+
hana (~> 1.3)
82+
regexp_parser (~> 2.0)
83+
simpleidn (~> 0.2)
8484
language_server-protocol (3.17.0.5)
8585
lint_roller (1.1.0)
8686
logger (1.7.0)
@@ -93,22 +93,8 @@ GEM
9393
minitest (5.26.0)
9494
multi_test (1.1.0)
9595
netrc (0.11.0)
96-
nokogiri (1.18.10-aarch64-linux-gnu)
97-
racc (~> 1.4)
98-
nokogiri (1.18.10-aarch64-linux-musl)
99-
racc (~> 1.4)
100-
nokogiri (1.18.10-arm-linux-gnu)
101-
racc (~> 1.4)
102-
nokogiri (1.18.10-arm-linux-musl)
103-
racc (~> 1.4)
104-
nokogiri (1.18.10-arm64-darwin)
105-
racc (~> 1.4)
106-
nokogiri (1.18.10-x86_64-darwin)
107-
racc (~> 1.4)
10896
nokogiri (1.18.10-x86_64-linux-gnu)
10997
racc (~> 1.4)
110-
nokogiri (1.18.10-x86_64-linux-musl)
111-
racc (~> 1.4)
11298
ostruct (0.5.5)
11399
parallel (1.27.0)
114100
parser (3.3.10.0)
@@ -159,6 +145,7 @@ GEM
159145
simplecov_json_formatter (~> 0.1)
160146
simplecov-html (0.13.2)
161147
simplecov_json_formatter (0.1.4)
148+
simpleidn (0.2.3)
162149
stone_checksums (1.0.3)
163150
version_gem (~> 1.1, >= 1.1.9)
164151
sys-uname (1.4.1)
@@ -173,14 +160,7 @@ GEM
173160
version_gem (1.1.9)
174161

175162
PLATFORMS
176-
aarch64-linux-gnu
177-
aarch64-linux-musl
178-
arm-linux-gnu
179-
arm-linux-musl
180-
arm64-darwin
181-
x86_64-darwin
182163
x86_64-linux-gnu
183-
x86_64-linux-musl
184164

185165
DEPENDENCIES
186166
aruba (~> 2.2)

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
@@ -61,6 +61,7 @@ Gem::Specification.new do |spec|
6161
spec.add_dependency('ostruct', '~> 0.5.5')
6262
spec.add_dependency('rest-client', '~> 2.0')
6363
spec.add_dependency('activesupport', '~> 7.0')
64+
spec.add_dependency('json_schemer', '~> 2.2')
6465
spec.add_development_dependency 'rake', '~> 13'
6566
spec.add_development_dependency 'rspec', '~> 3.12'
6667
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: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ class BomBuilder
1010
def self.build(path)
1111
original_working_directory = Dir.pwd
1212
setup(path)
13+
14+
# If asked to validate an existing file, do not generate a new one
15+
if @options[:validate] && @options[:validate_file]
16+
content = begin
17+
File.read(@options[:validate_file])
18+
rescue StandardError => e
19+
@logger.error("Unable to read file for validation: #{@options[:validate_file]}. #{e.message}")
20+
exit(1)
21+
end
22+
# Use explicitly provided format if set, otherwise infer from file extension
23+
format = @options[:bom_output_format] || infer_format_from_path(@options[:validate_file])
24+
success, message = validate_bom_content(content, format, @spec_version)
25+
unless success
26+
@logger.error(message)
27+
exit(1)
28+
end
29+
puts "Validation succeeded for #{@options[:validate_file]} (spec #{@spec_version})" unless @options[:verbose]
30+
return
31+
end
32+
1333
specs_list
1434
bom = build_bom(@gems, @bom_output_format, @spec_version)
1535

@@ -42,8 +62,24 @@ def self.build(path)
4262
@logger.error("Unable to write BOM to #{@bom_file_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
4363
abort
4464
end
65+
66+
if @options[:validate]
67+
success, message = validate_bom_content(bom, @bom_output_format, @spec_version)
68+
unless success
69+
@logger.error(message)
70+
exit(1)
71+
end
72+
@logger.info("BOM validation succeeded for spec #{@spec_version}") if @options[:verbose]
73+
end
74+
end
75+
76+
# Infer format from file extension when not explicitly provided
77+
def self.infer_format_from_path(path)
78+
File.extname(path).downcase == '.json' ? 'json' : 'xml'
4579
end
4680

81+
private
82+
4783
def self.setup(path)
4884
@options = {}
4985
OptionParser.new do |opts|
@@ -64,6 +100,12 @@ def self.setup(path)
64100
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|
65101
@options[:spec_version] = spec_version
66102
end
103+
opts.on('--validate', 'Validate the produced BOM against the selected CycloneDX schema') do
104+
@options[:validate] = true
105+
end
106+
opts.on('--validate-file PATH', 'Validate an existing BOM file instead of generating one') do |path|
107+
@options[:validate_file] = path
108+
end
67109
opts.on_tail('-h', '--help', 'Show help message') do
68110
puts opts
69111
exit
@@ -86,26 +128,31 @@ def self.setup(path)
86128
licenses_file = File.read(licenses_path)
87129
@licenses_list = JSON.parse(licenses_file)
88130

89-
if @options[:path].nil?
90-
@logger.error('missing path to project directory')
91-
abort
92-
end
131+
# If only validating a file, project path is optional; otherwise require
132+
if @options[:validate_file].nil? || !@options[:validate]
133+
if @options[:path].nil?
134+
@logger.error('missing path to project directory')
135+
abort
136+
end
93137

94-
unless File.directory?(@options[:path])
95-
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
96-
abort
138+
unless File.directory?(@options[:path])
139+
@logger.error("path provided is not a valid directory. path provided was: #{@options[:path]}")
140+
abort
141+
end
97142
end
98143

99144
# Normalize to an absolute project path to avoid relative path issues later
100-
@project_path = File.expand_path(@options[:path])
145+
@project_path = File.expand_path(@options[:path]) if @options[:path]
101146
@provided_path = @options[:path]
102147

103-
begin
104-
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
105-
Dir.chdir @project_path
106-
rescue StandardError => e
107-
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
108-
abort
148+
if @project_path
149+
begin
150+
@logger.info("Changing directory to Ruby project directory located at #{@provided_path}")
151+
Dir.chdir @project_path
152+
rescue StandardError => e
153+
@logger.error("Unable to change directory to Ruby project directory located at #{@provided_path}. #{e.message}: #{Array(e.backtrace).join("\n")}")
154+
abort
155+
end
109156
end
110157

111158
if @options[:bom_output_format].nil?
@@ -132,20 +179,22 @@ def self.setup(path)
132179
@options[:bom_file_path]
133180
end
134181

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

lib/cyclonedx/bom_helpers.rb

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

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