sbom 0.1.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.
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Sbom
7
+ class Validator
8
+ SPDX_VERSIONS = %w[2.2 2.3].freeze
9
+ CYCLONEDX_VERSIONS = %w[1.4 1.5 1.6 1.7].freeze
10
+
11
+ EXTENSION_MAP = {
12
+ ".spdx" => :spdx,
13
+ ".spdx.json" => :spdx,
14
+ ".spdx.yaml" => :spdx,
15
+ ".spdx.yml" => :spdx,
16
+ ".spdx.xml" => :spdx,
17
+ ".spdx.rdf" => :spdx,
18
+ ".cdx.json" => :cyclonedx,
19
+ ".bom.json" => :cyclonedx,
20
+ ".cdx.xml" => :cyclonedx,
21
+ ".bom.xml" => :cyclonedx
22
+ }.freeze
23
+
24
+ def initialize(sbom_type: :auto, version: nil, schema_dir: nil)
25
+ @sbom_type = sbom_type
26
+ @version = version
27
+ @schema_dir = schema_dir || default_schema_dir
28
+ end
29
+
30
+ def validate_file(filename)
31
+ raise ValidatorError, "File not found: #{filename}" unless File.exist?(filename)
32
+ raise ValidatorError, "Empty file: #{filename}" if File.size(filename).zero?
33
+
34
+ content = File.read(filename)
35
+ sbom_type = detect_type(filename, content)
36
+
37
+ validate_content(content, sbom_type)
38
+ end
39
+
40
+ def validate_file!(filename)
41
+ result = validate_file(filename)
42
+ raise ValidatorError, "Invalid SBOM: #{result.errors.join(', ')}" if result.invalid?
43
+
44
+ result
45
+ end
46
+
47
+ def validate_string(content, sbom_type: nil)
48
+ sbom_type ||= detect_type_from_content(content)
49
+ validate_content(content, sbom_type)
50
+ end
51
+
52
+ def validate_string!(content, sbom_type: nil)
53
+ result = validate_string(content, sbom_type: sbom_type)
54
+ raise ValidatorError, "Invalid SBOM: #{result.errors.join(', ')}" if result.invalid?
55
+
56
+ result
57
+ end
58
+
59
+ def self.validate_file(filename, sbom_type: :auto)
60
+ new(sbom_type: sbom_type).validate_file(filename)
61
+ end
62
+
63
+ def self.validate_file!(filename, sbom_type: :auto)
64
+ new(sbom_type: sbom_type).validate_file!(filename)
65
+ end
66
+
67
+ private
68
+
69
+ def default_schema_dir
70
+ spec_dir = File.expand_path("../../spec", __dir__)
71
+ return spec_dir if File.directory?(spec_dir)
72
+
73
+ nil
74
+ end
75
+
76
+ def detect_type(filename, content)
77
+ return @sbom_type unless @sbom_type == :auto
78
+
79
+ EXTENSION_MAP.each do |ext, type|
80
+ return type if filename.end_with?(ext)
81
+ end
82
+
83
+ detect_type_from_content(content)
84
+ end
85
+
86
+ def detect_type_from_content(content)
87
+ stripped = content.strip
88
+
89
+ if stripped.start_with?("{")
90
+ begin
91
+ data = JSON.parse(stripped)
92
+ return :cyclonedx if data["bomFormat"] == "CycloneDX"
93
+ return :spdx if data["spdxVersion"]
94
+ rescue JSON::ParserError
95
+ nil
96
+ end
97
+ end
98
+
99
+ return :spdx if stripped.include?("SPDXVersion:")
100
+ return :spdx if stripped.include?("<spdx:")
101
+ return :cyclonedx if stripped.include?("cyclonedx")
102
+
103
+ :unknown
104
+ end
105
+
106
+ def validate_content(content, sbom_type)
107
+ case sbom_type
108
+ when :spdx
109
+ validate_spdx(content)
110
+ when :cyclonedx
111
+ validate_cyclonedx(content)
112
+ else
113
+ ValidationResult.new(valid: false, errors: ["Unknown SBOM format"])
114
+ end
115
+ end
116
+
117
+ def validate_spdx(content)
118
+ stripped = content.strip
119
+
120
+ if stripped.start_with?("{")
121
+ validate_spdx_json(content)
122
+ elsif stripped.include?("SPDXID:")
123
+ validate_spdx_yaml(content)
124
+ else
125
+ validate_spdx_tag(content)
126
+ end
127
+ end
128
+
129
+ def validate_spdx_json(content)
130
+ unless json_schemer_available?
131
+ return ValidationResult.new(valid: true, format: :spdx, version: extract_spdx_version_json(content))
132
+ end
133
+
134
+ schema_path = spdx_schema_path
135
+ unless schema_path && File.exist?(schema_path)
136
+ return ValidationResult.new(valid: true, format: :spdx, version: extract_spdx_version_json(content))
137
+ end
138
+
139
+ begin
140
+ data = JSON.parse(content)
141
+ schema = JSONSchemer.schema(Pathname.new(schema_path))
142
+ errors = schema.validate(data).map { |e| e["error"] }
143
+
144
+ if errors.empty?
145
+ version = data["spdxVersion"]&.gsub("SPDX-", "")
146
+ ValidationResult.new(valid: true, format: :spdx, version: version)
147
+ else
148
+ ValidationResult.new(valid: false, format: :spdx, errors: errors.first(5))
149
+ end
150
+ rescue JSON::ParserError => e
151
+ ValidationResult.new(valid: false, format: :spdx, errors: ["JSON parse error: #{e.message}"])
152
+ end
153
+ end
154
+
155
+ def validate_spdx_yaml(content)
156
+ begin
157
+ data = YAML.safe_load(content)
158
+ version = data["spdxVersion"]&.gsub("SPDX-", "")
159
+ ValidationResult.new(valid: true, format: :spdx, version: version)
160
+ rescue Psych::SyntaxError => e
161
+ ValidationResult.new(valid: false, format: :spdx, errors: ["YAML parse error: #{e.message}"])
162
+ end
163
+ end
164
+
165
+ def validate_spdx_tag(content)
166
+ version = nil
167
+ content.each_line do |line|
168
+ if line.start_with?("SPDXVersion:")
169
+ version = line.split(":").last.strip.gsub("SPDX-", "")
170
+ break
171
+ end
172
+ end
173
+
174
+ ValidationResult.new(valid: true, format: :spdx, version: version)
175
+ end
176
+
177
+ def validate_cyclonedx(content)
178
+ stripped = content.strip
179
+
180
+ if stripped.start_with?("{")
181
+ validate_cyclonedx_json(content)
182
+ else
183
+ validate_cyclonedx_xml(content)
184
+ end
185
+ end
186
+
187
+ def validate_cyclonedx_json(content)
188
+ unless json_schemer_available?
189
+ return ValidationResult.new(valid: true, format: :cyclonedx, version: extract_cyclonedx_version_json(content))
190
+ end
191
+
192
+ versions_to_try = @version ? [@version] : CYCLONEDX_VERSIONS.reverse
193
+
194
+ begin
195
+ data = JSON.parse(content)
196
+ rescue JSON::ParserError => e
197
+ return ValidationResult.new(valid: false, format: :cyclonedx, errors: ["JSON parse error: #{e.message}"])
198
+ end
199
+
200
+ versions_to_try.each do |version|
201
+ schema_path = cyclonedx_schema_path(version)
202
+ next unless schema_path && File.exist?(schema_path)
203
+
204
+ schema = JSONSchemer.schema(Pathname.new(schema_path))
205
+ return ValidationResult.new(valid: true, format: :cyclonedx, version: version) if schema.valid?(data)
206
+ end
207
+
208
+ ValidationResult.new(valid: false, format: :cyclonedx, errors: ["Does not match any known CycloneDX schema"])
209
+ end
210
+
211
+ def validate_cyclonedx_xml(content)
212
+ begin
213
+ doc = REXML::Document.new(content)
214
+ root = doc.root
215
+
216
+ if root && root.name == "bom"
217
+ namespace = root.namespace
218
+ version = namespace&.match(/bom[\/\-](\d+\.\d+)/)&.captures&.first
219
+ return ValidationResult.new(valid: true, format: :cyclonedx, version: version)
220
+ end
221
+ rescue REXML::ParseException => e
222
+ return ValidationResult.new(valid: false, format: :cyclonedx, errors: ["XML parse error: #{e.message}"])
223
+ end
224
+
225
+ ValidationResult.new(valid: false, format: :cyclonedx, errors: ["Not a valid CycloneDX XML document"])
226
+ end
227
+
228
+ def extract_spdx_version_json(content)
229
+ data = JSON.parse(content)
230
+ data["spdxVersion"]&.gsub("SPDX-", "")
231
+ rescue JSON::ParserError
232
+ nil
233
+ end
234
+
235
+ def extract_cyclonedx_version_json(content)
236
+ data = JSON.parse(content)
237
+ data["specVersion"]
238
+ rescue JSON::ParserError
239
+ nil
240
+ end
241
+
242
+ def spdx_schema_path
243
+ return nil unless @schema_dir
244
+
245
+ File.join(@schema_dir, "spdx", "schemas", "spdx-schema.json")
246
+ end
247
+
248
+ def cyclonedx_schema_path(version)
249
+ return nil unless @schema_dir
250
+
251
+ File.join(@schema_dir, "cyclonedx", "schema", "bom-#{version}.schema.json")
252
+ end
253
+
254
+ def json_schemer_available?
255
+ require "json_schemer"
256
+ true
257
+ rescue LoadError
258
+ false
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbom
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sbom.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "rexml/document"
6
+ require "purl"
7
+
8
+ require_relative "sbom/version"
9
+ require_relative "sbom/error"
10
+
11
+ # Data models
12
+ require_relative "sbom/data/document"
13
+ require_relative "sbom/data/package"
14
+ require_relative "sbom/data/file"
15
+ require_relative "sbom/data/relationship"
16
+ require_relative "sbom/data/sbom"
17
+
18
+ # License handling
19
+ require_relative "sbom/license/scanner"
20
+
21
+ # SPDX implementation
22
+ require_relative "sbom/spdx/parser"
23
+ require_relative "sbom/spdx/generator"
24
+
25
+ # CycloneDX implementation
26
+ require_relative "sbom/cyclonedx/parser"
27
+ require_relative "sbom/cyclonedx/generator"
28
+
29
+ # Facade classes
30
+ require_relative "sbom/parser"
31
+ require_relative "sbom/generator"
32
+ require_relative "sbom/validation_result"
33
+ require_relative "sbom/validator"
34
+ require_relative "sbom/output"
35
+
36
+ module Sbom
37
+ class << self
38
+ def parse_file(filename, sbom_type: :auto)
39
+ Parser.parse_file(filename, sbom_type: sbom_type)
40
+ end
41
+
42
+ def parse_string(content, sbom_type: :auto)
43
+ Parser.parse_string(content, sbom_type: sbom_type)
44
+ end
45
+
46
+ def generate(project_name, sbom_data, sbom_type: :spdx, format: :json)
47
+ Generator.generate(project_name, sbom_data, sbom_type: sbom_type, format: format)
48
+ end
49
+
50
+ def validate_file(filename, sbom_type: :auto)
51
+ Validator.validate_file(filename, sbom_type: sbom_type)
52
+ end
53
+ end
54
+ end
data/sig/sbom.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Sbom
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sbom
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Nesbitt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: purl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rexml
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.2'
54
+ description: A Ruby library for working with Software Bill of Materials in SPDX and
55
+ CycloneDX formats. Supports parsing, generation, validation, and format conversion.
56
+ email:
57
+ - andrewnez@gmail.com
58
+ executables:
59
+ - sbom
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".gitmodules"
64
+ - CHANGELOG.md
65
+ - CODE_OF_CONDUCT.md
66
+ - README.md
67
+ - Rakefile
68
+ - exe/sbom
69
+ - lib/sbom.rb
70
+ - lib/sbom/cyclonedx/generator.rb
71
+ - lib/sbom/cyclonedx/parser.rb
72
+ - lib/sbom/data/document.rb
73
+ - lib/sbom/data/file.rb
74
+ - lib/sbom/data/package.rb
75
+ - lib/sbom/data/relationship.rb
76
+ - lib/sbom/data/sbom.rb
77
+ - lib/sbom/error.rb
78
+ - lib/sbom/generator.rb
79
+ - lib/sbom/license/data/spdx_licenses.json
80
+ - lib/sbom/license/scanner.rb
81
+ - lib/sbom/output.rb
82
+ - lib/sbom/parser.rb
83
+ - lib/sbom/spdx/generator.rb
84
+ - lib/sbom/spdx/parser.rb
85
+ - lib/sbom/validation_result.rb
86
+ - lib/sbom/validator.rb
87
+ - lib/sbom/version.rb
88
+ - sig/sbom.rbs
89
+ homepage: https://github.com/andrew/sbom
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/andrew/sbom
94
+ source_code_uri: https://github.com/andrew/sbom
95
+ changelog_uri: https://github.com/andrew/sbom/blob/main/CHANGELOG.md
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.2.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.1
112
+ specification_version: 4
113
+ summary: Parse, generate, and validate Software Bill of Materials (SBOM)
114
+ test_files: []