bundler-sbom 0.3.2 → 0.4.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: ec80424de66f7be5b67cd7d4f0e84b716b36f5862595022f7558c6d768f39f55
4
- data.tar.gz: cf38dd73e3a0a16b375653d2fb12fa61d90f09b2105d583d1d354c54367ebcb7
3
+ metadata.gz: b17b7f0d6f921d10fa80cc62402f321077779f4c51b81cc22bad0720f3767576
4
+ data.tar.gz: edecba78478c7979ae85a48b5cdb98175a48c9506d56db8959ebddb5c398abfc
5
5
  SHA512:
6
- metadata.gz: 9031f62301d2d1b4ddfff68ef0be7b58b30bca61f6c173d44ad709a0a43441e665357fb1a1ef3d375a6d7c0c8c1faf2a3f3a0bfc148d3b66933c14adc2dcf3a4
7
- data.tar.gz: ca55f0cb0b4a754e64c7c445d30372e447ed71670bd62a0d77029c64b7ab18fdcf4934c0a4ba84355a4dde10912424d3bf639124411968cfa376be8cce73d38c
6
+ metadata.gz: a450eb2b7aca4e994e888cee8a60d4af2738a8b619f19185f2a4504001af3f12a2ef8bb2b2b8df7254b015c31755b5c9972988e796309469607b51b3ecc848e3
7
+ data.tar.gz: 4b1e698caa08514bd595403948f0a2e989144780b425d3afee08f0b8b6a5e144adab155916b161c1f10aaf9f7bd73d2799d36de3fb2a40a76aad4c0be9ea4526
data/Gemfile CHANGED
@@ -5,8 +5,8 @@ gemspec
5
5
  group :development do
6
6
  gem "thor"
7
7
  gem "rake"
8
- gem "rspec"
8
+ gem "minitest"
9
+ gem "minitest-mock"
9
10
  gem "simplecov", require: false
10
- gem "rspec-its"
11
- gem "rspec-mocks"
11
+ gem "json_schemer"
12
12
  end
data/README.md CHANGED
@@ -26,13 +26,12 @@ Available options:
26
26
  - `--without GROUPS`: Exclude groups (comma or colon separated, e.g., 'development:test' or 'development,test')
27
27
 
28
28
  Generated files will be named according to the following pattern:
29
- - SPDX format: `bom.json` or `bom.xml`
29
+ - SPDX format: `bom.json`
30
30
  - CycloneDX format: `bom-cyclonedx.json` or `bom-cyclonedx.xml`
31
31
 
32
32
  Examples:
