bundler-sbom 0.1.5 → 0.1.7

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: bb32eb6e21ec3b09fe1d3d199b978c44313f119ee57bb9d3899b20ae443020c0
4
- data.tar.gz: 36b4b1b637fbafa93ac463418e5e26330146cff96b7c2e76733bc7c5b4a2cfc9
3
+ metadata.gz: d5d7fea7d39796fd36fec03e1e5b13695fdccb0cfc6926cfb5a8631d86f94f09
4
+ data.tar.gz: fe3abc900c32f11af2b0c1f432c9b376042b48a9d8e69c293ccc76b7383a97b0
5
5
  SHA512:
6
- metadata.gz: 39825222126b541eaf3df0b04c5b9784ce7cdc68d6fd837f642cad6e6ce5e6196a709a31154565f97efaea0ab8f1f483417daada360e331990a675880f3f75e6
7
- data.tar.gz: f95ddbec1b92a9eeffd0732f31d30005e3fb36d85f3c381454977a7130e24630e32fe4d6fbfbf005f3cefaaa35d5bc264c92aa29403d64aff33050797727505b
6
+ metadata.gz: 8b79df0b6ab84ce586694fffcf9b9a25e4de2586f85b0430be60f93404df699675e5890936a4ea963f528ed7526b6ffb9e9afe591ee8c8b327952981457654a0
7
+ data.tar.gz: dec35c52cf5cd36d1b0692a3eb6bb73431f2e07836226af366fd7a8d0c58c964c9577545bf10c01e5178333acbf75a299a63f205bbc3496c557835af5473496b
data/Gemfile CHANGED
@@ -3,6 +3,7 @@ source "https://rubygems.org"
3
3
  gemspec
4
4
 
5
5
  group :development do
6
+ gem "thor"
6
7
  gem "rake"
7
8
  gem "rspec"
8
9
  gem "simplecov", require: false
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SHIBATA Hiroshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,31 +1,105 @@
1
1
  require "json"
2
2
  require "bundler/sbom/generator"
3
+ require "bundler/sbom/reporter"
3
4
 
4
5
  module Bundler
5
6
  module Sbom
6
7
  class CLI < Thor
7
- desc "dump", "Generate SBOM and save to bom.json"
8
+ desc "dump", "Generate SBOM and save to file"
9
+ method_option :format, type: :string, default: "json", desc: "Output format: json or xml", aliases: "-f"
10
+ method_option :sbom, type: :string, default: "spdx", desc: "SBOM format: spdx or cyclonedx", aliases: "-s"
8
11
  def dump
9
- sbom = Bundler::Sbom::Generator.generate_sbom
10
- File.write("bom.json", JSON.pretty_generate(sbom))
11
- Bundler.ui.info("Generated SBOM at bom.json")
12
- end
12
+ format = options[:format].downcase
13
+ sbom_format = options[:sbom].downcase
13
14
 
14
- desc "license", "Display license report from existing bom.json"
15
+ # Validate output format
16
+ unless ["json", "xml"].include?(format)
17
+ Bundler.ui.error("Error: Unsupported output format '#{format}'. Supported formats: json, xml")
18
+ exit 1
19
+ end
20
+
21
+ # Validate SBOM format
22
+ unless ["spdx", "cyclonedx"].include?(sbom_format)
23
+ Bundler.ui.error("Error: Unsupported SBOM format '#{sbom_format}'. Supported formats: spdx, cyclonedx")
24
+ exit 1
25
+ end
26
+
27
+ # Generate SBOM based on specified format
28
+ sbom = Bundler::Sbom::Generator.generate_sbom(sbom_format)
29
+
30
+ # Determine file extension based on output format
31
+ ext = format == "json" ? "json" : "xml"
32
+
33
+ # Determine filename prefix based on SBOM format
34
+ prefix = sbom_format == "spdx" ? "bom" : "bom-cyclonedx"
35
+ output_file = "#{prefix}.#{ext}"
36
+
37
+ if format == "json"
38
+ File.write(output_file, JSON.pretty_generate(sbom))
39
+ else # xml
40
+ xml_content = Bundler::Sbom::Generator.convert_to_xml(sbom)
41
+ File.write(output_file, xml_content)
42
+ end
43
+
44
+ Bundler.ui.info("Generated #{sbom_format.upcase} SBOM at #{output_file}")
45
+ end
46
+
47
+ desc "license", "Display license report from SBOM file"
48
+ method_option :file, type: :string, desc: "Input SBOM file path", aliases: "-f"
49
+ method_option :format, type: :string, desc: "Input format: json or xml", aliases: "-F"
15
50
  def license
