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 +4 -4
- data/Gemfile +1 -1
- data/README.md +43 -5
- data/Rakefile +1 -1
- 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 +19 -3
- data/lib/bundler/sbom/spdx.rb +222 -0
- data/lib/bundler/sbom/version.rb +1 -1
- metadata +20 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 590a4da5b45a12b7d7946aa14626eae531db3220a2441047895db77c078ccf4b
|
4
|
+
data.tar.gz: 525d811c49ee31132eafbccc53e96d7fc4b39e92cd1dac8c704da334120584d4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a4bc5d533a846c9c0786f5fcc17d9ca6f85fe6bbd72719ff016caeebedd2a6e6b68fef6d86c59fced37a5a96a1c601eb5f6fbda00554a67b3e2cb287b1c95a37
|
7
|
+
data.tar.gz: b7e135ea95bbdc7cb93b90864f7f0daf75cda5640f6be982813f6d156d7942bfb4d6c8da8df7f688fb1399afdf9d30d428e75bb4dfcccb4db0ff6fe905d44b9c
|
data/Gemfile
CHANGED
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
|
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
|
-
|
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
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
|
-
|
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
|
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
|
-
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
|
-
|
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
|
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
|
-
|
48
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
end
|
39
|
+
def self.parse_xml(xml_content)
|
40
|
+
doc = REXML::Document.new(xml_content)
|
41
|
+
root = doc.root
|
54
42
|
|
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
|
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
|
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.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- SHIBATA Hiroshi
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date:
|
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:
|
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.
|
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: []
|