bundler-sbom 0.1.6 → 0.1.8

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: bb720a4c1407de48380f89d81cfb76c76c9aaf72477e06de9d42e2154b278ddf
4
- data.tar.gz: '0596c33babc35714c4515f6eb7f018bbabbe22350734650c060f34788a28bb0f'
3
+ metadata.gz: 590a4da5b45a12b7d7946aa14626eae531db3220a2441047895db77c078ccf4b
4
+ data.tar.gz: 525d811c49ee31132eafbccc53e96d7fc4b39e92cd1dac8c704da334120584d4
5
5
  SHA512:
6
- metadata.gz: a1878c226f0f1dc92967c05dcb52539de886734eed0395a734164c426d14cffcccebf53c13dab7f5113c4497efe44c9b7bcca2898d638a1f84cc88362502f1f8
7
- data.tar.gz: 5fb51be53f651ec22422f33fdbb8859842cef43911681ed70d104f9582856f741816491df1a8b67a322122f4eecb21355dabdd9e6d103ab85016f08ea5506a90
6
+ metadata.gz: a4bc5d533a846c9c0786f5fcc17d9ca6f85fe6bbd72719ff016caeebedd2a6e6b68fef6d86c59fced37a5a96a1c601eb5f6fbda00554a67b3e2cb287b1c95a37
7
+ data.tar.gz: b7e135ea95bbdc7cb93b90864f7f0daf75cda5640f6be982813f6d156d7942bfb4d6c8da8df7f688fb1399afdf9d30d428e75bb4dfcccb4db0ff6fe905d44b9c
data/Gemfile CHANGED
@@ -9,4 +9,4 @@ group :development do
9
9
  gem "simplecov", require: false
10
10
  gem "rspec-its"
11
11
  gem "rspec-mocks"
12
- end
12
+ end
data/README.md CHANGED
@@ -14,24 +14,62 @@ $ bundler plugin install bundler-sbom
14
14
 
15
15
  ### Generate SBOM
16
16
 
17
- To generate an SBOM file in SPDX format from your project's Gemfile.lock:
17
+ To generate an SBOM file from your project's Gemfile.lock:
18
18
 
19
19
  ```
20
- $ bundle sbom dump
20
+ $ bundle sbom dump [options]
21
21
  ```
22
22
 
23
- This will create a `bom.json` file in your project directory.
23
+ Available options:
24
+ - `-f, --format FORMAT`: Output format (json or xml, default: json)
25
+ - `-s, --sbom FORMAT`: SBOM specification format (spdx or cyclonedx, default: spdx)
26
+
27
+ Generated files will be named according to the following pattern:
28
+ - SPDX format: `bom.json` or `bom.xml`
29
+ - CycloneDX format: `bom-cyclonedx.json` or `bom-cyclonedx.xml`
30
+
31
+ Examples:
32
+ ```
33
+ $ bundle sbom dump # Generates SPDX format in JSON (bom.json)
34
+ $ bundle sbom dump -f xml # Generates SPDX format in XML (bom.xml)
35
+ $ bundle sbom dump -s cyclonedx # Generates CycloneDX format in JSON (bom-cyclonedx.json)
36
+ $ bundle sbom dump -s cyclonedx -f xml # Generates CycloneDX format in XML (bom-cyclonedx.xml)
37
+ ```
24
38
 
25
39
  ### Analyze License Information
26
40
 
27
41
  To view a summary of licenses used in your project's dependencies:
28
42
 
29
43
  ```
30
- $ bundle sbom license
44
+ $ bundle sbom license [options]
31
45
  ```
32
46
 
47
+ Available options:
48
+ - `-f, --file PATH`: Input SBOM file path
49
+ - `-F, --format FORMAT`: Input format (json or xml)
50
+
51
+ If no options are specified, the command will automatically look for SBOM files in the following order:
52
+ 1. `bom.xml` (if format is xml)
53
+ 2. `bom-cyclonedx.json`
54
+ 3. `bom-cyclonedx.xml`
55
+ 4. `bom.json`
56
+
33
57
  This command will show:
