sbom 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a22cb6e2f3394ccd7abb7e8a28d7282f11e4a9277cf1451dca16060d00ceb0f6
4
- data.tar.gz: aaf00ea47acdb68969fb20a638a9300849fe363c1d7e8dfeb1955405d6a9d570
3
+ metadata.gz: '080bc0e7e22560d9779d7dc7914a6fd6d440dee48508da1db84d1c9d03fd3c5b'
4
+ data.tar.gz: 007f185f1ebc3a95bac42ca6419155e9a269bb0b98e1be5a478b6eab5f43e06c
5
5
  SHA512:
6
- metadata.gz: 8151a326269d029319b4dcb69bd61edcf6ef5745a673cda96464bb91afa2103aa5896d4c8e315951e54ac605b031582af3bd1eae0eb2d7f7b673cd4afc8999df
7
- data.tar.gz: 6c47ebcbbdeef79496348fdde56280b90ac6e90bec8e52cded836d95415fa1ec0b8b2ad3874992d3bd7ca9e928114e57cabc1a72d7525e1923faf94db3273d07
6
+ metadata.gz: e67dca4dfb55a94d8f55093938773389206f69d5765748323bed4672da6182d76e6d503bf96bda90bde6c721cabac9b21f061714de005fba6c4d4d25e874e4d5
7
+ data.tar.gz: fa270f7dad9214c3a15c1b1bad23d3982955bd262f4bdb332b6bf73f464ac9e846761f8ef01e4fb34058cbbe3253d80ee1f2c04460fa0e385754364acf75e1e9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2025-12-23
4
+
5
+ - Add `merge` command to CLI for combining multiple SBOMs into one
6
+ - Add `Sbom.merge` and `Sbom.merge_files` library methods
7
+ - Add `Sbom::Merger` class for merging SBOMs with configurable deduplication
8
+ - Merge deduplicates packages by PURL by default, with option to keep all
9
+ - Supports merging across formats (SPDX + CycloneDX)
10
+
3
11
  ## [0.2.0] - 2025-12-14
4
12
 
5
13
  - Add `enrich` command to CLI for enriching SBOMs with data from ecosyste.ms
data/README.md CHANGED
@@ -85,6 +85,23 @@ enriched = Sbom.enrich_file("example.cdx.json")
85
85
 
86
86
  Enrichment adds: description, homepage, download location, license, repository URL, registry URL, documentation URL, supplier info, and security advisories.
87
87
 
88
+ ### Merging SBOMs
89
+
90
+ Combine multiple SBOMs into one:
91
+
92
+ ```ruby
93
+ # Merge from files (dedupes by PURL by default)
94
+ merged = Sbom.merge_files(["app1.cdx.json", "app2.spdx.json"])
95
+
96
+ # Merge SBOM objects
97
+ merged = Sbom.merge([sbom1, sbom2, sbom3])
98
+
99
+ # Keep all packages without deduplication
100
+ merged = Sbom.merge([sbom1, sbom2], dedupe: :none)
101
+ ```
102
+
103
+ Merging works across formats. Packages are deduplicated by PURL by default. Relationships and licenses are also deduplicated.
104
+
88
105
  ### Building Packages
89
106
 
90
107
  The Package class provides an object interface for building package data:
@@ -139,6 +156,11 @@ sbom document query example.cdx.json --license MIT
139
156
  sbom enrich example.cdx.json
140
157
  sbom enrich example.cdx.json --output enriched.json
141
158
  cat example.cdx.json | sbom enrich -