16
- unless File.exist?("bom.json")
17
- Bundler.ui.error("Error: bom.json not found. Run 'bundle sbom dump' first.")
51
+ format = options[:format]&.downcase
52
+ input_file = options[:file]
53
+
54
+ # Validate format if provided
55
+ if format && !["json", "xml"].include?(format)
56
+ Bundler.ui.error("Error: Unsupported format '#{format}'. Supported formats: json, xml")
57
+ exit 1
58
+ end
59
+
60
+ # Determine input file based on format or find default files
61
+ if input_file.nil?
62
+ if format == "xml" || (format.nil? && File.exist?("bom.xml"))
63
+ input_file = "bom.xml"
64
+ elsif File.exist?("bom-cyclonedx.json")
65
+ input_file = "bom-cyclonedx.json"
66
+ elsif File.exist?("bom-cyclonedx.xml")
67
+ input_file = "bom-cyclonedx.xml"
68
+ else
69
+ input_file = "bom.json"
70
+ end
71
+ end
72
+
73
+ unless File.exist?(input_file)
74
+ file_type = File.extname(input_file) == ".xml" ? "xml" : "json"
75
+ sbom_type = input_file.include?("cyclonedx") ? "cyclonedx" : "spdx"
76
+ Bundler.ui.error("Error: #{input_file} not found. Run 'bundle sbom dump --format=#{file_type} --sbom=#{sbom_type}' first.")
18
77
  exit 1
19
78
  end
20
79
 
21
80
  begin
22
- sbom = JSON.parse(File.read("bom.json"))
23
- Bundler::Sbom::Generator.display_license_report(sbom)
81
+ content = File.read(input_file)
82
+
83
+ sbom = if format == "xml" || (!format && File.extname(input_file) == ".xml")
84
+ Bundler::Sbom::Generator.parse_xml(content)
85
+ else
86
+ JSON.parse(content)
87
+ end
88
+
89
+ Bundler::Sbom::Reporter.display_license_report(sbom)
24
90
  rescue JSON::ParserError
25
- Bundler.ui.error("Error: bom.json is not a valid JSON file")
91
+ Bundler.ui.error("Error: #{input_file} is not a valid JSON file")
92
+ exit 1
93
+ rescue StandardError => e
94
+ Bundler.ui.error("Error processing #{input_file}: #{e.message}")
26
95
  exit 1
27
96
  end
28
97
  end
98
+
99
+ # 適切にエラーで終了することを保証するためのメソッド
100
+ def self.exit_on_failure?
101
+ true
102
+ end
29
103
  end
30
104
  end
31
105
  end