34
58
  - A count of packages using each license
35
59
  - A detailed list of packages grouped by license
36
60
 
37
- Note: The `license` command requires that you've already generated the SBOM using `bundle sbom dump`.
61
+ Note: The `license` command requires that you've already generated the SBOM using `bundle sbom dump`.
62
+
63
+ ## Supported SBOM Formats
64
+
65
+ ### SPDX
66
+ [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.
67
+
68
+ ### CycloneDX
69
+ [CycloneDX](https://cyclonedx.org/) is a lightweight SBOM specification designed for use in application security contexts and supply chain component analysis.
70
+
71
+ ## References
72
+
73
+ - [SPDX Specification](https://spdx.github.io/spdx-spec/)
74
+ - [CycloneDX Specification](https://cyclonedx.org/specification/overview/)
75
+ - [About Software Bill of Materials (SBOM)](https://www.cisa.gov/sbom)
data/Rakefile CHANGED
@@ -3,4 +3,4 @@ require "rspec/core/rake_task"
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task :default => :spec
6
+ task default: :spec
@@ -5,28 +5,101 @@ require "bundler/sbom/reporter"
5
5
  module Bundler
6
6
  module Sbom
7
7
  class CLI < Thor
8
- 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"
9
11
  def dump
10
- sbom = Bundler::Sbom::Generator.generate_sbom
11
- File.write("bom.json", JSON.pretty_generate(sbom))
12
- Bundler.ui.info("Generated SBOM at bom.json")
12
+ format = options[:format].downcase
13
+ sbom_format = options[:sbom].downcase
14
+
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}")
13
45
  end
14
46
 
15
- desc "license", "Display license report from existing bom.json"
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"
16
50
  def license
17
- unless File.exist?("bom.json")
18
- 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.")
19
77
  exit 1
20
78
  end
21
79
 
22
80
  begin
23
- sbom = JSON.parse(File.read("bom.json"))
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
+
24
89
  Bundler::Sbom::Reporter.display_license_report(sbom)
25
90
  rescue JSON::ParserError
26
- 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}")
27
95
  exit 1
28
96
  end
29
97
  end
98
+
99
+ # 適切にエラーで終了することを保証するためのメソッド
100
+ def self.exit_on_failure?
101
+ true
102
+ end
30
103
  end
31
104
  end
32
- end
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,12 +1,16 @@
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
6
10
  class GemfileLockNotFoundError < StandardError; end
7
11
 
8
12
  class Generator
9
- def self.generate_sbom
13
+ def self.generate_sbom(format = "spdx")
10
14
  lockfile_path = Bundler.default_lockfile
11
15
  if !lockfile_path || !lockfile_path.exist?
12
16
  Bundler.ui.error "No Gemfile.lock found. Run `bundle install` first."
@@ -15,67 +19,34 @@ module Bundler
15
19
 
16
20
  lockfile = Bundler::LockfileParser.new(lockfile_path.read)
17
21
  document_name = File.basename(Dir.pwd)
18
- spdx_id = SecureRandom.uuid
19
22
 
20
- sbom = {
21
- "SPDXID" => "SPDXRef-DOCUMENT",
22
- "spdxVersion" => "SPDX-2.2",
23
- "creationInfo" => {
24
- "created" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
25
- "creators" => ["Tool: bundle-sbom"],
26
- "licenseListVersion" => "3.17"
27
- },
28
- "name" => document_name,
29
- "dataLicense" => "CC0-1.0",
30
- "documentNamespace" => "https://spdx.org/spdxdocs/#{document_name}-#{spdx_id}",
31
- "packages" => []
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
-
43
- if gemspec.licenses && !gemspec.licenses.empty?
44
- licenses.concat(gemspec.licenses)
45
- end
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)
28
+ end
29
+ end
46
30
 
47
- licenses.uniq!
48
- 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)
36
+ end
37
+ end
49
38
 
50
- license_string = licenses.empty? ? "NOASSERTION" : licenses.join(", ")
51
- rescue Gem::LoadError
52
- license_string = "NOASSERTION"
53
- end
39
+ def self.parse_xml(xml_content)
40
+ doc = REXML::Document.new(xml_content)
41
+ root = doc.root
54
42
 
55
- package = {
56
- "SPDXID" => "SPDXRef-Package-#{spec.name}",
57
- "name" => spec.name,
58
- "versionInfo" => spec.version.to_s,
59
- "downloadLocation" => "NOASSERTION",
60
- "filesAnalyzed" => false,
61
- "licenseConcluded" => license_string,
62
- "licenseDeclared" => license_string,
63
- "copyrightText" => "NOASSERTION",
64
- "supplier" => "NOASSERTION",
65
- "externalRefs" => [
66
- {
67
- "referenceCategory" => "PACKAGE_MANAGER",
68
- "referenceType" => "purl",
69
- "referenceLocator" => "pkg:gem/#{spec.name}@#{spec.version}"
70
- }
71
- ]
72
- }
73
- sbom["packages"] << package
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)
74
48
  end
75
-
76
- sbom["documentDescribes"] = sbom["packages"].map { |p| p["SPDXID"] }
77
- sbom
78
49
  end
79
50
  end
80
51
  end
81
- end
52
+ end
@@ -2,6 +2,24 @@ module Bundler
2
2
  module Sbom
3
3
  class Reporter
4
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)
5
23
  license_count = analyze_licenses(sbom)
