sbom: fix errors, improve reproducibility, tests.

- Remove/change data from bottle SBOM to avoid harming reproduciblity
- Add `schema_validation_errors` method to provide nicer test failures
- Add tests more tests for SBOM when bottling
- Cleanup SBOM tests to use more typical RSpec form and be DRYer
This commit is contained in:
Mike McQuaid 2024-08-08 09:34:32 +01:00
parent 6b186f9874
commit 42a6b59de5
No known key found for this signature in database
2 changed files with 120 additions and 90 deletions

View File

@ -22,18 +22,11 @@ class SBOM
end end
active_spec_sym = formula.active_spec_sym active_spec_sym = formula.active_spec_sym
homebrew_version_maybe_dev = if (match_data = HOMEBREW_VERSION.match(/^[\d.]+/))
suffix = "-dev" if HOMEBREW_VERSION.include?("-")
match_data[0] + suffix.to_s
else
HOMEBREW_VERSION
end
attributes = { attributes = {
name: formula.name, name: formula.name,
homebrew_version: homebrew_version_maybe_dev, homebrew_version: HOMEBREW_VERSION,
spdxfile: SBOM.spdxfile(formula), spdxfile: SBOM.spdxfile(formula),
time: tab.time, time: tab.time || Time.now,
source_modified_time: tab.source_modified_time.to_i, source_modified_time: tab.source_modified_time.to_i,
compiler: tab.compiler, compiler: tab.compiler,
stdlib: tab.stdlib, stdlib: tab.stdlib,
@ -92,22 +85,27 @@ class SBOM
@schema ||= JSON.parse(SCHEMA_FILE.read, freeze: true) @schema ||= JSON.parse(SCHEMA_FILE.read, freeze: true)
end end
sig { params(bottling: T::Boolean).returns(T::Boolean) } sig { params(bottling: T::Boolean).returns(T::Array[T::Hash[String, T.untyped]]) }
def valid?(bottling: false) def schema_validation_errors(bottling: false)
unless require? "json_schemer" unless require? "json_schemer"
error_message = "Need json_schemer to validate SBOM, run `brew install-bundler-gems --add-groups=bottle`!" error_message = "Need json_schemer to validate SBOM, run `brew install-bundler-gems --add-groups=bottle`!"
odie error_message if ENV["HOMEBREW_ENFORCE_SBOM"] odie error_message if ENV["HOMEBREW_ENFORCE_SBOM"]
return true return []
end end
schemer = JSONSchemer.schema(SBOM.schema) schemer = JSONSchemer.schema(SBOM.schema)
data = to_spdx_sbom(bottling:) data = to_spdx_sbom(bottling:)
return true if schemer.valid?(data)
schemer.validate(data).map { |error| error["error"] }
end
sig { params(bottling: T::Boolean).returns(T::Boolean) }
def valid?(bottling: false)
validation_errors = schema_validation_errors(bottling:)
return true if validation_errors.empty?
opoo "SBOM validation errors:" opoo "SBOM validation errors:"
schemer.validate(data).to_a.each do |error| validation_errors.each(&:puts)
puts error["error"]
end
odie "Failed to validate SBOM against JSON schema!" if ENV["HOMEBREW_ENFORCE_SBOM"] odie "Failed to validate SBOM against JSON schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]
@ -210,7 +208,7 @@ class SBOM
filesAnalyzed: false, filesAnalyzed: false,
licenseDeclared: assert_value(nil), licenseDeclared: assert_value(nil),
builtDate: source_modified_time.to_s, builtDate: source_modified_time.to_s,
licenseConcluded: license, licenseConcluded: assert_value(license),
downloadLocation: bottle_info.fetch("url"), downloadLocation: bottle_info.fetch("url"),
copyrightText: assert_value(nil), copyrightText: assert_value(nil),
externalRefs: [ externalRefs: [
@ -323,8 +321,8 @@ class SBOM
if stdlib.present? if stdlib.present?
compiler_info["SPDXRef-Stdlib"] = { compiler_info["SPDXRef-Stdlib"] = {
SPDXID: "SPDXRef-Stdlib", SPDXID: "SPDXRef-Stdlib",
name: stdlib, name: stdlib.to_s,
versionInfo: stdlib, versionInfo: stdlib.to_s,
filesAnalyzed: false, filesAnalyzed: false,
licenseDeclared: assert_value(nil), licenseDeclared: assert_value(nil),
licenseConcluded: assert_value(nil), licenseConcluded: assert_value(nil),
@ -335,15 +333,21 @@ class SBOM
} }
end end
# Improve reproducibility when bottling.
if bottling
created = source_modified_time.iso8601
creators = ["Tool: https://github.com/Homebrew/brew"]
else
created = Time.at(time).utc.iso8601
creators = ["Tool: https://github.com/Homebrew/brew@#{homebrew_version}"]
end
packages = generate_packages_json(runtime_full, compiler_info, bottling:) packages = generate_packages_json(runtime_full, compiler_info, bottling:)
{ {
SPDXID: "SPDXRef-DOCUMENT", SPDXID: "SPDXRef-DOCUMENT",
spdxVersion: "SPDX-2.3", spdxVersion: "SPDX-2.3",
name: "SBOM-SPDX-#{name}-#{spec_version}", name: "SBOM-SPDX-#{name}-#{spec_version}",
creationInfo: { creationInfo: { created:, creators: },
created: (Time.at(time).utc.iso8601 if time.present? && !bottling),
creators: ["Tool: https://github.com/homebrew/brew@#{homebrew_version}"],
},
dataLicense: "CC0-1.0", dataLicense: "CC0-1.0",
documentNamespace: "https://formulae.brew.sh/spdx/#{name}-#{spec_version}.json", documentNamespace: "https://formulae.brew.sh/spdx/#{name}-#{spec_version}.json",
documentDescribes: packages.map { |dependency| dependency[:SPDXID] }, documentDescribes: packages.map { |dependency| dependency[:SPDXID] },

View File

@ -3,15 +3,25 @@
require "sbom" require "sbom"
RSpec.describe SBOM do RSpec.describe SBOM do
describe "#valid?" do describe "#schema_validation_errors" do
it "returns true if a minimal SBOM is valid" do subject(:sbom) { described_class.create(f, tab) }
f = formula { url "foo-1.0" }
sbom = described_class.create(f, Tab.new) before { ENV.delete("HOMEBREW_ENFORCE_SBOM") }
expect(sbom).to be_valid
let(:f) { formula { url "foo-1.0" } }
let(:tab) { Tab.new }
it "returns true if valid" do
expect(sbom.schema_validation_errors).to be_empty
end end
it "returns true if a maximal SBOM is valid" do it "returns true if valid when bottling" do
f = formula do expect(sbom.schema_validation_errors(bottling: true)).to be_empty
end
context "with a maximal SBOM" do
let(:f) do
formula do
homepage "https://brew.sh" homepage "https://brew.sh"
url "https://brew.sh/test-0.1.tbz" url "https://brew.sh/test-0.1.tbz"
@ -32,7 +42,8 @@ RSpec.describe SBOM do
uses_from_macos "python" => :build uses_from_macos "python" => :build
uses_from_macos "zlib" uses_from_macos "zlib"
end end
end
let(:tab) do
beanstalkd = formula "beanstalkd" do beanstalkd = formula "beanstalkd" do
url "one-1.1" url "one-1.1"
@ -59,21 +70,36 @@ RSpec.describe SBOM do
"declared_directly" => true, "declared_directly" => true,
} }
end end
expect(Tab).to receive(:runtime_deps_hash).and_return(runtime_deps_hash) allow(Tab).to receive(:runtime_deps_hash).and_return(runtime_deps_hash)
tab = Tab.create(f, DevelopmentTools.default_compiler, :libcxx) tab = Tab.create(f, DevelopmentTools.default_compiler, :libcxx)
expect(Formulary).to receive(:factory).with("beanstalkd").and_return(beanstalkd) allow(Formulary).to receive(:factory).with("beanstalkd").and_return(beanstalkd)
expect(Formulary).to receive(:factory).with("zlib").and_return(zlib) allow(Formulary).to receive(:factory).with("zlib").and_return(zlib)
sbom = described_class.create(f, tab) tab
expect(sbom).to be_valid
end end
it "returns false if an SBOM is invalid" do it "returns true if valid" do
f = formula { url "foo-1.0" } expect(sbom.schema_validation_errors).to be_empty
sbom = described_class.create(f, Tab.new) end
it "returns true if valid when bottling" do
expect(sbom.schema_validation_errors(bottling: true)).to be_empty
end
end
context "with an invalid SBOM" do
before do
allow(sbom).to receive(:to_spdx_sbom).and_return({}) # fake an empty SBOM allow(sbom).to receive(:to_spdx_sbom).and_return({}) # fake an empty SBOM
expect(sbom).not_to be_valid end
it "returns false" do
expect(sbom.schema_validation_errors).not_to be_empty
end
it "returns false when bottling" do
expect(sbom.schema_validation_errors(bottling: true)).not_to be_empty
end
end end
end end
end end