@@ -0,0 +1,244 @@
1
+ require "bundler"
2
+ require "securerandom"
3
+ require "rexml/document"
4
+
5
+ module Bundler
6
+ module Sbom
7
+ class CycloneDX
8
+ def self.generate(lockfile, document_name)
9
+ serial_number = SecureRandom.uuid
10
+ timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
11
+ sbom = {
12
+ "bomFormat" => "CycloneDX",
13
+ "specVersion" => "1.4",
14
+ "serialNumber" => "urn:uuid:#{serial_number}",
15
+ "version" => 1,
16
+ "metadata" => {
17
+ "timestamp" => timestamp,
18
+ "tools" => [
19
+ {
20
+ "vendor" => "Bundler",
21
+ "name" => "bundle-sbom",
22
+ "version" => Bundler::Sbom::VERSION
23
+ }
24
+ ],
25
+ "component" => {
26
+ "type" => "application",
27
+ "name" => document_name,
28
+ "version" => "0.0.0" # Default version
29
+ }
30
+ },
31
+ "components" => []
32
+ }
33
+
34
+ lockfile.specs.each do |spec|
35
+ begin
36
+ gemspec = Gem::Specification.find_by_name(spec.name, spec.version)
37
+ licenses = []
38
+ if gemspec
39
+ if gemspec.license && !gemspec.license.empty?
40
+ licenses << gemspec.license
41
+ end
42
+ if gemspec.licenses && !gemspec.licenses.empty?
43
+ licenses.concat(gemspec.licenses)
44
+ end
45
+ licenses.uniq!
46
+ end
47
+ rescue Gem::LoadError
48
+ licenses = []
49
+ end
50
+
51
+ component = {
52
+ "type" => "library",
53
+ "name" => spec.name,
54
+ "version" => spec.version.to_s,
55
+ "purl" => "pkg:gem/#{spec.name}@#{spec.version}"
56
+ }
57
+
58
+ unless licenses.empty?
59
+ component["licenses"] = licenses.map { |license| { "license" => { "id" => license } } }
60
+ end
61
+
62
+ sbom["components"] << component
63
+ end
64
+
65
+ sbom
66
+ end
67
+
68
+ def self.to_xml(sbom)
69
+ doc = REXML::Document.new
70
+ doc << REXML::XMLDecl.new("1.0", "UTF-8")
71
+
72
+ # Root element
73
+ root = REXML::Element.new("bom")
74
+ root.add_namespace("http://cyclonedx.org/schema/bom/1.4")
75
+ root.add_attributes({
76
+ "serialNumber" => sbom["serialNumber"],
77
+ "version" => sbom["version"].to_s,
78
+ })
79
+ doc.add_element(root)
80
+
81
+ # Metadata
82
+ metadata = REXML::Element.new("metadata")
83
+ root.add_element(metadata)
84
+
85
+ add_element(metadata, "timestamp", sbom["metadata"]["timestamp"])
86
+
87
+ # Tools
88
+ tools = REXML::Element.new("tools")
89
+ metadata.add_element(tools)
90
+
91
+ sbom["metadata"]["tools"].each do |tool_data|
92
+ tool = REXML::Element.new("tool")
93
+ tools.add_element(tool)
94
+
95
+ add_element(tool, "vendor", tool_data["vendor"])
96
+ add_element(tool, "name", tool_data["name"])
97
+ add_element(tool, "version", tool_data["version"].to_s)
98
+ end
99
+
100
+ # Component (root project)
101
+ component = REXML::Element.new("component")
102
+ component.add_attribute("type", sbom["metadata"]["component"]["type"])
103
+ metadata.add_element(component)
104
+
105
+ add_element(component, "name", sbom["metadata"]["component"]["name"])
106
+ add_element(component, "version", sbom["metadata"]["component"]["version"])
107
+
108
+ # Components
109
+ components = REXML::Element.new("components")
110
+ root.add_element(components)
111
+
112
+ sbom["components"].each do |comp_data|
113
+ comp = REXML::Element.new("component")
114
+ comp.add_attribute("type", comp_data["type"])
115
+ components.add_element(comp)
116
+
117
+ add_element(comp, "name", comp_data["name"])
118
+ add_element(comp, "version", comp_data["version"])
119
+ add_element(comp, "purl", comp_data["purl"])
120
+
121
+ # Licenses
122
+ if comp_data["licenses"] && !comp_data["licenses"].empty?
123
+ licenses = REXML::Element.new("licenses")
124
+ comp.add_element(licenses)
125
+
126
+ comp_data["licenses"].each do |license_data|
127
+ license = REXML::Element.new("license")
128
+ licenses.add_element(license)
129
+
130
+ if license_data["license"]["id"]
131
+ add_element(license, "id", license_data["license"]["id"])
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ formatter = REXML::Formatters::Pretty.new(2)
138
+ formatter.compact = true
139
+ output = ""
140
+ formatter.write(doc, output)
141
+ output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
142
+ end
143
+
144
+ def self.parse_xml(doc)
145
+ root = doc.root
146
+
147
+ sbom = {
148
+ "bomFormat" => "CycloneDX",
149
+ "specVersion" => "1.4",
150
+ "serialNumber" => root.attributes["serialNumber"],
151
+ "version" => root.attributes["version"].to_i,
152
+ "metadata" => {
153
+ "timestamp" => get_element_text(root, "metadata/timestamp"),
154
+ "tools" => [],
155
+ "component" => {
156
+ "type" => REXML::XPath.first(root, "metadata/component").attributes["type"],
157
+ "name" => get_element_text(root, "metadata/component/name"),
158
+ "version" => get_element_text(root, "metadata/component/version")
159
+ }
160
+ },
161
+ "components" => []
162
+ }
163
+
164
+ # Collect tools
165
+ REXML::XPath.each(root, "metadata/tools/tool") do |tool|
166
+ tool_data = {
167
+ "vendor" => get_element_text(tool, "vendor"),
168
+ "name" => get_element_text(tool, "name"),
169
+ "version" => get_element_text(tool, "version")
170
+ }
171
+ sbom["metadata"]["tools"] << tool_data
172
+ end
173
+
174
+ # Collect components
175
+ REXML::XPath.each(root, "components/component") do |comp|
176
+ component = {
177
+ "type" => comp.attributes["type"],
178
+ "name" => get_element_text(comp, "name"),
179
+ "version" => get_element_text(comp, "version"),
180
+ "purl" => get_element_text(comp, "purl")
181
+ }
182
+
183
+ # Collect licenses
184
+ licenses = []
185
+ REXML::XPath.each(comp, "licenses/license") do |license|
186
+ license_id = get_element_text(license, "id")
187
+ licenses << { "license" => { "id" => license_id } } if license_id
188
+ end
189
+
190
+ component["licenses"] = licenses unless licenses.empty?
191
+ sbom["components"] << component
192
+ end
193
+
194
+ # Convert CycloneDX format to SPDX-like format for compatibility with Reporter
195
+ converted_sbom = {
196
+ "packages" => sbom["components"].map do |comp|
197
+ license_string = if comp["licenses"]
198
+ comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
199
+ else
200
+ "NOASSERTION"
201
+ end
202
+ {
203
+ "name" => comp["name"],
204
+ "versionInfo" => comp["version"],
205
+ "licenseDeclared" => license_string
206
+ }
207
+ end
208
+ }
209
+
210
+ converted_sbom
211
+ end
212
+
213
+ def self.to_report_format(sbom)
214
+ {
215
+ "packages" => sbom["components"].map do |comp|
216
+ license_string = if comp["licenses"]
217
+ comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
218
+ else
219
+ "NOASSERTION"
220
+ end
221
+ {
222
+ "name" => comp["name"],
223
+ "versionInfo" => comp["version"],
224
+ "licenseDeclared" => license_string
225
+ }
226
+ end
227
+ }
228
+ end
229
+
230
+ private
231
+
232
+ def self.add_element(parent, name, value)
233
+ element = REXML::Element.new(name)
234
+ element.text = value
235
+ parent.add_element(element)
236
+ end
237
+
238
+ def self.get_element_text(element, xpath)
239
+ result = REXML::XPath.first(element, xpath)
240
+ result ? result.text : nil
241
+ end
242
+ end
243
+ end
244
+ end
@@ -1,117 +1,51 @@
1
1
  require "bundler"
