sbom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e5779ca3a21e46aa2032845504cda20229928dad1570d5de2a055ffe2a2dae8d
4
+ data.tar.gz: d57cc2a38620373ca402ca34322e0cfcb4d30bfa88b8a6791868c92f87b2393d
5
+ SHA512:
6
+ metadata.gz: 221caf4d995e38991fd6c720b00e662fe90ae7709b7032a35d6592848ce669e5245591ca66866b66b29b68326d78afc213ce5292815b4e0f18cce51dcbcf651d
7
+ data.tar.gz: 1a4e9340fda31c7fff6dddbfc12b912220a7aee701c4e3df860c41c00e26c6c5a7999727017bb7af201a09d5e505b8fd24a67d5188eef0b3450b1efdfbfc50e7
data/.gitmodules ADDED
@@ -0,0 +1,7 @@
1
+ [submodule "spec/cyclonedx"]
2
+ path = spec/cyclonedx
3
+ url = https://github.com/CycloneDX/specification
4
+ [submodule "spec/spdx"]
5
+ path = spec/spdx
6
+ url = https://github.com/spdx/spdx-spec
7
+ branch = support/2.3.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-14
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "sbom" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["andrewnez@gmail.com"](mailto:"andrewnez@gmail.com").
data/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # SBOM
2
+
3
+ A Ruby library for parsing, generating, and validating Software Bill of Materials in SPDX and CycloneDX formats.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'sbom'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install sbom
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Parsing SBOMs
22
+
23
+ ```ruby
24
+ require 'sbom'
25
+
26
+ # Parse from file (auto-detects format)
27
+ sbom = Sbom.parse_file("example.spdx.json")
28
+
29
+ # Parse from string
30
+ sbom = Sbom.parse_string(content, sbom_type: :cyclonedx)
31
+
32
+ # Parsed data is returned as hashes
33
+ sbom.packages.each do |pkg|
34
+ puts "#{pkg[:name]} @ #{pkg[:version]}"
35
+ puts " License: #{pkg[:license_concluded]}"
36
+ end
37
+
38
+ sbom.relationships.each do |rel|
39
+ puts "#{rel[:source_id]} --[#{rel[:type]}]--> #{rel[:target_id]}"
40
+ end
41
+ ```
42
+
43
+ ### Generating SBOMs
44
+
45
+ ```ruby
46
+ # Generate SPDX JSON
47
+ generator = Sbom::Generator.new(sbom_type: :spdx, format: :json)
48
+ generator.generate("MyProject", { packages: packages_data })
49
+ puts generator.output
50
+
51
+ # Generate CycloneDX
52
+ generator = Sbom::Generator.new(sbom_type: :cyclonedx)
53
+ generator.generate("MyProject", sbom_data)
54
+ File.write("sbom.cdx.json", generator.output)
55
+ ```
56
+
57
+ ### Validating SBOMs
58
+
59
+ ```ruby
60
+ result = Sbom.validate_file("example.cdx.json")
61
+
62
+ if result.valid?
63
+ puts "#{result.format}: version #{result.version}"
64
+ else
65
+ puts "Invalid: #{result.errors.join(', ')}"
66
+ end
67
+
68
+ # Or raise on invalid
69
+ Sbom::Validator.validate_file!("example.cdx.json")
70
+ ```
71
+
72
+ ### Building Packages
73
+
74
+ The Package class provides an object interface for building package data:
75
+
76
+ ```ruby
77
+ package = Sbom::Data::Package.new
78
+ package.name = "rails"
79
+ package.version = "7.0.0"
80
+ package.license_concluded = "MIT"
81
+ package.add_checksum("SHA256", "abc123...")
82
+
83
+ # Generate a PURL
84
+ package.generate_purl(type: "gem")
85
+ # => "pkg:gem/rails@7.0.0"
86
+
87
+ # Or set an existing PURL
88
+ package.purl = "pkg:npm/%40angular/core@16.0.0"
89
+
90
+ # Access parsed PURL components
91
+ package.purl_type # => "npm"
92
+ package.purl_namespace # => "@angular"
93
+ package.purl_name # => "core"
94
+ package.purl_version # => "16.0.0"
95
+
96
+ # Convert to hash for generation
97
+ package.to_h
98
+ ```
99
+
100
+ ## CLI
101
+
102
+ ```bash
103
+ # Parse and display SBOM
104
+ sbom parse example.spdx.json
105
+ sbom parse example.cdx.json --format json
106
+
107
+ # Validate SBOM against schema
108
+ sbom validate example.spdx.json
109
+
110
+ # Convert between formats
111
+ sbom convert example.spdx.json --type cyclonedx --output example.cdx.json
112
+
113
+ # Generate new SBOM
114
+ sbom generate --name MyProject --type spdx --format json
115
+
116
+ # Document commands
117
+ sbom document outline example.cdx.json
118
+ sbom document info example.spdx.json
119
+ sbom document query example.cdx.json --package rails
120
+ sbom document query example.cdx.json --license MIT
121
+ ```
122
+
123
+ ## Supported Formats
124
+
125
+ **SPDX** (versions 2.2, 2.3):
126
+ - Tag-Value (.spdx)
127
+ - JSON (.spdx.json)
128
+ - YAML (.spdx.yaml, .spdx.yml)
129
+ - XML (.spdx.xml)
130
+ - RDF (.spdx.rdf)
131
+
132
+ **CycloneDX** (versions 1.4, 1.5, 1.6, 1.7):
133
+ - JSON (.cdx.json, .bom.json)
134
+ - XML (.cdx.xml, .bom.xml)
135
+
136
+ ## Related Libraries
137
+
138
+ - [purl](https://github.com/andrew/purl) - Package URL (PURL) parsing and generation
139
+ - [vers](https://github.com/andrew/vers) - Version range parsing and matching
140
+
141
+ ## Development
142
+
143
+ After checking out the repo, run `bin/setup` to install dependencies. Then run `rake test` to run the tests.
144
+
145
+ The project uses git submodules for the official SPDX and CycloneDX specifications:
146
+
147
+ ```bash
148
+ git submodule update --init --recursive
149
+ ```
150
+
151
+ ## Contributing
152
+
153
+ Bug reports and pull requests are welcome on GitHub at https://github.com/andrew/sbom.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/exe/sbom ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "sbom"
6
+
7
+ module Sbom
8
+ class CLI
9
+ def initialize(args)
10
+ @args = args
11
+ @options = {}
12
+ end
13
+
14
+ def run
15
+ return show_help if @args.empty?
16
+
17
+ command = @args.shift
18
+
19
+ case command
20
+ when "parse"
21
+ parse_command
22
+ when "generate"
23
+ generate_command
24
+ when "validate"
25
+ validate_command
26
+ when "convert"
27
+ convert_command
28
+ when "document"
29
+ document_command
30
+ when "version", "-v", "--version"
31
+ puts "sbom #{Sbom::VERSION}"
32
+ when "help", "-h", "--help"
33
+ show_help
34
+ else
35
+ warn "Unknown command: #{command}"
36
+ show_help
37
+ exit 1
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def parse_command
44
+ parser = OptionParser.new do |opts|
45
+ opts.banner = "Usage: sbom parse <file> [options]"
46
+ opts.on("-t", "--type TYPE", "SBOM type (spdx, cyclonedx, auto)") { |v| @options[:type] = v.to_sym }
47
+ opts.on("-f", "--format FORMAT", "Output format (json, yaml, summary)") { |v| @options[:format] = v }
48
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
49
+ end
50
+ parser.parse!(@args)
51
+
52
+ file = @args.shift
53
+ abort "Error: No file specified" unless file
54
+
55
+ sbom_type = @options[:type] || :auto
56
+ sbom = Sbom::Parser.parse_file(file, sbom_type: sbom_type)
57
+
58
+ format = @options[:format] || "summary"
59
+
60
+ case format
61
+ when "json"
62
+ puts JSON.pretty_generate(sbom.to_h)
63
+ when "yaml"
64
+ puts sbom.to_h.to_yaml
65
+ else
66
+ print_summary(sbom)
67
+ end
68
+ rescue Sbom::ParserError => e
69
+ abort "Parse error: #{e.message}"
70
+ end
71
+
72
+ def generate_command
73
+ parser = OptionParser.new do |opts|
74
+ opts.banner = "Usage: sbom generate [options]"
75
+ opts.on("-n", "--name NAME", "Project name") { |v| @options[:name] = v }
76
+ opts.on("-t", "--type TYPE", "SBOM type (spdx, cyclonedx)") { |v| @options[:type] = v.to_sym }
77
+ opts.on("-f", "--format FORMAT", "Output format (tag, json, yaml)") { |v| @options[:format] = v.to_sym }
78
+ opts.on("-o", "--output FILE", "Output file") { |v| @options[:output] = v }
79
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
80
+ end
81
+ parser.parse!(@args)
82
+
83
+ name = @options[:name] || "SBOM"
84
+ sbom_type = @options[:type] || :spdx
85
+ format = @options[:format] || :json
86
+
87
+ generator = Sbom::Generator.new(sbom_type: sbom_type, format: format)
88
+
89
+ sbom_data = { packages: {} }
90
+ generator.generate(name, sbom_data)
91
+
92
+ output = generator.output
93
+
94
+ if @options[:output]
95
+ File.write(@options[:output], output)
96
+ puts "SBOM written to #{@options[:output]}"
97
+ else
98
+ puts output
99
+ end
100
+ end
101
+
102
+ def validate_command
103
+ parser = OptionParser.new do |opts|
104
+ opts.banner = "Usage: sbom validate <file> [options]"
105
+ opts.on("-t", "--type TYPE", "SBOM type (spdx, cyclonedx, auto)") { |v| @options[:type] = v.to_sym }
106
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
107
+ end
108
+ parser.parse!(@args)
109
+
110
+ file = @args.shift
111
+ abort "Error: No file specified" unless file
112
+
113
+ sbom_type = @options[:type] || :auto
114
+ validator = Sbom::Validator.new(sbom_type: sbom_type)
115
+ result = validator.validate_file(file)
116
+
117
+ if result.valid?
118
+ puts "#{result.format.to_s.upcase}: Valid (version #{result.version})"
119
+ else
120
+ puts "#{result.format&.to_s&.upcase || 'SBOM'}: INVALID"
121
+ result.errors.each { |e| puts " - #{e}" }
122
+ exit 1
123
+ end
124
+ rescue Sbom::ValidatorError => e
125
+ abort "Validation error: #{e.message}"
126
+ end
127
+
128
+ def convert_command
129
+ parser = OptionParser.new do |opts|
130
+ opts.banner = "Usage: sbom convert <file> [options]"
131
+ opts.on("-t", "--type TYPE", "Output SBOM type (spdx, cyclonedx)") { |v| @options[:type] = v.to_sym }
132
+ opts.on("-f", "--format FORMAT", "Output format (tag, json, yaml)") { |v| @options[:format] = v.to_sym }
133
+ opts.on("-o", "--output FILE", "Output file") { |v| @options[:output] = v }
134
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
135
+ end
136
+ parser.parse!(@args)
137
+
138
+ file = @args.shift
139
+ abort "Error: No file specified" unless file
140
+
141
+ sbom = Sbom::Parser.parse_file(file)
142
+
143
+ output_type = @options[:type] || :spdx
144
+ output_format = @options[:format] || :json
145
+
146
+ generator = Sbom::Generator.new(sbom_type: output_type, format: output_format)
147
+ generator.generate(sbom.document&.dig(:name) || "Converted", sbom.to_h)
148
+
149
+ output = generator.output
150
+
151
+ if @options[:output]
152
+ File.write(@options[:output], output)
153
+ puts "Converted SBOM written to #{@options[:output]}"
154
+ else
155
+ puts output
156
+ end
157
+ rescue Sbom::ParserError, Sbom::GeneratorError => e
158
+ abort "Conversion error: #{e.message}"
159
+ end
160
+
161
+ def document_command
162
+ subcommand = @args.shift
163
+
164
+ case subcommand
165
+ when "outline"
166
+ document_outline_command
167
+ when "query"
168
+ document_query_command
169
+ when "info"
170
+ document_info_command
171
+ else
172
+ puts "Usage: sbom document <subcommand>"
173
+ puts ""
174
+ puts "Subcommands:"
175
+ puts " outline Show structure of an SBOM document"
176
+ puts " query Search for information in an SBOM"
177
+ puts " info Show document metadata"
178
+ exit 1
179
+ end
180
+ end
181
+
182
+ def document_outline_command
183
+ parser = OptionParser.new do |opts|
184
+ opts.banner = "Usage: sbom document outline <file>"
185
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
186
+ end
187
+ parser.parse!(@args)
188
+
189
+ file = @args.shift
190
+ abort "Error: No file specified" unless file
191
+
192
+ sbom = Sbom::Parser.parse_file(file)
193
+
194
+ puts "SBOM Structure"
195
+ puts "=============="
196
+ puts ""
197
+ puts "Type: #{sbom.sbom_type}"
198
+ puts "Version: #{sbom.version}"
199
+ puts ""
200
+
201
+ if sbom.document
202
+ puts "Document:"
203
+ puts " Name: #{sbom.document[:name]}"
204
+ puts " ID: #{sbom.document[:id]}"
205
+ puts ""
206
+ end
207
+
208
+ puts "Packages (#{sbom.packages.count}):"
209
+ sbom.packages.each_with_index do |pkg, i|
210
+ puts " #{i + 1}. #{pkg[:name]}#{pkg[:version] ? " @ #{pkg[:version]}" : ""}"
211
+ end
212
+ puts ""
213
+
214
+ if sbom.files.any?
215
+ puts "Files (#{sbom.files.count}):"
216
+ sbom.files.each_with_index do |file_data, i|
217
+ puts " #{i + 1}. #{file_data[:name]}"
218
+ end
219
+ puts ""
220
+ end
221
+
222
+ puts "Relationships (#{sbom.relationships.count}):"
223
+ sbom.relationships.each do |rel|
224
+ puts " #{rel[:source]} --[#{rel[:type]}]--> #{rel[:target]}"
225
+ end
226
+ rescue Sbom::ParserError => e
227
+ abort "Error: #{e.message}"
228
+ end
229
+
230
+ def document_query_command
231
+ parser = OptionParser.new do |opts|
232
+ opts.banner = "Usage: sbom document query <file> [options]"
233
+ opts.on("-p", "--package NAME", "Find package by name") { |v| @options[:package] = v }
234
+ opts.on("-l", "--license LICENSE", "Find packages with license") { |v| @options[:license] = v }
235
+ opts.on("--purl PURL", "Find package by PURL") { |v| @options[:purl] = v }
236
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
237
+ end
238
+ parser.parse!(@args)
239
+
240
+ file = @args.shift
241
+ abort "Error: No file specified" unless file
242
+
243
+ sbom = Sbom::Parser.parse_file(file)
244
+
245
+ if @options[:package]
246
+ results = sbom.packages.select { |p| p[:name]&.include?(@options[:package]) }
247
+ puts "Packages matching '#{@options[:package]}':"
248
+ results.each do |pkg|
249
+ puts " - #{pkg[:name]} @ #{pkg[:version]}"
250
+ puts " License: #{pkg[:license_concluded] || pkg[:license_declared] || 'Unknown'}"
251
+ end
252
+ elsif @options[:license]
253
+ results = sbom.packages.select do |p|
254
+ (p[:license_concluded] || p[:license_declared])&.include?(@options[:license])
255
+ end
256
+ puts "Packages with license '#{@options[:license]}':"
257
+ results.each { |pkg| puts " - #{pkg[:name]} @ #{pkg[:version]}" }
258
+ elsif @options[:purl]
259
+ results = sbom.packages.select { |p| p[:purl]&.include?(@options[:purl]) }
260
+ puts "Packages matching PURL '#{@options[:purl]}':"
261
+ results.each { |pkg| puts " - #{pkg[:name]} @ #{pkg[:version]}" }
262
+ else
263
+ puts "Specify a query option. Use --help for options."
264
+ end
265
+ rescue Sbom::ParserError => e
266
+ abort "Error: #{e.message}"
267
+ end
268
+
269
+ def document_info_command
270
+ parser = OptionParser.new do |opts|
271
+ opts.banner = "Usage: sbom document info <file>"
272
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
273
+ end
274
+ parser.parse!(@args)
275
+
276
+ file = @args.shift
277
+ abort "Error: No file specified" unless file
278
+
279
+ sbom = Sbom::Parser.parse_file(file)
280
+
281
+ puts "Document Information"
282
+ puts "===================="
283
+ puts ""
284
+ puts "Format: #{sbom.sbom_type.to_s.upcase}"
285
+ puts "Version: #{sbom.version}"
286
+
287
+ if sbom.document
288
+ doc = sbom.document
289
+ puts "Name: #{doc[:name]}" if doc[:name]
290
+ puts "ID: #{doc[:id]}" if doc[:id]
291
+ puts "Namespace: #{doc[:namespace]}" if doc[:namespace]
292
+ puts "Created: #{doc[:created]}" if doc[:created]
293
+ puts "Supplier: #{doc[:metadata_supplier]}" if doc[:metadata_supplier]
294
+ end
295
+
296
+ puts ""
297
+ puts "Statistics:"
298
+ puts " Packages: #{sbom.packages.count}"
299
+ puts " Files: #{sbom.files.count}"
300
+ puts " Relationships: #{sbom.relationships.count}"
301
+
302
+ licenses = sbom.packages.map { |p| p[:license_concluded] || p[:license_declared] }.compact.uniq
303
+ if licenses.any?
304
+ puts ""
305
+ puts "Licenses found:"
306
+ licenses.each { |lic| puts " - #{lic}" }
307
+ end
308
+ rescue Sbom::ParserError => e
309
+ abort "Error: #{e.message}"
310
+ end
311
+
312
+ def print_summary(sbom)
313
+ puts "SBOM Summary"
314
+ puts "============"
315
+ puts "Type: #{sbom.sbom_type}"
316
+ puts "Packages: #{sbom.packages.count}"
317
+ puts "Files: #{sbom.files.count}"
318
+ puts "Relationships: #{sbom.relationships.count}"
319
+ end
320
+
321
+ def show_help
322
+ puts <<~HELP
323
+ sbom - Software Bill of Materials tool
324
+
325
+ Usage: sbom <command> [options]
326
+
327
+ Commands:
328
+ parse Parse and display SBOM contents
329
+ generate Create a new SBOM
330
+ validate Validate SBOM against schema
331
+ convert Convert between SBOM formats
332
+ document Work with SBOM documents
333
+ version Show version
334
+
335
+ Document subcommands:
336
+ outline Show structure of an SBOM
337
+ query Search for information in an SBOM
338
+ info Show document metadata
339
+
340
+ Run 'sbom <command> --help' for more information on a command.
341
+ HELP
342
+ end
343
+ end
344
+ end
345
+
346
+ Sbom::CLI.new(ARGV).run