33
33
  ```
34
34
  $ bundle sbom dump # Generates SPDX format in JSON (bom.json)
35
- $ bundle sbom dump -f xml # Generates SPDX format in XML (bom.xml)
36
35
  $ bundle sbom dump -s cyclonedx # Generates CycloneDX format in JSON (bom-cyclonedx.json)
37
36
  $ bundle sbom dump -s cyclonedx -f xml # Generates CycloneDX format in XML (bom-cyclonedx.xml)
38
37
  $ bundle sbom dump --without development # Excludes development group
@@ -52,10 +51,9 @@ Available options:
52
51
  - `-F, --format FORMAT`: Input format (json or xml)
53
52
 
54
53
  If no options are specified, the command will automatically look for SBOM files in the following order:
55
- 1. `bom.xml` (if format is xml)
54
+ 1. `bom.json`
56
55
  2. `bom-cyclonedx.json`
57
56
  3. `bom-cyclonedx.xml`
58
- 4. `bom.json`
59
57
 
60
58
  This command will show:
61
59
  - A count of packages using each license
@@ -65,12 +63,23 @@ Note: The `license` command requires that you've already generated the SBOM usin
65
63
 
66
64
  ## Supported SBOM Formats
67
65
 
68
- ### SPDX
66
+ ### SPDX (v2.3)
69
67
  [SPDX (Software Package Data Exchange)](https://spdx.dev/) is a standard format for communicating software bill of material information, including components, licenses, copyrights, and security references.
70
68
 
71
- ### CycloneDX
69
+ - Spec version: [SPDX 2.3](https://spdx.github.io/spdx-spec/v2.3/)
70
+ - Output formats: JSON
71
+ - License identifiers are validated against the [SPDX License List](https://spdx.org/licenses/) via the `spdx-licenses` gem. Non-SPDX licenses are output as `LicenseRef-` identifiers, and deprecated SPDX IDs (e.g., `GPL-2.0`) are mapped to their current equivalents (e.g., `GPL-2.0-only`).
72
+ - `DEPENDS_ON` relationships between packages are emitted from `Gemfile.lock`.
73
+ - `creationInfo.licenseListVersion` reflects the SPDX license list version bundled with the `spdx-licenses` gem.
74
+
75
+ ### CycloneDX (v1.7)
72
76
  [CycloneDX](https://cyclonedx.org/) is a lightweight SBOM specification designed for use in application security contexts and supply chain component analysis.
73
77
 
78
+ - Spec version: [CycloneDX 1.7](https://cyclonedx.org/docs/1.7/json/)
79
+ - Output formats: JSON, XML
80
+ - SPDX license IDs are placed in the `license.id` field, and non-SPDX licenses use the `license.name` field, per the CycloneDX specification.
81
+ - Each component is assigned a `bom-ref` (its purl) and the full dependency graph from `Gemfile.lock` is emitted in the `dependencies` section.
82
+
74
83
  ## References
75
84
 
76
85
  - [SPDX Specification](https://spdx.github.io/spdx-spec/)
data/Rakefile CHANGED
@@ -1,6 +1,9 @@
1
1
  require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
2
+ require "rake/testtask"
3
3
 
4
- RSpec::Core::RakeTask.new(:spec)
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.test_files = FileList["test/**/test_*.rb"]
7
+ end
5
8
 
6
- task default: :spec
9
+ task default: :test
@@ -14,33 +14,29 @@ module Bundler
14
14
  sbom_format = options[:sbom].downcase
15
15
  without_groups = parse_without_groups(options[:without])
16
16
 
17
- # Validate output format
18
17
  unless ["json", "xml"].include?(format)
19
- Bundler.ui.error("Error: Unsupported output format '#{format}'. Supported formats: json, xml")
20
- exit 1
18
+ raise Thor::Error, "Error: Unsupported output format '#{format}'. Supported formats: json, xml"
21
19
  end
22
20
 
23
- # Validate SBOM format
24
21
  unless ["spdx", "cyclonedx"].include?(sbom_format)
25
- Bundler.ui.error("Error: Unsupported SBOM format '#{sbom_format}'. Supported formats: spdx, cyclonedx")
26
- exit 1
22
+ raise Thor::Error, "Error: Unsupported SBOM format '#{sbom_format}'. Supported formats: spdx, cyclonedx"
27
23
  end
28
24
 
29
- # Generate SBOM based on specified format
30
- sbom = Bundler::Sbom::Generator.generate_sbom(sbom_format, without_groups: without_groups)
25
+ if sbom_format == "spdx" && format == "xml"
26
+ raise Thor::Error, "Error: SPDX 2.3 does not define an XML serialization. Use '--format json' or '--sbom cyclonedx --format xml'."
27
+ end
31
28
 
32
- # Determine file extension based on output format
33
- ext = (format == "json") ? "json" : "xml"
29
+ generator = Bundler::Sbom::Generator.new(format: sbom_format, without_groups: without_groups)
30
+ sbom = generator.generate
34
31
 
35
- # Determine filename prefix based on SBOM format
32
+ ext = (format == "json") ? "json" : "xml"
36
33
  prefix = (sbom_format == "spdx") ? "bom" : "bom-cyclonedx"
37
34
  output_file = "#{prefix}.#{ext}"
38
35
 
39
36
  if format == "json"
40
- File.write(output_file, JSON.pretty_generate(sbom))
41
- else # xml
42
- xml_content = Bundler::Sbom::Generator.convert_to_xml(sbom)
43
- File.write(output_file, xml_content)
37
+ File.write(output_file, JSON.pretty_generate(sbom.to_hash))
38
+ else
39
+ File.write(output_file, sbom.to_xml)
44
40
  end
45
41
 
46
42
  Bundler.ui.info("Generated #{sbom_format.upcase} SBOM at #{output_file}")
@@ -53,16 +49,13 @@ module Bundler
53
49
  format = options[:format]&.downcase
54
50
  input_file = options[:file]
55
51
 
56
- # Validate format if provided
57
52
  if format && !["json", "xml"].include?(format)
58
- Bundler.ui.error("Error: Unsupported format '#{format}'. Supported formats: json, xml")
59
- exit 1
53
+ raise Thor::Error, "Error: Unsupported format '#{format}'. Supported formats: json, xml"
60
54
  end
61
55
 
62
- # Determine input file based on format or find default files
63
56
  if input_file.nil?
64
- input_file = if format == "xml" || (format.nil? && File.exist?("bom.xml"))
65
- "bom.xml"
57
+ input_file = if File.exist?("bom.json")
58
+ "bom.json"
66
59
  elsif File.exist?("bom-cyclonedx.json")
67
60
  "bom-cyclonedx.json"
68
61
  elsif File.exist?("bom-cyclonedx.xml")
@@ -75,30 +68,26 @@ module Bundler
75
68
  unless File.exist?(input_file)
76
69
  file_type = (File.extname(input_file) == ".xml") ? "xml" : "json"
77
70
  sbom_type = input_file.include?("cyclonedx") ? "cyclonedx" : "spdx"
78
- Bundler.ui.error("Error: #{input_file} not found. Run 'bundle sbom dump --format=#{file_type} --sbom=#{sbom_type}' first.")
79
- exit 1
71
+ raise Thor::Error, "Error: #{input_file} not found. Run 'bundle sbom dump --format=#{file_type} --sbom=#{sbom_type}' first."
80
72
  end
81
73
 
82
- begin
83
- content = File.read(input_file)
74
+ content = File.read(input_file)
84
75
 
85
- sbom = if format == "xml" || (!format && File.extname(input_file) == ".xml")
86
- Bundler::Sbom::Generator.parse_xml(content)
87
- else
88
- JSON.parse(content)
89
- end
90
-
91
- Bundler::Sbom::Reporter.display_license_report(sbom)
92
- rescue JSON::ParserError
93
- Bundler.ui.error("Error: #{input_file} is not a valid JSON file")
94
- exit 1
95
- rescue => e
96
- Bundler.ui.error("Error processing #{input_file}: #{e.message}")
97
- exit 1
76
+ sbom = if format == "xml" || (!format && File.extname(input_file) == ".xml")
77
+ Bundler::Sbom::Generator.parse_xml(content)
78
+ else
79
+ Bundler::Sbom::Generator.from_hash(JSON.parse(content))
98
80
  end
81
+
82
+ Bundler::Sbom::Reporter.new(sbom).display_license_report
83
+ rescue JSON::ParserError
84
+ raise Thor::Error, "Error: #{input_file} is not a valid JSON file"
85
+ rescue Thor::Error
86
+ raise
87
+ rescue => e
88
+ raise Thor::Error, "Error processing #{input_file}: #{e.message}"
99
89
  end
100
90
 
101
- # 適切にエラーで終了することを保証するためのメソッド
102
91
  def self.exit_on_failure?
103
92
  true
104
93
  end
@@ -108,7 +97,6 @@ module Bundler
108
97
  def parse_without_groups(without_option)
109
98
  return [] unless without_option
110
99
 
111
- # Split by comma or colon and clean up whitespace
112
100
  groups = without_option.split(%r{[:,]}).map(&:strip).reject(&:empty?)
113
101
  groups.map(&:to_sym)
114
102
  end
@@ -1,115 +1,207 @@
1
1
  require "bundler"
2
2
  require "securerandom"
3
- require "rexml/document"
3
+ require "bundler/sbom/sbom_document"
4
4
 
5
5
  module Bundler
6
6
  module Sbom
7
7
  class CycloneDX
8
- def self.generate(gems, document_name)
8
+ include SbomDocument
9
+
10
+ SPEC_VERSION = "1.7"
11
+ XML_NAMESPACE = "http://cyclonedx.org/schema/bom/#{SPEC_VERSION}"
12
+
13
+ def self.generate(gem_data, document_name, direct_dependencies: [])
9
14
  serial_number = SecureRandom.uuid
10
15
  timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
16
+ root_ref = document_name
11
17
  sbom = {
12
18
  "bomFormat" => "CycloneDX",
13
- "specVersion" => "1.4",
19
+ "specVersion" => SPEC_VERSION,
14
20
  "serialNumber" => "urn:uuid:#{serial_number}",
15
21
  "version" => 1,
16
22
  "metadata" => {
17
23
  "timestamp" => timestamp,
18
- "tools" => [
19
- {
20
- "vendor" => "Bundler",
21
- "name" => "bundle-sbom",
22
- "version" => Bundler::Sbom::VERSION
23
- }
24
- ],
24
+ "tools" => {
25
+ "components" => [
26
+ {
27
+ "type" => "application",
28
+ "name" => "bundle-sbom",
29
+ "version" => Bundler::Sbom::VERSION
30
+ }
31
+ ]
32
+ },
25
33
  "component" => {
26
34
  "type" => "application",
35
+ "bom-ref" => root_ref,
27
36
  "name" => document_name,
28
- "version" => "0.0.0" # Default version
37
+ "version" => "0.0.0"
29
38
  }
30
39
  },
31
- "components" => []
40
+ "components" => [],
41
+ "dependencies" => []
32
42
  }
33
43
 
34
- # Deduplicate specs by name and version
35
- seen_gems = Set.new
36
- gems.each do |spec|
37
- gem_key = "#{spec.name}:#{spec.version}"
38
- next if seen_gems.include?(gem_key)
39
- seen_gems.add(gem_key)
40
- licenses = SpecLicenseFinder.find_licenses(spec)
44
+ ref_by_name = {}
45
+ gem_data.each do |gem|
46
+ ref_by_name[gem[:name]] = "pkg:gem/#{gem[:name]}@#{gem[:version]}"
47
+ end
41
48
 
49
+ gem_data.each do |gem|
50
+ purl = ref_by_name[gem[:name]]
42
51
  component = {
43
52
  "type" => "library",
44
- "name" => spec.name,
45
- "version" => spec.version.to_s,
46
- "purl" => "pkg:gem/#{spec.name}@#{spec.version}"
53
+ "bom-ref" => purl,
54
+ "name" => gem[:name],
55
+ "version" => gem[:version],
56
+ "purl" => purl
57
+ }
58
+
59
+ unless gem[:licenses].empty?
60
+ component["licenses"] = gem[:licenses].map { |license| build_license_entry(license) }
61
+ end
62
+
63
+ sbom["components"] << component
64
+
65
+ dep_refs = (gem[:dependencies] || []).filter_map { |name| ref_by_name[name] }
66
+ sbom["dependencies"] << {"ref" => purl, "dependsOn" => dep_refs}
67
+ end
68
+
69
+ root_deps = direct_dependencies.filter_map { |name| ref_by_name[name] }
70
+ sbom["dependencies"].unshift({"ref" => root_ref, "dependsOn" => root_deps})
71
+
72
+ new(sbom)
73
+ end
74
+
75
+ def self.parse_xml(doc)
76
+ root = doc.root
77
+ spec_version = root.namespace.to_s[%r{/bom/(\d+\.\d+)}, 1] || SPEC_VERSION
78
+
79
+ sbom = {
80
+ "bomFormat" => "CycloneDX",
81
+ "specVersion" => spec_version,
82
+ "serialNumber" => root.attributes["serialNumber"],
83
+ "version" => root.attributes["version"].to_i,
84
+ "metadata" => {
85
+ "timestamp" => get_element_text(root, "metadata/timestamp"),
86
+ "tools" => {"components" => []},
87
+ "component" => {
88
+ "type" => REXML::XPath.first(root, "metadata/component").attributes["type"],
89
+ "name" => get_element_text(root, "metadata/component/name"),
90
+ "version" => get_element_text(root, "metadata/component/version")
91
+ }
92
+ },
93
+ "components" => []
94
+ }
95
+
96
+ REXML::XPath.each(root, "metadata/tools/components/component") do |tool|
97
+ sbom["metadata"]["tools"]["components"] << {
98
+ "type" => tool.attributes["type"],
99
+ "name" => get_element_text(tool, "name"),
100
+ "version" => get_element_text(tool, "version")
47
101
  }
102
+ end
48
103
 
49
- unless licenses.empty?
50
- component["licenses"] = licenses.map { |license| {"license" => {"id" => license}} }
104
+ REXML::XPath.each(root, "metadata/tools/tool") do |tool|
105
+ sbom["metadata"]["tools"]["components"] << {
106
+ "type" => "application",
107
+ "name" => get_element_text(tool, "name"),
108
+ "version" => get_element_text(tool, "version")
109
+ }
110
+ end
111
+
112
+ REXML::XPath.each(root, "components/component") do |comp|
113
+ component = {
114
+ "type" => comp.attributes["type"],
115
+ "bom-ref" => comp.attributes["bom-ref"],
116
+ "name" => get_element_text(comp, "name"),
117
+ "version" => get_element_text(comp, "version"),
118
+ "purl" => get_element_text(comp, "purl")
119
+ }.compact
120
+
121
+ licenses = []
122
+ REXML::XPath.each(comp, "licenses/license") do |license|
123
+ license_id = get_element_text(license, "id")
124
+ license_name = get_element_text(license, "name")
125
+ if license_id
126
+ licenses << {"license" => {"id" => license_id}}
127
+ elsif license_name
128
+ licenses << {"license" => {"name" => license_name}}
129
+ end
51
130
  end
52
131
 
132
+ component["licenses"] = licenses unless licenses.empty?
53
133
  sbom["components"] << component
54
134
  end
55
135
 
56
- sbom
136
+ meta_component = REXML::XPath.first(root, "metadata/component")
137
+ if meta_component && meta_component.attributes["bom-ref"]
138
+ sbom["metadata"]["component"]["bom-ref"] = meta_component.attributes["bom-ref"]
139
+ end
140
+
141
+ sbom["dependencies"] = []
142
+ REXML::XPath.each(root, "dependencies/dependency") do |dep|
143
+ depends_on = REXML::XPath.each(dep, "dependency").map { |c| c.attributes["ref"] }
144
+ sbom["dependencies"] << {"ref" => dep.attributes["ref"], "dependsOn" => depends_on}
145
+ end
146
+
147
+ new(sbom)
57
148
  end
58
149
 
59
- def self.to_xml(sbom)
150
+ def to_xml
60
151
  doc = REXML::Document.new
61
152
  doc << REXML::XMLDecl.new("1.0", "UTF-8")
62
153
 
63
- # Root element
64
154
  root = REXML::Element.new("bom")
65
- root.add_namespace("http://cyclonedx.org/schema/bom/1.4")
155
+ root.add_namespace(XML_NAMESPACE)
66
156
  root.add_attributes({
67
- "serialNumber" => sbom["serialNumber"],
68
- "version" => sbom["version"].to_s
157
+ "serialNumber" => @data["serialNumber"],
158
+ "version" => @data["version"].to_s
69
159
  })
70
160
  doc.add_element(root)
71
161
 
72
- # Metadata
73
162
  metadata = REXML::Element.new("metadata")
74
163
  root.add_element(metadata)
75
164
 
76
- add_element(metadata, "timestamp", sbom["metadata"]["timestamp"])
165
+ add_element(metadata, "timestamp", @data["metadata"]["timestamp"])
77
166
 
78
- # Tools
79
167
  tools = REXML::Element.new("tools")
80
168
  metadata.add_element(tools)
81
169
 
82
- sbom["metadata"]["tools"].each do |tool_data|
83
- tool = REXML::Element.new("tool")
84
- tools.add_element(tool)
170
+ tool_components = REXML::Element.new("components")
171
+ tools.add_element(tool_components)
172
+
173
+ each_tool_component do |tool_data|
174
+ tool = REXML::Element.new("component")
175
+ tool.add_attribute("type", tool_data["type"] || "application")
176
+ tool_components.add_element(tool)
85
177
 
86
- add_element(tool, "vendor", tool_data["vendor"])
87
178
  add_element(tool, "name", tool_data["name"])
88
179
  add_element(tool, "version", tool_data["version"].to_s)
89
180
  end
90
181
 
91
- # Component (root project)
92
182
  component = REXML::Element.new("component")
93
- component.add_attribute("type", sbom["metadata"]["component"]["type"])
183
+ component.add_attribute("type", @data["metadata"]["component"]["type"])
184
+ if @data["metadata"]["component"]["bom-ref"]
185
+ component.add_attribute("bom-ref", @data["metadata"]["component"]["bom-ref"])
186
+ end
94
187
  metadata.add_element(component)
95
188
 
96
- add_element(component, "name", sbom["metadata"]["component"]["name"])
97
- add_element(component, "version", sbom["metadata"]["component"]["version"])
189
+ add_element(component, "name", @data["metadata"]["component"]["name"])
190
+ add_element(component, "version", @data["metadata"]["component"]["version"])
98
191
 
99
- # Components
100
192
  components = REXML::Element.new("components")
101
193
  root.add_element(components)
102
194
 
103
- sbom["components"].each do |comp_data|
195
+ @data["components"].each do |comp_data|
104
196
  comp = REXML::Element.new("component")
105
197
  comp.add_attribute("type", comp_data["type"])
198
+ comp.add_attribute("bom-ref", comp_data["bom-ref"]) if comp_data["bom-ref"]
106
199
  components.add_element(comp)
107
200
 
108
201
  add_element(comp, "name", comp_data["name"])
109
202
  add_element(comp, "version", comp_data["version"])
110
203
  add_element(comp, "purl", comp_data["purl"])
111
204
 
112
- # Licenses
113
205
  if comp_data["licenses"] && !comp_data["licenses"].empty?
114
206
  licenses = REXML::Element.new("licenses")
115
207
  comp.add_element(licenses)
@@ -120,90 +212,38 @@ module Bundler
120
212
 
121
213
  if license_data["license"]["id"]
122
214
  add_element(license, "id", license_data["license"]["id"])
215
+ elsif license_data["license"]["name"]
216
+ add_element(license, "name", license_data["license"]["name"])
123
217
  end
124
218
  end
125
219
  end
126
220
  end
127
221
 
128
- formatter = REXML::Formatters::Pretty.new(2)
129
- formatter.compact = true
130
- output = ""
131
- formatter.write(doc, output)
132
- output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
133
- end
222
+ if @data["dependencies"] && !@data["dependencies"].empty?
223
+ deps_el = REXML::Element.new("dependencies")
224
+ root.add_element(deps_el)
134
225
 
135
- def self.parse_xml(doc)
136
- root = doc.root
226
+ @data["dependencies"].each do |dep|
227
+ dep_el = REXML::Element.new("dependency")
228
+ dep_el.add_attribute("ref", dep["ref"])
229
+ deps_el.add_element(dep_el)
137
230
 
138
- sbom = {
139
- "bomFormat" => "CycloneDX",
140
- "specVersion" => "1.4",
141
- "serialNumber" => root.attributes["serialNumber"],
142
- "version" => root.attributes["version"].to_i,
143
- "metadata" => {
144
- "timestamp" => get_element_text(root, "metadata/timestamp"),
145
- "tools" => [],
146
- "component" => {
147
- "type" => REXML::XPath.first(root, "metadata/component").attributes["type"],
148
- "name" => get_element_text(root, "metadata/component/name"),
149
- "version" => get_element_text(root, "metadata/component/version")
150
- }
151
- },
152
- "components" => []
153
- }
154
-
155
- # Collect tools
156
- REXML::XPath.each(root, "metadata/tools/tool") do |tool|
157
- tool_data = {
158
- "vendor" => get_element_text(tool, "vendor"),
159
- "name" => get_element_text(tool, "name"),
160
- "version" => get_element_text(tool, "version")
161
- }
162
- sbom["metadata"]["tools"] << tool_data
163
- end
164
-
165
- # Collect components
166
- REXML::XPath.each(root, "components/component") do |comp|
167
- component = {
168
- "type" => comp.attributes["type"],
169
- "name" => get_element_text(comp, "name"),
170
- "version" => get_element_text(comp, "version"),
171
- "purl" => get_element_text(comp, "purl")
172
- }
173
-
174
- # Collect licenses
175
- licenses = []
176
- REXML::XPath.each(comp, "licenses/license") do |license|
177
- license_id = get_element_text(license, "id")
178
- licenses << {"license" => {"id" => license_id}} if license_id
231
+ (dep["dependsOn"] || []).each do |child_ref|
232
+ child = REXML::Element.new("dependency")
233
+ child.add_attribute("ref", child_ref)
234
+ dep_el.add_element(child)
235
+ end
179
236
  end
180
-
181
- component["licenses"] = licenses unless licenses.empty?
182
- sbom["components"] << component
183
237
  end
184
238
 
185
- # Convert CycloneDX format to SPDX-like format for compatibility with Reporter
186
- {
187
- "packages" => sbom["components"].map do |comp|
188
- license_string = if comp["licenses"]
189
- comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
190
- else
191
- "NOASSERTION"
192
- end
193
- {
194
- "name" => comp["name"],
195
- "versionInfo" => comp["version"],
196
- "licenseDeclared" => license_string
197
- }
198
- end
199
- }
239
+ format_xml(doc)
200
240
  end
201
241
 
202
- def self.to_report_format(sbom)
242
+ def to_report_format
203
243
  {
204
- "packages" => sbom["components"].map do |comp|
244
+ "packages" => @data["components"].map do |comp|
205
245
  license_string = if comp["licenses"]
206
- comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
246
+ comp["licenses"].map { |l| l["license"]["id"] || l["license"]["name"] }.join(", ")
207
247
  else
208
248
  "NOASSERTION"
209
249
  end
@@ -216,18 +256,27 @@ module Bundler
216
256
  }
217
257
  end
218
258
 
219
- private
220
-
221
- def self.add_element(parent, name, value)
222
- element = REXML::Element.new(name)
223
- element.text = value
224
- parent.add_element(element)
259
+ def each_tool_component(&block)
260
+ tools = @data.dig("metadata", "tools")
261
+ case tools
262
+ when Hash
263
+ (tools["components"] || []).each(&block)
264
+ when Array
265
+ tools.each(&block)
266
+ end
225
267
  end
226
268
 
227
- def self.get_element_text(element, xpath)
228
- result = REXML::XPath.first(element, xpath)
229
- result ? result.text : nil
269
+ def self.build_license_entry(license)
270
+ mapped = SPDX::DEPRECATED_LICENSE_MAP[license]
271
+ license = mapped if mapped
272
+
273
+ if SpdxLicenses.exist?(license)
274
+ {"license" => {"id" => license}}
275
+ else
276
+ {"license" => {"name" => license}}
277
+ end
230
278
  end
279
+ private_class_method :build_license_entry
231
280
  end
232
281
  end
233
282
  end
@@ -1,6 +1,5 @@
1
1
  require "bundler"
2
- require "securerandom"
3
- require "json"
2
+ require "set"
4
3
  require "rexml/document"
5
4
  require "bundler/sbom/spdx"
6
5
  require "bundler/sbom/cyclonedx"
@@ -10,7 +9,12 @@ module Bundler
10
9
  class GemfileLockNotFoundError < StandardError; end
11
10
 
12
11
  class Generator
13
- def self.generate_sbom(format = "spdx", without_groups: [])
12
+ def initialize(format: "spdx", without_groups: [])
13
+ @format = format.to_s.downcase
14
+ @without_groups = without_groups
15
+ end
16
+
17
+ def generate
14
18
  lockfile_path = Bundler.default_lockfile
15
19
  if !lockfile_path || !lockfile_path.exist?
16
20
  Bundler.ui.error "No Gemfile.lock found. Run `bundle install` first."
@@ -20,22 +24,15 @@ module Bundler
20
24
  lockfile = Bundler::LockfileParser.new(lockfile_path.read)
21
25
  document_name = File.basename(Dir.pwd)
22
26
 
23
- # Get gems to include based on groups
24
- gems = get_gems_for_groups(lockfile, without_groups)
27
+ gems = get_gems_for_groups(lockfile)
28
+ gem_data = resolve_gem_data(gems)
29
+ direct_dependencies = lockfile.dependencies.keys
25
30
 
26
- case format.to_s.downcase
31
+ case @format
27
32
  when "cyclonedx"
28
- CycloneDX.generate(gems, document_name)
29
- else # default to spdx
30
- SPDX.generate(gems, document_name)
31
- end
32
- end
33
-
34
- def self.convert_to_xml(sbom)
35
- if sbom["bomFormat"] == "CycloneDX"
36
- CycloneDX.to_xml(sbom)
33
+ CycloneDX.generate(gem_data, document_name, direct_dependencies: direct_dependencies)
37
34
  else
38
- SPDX.to_xml(sbom)
35
+ SPDX.generate(gem_data, document_name)
39
36
  end
40
37
  end
41
38
 
@@ -43,34 +40,37 @@ module Bundler
43
40
  doc = REXML::Document.new(xml_content)
44
41
  root = doc.root
45
42
 
46
- # Determine if it's CycloneDX or SPDX
47
43
  if root.name == "bom" && root.namespace.include?("cyclonedx.org")
48
44
  CycloneDX.parse_xml(doc)
49
45
  else
50
- SPDX.parse_xml(doc)
46
+ raise ArgumentError, "Unsupported XML SBOM: only CycloneDX XML can be read"
47
+ end
48
+ end
49
+
50
+ def self.from_hash(hash)
51
+ if hash["bomFormat"] == "CycloneDX"
52
+ CycloneDX.new(hash)
53
+ else
54
+ SPDX.new(hash)
51
55
  end
52
56
  end
53
57
 
54
58
  private
55
59
 
56
- def self.get_gems_for_groups(lockfile, without_groups)
57
- # If no groups specified, use all specs
58
- if without_groups.empty?
60
+ def get_gems_for_groups(lockfile)
61
+ if @without_groups.empty?
59
62
  return lockfile.specs
60
63
  end
61
64
 
62
- # Try to get group information from Bundler.definition if available
63
65
  if defined?(Bundler::Definition) && Bundler.respond_to?(:definition)
64
66
  begin
65
67
  definition = Bundler.definition
66
68
  all_groups = definition.groups
67
- include_groups = all_groups - without_groups
69
+ include_groups = all_groups - @without_groups
68
70
 
69
- # Use specs_for to get all gems (including transitive dependencies) for included groups
70
71
  if definition.respond_to?(:specs_for)
71
72
  definition.specs_for(include_groups)
72
73
  else
73
- # Fallback to old method if specs_for is not available
74
74
  included_gems = Set.new
75
75
  include_groups.each do |group|
76
76
  definition.dependencies_for(group).each do |dep|
@@ -80,7 +80,6 @@ module Bundler
80
80
  lockfile.specs.select { |spec| included_gems.include?(spec.name) }
81
81
  end
82
82
  rescue => e
83
- # Fallback to all specs if there's any issue with Bundler.definition
84
83
  Bundler.ui.warn("Warning: Could not determine group information: #{e.message}")
85
84
  lockfile.specs
86
85
  end
@@ -88,6 +87,26 @@ module Bundler
88
87
  lockfile.specs
89
88
  end
90
89
  end
90
+
91
+ def resolve_gem_data(gems)
92
+ seen = Set.new
93
+ gems.filter_map do |spec|
94
+ gem_key = "#{spec.name}:#{spec.version}"
95
+ next if seen.include?(gem_key)
96
+ seen.add(gem_key)
97
+ {
98
+ name: spec.name,
99
+ version: spec.version.to_s,
100
+ licenses: SpecLicenseFinder.find_licenses(spec),
101
+ dependencies: spec_dependency_names(spec)
102
+ }
103
+ end
104
+ end
105
+
106
+ def spec_dependency_names(spec)
107
+ return [] unless spec.respond_to?(:dependencies) && spec.dependencies
108
+ spec.dependencies.reject { |d| d.respond_to?(:type) && d.type == :development }.map(&:name)
109
+ end
91
110
  end
92
111
  end
93
112
  end
@@ -1,63 +1,55 @@
1
1
  module Bundler
2
2
  module Sbom
3
3
  class Reporter
4
- def self.display_license_report(sbom)
5
- # フォーマットに応じて適切な形式に変換
6
- sbom = if sbom_format(sbom) == :cyclonedx
7
- CycloneDX.to_report_format(sbom)
8
- else
9
- SPDX.to_report_format(sbom)
10
- end
4
+ def initialize(sbom)
5
+ @sbom = sbom
6
+ end
11
7
 
12
- display_report(sbom)
8
+ def display_license_report
9
+ report = @sbom.to_report_format
10
+ display_report(report)
13
11
  end
14
12
 
15
13
  private
16
14
 
17
- def self.sbom_format(sbom)
18
- return :cyclonedx if sbom["bomFormat"] == "CycloneDX"
19
- :spdx
20
- end
21
-
22
- def self.display_report(sbom)
23
- license_count = analyze_licenses(sbom)
15
+ def display_report(report)
16
+ license_count = analyze_licenses(report)
24
17
  sorted_licenses = license_count.sort_by { |_, count| -count }
25
18
 
26
- puts "=== License Usage in SBOM ==="
27
- puts "Total packages: #{sbom["packages"].size}"
28
- puts
19
+ Bundler.ui.info "=== License Usage in SBOM ==="
20
+ Bundler.ui.info "Total packages: #{report["packages"].size}"
21
+ Bundler.ui.info ""
29
22
 
30
23
  sorted_licenses.each do |license, count|
31
- puts "#{license}: #{count} package(s)"
24
+ Bundler.ui.info "#{license}: #{count} package(s)"
32
25
  end
33
26
 
34
- puts "\n=== Packages by License ==="
27
+ Bundler.ui.info "\n=== Packages by License ==="
35
28
  sorted_licenses.each do |license, _|
36
- packages = sbom["packages"].select do |package|
37
- if package["licenseDeclared"].include?(",")
38
- package["licenseDeclared"].split(",").map(&:strip).include?(license)
39
- else
40
- package["licenseDeclared"] == license
41
- end
29
+ packages = report["packages"].select do |package|
30
+ split_licenses(package["licenseDeclared"]).include?(license)
42
31
  end
43
32
 
44
- puts "\n#{license} (#{packages.size} package(s)):"
33
+ Bundler.ui.info "\n#{license} (#{packages.size} package(s)):"
45
34
  packages.each do |package|
46
- puts " - #{package["name"]} (#{package["versionInfo"]})"
35
+ Bundler.ui.info " - #{package["name"]} (#{package["versionInfo"]})"
47
36
  end
48
37
  end
49
38
  end
50
39
 
51
- def self.analyze_licenses(sbom)
40
+ def analyze_licenses(report)
52
41
  license_count = Hash.new(0)
53
- sbom["packages"].each do |package|
54
- licenses = package["licenseDeclared"].split(",").map(&:strip)
55
- licenses.each do |license|
42
+ report["packages"].each do |package|
43
+ split_licenses(package["licenseDeclared"]).each do |license|
56
44
  license_count[license] += 1
57
45
  end
58
46
  end
59
47
  license_count
60
48
  end
49
+
50
+ def split_licenses(license_declared)
51
+ license_declared.split(/ AND |, /).map(&:strip)
52
+ end
61
53
  end
62
54
  end
63
55
  end
@@ -0,0 +1,45 @@
1
+ require "rexml/document"
2
+
3
+ module Bundler
4
+ module Sbom
5
+ module SbomDocument
6
+ def self.included(base)
7
+ base.attr_reader :data
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ def initialize(data)
12
+ @data = data
13
+ end
14
+
15
+ def to_hash
16
+ @data
17
+ end
18
+
19
+ module ClassMethods
20
+ private
21
+
22
+ def get_element_text(element, xpath)
23
+ result = REXML::XPath.first(element, xpath)
24
+ result ? result.text : nil
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def add_element(parent, name, value)
31
+ element = REXML::Element.new(name)
32
+ element.text = value
33
+ parent.add_element(element)
34
+ end
35
+
36
+ def format_xml(doc)
37
+ formatter = REXML::Formatters::Pretty.new(2)
38
+ formatter.compact = true
39
+ output = ""
40
+ formatter.write(doc, output)
41
+ output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,19 +1,21 @@
1
1
  require "bundler"
2
2
  require "securerandom"
3
- require "rexml/document"
3
+ require "spdx-licenses"
4
+ require "bundler/sbom/sbom_document"
4
5
 
5
6
  module Bundler
6
7
  module Sbom
7
8
  class SPDX
8
- def self.generate(gems, document_name)
9
- spdx_id = generate_spdx_id
9
+ include SbomDocument
10
+
11
+ def self.generate(gem_data, document_name)
12
+ spdx_id = SecureRandom.uuid
10
13
  sbom = {
11
14
  "SPDXID" => "SPDXRef-DOCUMENT",
12
15
  "spdxVersion" => "SPDX-2.3",
13
16
  "creationInfo" => {
14
17
  "created" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
15
- "creators" => ["Tool: bundle-sbom"],
16
- "licenseListVersion" => "3.20"
18
+ "creators" => ["Tool: bundle-sbom"]
17
19
  },
18
20
  "name" => document_name,
19
21
  "dataLicense" => "CC0-1.0",
@@ -21,20 +23,24 @@ module Bundler
21
23
  "packages" => []
22
24
  }
23
25
 
24
- # Deduplicate specs by name and version
25
- seen_gems = Set.new
26
- gems.each do |spec|
27
- gem_key = "#{spec.name}:#{spec.version}"
28
- next if seen_gems.include?(gem_key)
29
- seen_gems.add(gem_key)
30
- licenses = SpecLicenseFinder.find_licenses(spec)
31
- license_string = licenses.empty? ? "NOASSERTION" : licenses.join(", ")
26
+ if (list_version = license_list_version)
27
+ sbom["creationInfo"]["licenseListVersion"] = list_version
28
+ end
29
+
30
+ package_ids = {}
31
+ gem_data.each do |gem|
32
+ package_ids[gem[:name]] = "SPDXRef-Package-#{gem[:name]}"
33
+ end
34
+
35
+ gem_data.each do |gem|
36
+ spdx_licenses = gem[:licenses].map { |l| normalize_license_id(l) }
37
+ license_string = spdx_licenses.empty? ? "NOASSERTION" : spdx_licenses.join(" AND ")
32
38
 
33
39
  package = {
34
- "SPDXID" => "SPDXRef-Package-#{spec.name}",
35
- "name" => spec.name,
36
- "versionInfo" => spec.version.to_s,
37
- "downloadLocation" => "NOASSERTION",
40
+ "SPDXID" => package_ids[gem[:name]],
41
+ "name" => gem[:name],
42
+ "versionInfo" => gem[:version],
43
+ "downloadLocation" => "https://rubygems.org/gems/#{gem[:name]}/versions/#{gem[:version]}",
38
44
  "filesAnalyzed" => false,
39
45
  "licenseConcluded" => license_string,
40
46
  "licenseDeclared" => license_string,
@@ -42,9 +48,9 @@ module Bundler
42
48
  "supplier" => "NOASSERTION",
43
49
  "externalRefs" => [
44
50
  {
45
- "referenceCategory" => "PACKAGE_MANAGER",
51
+ "referenceCategory" => "PACKAGE-MANAGER",
46
52
  "referenceType" => "purl",
47
- "referenceLocator" => "pkg:gem/#{spec.name}@#{spec.version}"
53
+ "referenceLocator" => "pkg:gem/#{gem[:name]}@#{gem[:version]}"
48
54
  }
49
55
  ]
50
56
  }
@@ -52,139 +58,66 @@ module Bundler
52
58
  end
53
59
 
54
60
  sbom["documentDescribes"] = sbom["packages"].map { |p| p["SPDXID"] }
55
- sbom
56
- end
57
-
58
- def self.to_xml(sbom)
59
- doc = REXML::Document.new
60
- doc << REXML::XMLDecl.new("1.0", "UTF-8")
61
-
62
- # Root element
63
- root = REXML::Element.new("SpdxDocument")
64
- root.add_namespace("https://spdx.org/spdxdocs/")
65
- doc.add_element(root)
66
-
67
- # Document info
68
- add_element(root, "SPDXID", sbom["SPDXID"])
69
- add_element(root, "spdxVersion", sbom["spdxVersion"])
70
- add_element(root, "name", sbom["name"])
71
- add_element(root, "dataLicense", sbom["dataLicense"])
72
- add_element(root, "documentNamespace", sbom["documentNamespace"])
73
-
74
- # Creation info
75
- creation_info = REXML::Element.new("creationInfo")
76
- root.add_element(creation_info)
77
- add_element(creation_info, "created", sbom["creationInfo"]["created"])
78
- add_element(creation_info, "licenseListVersion", sbom["creationInfo"]["licenseListVersion"])
79
-
80
- sbom["creationInfo"]["creators"].each do |creator|
81
- add_element(creation_info, "creator", creator)
82
- end
83
61
 
84
- # Describes
85
- sbom["documentDescribes"].each do |describes|
86
- add_element(root, "documentDescribes", describes)
62
+ sbom["relationships"] = sbom["packages"].map do |p|
63
+ {
64
+ "spdxElementId" => "SPDXRef-DOCUMENT",
65
+ "relatedSpdxElement" => p["SPDXID"],
66
+ "relationshipType" => "DESCRIBES"
67
+ }
87
68
  end
88
69
 
89
- # Packages
90
- sbom["packages"].each do |pkg|
91
- package = REXML::Element.new("package")
92
- root.add_element(package)
93
-
94
- add_element(package, "SPDXID", pkg["SPDXID"])
95
- add_element(package, "name", pkg["name"])
96
- add_element(package, "versionInfo", pkg["versionInfo"])
97
- add_element(package, "downloadLocation", pkg["downloadLocation"])
98
- add_element(package, "filesAnalyzed", pkg["filesAnalyzed"].to_s)
99
- add_element(package, "licenseConcluded", pkg["licenseConcluded"])
100
- add_element(package, "licenseDeclared", pkg["licenseDeclared"])
101
- add_element(package, "copyrightText", pkg["copyrightText"])
102
- add_element(package, "supplier", pkg["supplier"])
103
-
104
- # External references
105
- if pkg["externalRefs"]
106
- pkg["externalRefs"].each do |ref|
107
- ext_ref = REXML::Element.new("externalRef")
108
- package.add_element(ext_ref)
109
-
110
- add_element(ext_ref, "referenceCategory", ref["referenceCategory"])
111
- add_element(ext_ref, "referenceType", ref["referenceType"])
112
- add_element(ext_ref, "referenceLocator", ref["referenceLocator"])
113
- end
70
+ gem_data.each do |gem|
71
+ (gem[:dependencies] || []).each do |dep_name|
72
+ dep_id = package_ids[dep_name]
73
+ next unless dep_id
74
+ sbom["relationships"] << {
75
+ "spdxElementId" => package_ids[gem[:name]],
76
+ "relatedSpdxElement" => dep_id,
77
+ "relationshipType" => "DEPENDS_ON"
78
+ }
114
79
  end
115
80
  end
116
81
 
117
- formatter = REXML::Formatters::Pretty.new(2)
118
- formatter.compact = true
119
- output = ""
120
- formatter.write(doc, output)
121
- output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
82
+ new(sbom)
122
83
  end
123
84
 
124
- def self.parse_xml(doc)
125
- root = doc.root
126
-
127
- sbom = {
128
- "SPDXID" => get_element_text(root, "SPDXID"),
129
- "spdxVersion" => get_element_text(root, "spdxVersion"),
130
- "name" => get_element_text(root, "name"),
131
- "dataLicense" => get_element_text(root, "dataLicense"),
132
- "documentNamespace" => get_element_text(root, "documentNamespace"),
133
- "creationInfo" => {
134
- "created" => get_element_text(root, "creationInfo/created"),
135
- "licenseListVersion" => get_element_text(root, "creationInfo/licenseListVersion"),
136
- "creators" => []
137
- },
138
- "packages" => [],
139
- "documentDescribes" => []
140
- }
141
-
142
- # Collect creators
143
- REXML::XPath.each(root, "creationInfo/creator") do |creator|
144
- sbom["creationInfo"]["creators"] << creator.text
85
+ DEPRECATED_LICENSE_MAP = {
86
+ "AGPL-3.0" => "AGPL-3.0-only",
87
+ "GPL-2.0" => "GPL-2.0-only",
88
+ "GPL-3.0" => "GPL-3.0-only",
89
+ "LGPL-2.1" => "LGPL-2.1-only",
90
+ "LGPL-3.0" => "LGPL-3.0-only",
91
+ }.freeze
92
+
93
+ def self.normalize_license_id(license_id)
94
+ if mapped = DEPRECATED_LICENSE_MAP[license_id]
95
+ return mapped
145
96
  end
146
97
 
147
- # Collect documentDescribes
148
- REXML::XPath.each(root, "documentDescribes") do |describes|
149
- sbom["documentDescribes"] << describes.text
150
- end
151
-
152
- # Collect packages
153
- REXML::XPath.each(root, "package") do |pkg_element|
154
- package = {
155
- "SPDXID" => get_element_text(pkg_element, "SPDXID"),
156
- "name" => get_element_text(pkg_element, "name"),
157
- "versionInfo" => get_element_text(pkg_element, "versionInfo"),
158
- "downloadLocation" => get_element_text(pkg_element, "downloadLocation"),
159
- "filesAnalyzed" => get_element_text(pkg_element, "filesAnalyzed") == "true",
160
- "licenseConcluded" => get_element_text(pkg_element, "licenseConcluded"),
161
- "licenseDeclared" => get_element_text(pkg_element, "licenseDeclared"),
162
- "copyrightText" => get_element_text(pkg_element, "copyrightText"),
163
- "supplier" => get_element_text(pkg_element, "supplier"),
164
- "externalRefs" => []
165
- }
98
+ return license_id if SpdxLicenses.exist?(license_id)
166
99
 
167
- # Collect external references
168
- REXML::XPath.each(pkg_element, "externalRef") do |ref_element|
169
- ref = {
170
- "referenceCategory" => get_element_text(ref_element, "referenceCategory"),
171
- "referenceType" => get_element_text(ref_element, "referenceType"),
172
- "referenceLocator" => get_element_text(ref_element, "referenceLocator")
173
- }
174
- package["externalRefs"] << ref
175
- end
176
-
177
- sbom["packages"] << package
100
+ if license_id.start_with?("LicenseRef-")
101
+ license_id
102
+ else
103
+ "LicenseRef-#{license_id}"
178
104
  end
179
-
180
- sbom
181
105
  end
106
+ private_class_method :normalize_license_id
107
+
108
+ def self.license_list_version
109
+ return @license_list_version if defined?(@license_list_version)
110
+ gem_spec = Gem.loaded_specs["spdx-licenses"]
111
+ path = File.join(gem_spec.full_gem_path, "licenses.json")
112
+ @license_list_version = JSON.parse(File.read(path))["licenseListVersion"]
113
+ rescue StandardError
114
+ @license_list_version = nil
115
+ end
116
+ private_class_method :license_list_version
182
117
 
183
- def self.to_report_format(sbom)
184
- # SPDXフォーマットは既にレポート形式と互換性があるため、
185
- # packagesセクションだけを抽出して返す
118
+ def to_report_format
186
119
  {
187
- "packages" => sbom["packages"].map do |pkg|
120
+ "packages" => @data["packages"].map do |pkg|
188
121
  {
189
122
  "name" => pkg["name"],
190
123
  "versionInfo" => pkg["versionInfo"],
@@ -193,21 +126,6 @@ module Bundler
193
126
  end
194
127
  }
195
128
  end
196
-
197
- def self.generate_spdx_id
198
- SecureRandom.uuid
199
- end
200
-
201
- def self.add_element(parent, name, value)
202
- element = REXML::Element.new(name)
203
- element.text = value
204
- parent.add_element(element)
205
- end
206
-
207
- def self.get_element_text(element, xpath)
208
- result = REXML::XPath.first(element, xpath)
209
- result ? result.text : nil
210
- end
211
129
  end
212
130
  end
213
131
  end
@@ -21,17 +21,11 @@ module Bundler
21
21
  Bundler.ui.warn("Warning: Could not find license information for #{spec.name} (#{spec.version})")
22
22
  end
23
23
 
24
- licenses = []
25
- if gemspec
26
- if gemspec.respond_to?(:license) && gemspec.license && !gemspec.license.empty?
27
- licenses << gemspec.license
28
- end
29
- if gemspec.respond_to?(:licenses) && gemspec.licenses && !gemspec.licenses.empty?
30
- licenses.concat(gemspec.licenses)
31
- end
32
- licenses.uniq!
24
+ if gemspec && gemspec.respond_to?(:licenses) && gemspec.licenses && !gemspec.licenses.empty?
25
+ gemspec.licenses
26
+ else
27
+ []
33
28
  end
34
- licenses
35
29
  end
36
30
  end
37
31
  end
@@ -1,5 +1,5 @@
1
1
  module Bundler
2
2
  module Sbom
3
- VERSION = "0.3.2"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-sbom
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SHIBATA Hiroshi
@@ -37,7 +37,21 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
- description: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
40
+ - !ruby/object:Gem::Dependency
41
+ name: spdx-licenses
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Generate SBOM(Software Bill of Materials) files with Bundler
41
55
  email:
42
56
  - hsbt@ruby-lang.org
43
57
  executables: []
@@ -53,6 +67,7 @@ files:
53
67
  - lib/bundler/sbom/cyclonedx.rb
54
68
  - lib/bundler/sbom/generator.rb
55
69
  - lib/bundler/sbom/reporter.rb
70
+ - lib/bundler/sbom/sbom_document.rb
56
71
  - lib/bundler/sbom/spdx.rb
57
72
  - lib/bundler/sbom/spec_license_finder.rb
58
73
  - lib/bundler/sbom/version.rb
@@ -63,7 +78,7 @@ licenses:
63
78
  metadata:
64
79
  homepage_uri: https://github.com/hsbt/bundler-sbom
65
80
  source_code_uri: https://github.com/hsbt/bundler-sbom
66
- changelog_uri: https://github.com/hsbt/bundler-sbom/blob/main/CHANGELOG.md
81
+ changelog_uri: https://github.com/hsbt/bundler-sbom/releases
67
82
  bug_tracker_uri: https://github.com/hsbt/bundler-sbom/issues
68
83
  rdoc_options: []
69
84
  require_paths:
@@ -79,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
94
  - !ruby/object:Gem::Version
80
95
  version: '0'
81
96
  requirements: []
82
- rubygems_version: 4.0.6
97
+ rubygems_version: 4.0.10
83
98
  specification_version: 4
84
- summary: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
99
+ summary: Generate SBOM(Software Bill of Materials) files with Bundler
85
100
  test_files: []