2
2
  require "securerandom"
3
+ require "json"
4
+ require "rexml/document"
5
+ require "bundler/sbom/spdx"
6
+ require "bundler/sbom/cyclonedx"
3
7
 
4
8
  module Bundler
5
9
  module Sbom
10
+ class GemfileLockNotFoundError < StandardError; end
11
+
6
12
  class Generator
7
- def self.generate_sbom
13
+ def self.generate_sbom(format = "spdx")
8
14
  lockfile_path = Bundler.default_lockfile
9
- unless lockfile_path.exist?
10
- abort "No Gemfile.lock found. Run `bundle install` first."
15
+ if !lockfile_path || !lockfile_path.exist?
16
+ Bundler.ui.error "No Gemfile.lock found. Run `bundle install` first."
17
+ raise GemfileLockNotFoundError, "No Gemfile.lock found"
11
18
  end
12
19
 
13
20
  lockfile = Bundler::LockfileParser.new(lockfile_path.read)
14
21
  document_name = File.basename(Dir.pwd)
15
- spdx_id = SecureRandom.uuid
16
-
17
- sbom = {
18
- "SPDXID" => "SPDXRef-DOCUMENT",
19
- "spdxVersion" => "SPDX-2.2",
20
- "creationInfo" => {
21
- "created" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
22
- "creators" => ["Tool: bundle-sbom"],
23
- "licenseListVersion" => "3.17"
24
- },
25
- "name" => document_name,
26
- "dataLicense" => "CC0-1.0",
27
- "documentNamespace" => "https://spdx.org/spdxdocs/#{document_name}-#{spdx_id}",
28
- "packages" => []
29
- }
30
-
31
- lockfile.specs.each do |spec|
32
- begin
33
- gemspec = Gem::Specification.find_by_name(spec.name, spec.version)
34
- licenses = []
35
- if gemspec
36
- if gemspec.license && !gemspec.license.empty?
37
- licenses << gemspec.license
38
- end
39
-
40
- if gemspec.licenses && !gemspec.licenses.empty?
41
- licenses.concat(gemspec.licenses)
42
- end
43
22
 
