Skip to content

Commit d307c8b

Browse files
committed
✨ --enrich-components
- 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. - 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. - 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. - 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 01ec4b1 commit d307c8b

File tree

6 files changed

+169
-44
lines changed

6 files changed

+169
-44
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}")
@@ -109,6 +109,9 @@ def self.setup(path)
109109
opts.on('--include-metadata', 'Include metadata.tools identifying cyclonedx-ruby as the producer') do
110110
@options[:include_metadata] = true
111111
end
112+
opts.on('--enrich-components', 'Include bom-ref and publisher fields on components (uses purl and first author)') do
113+
@options[:enrich_components] = true
114+
end
112115
opts.on_tail('-h', '--help', 'Show help message') do
113116
puts opts
114117
exit

lib/cyclonedx/bom_component.rb

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,71 @@ class BomComponent
66
HASH_ALG = 'SHA-256'
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-
alg: HASH_ALG,
26-
content: @hash
25+
{
26+
alg: HASH_ALG,
27+
content: @hash
28+
}
2729
]
2830
}
2931

30-
if @gem['license_id']
31-
component_hash[:licenses] = [
32-
license: {
33-
id: @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')
44+
component_hash[:"licenses"] = [
45+
{
46+
"license": {
47+
"id": fetch('license_id')
48+
}
3449
}
3550
]
36-
elsif @gem['license_name']
37-
component_hash[:licenses] = [
38-
license: {
39-
name: @gem['license_name']
51+
elsif fetch('license_name')
52+
component_hash[:"licenses"] = [
53+
{
54+
"license": {
55+
"name": fetch('license_name')
56+
}
4057
}
4158
]
4259
end
4360

4461
[component_hash]
4562
end
63+
64+
private
65+
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
74+
end
4675
end
4776
end

lib/cyclonedx/bom_helpers.rb

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

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

68-
def build_json_bom(gems, spec_version, include_metadata: false)
79+
def build_json_bom(gems, spec_version, include_metadata: false, include_enrichment: false)
6980
bom_hash = {
7081
bomFormat: 'CycloneDX',
7182
specVersion: spec_version,
@@ -84,13 +95,13 @@ def build_json_bom(gems, spec_version, include_metadata: false)
8495
end
8596

8697
gems.each do |gem|
87-
bom_hash[:components] += Cyclonedx::BomComponent.new(gem).hash_val
98+
bom_hash[:components] += Cyclonedx::BomComponent.new(gem).hash_val(include_enrichment: include_enrichment)
8899
end
89100

90101
JSON.pretty_generate(bom_hash)
91102
end
92103

93-
def build_bom_xml(gems, spec_version, include_metadata: false)
104+
def build_bom_xml(gems, spec_version, include_metadata: false, include_enrichment: false)
94105
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
95106
attributes = { 'xmlns' => cyclonedx_xml_namespace(spec_version), 'version' => '1', 'serialNumber' => random_urn_uuid }
96107
xml.bom(attributes) do
@@ -109,31 +120,46 @@ def build_bom_xml(gems, spec_version, include_metadata: false)
109120

110121
xml.components do
111122
gems.each do |gem|
112-
xml.component('type' => 'library') do
113-
xml.name gem['name']
114-
xml.version gem['version']
115-
xml.description gem['description']
123+
comp_attrs = { 'type' => 'library' }
124+
if include_enrichment
125+
# Add bom-ref attribute using purl if available
126+
ref = _get(gem, 'purl')
127+
comp_attrs['bom-ref'] = ref if ref && !ref.to_s.empty?
128+
end
129+
xml.component(comp_attrs) do
130+
xml.name _get(gem, 'name')
131+
xml.version _get(gem, 'version')
132+
xml.description _get(gem, 'description')
116133
xml.hashes do
117-
xml.hash_ gem['hash'], alg: 'SHA-256'
134+
xml.hash_ _get(gem, 'hash'), alg: 'SHA-256'
118135
end
119-
if gem['license_id']
136+
if _get(gem, 'license_id')
120137
xml.licenses do
121138
xml.license do
122-
xml.id gem['license_id']
139+
xml.id _get(gem, 'license_id')
123140
end
124141
end
125-
elsif gem['license_name']
142+
elsif _get(gem, 'license_name')
126143
xml.licenses do
127144
xml.license do
128-
xml.name gem['license_name']
145+
xml.name _get(gem, 'license_name')
129146
end
130147
end
131148
end
132149
# The globally scoped legacy `Object#purl` method breaks the Nokogiri builder context
133150
# Fortunately Nokogiri has a built-in workaround, adding an underscore to the method name.
134151
# The resulting XML tag is still `<purl>`.
135152
# Globally scoped legacy `Object#purl` will be removed in v2.0.0, and this hack can be removed then.
136-
xml.purl_ gem['purl']
153+
xml.purl_ _get(gem, 'purl')
154+
155+
if include_enrichment
156+
# Add optional publisher element if author info exists
157+
author = _get(gem, 'author')
158+
if author && !author.to_s.strip.empty?
159+
first_author = author.to_s.split(/[,&]/).first.to_s.strip
160+
xml.publisher first_author unless first_author.empty?
161+
end
162+
end
137163
end
138164
end
139165
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)