159
+
160
+ # Merge multiple SBOMs
161
+ sbom merge app1.cdx.json app2.spdx.json --output merged.json
162
+ sbom merge app1.json app2.json --no-dedupe
163
+ sbom merge app1.json app2.json --type cyclonedx
142
164
  ```
143
165
 
144
166
  ## Supported Formats
data/exe/sbom CHANGED
@@ -27,6 +27,8 @@ module Sbom
27
27
  convert_command
28
28
  when "enrich"
29
29
  enrich_command
30
+ when "merge"
31
+ merge_command
30
32
  when "document"
31
33
  document_command
32
34
  when "version", "-v", "--version"
@@ -205,6 +207,40 @@ module Sbom
205
207
  abort "Error: #{e.message}"
206
208
  end
207
209
 
210
+ def merge_command
211
+ parser = OptionParser.new do |opts|
212
+ opts.banner = "Usage: sbom merge <file1> <file2> [file3...] [options]"
213
+ opts.on("-t", "--type TYPE", "Output SBOM type (spdx, cyclonedx)") { |v| @options[:type] = v.to_sym }
214
+ opts.on("-f", "--format FORMAT", "Output format (json, yaml, tag)") { |v| @options[:format] = v.to_sym }
215
+ opts.on("-o", "--output FILE", "Output file") { |v| @options[:output] = v }
216
+ opts.on("--no-dedupe", "Keep all packages (don't dedupe by PURL)") { @options[:dedupe] = :none }
217
+ opts.on("-h", "--help", "Show help") { puts opts; exit }
218
+ end
219
+ parser.parse!(@args)
220
+
221
+ abort "Error: At least 2 files required" if @args.size < 2
222
+
223
+ dedupe = @options[:dedupe] || :purl
224
+ merged = Sbom::Merger.merge_files(@args, dedupe: dedupe)
225
+
226
+ output_type = @options[:type] || merged.sbom_type || :spdx
227
+ output_format = @options[:format] || :json
228
+
229
+ generator = Sbom::Generator.new(sbom_type: output_type, format: output_format)
230
+ generator.generate(merged.document&.dig(:name) || "Merged SBOM", merged.to_h)
231
+
232
+ output = generator.output
233
+
234
+ if @options[:output]
235
+ File.write(@options[:output], output)
236
+ puts "Merged SBOM written to #{@options[:output]}"
237
+ else
238
+ puts output
239
+ end
240
+ rescue Sbom::ParserError, Sbom::GeneratorError => e
241
+ abort "Error: #{e.message}"
242
+ end
243
+
208
244
  def document_command
209
245
  subcommand = @args.shift
210
246
 
@@ -377,6 +413,7 @@ module Sbom
377
413
  validate Validate SBOM against schema
378
414
  convert Convert between SBOM formats
379
415
  enrich Enrich SBOM with data from ecosyste.ms
416
+ merge Combine multiple SBOMs into one
380
417
  document Work with SBOM documents
381
418
  version Show version
382
419
 
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbom
4
+ class Merger
5
+ attr_reader :sboms, :options, :result
6
+
7
+ def initialize(sboms, dedupe: :purl)
8
+ @sboms = sboms
9
+ @options = { dedupe: dedupe }
10
+ @result = nil
11
+ end
12
+
13
+ def merge
14
+ @result = Data::Sbom.new(sbom_type: determine_sbom_type)
15
+ @result.version = determine_version
16
+
17
+ build_document
18
+ merge_packages
19
+ merge_files
20
+ merge_relationships
21
+ merge_licenses
22
+ merge_annotations
23
+
24
+ @result
25
+ end
26
+
27
+ def determine_sbom_type
28
+ types = @sboms.map(&:sbom_type).uniq
29
+ return types.first if types.size == 1
30
+
31
+ :spdx
32
+ end
33
+
34
+ def determine_version
35
+ versions = @sboms.map(&:version).compact.uniq
36
+ versions.first
37
+ end
38
+
39
+ def build_document
40
+ names = @sboms.map { |s| s.document&.dig(:name) }.compact
41
+ merged_name = names.any? ? "Merged: #{names.join(', ')}" : "Merged SBOM"
42
+
43
+ @result.document = {
44
+ name: merged_name,
45
+ id: "SPDXRef-DOCUMENT",
46
+ created: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
47
+ }
48
+ end
49
+
50
+ def merge_packages
51
+ seen_purls = {}
52
+
53
+ @sboms.each do |sbom|
54
+ sbom.packages.each do |pkg|
55
+ if @options[:dedupe] == :purl && pkg[:purl]
56
+ next if seen_purls[pkg[:purl]]
57
+
58
+ seen_purls[pkg[:purl]] = true
59
+ end
60
+
61
+ @result.add_package(pkg)
62
+ end
63
+ end
64
+ end
65
+
66
+ def merge_files
67
+ @sboms.each do |sbom|
68
+ sbom.files.each do |file|
69
+ @result.add_file(file)
70
+ end
71
+ end
72
+ end
73
+
74
+ def merge_relationships
75
+ seen = Set.new
76
+
77
+ @sboms.each do |sbom|
78
+ sbom.relationships.each do |rel|
79
+ key = [rel[:source], rel[:type], rel[:target]]
80
+ next if seen.include?(key)
81
+
82
+ seen.add(key)
83
+ @result.add_relationship(rel)
84
+ end
85
+ end
86
+ end
87
+
88
+ def merge_licenses
89
+ seen = Set.new
90
+
91
+ @sboms.each do |sbom|
92
+ sbom.licenses.each do |lic|
93
+ next if seen.include?(lic)
94
+
95
+ seen.add(lic)
96
+ @result.add_license(lic)
97
+ end
98
+ end
99
+ end
100
+
101
+ def merge_annotations
102
+ @sboms.each do |sbom|
103
+ sbom.annotations.each do |ann|
104
+ @result.add_annotation(ann)
105
+ end
106
+ end
107
+ end
108
+
109
+ class << self
110
+ def merge(sboms, dedupe: :purl)
111
+ new(sboms, dedupe: dedupe).merge
112
+ end
113
+
114
+ def merge_files(filenames, dedupe: :purl)
115
+ sboms = filenames.map { |f| Parser.parse_file(f) }
116
+ merge(sboms, dedupe: dedupe)
117
+ end
118
+ end
119
+ end
120
+ end
data/lib/sbom/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sbom
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/sbom.rb CHANGED
@@ -33,6 +33,7 @@ require_relative "sbom/validation_result"
33
33
  require_relative "sbom/validator"
34
34
  require_relative "sbom/output"
35
35
  require_relative "sbom/enricher"
36
+ require_relative "sbom/merger"
36
37
 
37
38
  module Sbom
38
39
  class << self
@@ -60,5 +61,13 @@ module Sbom
60
61
  sbom = parse_file(filename, sbom_type: sbom_type)
61
62
  Enricher.enrich(sbom)
62
63
  end
64
+
65
+ def merge(sboms, dedupe: :purl)
66
+ Merger.merge(sboms, dedupe: dedupe)
67
+ end
68
+
69
+ def merge_files(filenames, dedupe: :purl)
70
+ Merger.merge_files(filenames, dedupe: dedupe)
71
+ end
63
72
  end
64
73
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sbom
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -82,6 +82,7 @@ files:
82
82
  - lib/sbom/generator.rb
83
83
  - lib/sbom/license/data/spdx_licenses.json
84
84
  - lib/sbom/license/scanner.rb
85
+ - lib/sbom/merger.rb
85
86
  - lib/sbom/output.rb
86
87
  - lib/sbom/parser.rb
87
88
  - lib/sbom/spdx/generator.rb