44
- licenses.uniq!
45
- end
46
-
47
- license_string = licenses.empty? ? "NOASSERTION" : licenses.join(", ")
48
- rescue Gem::LoadError
49
- license_string = "NOASSERTION"
50
- end
51
-
52
- package = {
53
- "SPDXID" => "SPDXRef-Package-#{spec.name}",
54
- "name" => spec.name,
55
- "versionInfo" => spec.version.to_s,
56
- "downloadLocation" => "NOASSERTION",
57
- "filesAnalyzed" => false,
58
- "licenseConcluded" => license_string,
59
- "licenseDeclared" => license_string,
60
- "supplier" => "NOASSERTION",
61
- "externalRefs" => [
62
- {
63
- "referenceCategory" => "PACKAGE_MANAGER",
64
- "referenceType" => "purl",
65
- "referenceLocator" => "pkg:gem/#{spec.name}@#{spec.version}"
66
- }
67
- ]
68
- }
69
- sbom["packages"] << package
23
+ case format.to_s.downcase
24
+ when "cyclonedx"
25
+ CycloneDX.generate(lockfile, document_name)
26
+ else # default to spdx
27
+ SPDX.generate(lockfile, document_name)
70
28
  end
71
-
72
- sbom
73
29
  end
74
30
 
75
- def self.display_license_report(sbom)
76
- license_count = analyze_licenses(sbom)
77
- sorted_licenses = license_count.sort_by { |_, count| -count }
78
-
79
- puts "=== License Usage in SBOM ==="
80
- puts "Total packages: #{sbom["packages"].size}"
81
- puts
82
-
83
- sorted_licenses.each do |license, count|
84
- puts "#{license}: #{count} package(s)"
85
- end
86
-
87
- puts "\n=== Packages by License ==="
88
- sorted_licenses.each do |license, _|
89
- packages = sbom["packages"].select do |package|
90
- if package["licenseDeclared"].include?(",")
91
- package["licenseDeclared"].split(",").map(&:strip).include?(license)
92
- else
93
- package["licenseDeclared"] == license
94
- end
95
- end
96
-
97
- puts "\n#{license} (#{packages.size} package(s)):"
98
- packages.each do |package|
99
- puts " - #{package["name"]} (#{package["versionInfo"]})"
100
- end
31
+ def self.convert_to_xml(sbom)
32
+ if sbom["bomFormat"] == "CycloneDX"
33
+ CycloneDX.to_xml(sbom)
34
+ else
35
+ SPDX.to_xml(sbom)
101
36
  end
102
37
  end
103
38
 
104
- private
105
-
106
- def self.analyze_licenses(sbom)
107
- license_count = Hash.new(0)
108
- sbom["packages"].each do |package|
109
- licenses = package["licenseDeclared"].split(",").map(&:strip)
110
- licenses.each do |license|
111
- license_count[license] += 1
112
- end
39
+ def self.parse_xml(xml_content)
40
+ doc = REXML::Document.new(xml_content)
41
+ root = doc.root
42
+
43
+ # Determine if it's CycloneDX or SPDX
44
+ if root.name == "bom" && root.namespace.include?("cyclonedx.org")
45
+ CycloneDX.parse_xml(doc)
46
+ else
47
+ SPDX.parse_xml(doc)
113
48
  end
114
- license_count
115
49
  end
116
50
  end
117
51
  end
