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 +4 -4
- data/lib/bundler/sbom/cli.rb +83 -10
- data/lib/bundler/sbom/cyclonedx.rb +244 -0
- data/lib/bundler/sbom/generator.rb +28 -57
- data/lib/bundler/sbom/reporter.rb +18 -2
- data/lib/bundler/sbom/spdx.rb +220 -0
- data/lib/bundler/sbom/version.rb +1 -1
- metadata +19 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d5d7fea7d39796fd36fec03e1e5b13695fdccb0cfc6926cfb5a8631d86f94f09
|
4
|
+
data.tar.gz: fe3abc900c32f11af2b0c1f432c9b376042b48a9d8e69c293ccc76b7383a97b0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8b79df0b6ab84ce586694fffcf9b9a25e4de2586f85b0430be60f93404df699675e5890936a4ea963f528ed7526b6ffb9e9afe591ee8c8b327952981457654a0
|
7
|
+
data.tar.gz: dec35c52cf5cd36d1b0692a3eb6bb73431f2e07836226af366fd7a8d0c58c964c9577545bf10c01e5178333acbf75a299a63f205bbc3496c557835af5473496b
|
data/lib/bundler/sbom/cli.rb
CHANGED
@@ -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
|
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
|
-
|
11
|
-
|
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
|
-
|
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
|
-
|
18
|
-
|
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
|
-
|
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:
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
77
|
-
|
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
|
data/lib/bundler/sbom/version.rb
CHANGED
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.
|
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-
|
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:
|
74
|
+
version: 3.0.0
|
59
75
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
60
76
|
requirements:
|
61
77
|
- - ">="
|