From 42a6b59de569200bcaaa4d2b8515e6ad6d55670d Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 8 Aug 2024 09:34:32 +0100 Subject: [PATCH] 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 --- Library/Homebrew/sbom.rb | 50 ++++----- Library/Homebrew/test/sbom_spec.rb | 160 +++++++++++++++++------------ 2 files changed, 120 insertions(+), 90 deletions(-) diff --git a/Library/Homebrew/sbom.rb b/Library/Homebrew/sbom.rb index 56b0cf239c..deb2c43e51 100644 --- a/Library/Homebrew/sbom.rb +++ b/Library/Homebrew/sbom.rb @@ -22,18 +22,11 @@ class SBOM end 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 = { name: formula.name, - homebrew_version: homebrew_version_maybe_dev, + homebrew_version: HOMEBREW_VERSION, spdxfile: SBOM.spdxfile(formula), - time: tab.time, + time: tab.time || Time.now, source_modified_time: tab.source_modified_time.to_i, compiler: tab.compiler, stdlib: tab.stdlib, @@ -92,22 +85,27 @@ class SBOM @schema ||= JSON.parse(SCHEMA_FILE.read, freeze: true) end - sig { params(bottling: T::Boolean).returns(T::Boolean) } - def valid?(bottling: false) + sig { params(bottling: T::Boolean).returns(T::Array[T::Hash[String, T.untyped]]) } + def schema_validation_errors(bottling: false) unless require? "json_schemer" error_message = "Need json_schemer to validate SBOM, run `brew install-bundler-gems --add-groups=bottle`!" odie error_message if ENV["HOMEBREW_ENFORCE_SBOM"] - return true + return [] end schemer = JSONSchemer.schema(SBOM.schema) 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:" - schemer.validate(data).to_a.each do |error| - puts error["error"] - end + validation_errors.each(&:puts) odie "Failed to validate SBOM against JSON schema!" if ENV["HOMEBREW_ENFORCE_SBOM"] @@ -210,7 +208,7 @@ class SBOM filesAnalyzed: false, licenseDeclared: assert_value(nil), builtDate: source_modified_time.to_s, - licenseConcluded: license, + licenseConcluded: assert_value(license), downloadLocation: bottle_info.fetch("url"), copyrightText: assert_value(nil), externalRefs: [ @@ -323,8 +321,8 @@ class SBOM if stdlib.present? compiler_info["SPDXRef-Stdlib"] = { SPDXID: "SPDXRef-Stdlib", - name: stdlib, - versionInfo: stdlib, + name: stdlib.to_s, + versionInfo: stdlib.to_s, filesAnalyzed: false, licenseDeclared: assert_value(nil), licenseConcluded: assert_value(nil), @@ -335,15 +333,21 @@ class SBOM } 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:) { SPDXID: "SPDXRef-DOCUMENT", spdxVersion: "SPDX-2.3", name: "SBOM-SPDX-#{name}-#{spec_version}", - creationInfo: { - created: (Time.at(time).utc.iso8601 if time.present? && !bottling), - creators: ["Tool: https://github.com/homebrew/brew@#{homebrew_version}"], - }, + creationInfo: { created:, creators: }, dataLicense: "CC0-1.0", documentNamespace: "https://formulae.brew.sh/spdx/#{name}-#{spec_version}.json", documentDescribes: packages.map { |dependency| dependency[:SPDXID] }, diff --git a/Library/Homebrew/test/sbom_spec.rb b/Library/Homebrew/test/sbom_spec.rb index 3397dce130..adf79fb7d8 100644 --- a/Library/Homebrew/test/sbom_spec.rb +++ b/Library/Homebrew/test/sbom_spec.rb @@ -3,77 +3,103 @@ require "sbom" RSpec.describe SBOM do - describe "#valid?" do - it "returns true if a minimal SBOM is valid" do - f = formula { url "foo-1.0" } - sbom = described_class.create(f, Tab.new) - expect(sbom).to be_valid + describe "#schema_validation_errors" do + subject(:sbom) { described_class.create(f, tab) } + + before { ENV.delete("HOMEBREW_ENFORCE_SBOM") } + + 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 - it "returns true if a maximal SBOM is valid" do - f = formula do - homepage "https://brew.sh" - - url "https://brew.sh/test-0.1.tbz" - sha256 TEST_SHA256 - - patch do - url "patch_macos" - end - - bottle do - sha256 all: "9befdad158e59763fb0622083974a6252878019702d8c961e1bec3a5f5305339" - end - - # some random dependencies to test with - depends_on "cmake" => :build - depends_on "beanstalkd" - - uses_from_macos "python" => :build - uses_from_macos "zlib" - end - - beanstalkd = formula "beanstalkd" do - url "one-1.1" - - bottle do - sha256 all: "ac4c0330b70dae06eaa8065bfbea78dda277699d1ae8002478017a1bd9cf1908" - end - end - - zlib = formula "zlib" do - url "two-1.1" - - bottle do - sha256 all: "6a4642964fe5c4d1cc8cd3507541736d5b984e34a303a814ef550d4f2f8242f9" - end - end - - runtime_dependencies = [beanstalkd, zlib] - runtime_deps_hash = runtime_dependencies.map do |dep| - { - "full_name" => dep.full_name, - "version" => dep.version.to_s, - "revision" => dep.revision, - "pkg_version" => dep.pkg_version.to_s, - "declared_directly" => true, - } - end - expect(Tab).to receive(:runtime_deps_hash).and_return(runtime_deps_hash) - tab = Tab.create(f, DevelopmentTools.default_compiler, :libcxx) - - expect(Formulary).to receive(:factory).with("beanstalkd").and_return(beanstalkd) - expect(Formulary).to receive(:factory).with("zlib").and_return(zlib) - - sbom = described_class.create(f, tab) - expect(sbom).to be_valid + it "returns true if valid when bottling" do + expect(sbom.schema_validation_errors(bottling: true)).to be_empty end - it "returns false if an SBOM is invalid" do - f = formula { url "foo-1.0" } - sbom = described_class.create(f, Tab.new) - allow(sbom).to receive(:to_spdx_sbom).and_return({}) # fake an empty SBOM - expect(sbom).not_to be_valid + context "with a maximal SBOM" do + let(:f) do + formula do + homepage "https://brew.sh" + + url "https://brew.sh/test-0.1.tbz" + sha256 TEST_SHA256 + + patch do + url "patch_macos" + end + + bottle do + sha256 all: "9befdad158e59763fb0622083974a6252878019702d8c961e1bec3a5f5305339" + end + + # some random dependencies to test with + depends_on "cmake" => :build + depends_on "beanstalkd" + + uses_from_macos "python" => :build + uses_from_macos "zlib" + end + end + let(:tab) do + beanstalkd = formula "beanstalkd" do + url "one-1.1" + + bottle do + sha256 all: "ac4c0330b70dae06eaa8065bfbea78dda277699d1ae8002478017a1bd9cf1908" + end + end + + zlib = formula "zlib" do + url "two-1.1" + + bottle do + sha256 all: "6a4642964fe5c4d1cc8cd3507541736d5b984e34a303a814ef550d4f2f8242f9" + end + end + + runtime_dependencies = [beanstalkd, zlib] + runtime_deps_hash = runtime_dependencies.map do |dep| + { + "full_name" => dep.full_name, + "version" => dep.version.to_s, + "revision" => dep.revision, + "pkg_version" => dep.pkg_version.to_s, + "declared_directly" => true, + } + end + allow(Tab).to receive(:runtime_deps_hash).and_return(runtime_deps_hash) + tab = Tab.create(f, DevelopmentTools.default_compiler, :libcxx) + + allow(Formulary).to receive(:factory).with("beanstalkd").and_return(beanstalkd) + allow(Formulary).to receive(:factory).with("zlib").and_return(zlib) + + tab + end + + it "returns true if valid" do + expect(sbom.schema_validation_errors).to be_empty + 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 + 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