@@ -0,0 +1,63 @@
1
+ module Bundler
2
+ module Sbom
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
11
+
12
+ display_report(sbom)
13
+ end
14
+
15
+ private
16
+
17
+ def self.sbom_format(sbom)
18
+ return :cyclonedx if sbom["bomFormat"] == "CycloneDX"
19
+ return :spdx
20
+ end
21
+
22
+ def self.display_report(sbom)
23
+ license_count = analyze_licenses(sbom)
24
+ sorted_licenses = license_count.sort_by { |_, count| -count }
25
+
26
+ puts "=== License Usage in SBOM ==="
27
+ puts "Total packages: #{sbom["packages"].size}"
28
+ puts
29
+
30
+ sorted_licenses.each do |license, count|
31
+ puts "#{license}: #{count} package(s)"
32
+ end
33
+
34
+ puts "\n=== Packages by License ==="
35
+ 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
42
+ end
43
+
44
+ puts "\n#{license} (#{packages.size} package(s)):"
45
+ packages.each do |package|
46
+ puts " - #{package["name"]} (#{package["versionInfo"]})"
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.analyze_licenses(sbom)
52
+ license_count = Hash.new(0)
53
+ sbom["packages"].each do |package|
54
+ licenses = package["licenseDeclared"].split(",").map(&:strip)
55
+ licenses.each do |license|
56
+ license_count[license] += 1
57
+ end
58
+ end
59
+ license_count
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,220 @@
1
+ require "bundler"
2
+ require "securerandom"
3
+ require "rexml/document"
4
+
5
+ module Bundler
6
+ module Sbom
7
+ class SPDX
8
+ def self.generate(lockfile, document_name)
9
+ spdx_id = SecureRandom.uuid
10
+ sbom = {
11
+ "SPDXID" => "SPDXRef-DOCUMENT",
12
+ "spdxVersion" => "SPDX-2.3",
13
+ "creationInfo" => {
14
+ "created" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
15
+ "creators" => ["Tool: bundle-sbom"],
16
+ "licenseListVersion" => "3.20"
17
+ },
18
+ "name" => document_name,
19
+ "dataLicense" => "CC0-1.0",
20
+ "documentNamespace" => "https://spdx.org/spdxdocs/#{document_name}-#{spdx_id}",
21
+ "packages" => []
22
+ }
23
+
24
+ lockfile.specs.each do |spec|
25
+ begin
26
+ gemspec = Gem::Specification.find_by_name(spec.name, spec.version)
27
+ licenses = []
28
+ if gemspec
29
+ if gemspec.license && !gemspec.license.empty?
30
+ licenses << gemspec.license
31
+ end
32
+ if gemspec.licenses && !gemspec.licenses.empty?
33
+ licenses.concat(gemspec.licenses)
34
+ end
35
+ licenses.uniq!
36
+ end
37
+ license_string = licenses.empty? ? "NOASSERTION" : licenses.join(", ")
38
+ rescue Gem::LoadError
39
+ license_string = "NOASSERTION"
40
+ end
41
+
42
+ package = {
43
+ "SPDXID" => "SPDXRef-Package-#{spec.name}",
44
+ "name" => spec.name,
45
+ "versionInfo" => spec.version.to_s,
46
+ "downloadLocation" => "NOASSERTION",
47
+ "filesAnalyzed" => false,
48
+ "licenseConcluded" => license_string,
49
+ "licenseDeclared" => license_string,
50
+ "copyrightText" => "NOASSERTION",
51
+ "supplier" => "NOASSERTION",
52
+ "externalRefs" => [
53
+ {
54
+ "referenceCategory" => "PACKAGE_MANAGER",
55
+ "referenceType" => "purl",
56
+ "referenceLocator" => "pkg:gem/#{spec.name}@#{spec.version}"
57
+ }
58
+ ]
59
+ }
60
+ sbom["packages"] << package
61
+ end
62
+
63
+ sbom["documentDescribes"] = sbom["packages"].map { |p| p["SPDXID"] }
64
+ sbom
65
+ end
66
+
67
+ def self.to_xml(sbom)
68
+ doc = REXML::Document.new
69
+ doc << REXML::XMLDecl.new("1.0", "UTF-8")
70
+
71
+ # Root element
72
+ root = REXML::Element.new("SpdxDocument")
73
+ root.add_namespace("https://spdx.org/spdxdocs/")
74
+ doc.add_element(root)
75
+
76
+ # Document info
77
+ add_element(root, "SPDXID", sbom["SPDXID"])
78
+ add_element(root, "spdxVersion", sbom["spdxVersion"])
79
+ add_element(root, "name", sbom["name"])
80
+ add_element(root, "dataLicense", sbom["dataLicense"])
81
+ add_element(root, "documentNamespace", sbom["documentNamespace"])
82
+
83
+ # Creation info
84
+ creation_info = REXML::Element.new("creationInfo")
85
+ root.add_element(creation_info)
86
+ add_element(creation_info, "created", sbom["creationInfo"]["created"])
87
+ add_element(creation_info, "licenseListVersion", sbom["creationInfo"]["licenseListVersion"])
88
+
89
+ sbom["creationInfo"]["creators"].each do |creator|
90
+ add_element(creation_info, "creator", creator)
91
+ end
92
+
93
+ # Describes
94
+ sbom["documentDescribes"].each do |describes|
95
+ add_element(root, "documentDescribes", describes)
96
+ end
97
+
98
+ # Packages
99
+ sbom["packages"].each do |pkg|
100
+ package = REXML::Element.new("package")
101
+ root.add_element(package)
102
+
103
+ add_element(package, "SPDXID", pkg["SPDXID"])
104
+ add_element(package, "name", pkg["name"])
105
+ add_element(package, "versionInfo", pkg["versionInfo"])
106
+ add_element(package, "downloadLocation", pkg["downloadLocation"])
107
+ add_element(package, "filesAnalyzed", pkg["filesAnalyzed"].to_s)
108
+ add_element(package, "licenseConcluded", pkg["licenseConcluded"])
109
+ add_element(package, "licenseDeclared", pkg["licenseDeclared"])
110
+ add_element(package, "copyrightText", pkg["copyrightText"])
111
+ add_element(package, "supplier", pkg["supplier"])
112
+
113
+ # External references
114
+ if pkg["externalRefs"]
115
+ pkg["externalRefs"].each do |ref|
116
+ ext_ref = REXML::Element.new("externalRef")
117
+ package.add_element(ext_ref)
118
+
119
+ add_element(ext_ref, "referenceCategory", ref["referenceCategory"])
120
+ add_element(ext_ref, "referenceType", ref["referenceType"])
121
+ add_element(ext_ref, "referenceLocator", ref["referenceLocator"])
122
+ end
123
+ end
124
+ end
125
+
126
+ formatter = REXML::Formatters::Pretty.new(2)
127
+ formatter.compact = true
128
+ output = ""
129
+ formatter.write(doc, output)
130
+ output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
131
+ end
132
+
133
+ def self.parse_xml(doc)
134
+ root = doc.root
135
+
136
+ sbom = {
137
+ "SPDXID" => get_element_text(root, "SPDXID"),
138
+ "spdxVersion" => get_element_text(root, "spdxVersion"),
139
+ "name" => get_element_text(root, "name"),
140
+ "dataLicense" => get_element_text(root, "dataLicense"),
141
+ "documentNamespace" => get_element_text(root, "documentNamespace"),
142
+ "creationInfo" => {
143
+ "created" => get_element_text(root, "creationInfo/created"),
144
+ "licenseListVersion" => get_element_text(root, "creationInfo/licenseListVersion"),
145
+ "creators" => []
146
+ },
147
+ "packages" => [],
148
+ "documentDescribes" => []
149
+ }
150
+
151
+ # Collect creators
152
+ REXML::XPath.each(root, "creationInfo/creator") do |creator|
153
+ sbom["creationInfo"]["creators"] << creator.text
154
+ end
155
+
156
+ # Collect documentDescribes
157
+ REXML::XPath.each(root, "documentDescribes") do |describes|
158
+ sbom["documentDescribes"] << describes.text
159
+ end
160
+
161
+ # Collect packages
162
+ REXML::XPath.each(root, "package") do |pkg_element|
163
+ package = {
164
+ "SPDXID" => get_element_text(pkg_element, "SPDXID"),
165
+ "name" => get_element_text(pkg_element, "name"),
166
+ "versionInfo" => get_element_text(pkg_element, "versionInfo"),
167
+ "downloadLocation" => get_element_text(pkg_element, "downloadLocation"),
168
+ "filesAnalyzed" => get_element_text(pkg_element, "filesAnalyzed") == "true",
169
+ "licenseConcluded" => get_element_text(pkg_element, "licenseConcluded"),
170
+ "licenseDeclared" => get_element_text(pkg_element, "licenseDeclared"),
171
+ "copyrightText" => get_element_text(pkg_element, "copyrightText"),
172
+ "supplier" => get_element_text(pkg_element, "supplier"),
173
+ "externalRefs" => []
174
+ }
175
+
176
+ # Collect external references
177
+ REXML::XPath.each(pkg_element, "externalRef") do |ref_element|
178
+ ref = {
179
+ "referenceCategory" => get_element_text(ref_element, "referenceCategory"),
180
+ "referenceType" => get_element_text(ref_element, "referenceType"),
181
+ "referenceLocator" => get_element_text(ref_element, "referenceLocator")
182
+ }
183
+ package["externalRefs"] << ref
184
+ end
185
+
186
+ sbom["packages"] << package
187
+ end
188
+
189
+ sbom
190
+ end
191
+
192
+ def self.to_report_format(sbom)
193
+ # SPDXフォーマットは既にレポート形式と互換性があるため、
194
+ # packagesセクションだけを抽出して返す
195
+ {
196
+ "packages" => sbom["packages"].map do |pkg|
197
+ {
198
+ "name" => pkg["name"],
199
+ "versionInfo" => pkg["versionInfo"],
200
+ "licenseDeclared" => pkg["licenseDeclared"]
201
+ }
202
+ end
203
+ }
204
+ end
205
+
206
+ private
207
+
208
+ def self.add_element(parent, name, value)
209
+ element = REXML::Element.new(name)
210
+ element.text = value
211
+ parent.add_element(element)
212
+ end
213
+
214
+ def self.get_element_text(element, xpath)
215
+ result = REXML::XPath.first(element, xpath)
216
+ result ? result.text : nil
217
+ end
218
+ end
219
+ end
220
+ end
@@ -1,5 +1,5 @@
1
1
  module Bundler
