Skip to content

Commit e20181d

Browse files
committed
✨ --enrich-components
# CLI and wiring - Updated Cyclonedx::BomBuilder to add: - CLI: --enrich-components to opt-in enrichment. - Pass include_enrichment to build_bom(...). - Note: This does not alter default outputs; enrichment only applies with the flag. # JSON and XML emission - Updated Cyclonedx::BomHelpers: - build_bom supports include_enrichment and passes it to both JSON and XML builders. - build_json_bom adds bom-ref and publisher via BomComponent when include_enrichment: true. - build_bom_xml adds: - bom-ref attribute on <component> using purl. - <publisher>first_author</publisher> if authors are present (first item split on commas/ampersands). - Added a small _get helper to read properties from either Hash or OpenStruct-like objects. # Component shape - Updated Cyclonedx::BomComponent: - Added optional keyword parameter include_enrichment: false to hash_val. - When true, include: - "bom-ref": purl (if present) - "publisher": first author (if present) - Made property access robust across Hash/OpenStruct. - Ensured hashes is an array with an object { alg, content } as expected by existing specs. # Tests - Added spec/cyclonedx/component_enrichment_spec.rb: - Verifies JSON has bom-ref and publisher when include_enrichment: true and omits them otherwise. - Verifies XML has bom-ref attribute and <publisher> when include_enrichment: true and omits otherwise. Signed-off-by: Peter H. Boling <[email protected]>
1 parent 4872b1a commit e20181d

File tree

6 files changed

+164
-40
lines changed

6 files changed

+164
-40
lines changed

