bundler-sbom 0.3.1 → 0.4.0
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 +3 -3
- data/README.md +15 -6
- data/Rakefile +6 -3
- data/lib/bundler/sbom/cli.rb +28 -40
- data/lib/bundler/sbom/cyclonedx.rb +172 -123
- data/lib/bundler/sbom/generator.rb +45 -26
- data/lib/bundler/sbom/reporter.rb +24 -32
- data/lib/bundler/sbom/sbom_document.rb +45 -0
- data/lib/bundler/sbom/spdx.rb +70 -152
- data/lib/bundler/sbom/spec_license_finder.rb +18 -12
- data/lib/bundler/sbom/version.rb +1 -1
- metadata +20 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b17b7f0d6f921d10fa80cc62402f321077779f4c51b81cc22bad0720f3767576
|
|
4
|
+
data.tar.gz: edecba78478c7979ae85a48b5cdb98175a48c9506d56db8959ebddb5c398abfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a450eb2b7aca4e994e888cee8a60d4af2738a8b619f19185f2a4504001af3f12a2ef8bb2b2b8df7254b015c31755b5c9972988e796309469607b51b3ecc848e3
|
|
7
|
+
data.tar.gz: 4b1e698caa08514bd595403948f0a2e989144780b425d3afee08f0b8b6a5e144adab155916b161c1f10aaf9f7bd73d2799d36de3fb2a40a76aad4c0be9ea4526
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -26,13 +26,12 @@ Available options:
|
|
|
26
26
|
- `--without GROUPS`: Exclude groups (comma or colon separated, e.g., 'development:test' or 'development,test')
|
|
27
27
|
|
|
28
28
|
Generated files will be named according to the following pattern:
|
|
29
|
-
- SPDX format: `bom.json`
|
|
29
|
+
- SPDX format: `bom.json`
|
|
30
30
|
- CycloneDX format: `bom-cyclonedx.json` or `bom-cyclonedx.xml`
|
|
31
31
|
|
|
32
32
|
Examples:
|
|
33
33
|
```
|
|
34
34
|
$ bundle sbom dump # Generates SPDX format in JSON (bom.json)
|
|
35
|
-
$ bundle sbom dump -f xml # Generates SPDX format in XML (bom.xml)
|
|
36
35
|
$ bundle sbom dump -s cyclonedx # Generates CycloneDX format in JSON (bom-cyclonedx.json)
|
|
37
36
|
$ bundle sbom dump -s cyclonedx -f xml # Generates CycloneDX format in XML (bom-cyclonedx.xml)
|
|
38
37
|
$ bundle sbom dump --without development # Excludes development group
|
|
@@ -52,10 +51,9 @@ Available options:
|
|
|
52
51
|
- `-F, --format FORMAT`: Input format (json or xml)
|
|
53
52
|
|
|
54
53
|
If no options are specified, the command will automatically look for SBOM files in the following order:
|
|
55
|
-
1. `bom.
|
|
54
|
+
1. `bom.json`
|
|
56
55
|
2. `bom-cyclonedx.json`
|
|
57
56
|
3. `bom-cyclonedx.xml`
|
|
58
|
-
4. `bom.json`
|
|
59
57
|
|
|
60
58
|
This command will show:
|
|
61
59
|
- A count of packages using each license
|
|
@@ -65,12 +63,23 @@ Note: The `license` command requires that you've already generated the SBOM usin
|
|
|
65
63
|
|
|
66
64
|
## Supported SBOM Formats
|
|
67
65
|
|
|
68
|
-
### SPDX
|
|
66
|
+
### SPDX (v2.3)
|
|
69
67
|
[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.
|
|
70
68
|
|
|
71
|
-
|
|
69
|
+
- Spec version: [SPDX 2.3](https://spdx.github.io/spdx-spec/v2.3/)
|
|
70
|
+
- Output formats: JSON
|
|
71
|
+
- License identifiers are validated against the [SPDX License List](https://spdx.org/licenses/) via the `spdx-licenses` gem. Non-SPDX licenses are output as `LicenseRef-` identifiers, and deprecated SPDX IDs (e.g., `GPL-2.0`) are mapped to their current equivalents (e.g., `GPL-2.0-only`).
|
|
72
|
+
- `DEPENDS_ON` relationships between packages are emitted from `Gemfile.lock`.
|
|
73
|
+
- `creationInfo.licenseListVersion` reflects the SPDX license list version bundled with the `spdx-licenses` gem.
|
|
74
|
+
|
|
75
|
+
### CycloneDX (v1.7)
|
|
72
76
|
[CycloneDX](https://cyclonedx.org/) is a lightweight SBOM specification designed for use in application security contexts and supply chain component analysis.
|
|
73
77
|
|
|
78
|
+
- Spec version: [CycloneDX 1.7](https://cyclonedx.org/docs/1.7/json/)
|
|
79
|
+
- Output formats: JSON, XML
|
|
80
|
+
- SPDX license IDs are placed in the `license.id` field, and non-SPDX licenses use the `license.name` field, per the CycloneDX specification.
|
|
81
|
+
- Each component is assigned a `bom-ref` (its purl) and the full dependency graph from `Gemfile.lock` is emitted in the `dependencies` section.
|
|
82
|
+
|
|
74
83
|
## References
|
|
75
84
|
|
|
76
85
|
- [SPDX Specification](https://spdx.github.io/spdx-spec/)
|
data/Rakefile
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
require "bundler/gem_tasks"
|
|
2
|
-
require "
|
|
2
|
+
require "rake/testtask"
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Rake::TestTask.new(:test) do |t|
|
|
5
|
+
t.libs << "test"
|
|
6
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
|
7
|
+
end
|
|
5
8
|
|
|
6
|
-
task default: :
|
|
9
|
+
task default: :test
|
data/lib/bundler/sbom/cli.rb
CHANGED
|
@@ -14,33 +14,29 @@ module Bundler
|
|
|
14
14
|
sbom_format = options[:sbom].downcase
|
|
15
15
|
without_groups = parse_without_groups(options[:without])
|
|
16
16
|
|
|
17
|
-
# Validate output format
|
|
18
17
|
unless ["json", "xml"].include?(format)
|
|
19
|
-
|
|
20
|
-
exit 1
|
|
18
|
+
raise Thor::Error, "Error: Unsupported output format '#{format}'. Supported formats: json, xml"
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
# Validate SBOM format
|
|
24
21
|
unless ["spdx", "cyclonedx"].include?(sbom_format)
|
|
25
|
-
|
|
26
|
-
exit 1
|
|
22
|
+
raise Thor::Error, "Error: Unsupported SBOM format '#{sbom_format}'. Supported formats: spdx, cyclonedx"
|
|
27
23
|
end
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
if sbom_format == "spdx" && format == "xml"
|
|
26
|
+
raise Thor::Error, "Error: SPDX 2.3 does not define an XML serialization. Use '--format json' or '--sbom cyclonedx --format xml'."
|
|
27
|
+
end
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
generator = Bundler::Sbom::Generator.new(format: sbom_format, without_groups: without_groups)
|
|
30
|
+
sbom = generator.generate
|
|
34
31
|
|
|
35
|
-
|
|
32
|
+
ext = (format == "json") ? "json" : "xml"
|
|
36
33
|
prefix = (sbom_format == "spdx") ? "bom" : "bom-cyclonedx"
|
|
37
34
|
output_file = "#{prefix}.#{ext}"
|
|
38
35
|
|
|
39
36
|
if format == "json"
|
|
40
|
-
File.write(output_file, JSON.pretty_generate(sbom))
|
|
41
|
-
else
|
|
42
|
-
|
|
43
|
-
File.write(output_file, xml_content)
|
|
37
|
+
File.write(output_file, JSON.pretty_generate(sbom.to_hash))
|
|
38
|
+
else
|
|
39
|
+
File.write(output_file, sbom.to_xml)
|
|
44
40
|
end
|
|
45
41
|
|
|
46
42
|
Bundler.ui.info("Generated #{sbom_format.upcase} SBOM at #{output_file}")
|
|
@@ -53,16 +49,13 @@ module Bundler
|
|
|
53
49
|
format = options[:format]&.downcase
|
|
54
50
|
input_file = options[:file]
|
|
55
51
|
|
|
56
|
-
# Validate format if provided
|
|
57
52
|
if format && !["json", "xml"].include?(format)
|
|
58
|
-
|
|
59
|
-
exit 1
|
|
53
|
+
raise Thor::Error, "Error: Unsupported format '#{format}'. Supported formats: json, xml"
|
|
60
54
|
end
|
|
61
55
|
|
|
62
|
-
# Determine input file based on format or find default files
|
|
63
56
|
if input_file.nil?
|
|
64
|
-
input_file = if
|
|
65
|
-
"bom.
|
|
57
|
+
input_file = if File.exist?("bom.json")
|
|
58
|
+
"bom.json"
|
|
66
59
|
elsif File.exist?("bom-cyclonedx.json")
|
|
67
60
|
"bom-cyclonedx.json"
|
|
68
61
|
elsif File.exist?("bom-cyclonedx.xml")
|
|
@@ -75,30 +68,26 @@ module Bundler
|
|
|
75
68
|
unless File.exist?(input_file)
|
|
76
69
|
file_type = (File.extname(input_file) == ".xml") ? "xml" : "json"
|
|
77
70
|
sbom_type = input_file.include?("cyclonedx") ? "cyclonedx" : "spdx"
|
|
78
|
-
|
|
79
|
-
exit 1
|
|
71
|
+
raise Thor::Error, "Error: #{input_file} not found. Run 'bundle sbom dump --format=#{file_type} --sbom=#{sbom_type}' first."
|
|
80
72
|
end
|
|
81
73
|
|
|
82
|
-
|
|
83
|
-
content = File.read(input_file)
|
|
74
|
+
content = File.read(input_file)
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
Bundler::Sbom::Reporter.display_license_report(sbom)
|
|
92
|
-
rescue JSON::ParserError
|
|
93
|
-
Bundler.ui.error("Error: #{input_file} is not a valid JSON file")
|
|
94
|
-
exit 1
|
|
95
|
-
rescue => e
|
|
96
|
-
Bundler.ui.error("Error processing #{input_file}: #{e.message}")
|
|
97
|
-
exit 1
|
|
76
|
+
sbom = if format == "xml" || (!format && File.extname(input_file) == ".xml")
|
|
77
|
+
Bundler::Sbom::Generator.parse_xml(content)
|
|
78
|
+
else
|
|
79
|
+
Bundler::Sbom::Generator.from_hash(JSON.parse(content))
|
|
98
80
|
end
|
|
81
|
+
|
|
82
|
+
Bundler::Sbom::Reporter.new(sbom).display_license_report
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
raise Thor::Error, "Error: #{input_file} is not a valid JSON file"
|
|
85
|
+
rescue Thor::Error
|
|
86
|
+
raise
|
|
87
|
+
rescue => e
|
|
88
|
+
raise Thor::Error, "Error processing #{input_file}: #{e.message}"
|
|
99
89
|
end
|
|
100
90
|
|
|
101
|
-
# 適切にエラーで終了することを保証するためのメソッド
|
|
102
91
|
def self.exit_on_failure?
|
|
103
92
|
true
|
|
104
93
|
end
|
|
@@ -108,7 +97,6 @@ module Bundler
|
|
|
108
97
|
def parse_without_groups(without_option)
|
|
109
98
|
return [] unless without_option
|
|
110
99
|
|
|
111
|
-
# Split by comma or colon and clean up whitespace
|
|
112
100
|
groups = without_option.split(%r{[:,]}).map(&:strip).reject(&:empty?)
|
|
113
101
|
groups.map(&:to_sym)
|
|
114
102
|
end
|
|
@@ -1,115 +1,207 @@
|
|
|
1
1
|
require "bundler"
|
|
2
2
|
require "securerandom"
|
|
3
|
-
require "
|
|
3
|
+
require "bundler/sbom/sbom_document"
|
|
4
4
|
|
|
5
5
|
module Bundler
|
|
6
6
|
module Sbom
|
|
7
7
|
class CycloneDX
|
|
8
|
-
|
|
8
|
+
include SbomDocument
|
|
9
|
+
|
|
10
|
+
SPEC_VERSION = "1.7"
|
|
11
|
+
XML_NAMESPACE = "http://cyclonedx.org/schema/bom/#{SPEC_VERSION}"
|
|
12
|
+
|
|
13
|
+
def self.generate(gem_data, document_name, direct_dependencies: [])
|
|
9
14
|
serial_number = SecureRandom.uuid
|
|
10
15
|
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
16
|
+
root_ref = document_name
|
|
11
17
|
sbom = {
|
|
12
18
|
"bomFormat" => "CycloneDX",
|
|
13
|
-
"specVersion" =>
|
|
19
|
+
"specVersion" => SPEC_VERSION,
|
|
14
20
|
"serialNumber" => "urn:uuid:#{serial_number}",
|
|
15
21
|
"version" => 1,
|
|
16
22
|
"metadata" => {
|
|
17
23
|
"timestamp" => timestamp,
|
|
18
|
-
"tools" =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
"tools" => {
|
|
25
|
+
"components" => [
|
|
26
|
+
{
|
|
27
|
+
"type" => "application",
|
|
28
|
+
"name" => "bundle-sbom",
|
|
29
|
+
"version" => Bundler::Sbom::VERSION
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
25
33
|
"component" => {
|
|
26
34
|
"type" => "application",
|
|
35
|
+
"bom-ref" => root_ref,
|
|
27
36
|
"name" => document_name,
|
|
28
|
-
"version" => "0.0.0"
|
|
37
|
+
"version" => "0.0.0"
|
|
29
38
|
}
|
|
30
39
|
},
|
|
31
|
-
"components" => []
|
|
40
|
+
"components" => [],
|
|
41
|
+
"dependencies" => []
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
next if seen_gems.include?(gem_key)
|
|
39
|
-
seen_gems.add(gem_key)
|
|
40
|
-
licenses = SpecLicenseFinder.find_licenses(spec)
|
|
44
|
+
ref_by_name = {}
|
|
45
|
+
gem_data.each do |gem|
|
|
46
|
+
ref_by_name[gem[:name]] = "pkg:gem/#{gem[:name]}@#{gem[:version]}"
|
|
47
|
+
end
|
|
41
48
|
|
|
49
|
+
gem_data.each do |gem|
|
|
50
|
+
purl = ref_by_name[gem[:name]]
|
|
42
51
|
component = {
|
|
43
52
|
"type" => "library",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
53
|
+
"bom-ref" => purl,
|
|
54
|
+
"name" => gem[:name],
|
|
55
|
+
"version" => gem[:version],
|
|
56
|
+
"purl" => purl
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
unless gem[:licenses].empty?
|
|
60
|
+
component["licenses"] = gem[:licenses].map { |license| build_license_entry(license) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sbom["components"] << component
|
|
64
|
+
|
|
65
|
+
dep_refs = (gem[:dependencies] || []).filter_map { |name| ref_by_name[name] }
|
|
66
|
+
sbom["dependencies"] << {"ref" => purl, "dependsOn" => dep_refs}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
root_deps = direct_dependencies.filter_map { |name| ref_by_name[name] }
|
|
70
|
+
sbom["dependencies"].unshift({"ref" => root_ref, "dependsOn" => root_deps})
|
|
71
|
+
|
|
72
|
+
new(sbom)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.parse_xml(doc)
|
|
76
|
+
root = doc.root
|
|
77
|
+
spec_version = root.namespace.to_s[%r{/bom/(\d+\.\d+)}, 1] || SPEC_VERSION
|
|
78
|
+
|
|
79
|
+
sbom = {
|
|
80
|
+
"bomFormat" => "CycloneDX",
|
|
81
|
+
"specVersion" => spec_version,
|
|
82
|
+
"serialNumber" => root.attributes["serialNumber"],
|
|
83
|
+
"version" => root.attributes["version"].to_i,
|
|
84
|
+
"metadata" => {
|
|
85
|
+
"timestamp" => get_element_text(root, "metadata/timestamp"),
|
|
86
|
+
"tools" => {"components" => []},
|
|
87
|
+
"component" => {
|
|
88
|
+
"type" => REXML::XPath.first(root, "metadata/component").attributes["type"],
|
|
89
|
+
"name" => get_element_text(root, "metadata/component/name"),
|
|
90
|
+
"version" => get_element_text(root, "metadata/component/version")
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"components" => []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
REXML::XPath.each(root, "metadata/tools/components/component") do |tool|
|
|
97
|
+
sbom["metadata"]["tools"]["components"] << {
|
|
98
|
+
"type" => tool.attributes["type"],
|
|
99
|
+
"name" => get_element_text(tool, "name"),
|
|
100
|
+
"version" => get_element_text(tool, "version")
|
|
47
101
|
}
|
|
102
|
+
end
|
|
48
103
|
|
|
49
|
-
|
|
50
|
-
|
|
104
|
+
REXML::XPath.each(root, "metadata/tools/tool") do |tool|
|
|
105
|
+
sbom["metadata"]["tools"]["components"] << {
|
|
106
|
+
"type" => "application",
|
|
107
|
+
"name" => get_element_text(tool, "name"),
|
|
108
|
+
"version" => get_element_text(tool, "version")
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
REXML::XPath.each(root, "components/component") do |comp|
|
|
113
|
+
component = {
|
|
114
|
+
"type" => comp.attributes["type"],
|
|
115
|
+
"bom-ref" => comp.attributes["bom-ref"],
|
|
116
|
+
"name" => get_element_text(comp, "name"),
|
|
117
|
+
"version" => get_element_text(comp, "version"),
|
|
118
|
+
"purl" => get_element_text(comp, "purl")
|
|
119
|
+
}.compact
|
|
120
|
+
|
|
121
|
+
licenses = []
|
|
122
|
+
REXML::XPath.each(comp, "licenses/license") do |license|
|
|
123
|
+
license_id = get_element_text(license, "id")
|
|
124
|
+
license_name = get_element_text(license, "name")
|
|
125
|
+
if license_id
|
|
126
|
+
licenses << {"license" => {"id" => license_id}}
|
|
127
|
+
elsif license_name
|
|
128
|
+
licenses << {"license" => {"name" => license_name}}
|
|
129
|
+
end
|
|
51
130
|
end
|
|
52
131
|
|
|
132
|
+
component["licenses"] = licenses unless licenses.empty?
|
|
53
133
|
sbom["components"] << component
|
|
54
134
|
end
|
|
55
135
|
|
|
56
|
-
|
|
136
|
+
meta_component = REXML::XPath.first(root, "metadata/component")
|
|
137
|
+
if meta_component && meta_component.attributes["bom-ref"]
|
|
138
|
+
sbom["metadata"]["component"]["bom-ref"] = meta_component.attributes["bom-ref"]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
sbom["dependencies"] = []
|
|
142
|
+
REXML::XPath.each(root, "dependencies/dependency") do |dep|
|
|
143
|
+
depends_on = REXML::XPath.each(dep, "dependency").map { |c| c.attributes["ref"] }
|
|
144
|
+
sbom["dependencies"] << {"ref" => dep.attributes["ref"], "dependsOn" => depends_on}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
new(sbom)
|
|
57
148
|
end
|
|
58
149
|
|
|
59
|
-
def
|
|
150
|
+
def to_xml
|
|
60
151
|
doc = REXML::Document.new
|
|
61
152
|
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
62
153
|
|
|
63
|
-
# Root element
|
|
64
154
|
root = REXML::Element.new("bom")
|
|
65
|
-
root.add_namespace(
|
|
155
|
+
root.add_namespace(XML_NAMESPACE)
|
|
66
156
|
root.add_attributes({
|
|
67
|
-
"serialNumber" =>
|
|
68
|
-
"version" =>
|
|
157
|
+
"serialNumber" => @data["serialNumber"],
|
|
158
|
+
"version" => @data["version"].to_s
|
|
69
159
|
})
|
|
70
160
|
doc.add_element(root)
|
|
71
161
|
|
|
72
|
-
# Metadata
|
|
73
162
|
metadata = REXML::Element.new("metadata")
|
|
74
163
|
root.add_element(metadata)
|
|
75
164
|
|
|
76
|
-
add_element(metadata, "timestamp",
|
|
165
|
+
add_element(metadata, "timestamp", @data["metadata"]["timestamp"])
|
|
77
166
|
|
|
78
|
-
# Tools
|
|
79
167
|
tools = REXML::Element.new("tools")
|
|
80
168
|
metadata.add_element(tools)
|
|
81
169
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
170
|
+
tool_components = REXML::Element.new("components")
|
|
171
|
+
tools.add_element(tool_components)
|
|
172
|
+
|
|
173
|
+
each_tool_component do |tool_data|
|
|
174
|
+
tool = REXML::Element.new("component")
|
|
175
|
+
tool.add_attribute("type", tool_data["type"] || "application")
|
|
176
|
+
tool_components.add_element(tool)
|
|
85
177
|
|
|
86
|
-
add_element(tool, "vendor", tool_data["vendor"])
|
|
87
178
|
add_element(tool, "name", tool_data["name"])
|
|
88
179
|
add_element(tool, "version", tool_data["version"].to_s)
|
|
89
180
|
end
|
|
90
181
|
|
|
91
|
-
# Component (root project)
|
|
92
182
|
component = REXML::Element.new("component")
|
|
93
|
-
component.add_attribute("type",
|
|
183
|
+
component.add_attribute("type", @data["metadata"]["component"]["type"])
|
|
184
|
+
if @data["metadata"]["component"]["bom-ref"]
|
|
185
|
+
component.add_attribute("bom-ref", @data["metadata"]["component"]["bom-ref"])
|
|
186
|
+
end
|
|
94
187
|
metadata.add_element(component)
|
|
95
188
|
|
|
96
|
-
add_element(component, "name",
|
|
97
|
-
add_element(component, "version",
|
|
189
|
+
add_element(component, "name", @data["metadata"]["component"]["name"])
|
|
190
|
+
add_element(component, "version", @data["metadata"]["component"]["version"])
|
|
98
191
|
|
|
99
|
-
# Components
|
|
100
192
|
components = REXML::Element.new("components")
|
|
101
193
|
root.add_element(components)
|
|
102
194
|
|
|
103
|
-
|
|
195
|
+
@data["components"].each do |comp_data|
|
|
104
196
|
comp = REXML::Element.new("component")
|
|
105
197
|
comp.add_attribute("type", comp_data["type"])
|
|
198
|
+
comp.add_attribute("bom-ref", comp_data["bom-ref"]) if comp_data["bom-ref"]
|
|
106
199
|
components.add_element(comp)
|
|
107
200
|
|
|
108
201
|
add_element(comp, "name", comp_data["name"])
|
|
109
202
|
add_element(comp, "version", comp_data["version"])
|
|
110
203
|
add_element(comp, "purl", comp_data["purl"])
|
|
111
204
|
|
|
112
|
-
# Licenses
|
|
113
205
|
if comp_data["licenses"] && !comp_data["licenses"].empty?
|
|
114
206
|
licenses = REXML::Element.new("licenses")
|
|
115
207
|
comp.add_element(licenses)
|
|
@@ -120,90 +212,38 @@ module Bundler
|
|
|
120
212
|
|
|
121
213
|
if license_data["license"]["id"]
|
|
122
214
|
add_element(license, "id", license_data["license"]["id"])
|
|
215
|
+
elsif license_data["license"]["name"]
|
|
216
|
+
add_element(license, "name", license_data["license"]["name"])
|
|
123
217
|
end
|
|
124
218
|
end
|
|
125
219
|
end
|
|
126
220
|
end
|
|
127
221
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
formatter.write(doc, output)
|
|
132
|
-
output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
|
|
133
|
-
end
|
|
222
|
+
if @data["dependencies"] && !@data["dependencies"].empty?
|
|
223
|
+
deps_el = REXML::Element.new("dependencies")
|
|
224
|
+
root.add_element(deps_el)
|
|
134
225
|
|
|
135
|
-
|
|
136
|
-
|
|
226
|
+
@data["dependencies"].each do |dep|
|
|
227
|
+
dep_el = REXML::Element.new("dependency")
|
|
228
|
+
dep_el.add_attribute("ref", dep["ref"])
|
|
229
|
+
deps_el.add_element(dep_el)
|
|
137
230
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"metadata" => {
|
|
144
|
-
"timestamp" => get_element_text(root, "metadata/timestamp"),
|
|
145
|
-
"tools" => [],
|
|
146
|
-
"component" => {
|
|
147
|
-
"type" => REXML::XPath.first(root, "metadata/component").attributes["type"],
|
|
148
|
-
"name" => get_element_text(root, "metadata/component/name"),
|
|
149
|
-
"version" => get_element_text(root, "metadata/component/version")
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
"components" => []
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
# Collect tools
|
|
156
|
-
REXML::XPath.each(root, "metadata/tools/tool") do |tool|
|
|
157
|
-
tool_data = {
|
|
158
|
-
"vendor" => get_element_text(tool, "vendor"),
|
|
159
|
-
"name" => get_element_text(tool, "name"),
|
|
160
|
-
"version" => get_element_text(tool, "version")
|
|
161
|
-
}
|
|
162
|
-
sbom["metadata"]["tools"] << tool_data
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Collect components
|
|
166
|
-
REXML::XPath.each(root, "components/component") do |comp|
|
|
167
|
-
component = {
|
|
168
|
-
"type" => comp.attributes["type"],
|
|
169
|
-
"name" => get_element_text(comp, "name"),
|
|
170
|
-
"version" => get_element_text(comp, "version"),
|
|
171
|
-
"purl" => get_element_text(comp, "purl")
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
# Collect licenses
|
|
175
|
-
licenses = []
|
|
176
|
-
REXML::XPath.each(comp, "licenses/license") do |license|
|
|
177
|
-
license_id = get_element_text(license, "id")
|
|
178
|
-
licenses << {"license" => {"id" => license_id}} if license_id
|
|
231
|
+
(dep["dependsOn"] || []).each do |child_ref|
|
|
232
|
+
child = REXML::Element.new("dependency")
|
|
233
|
+
child.add_attribute("ref", child_ref)
|
|
234
|
+
dep_el.add_element(child)
|
|
235
|
+
end
|
|
179
236
|
end
|
|
180
|
-
|
|
181
|
-
component["licenses"] = licenses unless licenses.empty?
|
|
182
|
-
sbom["components"] << component
|
|
183
237
|
end
|
|
184
238
|
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
"packages" => sbom["components"].map do |comp|
|
|
188
|
-
license_string = if comp["licenses"]
|
|
189
|
-
comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
|
|
190
|
-
else
|
|
191
|
-
"NOASSERTION"
|
|
192
|
-
end
|
|
193
|
-
{
|
|
194
|
-
"name" => comp["name"],
|
|
195
|
-
"versionInfo" => comp["version"],
|
|
196
|
-
"licenseDeclared" => license_string
|
|
197
|
-
}
|
|
198
|
-
end
|
|
199
|
-
}
|
|
239
|
+
format_xml(doc)
|
|
200
240
|
end
|
|
201
241
|
|
|
202
|
-
def
|
|
242
|
+
def to_report_format
|
|
203
243
|
{
|
|
204
|
-
"packages" =>
|
|
244
|
+
"packages" => @data["components"].map do |comp|
|
|
205
245
|
license_string = if comp["licenses"]
|
|
206
|
-
comp["licenses"].map { |l| l["license"]["id"] }.join(", ")
|
|
246
|
+
comp["licenses"].map { |l| l["license"]["id"] || l["license"]["name"] }.join(", ")
|
|
207
247
|
else
|
|
208
248
|
"NOASSERTION"
|
|
209
249
|
end
|
|
@@ -216,18 +256,27 @@ module Bundler
|
|
|
216
256
|
}
|
|
217
257
|
end
|
|
218
258
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
259
|
+
def each_tool_component(&block)
|
|
260
|
+
tools = @data.dig("metadata", "tools")
|
|
261
|
+
case tools
|
|
262
|
+
when Hash
|
|
263
|
+
(tools["components"] || []).each(&block)
|
|
264
|
+
when Array
|
|
265
|
+
tools.each(&block)
|
|
266
|
+
end
|
|
225
267
|
end
|
|
226
268
|
|
|
227
|
-
def self.
|
|
228
|
-
|
|
229
|
-
|
|
269
|
+
def self.build_license_entry(license)
|
|
270
|
+
mapped = SPDX::DEPRECATED_LICENSE_MAP[license]
|
|
271
|
+
license = mapped if mapped
|
|
272
|
+
|
|
273
|
+
if SpdxLicenses.exist?(license)
|
|
274
|
+
{"license" => {"id" => license}}
|
|
275
|
+
else
|
|
276
|
+
{"license" => {"name" => license}}
|
|
277
|
+
end
|
|
230
278
|
end
|
|
279
|
+
private_class_method :build_license_entry
|
|
231
280
|
end
|
|
232
281
|
end
|
|
233
282
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require "bundler"
|
|
2
|
-
require "
|
|
3
|
-
require "json"
|
|
2
|
+
require "set"
|
|
4
3
|
require "rexml/document"
|
|
5
4
|
require "bundler/sbom/spdx"
|
|
6
5
|
require "bundler/sbom/cyclonedx"
|
|
@@ -10,7 +9,12 @@ module Bundler
|
|
|
10
9
|
class GemfileLockNotFoundError < StandardError; end
|
|
11
10
|
|
|
12
11
|
class Generator
|
|
13
|
-
def
|
|
12
|
+
def initialize(format: "spdx", without_groups: [])
|
|
13
|
+
@format = format.to_s.downcase
|
|
14
|
+
@without_groups = without_groups
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate
|
|
14
18
|
lockfile_path = Bundler.default_lockfile
|
|
15
19
|
if !lockfile_path || !lockfile_path.exist?
|
|
16
20
|
Bundler.ui.error "No Gemfile.lock found. Run `bundle install` first."
|
|
@@ -20,22 +24,15 @@ module Bundler
|
|
|
20
24
|
lockfile = Bundler::LockfileParser.new(lockfile_path.read)
|
|
21
25
|
document_name = File.basename(Dir.pwd)
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
gems = get_gems_for_groups(lockfile)
|
|
28
|
+
gem_data = resolve_gem_data(gems)
|
|
29
|
+
direct_dependencies = lockfile.dependencies.keys
|
|
25
30
|
|
|
26
|
-
case format
|
|
31
|
+
case @format
|
|
27
32
|
when "cyclonedx"
|
|
28
|
-
CycloneDX.generate(
|
|
29
|
-
else # default to spdx
|
|
30
|
-
SPDX.generate(gems, document_name)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def self.convert_to_xml(sbom)
|
|
35
|
-
if sbom["bomFormat"] == "CycloneDX"
|
|
36
|
-
CycloneDX.to_xml(sbom)
|
|
33
|
+
CycloneDX.generate(gem_data, document_name, direct_dependencies: direct_dependencies)
|
|
37
34
|
else
|
|
38
|
-
SPDX.
|
|
35
|
+
SPDX.generate(gem_data, document_name)
|
|
39
36
|
end
|
|
40
37
|
end
|
|
41
38
|
|
|
@@ -43,34 +40,37 @@ module Bundler
|
|
|
43
40
|
doc = REXML::Document.new(xml_content)
|
|
44
41
|
root = doc.root
|
|
45
42
|
|
|
46
|
-
# Determine if it's CycloneDX or SPDX
|
|
47
43
|
if root.name == "bom" && root.namespace.include?("cyclonedx.org")
|
|
48
44
|
CycloneDX.parse_xml(doc)
|
|
49
45
|
else
|
|
50
|
-
|
|
46
|
+
raise ArgumentError, "Unsupported XML SBOM: only CycloneDX XML can be read"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.from_hash(hash)
|
|
51
|
+
if hash["bomFormat"] == "CycloneDX"
|
|
52
|
+
CycloneDX.new(hash)
|
|
53
|
+
else
|
|
54
|
+
SPDX.new(hash)
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
private
|
|
55
59
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
if without_groups.empty?
|
|
60
|
+
def get_gems_for_groups(lockfile)
|
|
61
|
+
if @without_groups.empty?
|
|
59
62
|
return lockfile.specs
|
|
60
63
|
end
|
|
61
64
|
|
|
62
|
-
# Try to get group information from Bundler.definition if available
|
|
63
65
|
if defined?(Bundler::Definition) && Bundler.respond_to?(:definition)
|
|
64
66
|
begin
|
|
65
67
|
definition = Bundler.definition
|
|
66
68
|
all_groups = definition.groups
|
|
67
|
-
include_groups = all_groups - without_groups
|
|
69
|
+
include_groups = all_groups - @without_groups
|
|
68
70
|
|
|
69
|
-
# Use specs_for to get all gems (including transitive dependencies) for included groups
|
|
70
71
|
if definition.respond_to?(:specs_for)
|
|
71
72
|
definition.specs_for(include_groups)
|
|
72
73
|
else
|
|
73
|
-
# Fallback to old method if specs_for is not available
|
|
74
74
|
included_gems = Set.new
|
|
75
75
|
include_groups.each do |group|
|
|
76
76
|
definition.dependencies_for(group).each do |dep|
|
|
@@ -80,7 +80,6 @@ module Bundler
|
|
|
80
80
|
lockfile.specs.select { |spec| included_gems.include?(spec.name) }
|
|
81
81
|
end
|
|
82
82
|
rescue => e
|
|
83
|
-
# Fallback to all specs if there's any issue with Bundler.definition
|
|
84
83
|
Bundler.ui.warn("Warning: Could not determine group information: #{e.message}")
|
|
85
84
|
lockfile.specs
|
|
86
85
|
end
|
|
@@ -88,6 +87,26 @@ module Bundler
|
|
|
88
87
|
lockfile.specs
|
|
89
88
|
end
|
|
90
89
|
end
|
|
90
|
+
|
|
91
|
+
def resolve_gem_data(gems)
|
|
92
|
+
seen = Set.new
|
|
93
|
+
gems.filter_map do |spec|
|
|
94
|
+
gem_key = "#{spec.name}:#{spec.version}"
|
|
95
|
+
next if seen.include?(gem_key)
|
|
96
|
+
seen.add(gem_key)
|
|
97
|
+
{
|
|
98
|
+
name: spec.name,
|
|
99
|
+
version: spec.version.to_s,
|
|
100
|
+
licenses: SpecLicenseFinder.find_licenses(spec),
|
|
101
|
+
dependencies: spec_dependency_names(spec)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def spec_dependency_names(spec)
|
|
107
|
+
return [] unless spec.respond_to?(:dependencies) && spec.dependencies
|
|
108
|
+
spec.dependencies.reject { |d| d.respond_to?(:type) && d.type == :development }.map(&:name)
|
|
109
|
+
end
|
|
91
110
|
end
|
|
92
111
|
end
|
|
93
112
|
end
|
|
@@ -1,63 +1,55 @@
|
|
|
1
1
|
module Bundler
|
|
2
2
|
module Sbom
|
|
3
3
|
class Reporter
|
|
4
|
-
def
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
CycloneDX.to_report_format(sbom)
|
|
8
|
-
else
|
|
9
|
-
SPDX.to_report_format(sbom)
|
|
10
|
-
end
|
|
4
|
+
def initialize(sbom)
|
|
5
|
+
@sbom = sbom
|
|
6
|
+
end
|
|
11
7
|
|
|
12
|
-
|
|
8
|
+
def display_license_report
|
|
9
|
+
report = @sbom.to_report_format
|
|
10
|
+
display_report(report)
|
|
13
11
|
end
|
|
14
12
|
|
|
15
13
|
private
|
|
16
14
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
:spdx
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def self.display_report(sbom)
|
|
23
|
-
license_count = analyze_licenses(sbom)
|
|
15
|
+
def display_report(report)
|
|
16
|
+
license_count = analyze_licenses(report)
|
|
24
17
|
sorted_licenses = license_count.sort_by { |_, count| -count }
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
Bundler.ui.info "=== License Usage in SBOM ==="
|
|
20
|
+
Bundler.ui.info "Total packages: #{report["packages"].size}"
|
|
21
|
+
Bundler.ui.info ""
|
|
29
22
|
|
|
30
23
|
sorted_licenses.each do |license, count|
|
|
31
|
-
|
|
24
|
+
Bundler.ui.info "#{license}: #{count} package(s)"
|
|
32
25
|
end
|
|
33
26
|
|
|
34
|
-
|
|
27
|
+
Bundler.ui.info "\n=== Packages by License ==="
|
|
35
28
|
sorted_licenses.each do |license, _|
|
|
36
|
-
packages =
|
|
37
|
-
|
|
38
|
-
package["licenseDeclared"].split(",").map(&:strip).include?(license)
|
|
39
|
-
else
|
|
40
|
-
package["licenseDeclared"] == license
|
|
41
|
-
end
|
|
29
|
+
packages = report["packages"].select do |package|
|
|
30
|
+
split_licenses(package["licenseDeclared"]).include?(license)
|
|
42
31
|
end
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
Bundler.ui.info "\n#{license} (#{packages.size} package(s)):"
|
|
45
34
|
packages.each do |package|
|
|
46
|
-
|
|
35
|
+
Bundler.ui.info " - #{package["name"]} (#{package["versionInfo"]})"
|
|
47
36
|
end
|
|
48
37
|
end
|
|
49
38
|
end
|
|
50
39
|
|
|
51
|
-
def
|
|
40
|
+
def analyze_licenses(report)
|
|
52
41
|
license_count = Hash.new(0)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
licenses.each do |license|
|
|
42
|
+
report["packages"].each do |package|
|
|
43
|
+
split_licenses(package["licenseDeclared"]).each do |license|
|
|
56
44
|
license_count[license] += 1
|
|
57
45
|
end
|
|
58
46
|
end
|
|
59
47
|
license_count
|
|
60
48
|
end
|
|
49
|
+
|
|
50
|
+
def split_licenses(license_declared)
|
|
51
|
+
license_declared.split(/ AND |, /).map(&:strip)
|
|
52
|
+
end
|
|
61
53
|
end
|
|
62
54
|
end
|
|
63
55
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "rexml/document"
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Sbom
|
|
5
|
+
module SbomDocument
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.attr_reader :data
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(data)
|
|
12
|
+
@data = data
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_hash
|
|
16
|
+
@data
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ClassMethods
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def get_element_text(element, xpath)
|
|
23
|
+
result = REXML::XPath.first(element, xpath)
|
|
24
|
+
result ? result.text : nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def add_element(parent, name, value)
|
|
31
|
+
element = REXML::Element.new(name)
|
|
32
|
+
element.text = value
|
|
33
|
+
parent.add_element(element)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_xml(doc)
|
|
37
|
+
formatter = REXML::Formatters::Pretty.new(2)
|
|
38
|
+
formatter.compact = true
|
|
39
|
+
output = ""
|
|
40
|
+
formatter.write(doc, output)
|
|
41
|
+
output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/bundler/sbom/spdx.rb
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
require "bundler"
|
|
2
2
|
require "securerandom"
|
|
3
|
-
require "
|
|
3
|
+
require "spdx-licenses"
|
|
4
|
+
require "bundler/sbom/sbom_document"
|
|
4
5
|
|
|
5
6
|
module Bundler
|
|
6
7
|
module Sbom
|
|
7
8
|
class SPDX
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
include SbomDocument
|
|
10
|
+
|
|
11
|
+
def self.generate(gem_data, document_name)
|
|
12
|
+
spdx_id = SecureRandom.uuid
|
|
10
13
|
sbom = {
|
|
11
14
|
"SPDXID" => "SPDXRef-DOCUMENT",
|
|
12
15
|
"spdxVersion" => "SPDX-2.3",
|
|
13
16
|
"creationInfo" => {
|
|
14
17
|
"created" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
15
|
-
"creators" => ["Tool: bundle-sbom"]
|
|
16
|
-
"licenseListVersion" => "3.20"
|
|
18
|
+
"creators" => ["Tool: bundle-sbom"]
|
|
17
19
|
},
|
|
18
20
|
"name" => document_name,
|
|
19
21
|
"dataLicense" => "CC0-1.0",
|
|
@@ -21,20 +23,24 @@ module Bundler
|
|
|
21
23
|
"packages" => []
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
if (list_version = license_list_version)
|
|
27
|
+
sbom["creationInfo"]["licenseListVersion"] = list_version
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
package_ids = {}
|
|
31
|
+
gem_data.each do |gem|
|
|
32
|
+
package_ids[gem[:name]] = "SPDXRef-Package-#{gem[:name]}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
gem_data.each do |gem|
|
|
36
|
+
spdx_licenses = gem[:licenses].map { |l| normalize_license_id(l) }
|
|
37
|
+
license_string = spdx_licenses.empty? ? "NOASSERTION" : spdx_licenses.join(" AND ")
|
|
32
38
|
|
|
33
39
|
package = {
|
|
34
|
-
"SPDXID" =>
|
|
35
|
-
"name" =>
|
|
36
|
-
"versionInfo" =>
|
|
37
|
-
"downloadLocation" => "
|
|
40
|
+
"SPDXID" => package_ids[gem[:name]],
|
|
41
|
+
"name" => gem[:name],
|
|
42
|
+
"versionInfo" => gem[:version],
|
|
43
|
+
"downloadLocation" => "https://rubygems.org/gems/#{gem[:name]}/versions/#{gem[:version]}",
|
|
38
44
|
"filesAnalyzed" => false,
|
|
39
45
|
"licenseConcluded" => license_string,
|
|
40
46
|
"licenseDeclared" => license_string,
|
|
@@ -42,9 +48,9 @@ module Bundler
|
|
|
42
48
|
"supplier" => "NOASSERTION",
|
|
43
49
|
"externalRefs" => [
|
|
44
50
|
{
|
|
45
|
-
"referenceCategory" => "
|
|
51
|
+
"referenceCategory" => "PACKAGE-MANAGER",
|
|
46
52
|
"referenceType" => "purl",
|
|
47
|
-
"referenceLocator" => "pkg:gem/#{
|
|
53
|
+
"referenceLocator" => "pkg:gem/#{gem[:name]}@#{gem[:version]}"
|
|
48
54
|
}
|
|
49
55
|
]
|
|
50
56
|
}
|
|
@@ -52,139 +58,66 @@ module Bundler
|
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
sbom["documentDescribes"] = sbom["packages"].map { |p| p["SPDXID"] }
|
|
55
|
-
sbom
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def self.to_xml(sbom)
|
|
59
|
-
doc = REXML::Document.new
|
|
60
|
-
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
61
|
-
|
|
62
|
-
# Root element
|
|
63
|
-
root = REXML::Element.new("SpdxDocument")
|
|
64
|
-
root.add_namespace("https://spdx.org/spdxdocs/")
|
|
65
|
-
doc.add_element(root)
|
|
66
|
-
|
|
67
|
-
# Document info
|
|
68
|
-
add_element(root, "SPDXID", sbom["SPDXID"])
|
|
69
|
-
add_element(root, "spdxVersion", sbom["spdxVersion"])
|
|
70
|
-
add_element(root, "name", sbom["name"])
|
|
71
|
-
add_element(root, "dataLicense", sbom["dataLicense"])
|
|
72
|
-
add_element(root, "documentNamespace", sbom["documentNamespace"])
|
|
73
|
-
|
|
74
|
-
# Creation info
|
|
75
|
-
creation_info = REXML::Element.new("creationInfo")
|
|
76
|
-
root.add_element(creation_info)
|
|
77
|
-
add_element(creation_info, "created", sbom["creationInfo"]["created"])
|
|
78
|
-
add_element(creation_info, "licenseListVersion", sbom["creationInfo"]["licenseListVersion"])
|
|
79
|
-
|
|
80
|
-
sbom["creationInfo"]["creators"].each do |creator|
|
|
81
|
-
add_element(creation_info, "creator", creator)
|
|
82
|
-
end
|
|
83
61
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
sbom["relationships"] = sbom["packages"].map do |p|
|
|
63
|
+
{
|
|
64
|
+
"spdxElementId" => "SPDXRef-DOCUMENT",
|
|
65
|
+
"relatedSpdxElement" => p["SPDXID"],
|
|
66
|
+
"relationshipType" => "DESCRIBES"
|
|
67
|
+
}
|
|
87
68
|
end
|
|
88
69
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
add_element(package, "filesAnalyzed", pkg["filesAnalyzed"].to_s)
|
|
99
|
-
add_element(package, "licenseConcluded", pkg["licenseConcluded"])
|
|
100
|
-
add_element(package, "licenseDeclared", pkg["licenseDeclared"])
|
|
101
|
-
add_element(package, "copyrightText", pkg["copyrightText"])
|
|
102
|
-
add_element(package, "supplier", pkg["supplier"])
|
|
103
|
-
|
|
104
|
-
# External references
|
|
105
|
-
if pkg["externalRefs"]
|
|
106
|
-
pkg["externalRefs"].each do |ref|
|
|
107
|
-
ext_ref = REXML::Element.new("externalRef")
|
|
108
|
-
package.add_element(ext_ref)
|
|
109
|
-
|
|
110
|
-
add_element(ext_ref, "referenceCategory", ref["referenceCategory"])
|
|
111
|
-
add_element(ext_ref, "referenceType", ref["referenceType"])
|
|
112
|
-
add_element(ext_ref, "referenceLocator", ref["referenceLocator"])
|
|
113
|
-
end
|
|
70
|
+
gem_data.each do |gem|
|
|
71
|
+
(gem[:dependencies] || []).each do |dep_name|
|
|
72
|
+
dep_id = package_ids[dep_name]
|
|
73
|
+
next unless dep_id
|
|
74
|
+
sbom["relationships"] << {
|
|
75
|
+
"spdxElementId" => package_ids[gem[:name]],
|
|
76
|
+
"relatedSpdxElement" => dep_id,
|
|
77
|
+
"relationshipType" => "DEPENDS_ON"
|
|
78
|
+
}
|
|
114
79
|
end
|
|
115
80
|
end
|
|
116
81
|
|
|
117
|
-
|
|
118
|
-
formatter.compact = true
|
|
119
|
-
output = ""
|
|
120
|
-
formatter.write(doc, output)
|
|
121
|
-
output.sub(%r{<\?xml version='1\.0' encoding='UTF-8'\?>}, '<?xml version="1.0" encoding="UTF-8"?>')
|
|
82
|
+
new(sbom)
|
|
122
83
|
end
|
|
123
84
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"licenseListVersion" => get_element_text(root, "creationInfo/licenseListVersion"),
|
|
136
|
-
"creators" => []
|
|
137
|
-
},
|
|
138
|
-
"packages" => [],
|
|
139
|
-
"documentDescribes" => []
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
# Collect creators
|
|
143
|
-
REXML::XPath.each(root, "creationInfo/creator") do |creator|
|
|
144
|
-
sbom["creationInfo"]["creators"] << creator.text
|
|
85
|
+
DEPRECATED_LICENSE_MAP = {
|
|
86
|
+
"AGPL-3.0" => "AGPL-3.0-only",
|
|
87
|
+
"GPL-2.0" => "GPL-2.0-only",
|
|
88
|
+
"GPL-3.0" => "GPL-3.0-only",
|
|
89
|
+
"LGPL-2.1" => "LGPL-2.1-only",
|
|
90
|
+
"LGPL-3.0" => "LGPL-3.0-only",
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
def self.normalize_license_id(license_id)
|
|
94
|
+
if mapped = DEPRECATED_LICENSE_MAP[license_id]
|
|
95
|
+
return mapped
|
|
145
96
|
end
|
|
146
97
|
|
|
147
|
-
|
|
148
|
-
REXML::XPath.each(root, "documentDescribes") do |describes|
|
|
149
|
-
sbom["documentDescribes"] << describes.text
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Collect packages
|
|
153
|
-
REXML::XPath.each(root, "package") do |pkg_element|
|
|
154
|
-
package = {
|
|
155
|
-
"SPDXID" => get_element_text(pkg_element, "SPDXID"),
|
|
156
|
-
"name" => get_element_text(pkg_element, "name"),
|
|
157
|
-
"versionInfo" => get_element_text(pkg_element, "versionInfo"),
|
|
158
|
-
"downloadLocation" => get_element_text(pkg_element, "downloadLocation"),
|
|
159
|
-
"filesAnalyzed" => get_element_text(pkg_element, "filesAnalyzed") == "true",
|
|
160
|
-
"licenseConcluded" => get_element_text(pkg_element, "licenseConcluded"),
|
|
161
|
-
"licenseDeclared" => get_element_text(pkg_element, "licenseDeclared"),
|
|
162
|
-
"copyrightText" => get_element_text(pkg_element, "copyrightText"),
|
|
163
|
-
"supplier" => get_element_text(pkg_element, "supplier"),
|
|
164
|
-
"externalRefs" => []
|
|
165
|
-
}
|
|
98
|
+
return license_id if SpdxLicenses.exist?(license_id)
|
|
166
99
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"referenceType" => get_element_text(ref_element, "referenceType"),
|
|
172
|
-
"referenceLocator" => get_element_text(ref_element, "referenceLocator")
|
|
173
|
-
}
|
|
174
|
-
package["externalRefs"] << ref
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
sbom["packages"] << package
|
|
100
|
+
if license_id.start_with?("LicenseRef-")
|
|
101
|
+
license_id
|
|
102
|
+
else
|
|
103
|
+
"LicenseRef-#{license_id}"
|
|
178
104
|
end
|
|
179
|
-
|
|
180
|
-
sbom
|
|
181
105
|
end
|
|
106
|
+
private_class_method :normalize_license_id
|
|
107
|
+
|
|
108
|
+
def self.license_list_version
|
|
109
|
+
return @license_list_version if defined?(@license_list_version)
|
|
110
|
+
gem_spec = Gem.loaded_specs["spdx-licenses"]
|
|
111
|
+
path = File.join(gem_spec.full_gem_path, "licenses.json")
|
|
112
|
+
@license_list_version = JSON.parse(File.read(path))["licenseListVersion"]
|
|
113
|
+
rescue StandardError
|
|
114
|
+
@license_list_version = nil
|
|
115
|
+
end
|
|
116
|
+
private_class_method :license_list_version
|
|
182
117
|
|
|
183
|
-
def
|
|
184
|
-
# SPDXフォーマットは既にレポート形式と互換性があるため、
|
|
185
|
-
# packagesセクションだけを抽出して返す
|
|
118
|
+
def to_report_format
|
|
186
119
|
{
|
|
187
|
-
"packages" =>
|
|
120
|
+
"packages" => @data["packages"].map do |pkg|
|
|
188
121
|
{
|
|
189
122
|
"name" => pkg["name"],
|
|
190
123
|
"versionInfo" => pkg["versionInfo"],
|
|
@@ -193,21 +126,6 @@ module Bundler
|
|
|
193
126
|
end
|
|
194
127
|
}
|
|
195
128
|
end
|
|
196
|
-
|
|
197
|
-
def self.generate_spdx_id
|
|
198
|
-
SecureRandom.uuid
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def self.add_element(parent, name, value)
|
|
202
|
-
element = REXML::Element.new(name)
|
|
203
|
-
element.text = value
|
|
204
|
-
parent.add_element(element)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def self.get_element_text(element, xpath)
|
|
208
|
-
result = REXML::XPath.first(element, xpath)
|
|
209
|
-
result ? result.text : nil
|
|
210
|
-
end
|
|
211
129
|
end
|
|
212
130
|
end
|
|
213
131
|
end
|
|
@@ -2,24 +2,30 @@ module Bundler
|
|
|
2
2
|
module Sbom
|
|
3
3
|
module SpecLicenseFinder
|
|
4
4
|
def self.find_licenses(spec)
|
|
5
|
-
gemspec =
|
|
5
|
+
gemspec = begin
|
|
6
|
+
mat = spec.materialize_for_installation if spec.respond_to?(:materialize_for_installation)
|
|
7
|
+
mat if mat.respond_to?(:licenses)
|
|
8
|
+
rescue Bundler::GemspecError
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
gemspec ||= spec.__materialize__ if spec.respond_to?(:__materialize__)
|
|
14
|
+
rescue Bundler::GemspecError
|
|
15
|
+
# ignore
|
|
16
|
+
end
|
|
17
|
+
|
|
6
18
|
begin
|
|
7
19
|
gemspec ||= Gem::Specification.find_by_name(spec.name, spec.version)
|
|
8
20
|
rescue Gem::LoadError
|
|
9
|
-
#
|
|
21
|
+
Bundler.ui.warn("Warning: Could not find license information for #{spec.name} (#{spec.version})")
|
|
10
22
|
end
|
|
11
23
|
|
|
12
|
-
licenses
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
17
|
-
if gemspec.respond_to?(:licenses) && gemspec.licenses && !gemspec.licenses.empty?
|
|
18
|
-
licenses.concat(gemspec.licenses)
|
|
19
|
-
end
|
|
20
|
-
licenses.uniq!
|
|
24
|
+
if gemspec && gemspec.respond_to?(:licenses) && gemspec.licenses && !gemspec.licenses.empty?
|
|
25
|
+
gemspec.licenses
|
|
26
|
+
else
|
|
27
|
+
[]
|
|
21
28
|
end
|
|
22
|
-
licenses
|
|
23
29
|
end
|
|
24
30
|
end
|
|
25
31
|
end
|
data/lib/bundler/sbom/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bundler-sbom
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SHIBATA Hiroshi
|
|
@@ -37,7 +37,21 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
|
-
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: spdx-licenses
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Generate SBOM(Software Bill of Materials) files with Bundler
|
|
41
55
|
email:
|
|
42
56
|
- hsbt@ruby-lang.org
|
|
43
57
|
executables: []
|
|
@@ -53,6 +67,7 @@ files:
|
|
|
53
67
|
- lib/bundler/sbom/cyclonedx.rb
|
|
54
68
|
- lib/bundler/sbom/generator.rb
|
|
55
69
|
- lib/bundler/sbom/reporter.rb
|
|
70
|
+
- lib/bundler/sbom/sbom_document.rb
|
|
56
71
|
- lib/bundler/sbom/spdx.rb
|
|
57
72
|
- lib/bundler/sbom/spec_license_finder.rb
|
|
58
73
|
- lib/bundler/sbom/version.rb
|
|
@@ -63,7 +78,7 @@ licenses:
|
|
|
63
78
|
metadata:
|
|
64
79
|
homepage_uri: https://github.com/hsbt/bundler-sbom
|
|
65
80
|
source_code_uri: https://github.com/hsbt/bundler-sbom
|
|
66
|
-
changelog_uri: https://github.com/hsbt/bundler-sbom/
|
|
81
|
+
changelog_uri: https://github.com/hsbt/bundler-sbom/releases
|
|
67
82
|
bug_tracker_uri: https://github.com/hsbt/bundler-sbom/issues
|
|
68
83
|
rdoc_options: []
|
|
69
84
|
require_paths:
|
|
@@ -79,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
79
94
|
- !ruby/object:Gem::Version
|
|
80
95
|
version: '0'
|
|
81
96
|
requirements: []
|
|
82
|
-
rubygems_version: 4.0.
|
|
97
|
+
rubygems_version: 4.0.10
|
|
83
98
|
specification_version: 4
|
|
84
|
-
summary: Generate
|
|
99
|
+
summary: Generate SBOM(Software Bill of Materials) files with Bundler
|
|
85
100
|
test_files: []
|