2
2
  module Sbom
3
- VERSION = "0.1.5"
3
+ VERSION = "0.1.7"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-sbom
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - SHIBATA Hiroshi
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-03-05 00:00:00.000000000 Z
10
+ date: 2025-03-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -23,7 +23,21 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
26
- description: Generate CycloneDX SBOM(Software Bill of Materials) files with Bundler
26
+ - !ruby/object:Gem::Dependency
27
+ name: rexml
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
27
41
  email:
28
42
  - hsbt@ruby-lang.org
29
43
  executables: []
@@ -31,15 +45,20 @@ extensions: []
31
45
  extra_rdoc_files: []
32
46
  files:
33
47
  - Gemfile
48
+ - LICENSE
34
49
  - README.md
35
50
  - Rakefile
36
51
  - lib/bundler/sbom.rb
37
52
  - lib/bundler/sbom/cli.rb
53
+ - lib/bundler/sbom/cyclonedx.rb
38
54
  - lib/bundler/sbom/generator.rb
55
+ - lib/bundler/sbom/reporter.rb
56
+ - lib/bundler/sbom/spdx.rb
39
57
  - lib/bundler/sbom/version.rb
40
58
  - plugins.rb
41
59
  homepage: https://github.com/hsbt/bundler-sbom
42
- licenses: []
60
+ licenses:
61
+ - MIT
43
62
  metadata:
44
63
  homepage_uri: https://github.com/hsbt/bundler-sbom
45
64
  source_code_uri: https://github.com/hsbt/bundler-sbom
@@ -52,7 +71,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
52
71
  requirements:
53
72
  - - ">="
54
73
  - !ruby/object:Gem::Version
55
- version: 2.6.0
74
+ version: 3.0.0
56
75
  required_rubygems_version: !ruby/object:Gem::Requirement
57
76
  requirements:
58
77
  - - ">="
@@ -61,5 +80,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
80
  requirements: []
62
81
  rubygems_version: 3.6.2
63
82
  specification_version: 4
64
- summary: Generate CycloneDX SBOM(Software Bill of Materials) files with Bundler
83
+ summary: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
65
84
  test_files: []