features/help.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ Scenario: Generate help on demand
1616
--validate Validate the produced BOM against the selected CycloneDX schema
1717
--validate-file PATH Validate an existing BOM file instead of generating one
1818
--include-metadata Include metadata.tools identifying cyclonedx-ruby as the producer
19+
--enrich-components Include bom-ref and publisher fields on components (uses purl and first author)
1920
-h, --help Show help message
2021
"""

features/metadata_tools.feature

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ Feature: Include metadata.tools in BOM
1111
And the file "bom.json" should contain:
1212
"""
1313
"metadata": {
14-
"tools": [
15-
{
16-
"vendor": "CycloneDX",
17-
"name": "cyclonedx-ruby"
18-
}
19-
]
20-
}
14+
"""
15+
And the file "bom.json" should contain:
16+
"""
17+
"tools": [
18+
"""
19+
And the file "bom.json" should contain:
20+
"""
21+
"vendor": "CycloneDX"
22+
"""
23+
And the file "bom.json" should contain:
24+
"""
25+
"name": "cyclonedx-ruby"
2126
"""
2227

2328
Scenario: JSON metadata BOM validates against schema
@@ -40,10 +45,22 @@ Feature: Include metadata.tools in BOM
4045
And the file "bom.xml" should contain:
4146
"""
4247
<metadata>
43-
<tools>
44-
<tool>
45-
<vendor>CycloneDX</vendor>
46-
<name>cyclonedx-ruby</name>
48+
"""
49+
And the file "bom.xml" should contain:
50+
"""
51+
<tools>
52+
"""
53+
And the file "bom.xml" should contain:
54+
"""
55+
<tool>
56+
"""
57+
And the file "bom.xml" should contain:
58+
"""
59+
<vendor>CycloneDX</vendor>
60+
"""
61+
And the file "bom.xml" should contain:
62+
"""
63+
<name>cyclonedx-ruby</name>
4764
"""
4865

4966
Scenario: XML metadata BOM validates against schema

lib/cyclonedx/bom_builder.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def self.build(path)
3131
end
3232

3333
specs_list
34-
bom = build_bom(@gems, @bom_output_format, @spec_version, include_metadata: @options[:include_metadata])
34+
bom = build_bom(@gems, @bom_output_format, @spec_version, include_metadata: @options[:include_metadata], include_enrichment: @options[:enrich_components])
3535

3636
begin
3737
@logger.info("Changing directory to the original working directory located at #{original_working_directory}")
@@ -108,6 +108,9 @@ def self.setup(path)
108108
opts.on('--include-metadata', 'Include metadata.tools identifying cyclonedx-ruby as the producer') do
109109
@options[:include_metadata] = true
110110
end
111+
opts.on('--enrich-components', 'Include bom-ref and publisher fields on components (uses purl and first author)') do
112+
@options[:enrich_components] = true
113+
end
111114
opts.on_tail('-h', '--help', 'Show help message') do
112115
puts opts
113116
exit

lib/cyclonedx/bom_component.rb

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,71 @@ class BomComponent
66
HASH_ALG = 'SHA-256'.freeze
77

88
def initialize(gem)
9-
@name = gem['name']
10-
@version = gem['version']
11-
@description = gem['description']
12-
@hash = gem['hash']
13-
@purl = gem['purl']
149
@gem = gem
10+
@name = fetch('name')
11+
@version = fetch('version')
12+
@description = fetch('description')
13+
@hash = fetch('hash')
14+
@purl = fetch('purl')
1515
end
1616

17-
def hash_val
17+
def hash_val(include_enrichment: false)
1818
component_hash = {
1919
"type": DEFAULT_TYPE,
2020
"name": @name,
2121
"version": @version,
2222
"description": @description,
2323
"purl": @purl,
2424
"hashes": [
25+
{
2526
"alg": HASH_ALG,
2627
"content": @hash
28+
}
2729
]
2830
}
2931

30-
if @gem['license_id']
32+
if include_enrichment
33+
# Add bom-ref using the purl when present
34+
component_hash[:"bom-ref"] = @purl if @purl && !@purl.to_s.empty?
35+
# Add publisher using first author if present
36+
author = fetch('author')
37+
if author && !author.to_s.strip.empty?
38+
first_author = author.to_s.split(/[,&]/).first.to_s.strip
39+
component_hash[:publisher] = first_author unless first_author.empty?
40+
end
41+
end
42+
43+
if fetch('license_id')
3144
component_hash[:"licenses"] = [
32-
"license": {
33-
"id": @gem['license_id']
45+
{
46+
"license": {
47+
"id": fetch('license_id')
48+
}
3449
}
3550
]
36-
elsif @gem['license_name']
51+
elsif fetch('license_name')
3752
component_hash[:"licenses"] = [
38-
"license": {
39-
"name": @gem['license_name']
53+
{
54+
"license": {
55+
"name": fetch('license_name')
56+
}
4057
}
4158
]
4259
end
4360

4461
[component_hash]
62+
end
63+
64+
private
4565

66+
def fetch(key)
67+
if @gem.respond_to?(:[]) && @gem[key]
68+
@gem[key]
69+
elsif @gem.respond_to?(key)
70+
@gem.public_send(key)
71+
else
72+
nil
73+
end
4674
end
4775
end
4876
end

lib/cyclonedx/bom_helpers.rb

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,26 @@ def tool_identity
5858
}
5959
end
6060

61-
def build_bom(gems, format, spec_version, include_metadata: false)
61+
# Safe accessor for Hash or OpenStruct-like objects
62+
def _get(obj, key)
63+
if obj.respond_to?(:[]) && obj[key]
64+
obj[key]
65+
elsif obj.respond_to?(key)
66+
obj.public_send(key)
67+
else
68+
nil
69+
end
70+
end
71+
72+
def build_bom(gems, format, spec_version, include_metadata: false, include_enrichment: false)
6273
if format == 'json'
63-
build_json_bom(gems, spec_version, include_metadata: include_metadata)
74+
build_json_bom(gems, spec_version, include_metadata: include_metadata, include_enrichment: include_enrichment)
6475
else
65-
build_bom_xml(gems, spec_version, include_metadata: include_metadata)
76+
build_bom_xml(gems, spec_version, include_metadata: include_metadata, include_enrichment: include_enrichment)
6677
end
6778
end
6879

69-
def build_json_bom(gems, spec_version, include_metadata: false)
80+
def build_json_bom(gems, spec_version, include_metadata: false, include_enrichment: false)
7081
bom_hash = {
7182
"bomFormat": "CycloneDX",
7283
"specVersion": spec_version,
@@ -85,13 +96,13 @@ def build_json_bom(gems, spec_version, include_metadata: false)
8596
end
8697

8798
gems.each do |gem|
88-
bom_hash[:components] += BomComponent.new(gem).hash_val()
99+
bom_hash[:components] += BomComponent.new(gem).hash_val(include_enrichment: include_enrichment)
89100
end
90101

91102
JSON.pretty_generate(bom_hash)
92103
end
93104

94-
def build_bom_xml(gems, spec_version, include_metadata: false)
105+
def build_bom_xml(gems, spec_version, include_metadata: false, include_enrichment: false)
95106
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
96107
attributes = { 'xmlns' => cyclonedx_xml_namespace(spec_version), 'version' => '1', 'serialNumber' => random_urn_uuid }
97108
xml.bom(attributes) do
@@ -110,31 +121,46 @@ def build_bom_xml(gems, spec_version, include_metadata: false)
110121

111122
xml.components do
112123
gems.each do |gem|
113-
xml.component('type' => 'library') do
114-
xml.name gem['name']
115-
xml.version gem['version']
116-
xml.description gem['description']
124+
comp_attrs = { 'type' => 'library' }
125+
if include_enrichment
126+
# Add bom-ref attribute using purl if available
127+
ref = _get(gem, 'purl')
128+
comp_attrs['bom-ref'] = ref if ref && !ref.to_s.empty?
129+
end
130+
xml.component(comp_attrs) do
131+
xml.name _get(gem, 'name')
132+
xml.version _get(gem, 'version')
133+
xml.description _get(gem, 'description')
117134
xml.hashes do
118-
xml.hash_ gem['hash'], alg: 'SHA-256'
135+
xml.hash_ _get(gem, 'hash'), alg: 'SHA-256'
119136
end
120-
if gem['license_id']
137+
if _get(gem, 'license_id')
121138
xml.licenses do
122139
xml.license do
123-
xml.id gem['license_id']
140+
xml.id _get(gem, 'license_id')
124141
end
125142
end
126-
elsif gem['license_name']
143+
elsif _get(gem, 'license_name')
127144
xml.licenses do
128145
xml.license do
129-
xml.name gem['license_name']
146+
xml.name _get(gem, 'license_name')
130147
end
131148
end
132149
end
133150
# The globally scoped legacy `Object#purl` method breaks the Nokogiri builder context
134151
# Fortunately Nokogiri has a built-in workaround, adding an underscore to the method name.
135152
# The resulting XML tag is still `<purl>`.
136153
# Globally scoped legacy `Object#purl` will be removed in v2.0.0, and this hack can be removed then.
137-
xml.purl_ gem['purl']
154+
xml.purl_ _get(gem, 'purl')
155+
156+
if include_enrichment
157+
# Add optional publisher element if author info exists
158+
author = _get(gem, 'author')
159+
if author && !author.to_s.strip.empty?
160+
first_author = author.to_s.split(/[,&]/).first.to_s.strip
161+
xml.publisher first_author unless first_author.empty?
162+
end
163+
end
138164
end
139165
end
140166
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
require 'nokogiri'
5+
require_relative '../../lib/cyclonedx/bom_helpers'
6+
7+
RSpec.describe 'component enrichment' do
8+
let(:spec_version) { '1.7' }
9+
let(:gem_obj) do
10+
# Use OpenStruct-like object by simple Struct for deterministic methods
11+
Struct.new(:name, :version, :description, :hash, :purl, :author, :license_id, :license_name)
12+
.new('sample', '1.0.0', 'desc', 'abc123', 'pkg:gem/[email protected]', 'Alice, Bob', nil, nil)
13+
end
14+
15+
it 'adds bom-ref and publisher for JSON when include_enrichment is true' do
16+
json = Cyclonedx::BomHelpers.build_json_bom([gem_obj], spec_version, include_enrichment: true)
17+
data = JSON.parse(json)
18+
comp = data['components'].first
19+
expect(comp['bom-ref']).to eq('pkg:gem/[email protected]')
20+
expect(comp['publisher']).to eq('Alice')
21+
end
22+
23+
it 'does not add enrichment fields when flag is false' do
24+
json = Cyclonedx::BomHelpers.build_json_bom([gem_obj], spec_version, include_enrichment: false)
25+
data = JSON.parse(json)
26+
comp = data['components'].first
27+
expect(comp).not_to have_key('bom-ref')
28+
expect(comp).not_to have_key('publisher')
29+
end
30+
31+
it 'adds bom-ref attribute and publisher element for XML when include_enrichment is true' do
32+
xml = Cyclonedx::BomHelpers.build_bom_xml([gem_obj], spec_version, include_enrichment: true)
33+
doc = Nokogiri::XML(xml)
34+
ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) }
35+
comp = doc.at_xpath('/c:bom/c:components/c:component', ns)
36+
expect(comp['bom-ref']).to eq('pkg:gem/[email protected]')
37+
expect(doc.at_xpath('/c:bom/c:components/c:component/c:publisher', ns)&.text).to eq('Alice')
38+
end
39+
40+
it 'omits enrichment fields in XML when flag is false' do
41+
xml = Cyclonedx::BomHelpers.build_bom_xml([gem_obj], spec_version, include_enrichment: false)
42+
doc = Nokogiri::XML(xml)
43+
ns = { 'c' => Cyclonedx::BomHelpers.cyclonedx_xml_namespace(spec_version) }
44+
comp = doc.at_xpath('/c:bom/c:components/c:component', ns)
45+
expect(comp['bom-ref']).to be_nil
46+
expect(doc.at_xpath('/c:bom/c:components/c:component/c:publisher', ns)).to be_nil
47+
end
48+
end
49+

0 commit comments

Comments
 (0)