6
24
  sorted_licenses = license_count.sort_by { |_, count| -count }
7
25
 
@@ -30,8 +48,6 @@ module Bundler
30
48
  end
31
49
  end
32
50
 
33
- private
34
-
35
51
  def self.analyze_licenses(sbom)
36
52
  license_count = Hash.new(0)
37
53
  sbom["packages"].each do |package|
@@ -44,4 +60,4 @@ module Bundler
44
60
  end
45
61
  end
46
62
  end
47
- end
63
+ end
@@ -0,0 +1,222 @@
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 = generate_spdx_id
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
+ def self.generate_spdx_id
207
+ SecureRandom.uuid
208
+ end
209
+
210
+ def self.add_element(parent, name, value)
211
+ element = REXML::Element.new(name)
212
+ element.text = value
213
+ parent.add_element(element)
214
+ end
215
+
216
+ def self.get_element_text(element, xpath)
217
+ result = REXML::XPath.first(element, xpath)
218
+ result ? result.text : nil
219
+ end
220
+ end
221
+ end
222
+ end
@@ -1,5 +1,5 @@
1
1
  module Bundler
2
2
  module Sbom
3
- VERSION = "0.1.6"
3
+ VERSION = "0.1.8"
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.6
4
+ version: 0.1.8
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: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
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'
26
40
  description: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
27
41
  email:
28
42
  - hsbt@ruby-lang.org
@@ -36,8 +50,10 @@ files:
36
50
  - Rakefile
37
51
  - lib/bundler/sbom.rb
38
52
  - lib/bundler/sbom/cli.rb
53
+ - lib/bundler/sbom/cyclonedx.rb
39
54
  - lib/bundler/sbom/generator.rb
40
55
  - lib/bundler/sbom/reporter.rb
56
+ - lib/bundler/sbom/spdx.rb
41
57
  - lib/bundler/sbom/version.rb
42
58
  - plugins.rb
43
59
  homepage: https://github.com/hsbt/bundler-sbom
@@ -55,14 +71,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
55
71
  requirements:
56
72
  - - ">="
57
73
  - !ruby/object:Gem::Version
58
- version: 2.6.0
74
+ version: 3.0.0
59
75
  required_rubygems_version: !ruby/object:Gem::Requirement
60
76
  requirements:
61
77
  - - ">="
62
78
  - !ruby/object:Gem::Version
63
79
  version: '0'
64
80
  requirements: []
65
- rubygems_version: 3.6.2
81
+ rubygems_version: 3.6.9
66
82
  specification_version: 4
67
83
  summary: Generate SPDX SBOM(Software Bill of Materials) files with Bundler
68
84
  test_files: []