bundler-sbom 0.1.6 → 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: bb720a4c1407de48380f89d81cfb76c76c9aaf72477e06de9d42e2154b278ddf
4
- data.tar.gz: '0596c33babc35714c4515f6eb7f018bbabbe22350734650c060f34788a28bb0f'
3
+ metadata.gz: d5d7fea7d39796fd36fec03e1e5b13695fdccb0cfc6926cfb5a8631d86f94f09
4
+ data.tar.gz: fe3abc900c32f11af2b0c1f432c9b376042b48a9d8e69c293ccc76b7383a97b0
5
5
  SHA512:
6
- metadata.gz: a1878c226f0f1dc92967c05dcb52539de886734eed0395a734164c426d14cffcccebf53c13dab7f5113c4497efe44c9b7bcca2898d638a1f84cc88362502f1f8
7
- data.tar.gz: 5fb51be53f651ec22422f33fdbb8859842cef43911681ed70d104f9582856f741816491df1a8b67a322122f4eecb21355dabdd9e6d103ab85016f08ea5506a90
6
+ metadata.gz: 8b79df0b6ab84ce586694fffcf9b9a25e4de2586f85b0430be60f93404df699675e5890936a4ea963f528ed7526b6ffb9e9afe591ee8c8b327952981457654a0
7
+ data.tar.gz: dec35c52cf5cd36d1b0692a3eb6bb73431f2e07836226af366fd7a8d0c58c964c9577545bf10c01e5178333acbf75a299a63f205bbc3496c557835af5473496b
@@ -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")
13
- end
12
+ format = options[:format].downcase
13
+ sbom_format = options[:sbom].downcase
14
14
 
15
- 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"
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
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,66 +19,33 @@ 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
46
-
47
- licenses.uniq!
48
- end
49
-
50
- license_string = licenses.empty? ? "NOASSERTION" : licenses.join(", ")
51
- rescue Gem::LoadError
52
- license_string = "NOASSERTION"
53
- 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
54
30
 
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
31
+ def self.convert_to_xml(sbom)
32
+ if sbom["bomFormat"] == "CycloneDX"
33
+ CycloneDX.to_xml(sbom)
34
+ else
35
+ SPDX.to_xml(sbom)
74
36
  end
37
+ end
75
38
 
76
- sbom["documentDescribes"] = sbom["packages"].map { |p| p["SPDXID"] }
77
- sbom
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)
48
+ end
78
49
  end
79
50
  end
80
51
  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|
@@ -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.6"
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.6
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,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,7 +71,7 @@ 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
  - - ">="