Skip to content

Commit

Permalink
SBOM improvements
Browse files Browse the repository at this point in the history
- write a schema when installing formulae (if not already present)
- cache the schema on disk rather than downloading it every time
- make more methods/attributes `private`
- allow validation to be optional, only enable for Homebrew developers
  at installation time
  • Loading branch information
MikeMcQuaid committed May 8, 2024
1 parent 41cbfc3 commit 1db103d
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 40 deletions.
9 changes: 9 additions & 0 deletions Library/Homebrew/formula_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
require "unlink"
require "service"
require "attestation"
require "sbom"

# Installer for a formula.
class FormulaInstaller
Expand Down Expand Up @@ -828,6 +829,12 @@ def finish
tab.runtime_dependencies = Tab.runtime_deps_hash(formula, f_runtime_deps)
tab.write

# write a SBOM file (if we don't already have one)
unless SBOM.exist?(formula)
sbom = SBOM.create(formula)
sbom.write(validate: Homebrew::EnvConfig.developer?)
end

# let's reset Utils::Git.available? if we just installed git
Utils::Git.clear_available_cache if formula.name == "git"

Expand Down Expand Up @@ -1216,6 +1223,8 @@ def fetch_bottle_tab
def fetch
return if previously_fetched_formula

SBOM.fetch_schema! if Homebrew::EnvConfig.developer?

fetch_dependencies

return if only_deps?
Expand Down
136 changes: 96 additions & 40 deletions Library/Homebrew/sbom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,9 @@ class SBOM
extend Cachable

FILENAME = "sbom.spdx.json"
SCHEMA = "https://raw.githubusercontent.com/spdx/spdx-spec/v2.3/schemas/spdx-schema.json"

attr_accessor :homebrew_version, :spdxfile, :built_as_bottle, :installed_as_dependency, :installed_on_request,
:changed_files, :poured_from_bottle, :loaded_from_api, :time, :stdlib, :aliases, :arch, :source,
:built_on, :license, :name
attr_writer :compiler, :runtime_dependencies, :source_modified_time
SCHEMA_FILENAME = "spdx-schema.json"
SCHEMA_URL = "https://raw.githubusercontent.com/spdx/spdx-spec/v2.3/schemas/#{SCHEMA_FILENAME}".freeze
SCHEMA_CACHE_TARGET = (HOMEBREW_CACHE/"sbom/#{SCHEMA_FILENAME}").freeze

# Instantiates a {SBOM} for a new installation of a formula.
sig { params(formula: Formula, compiler: T.nilable(String), stdlib: T.nilable(String)).returns(T.attached_class) }
Expand All @@ -27,7 +24,7 @@ def self.create(formula, compiler: nil, stdlib: nil)
attributes = {
name: formula.name,
homebrew_version: HOMEBREW_VERSION,
spdxfile: formula.prefix/FILENAME,
spdxfile: SBOM.spdxfile(formula),
built_as_bottle: formula.build.bottle?,
installed_as_dependency: false,
installed_on_request: false,
Expand Down Expand Up @@ -63,51 +60,128 @@ def self.create(formula, compiler: nil, stdlib: nil)
new(attributes)
end

sig { params(attributes: Hash).void }
def initialize(attributes = {})
attributes.each { |key, value| instance_variable_set(:"@#{key}", value) }
sig { params(formula: Formula).returns(Pathname) }
def self.spdxfile(formula)
formula.prefix/FILENAME
end

sig { returns(T::Boolean) }
def valid?
data = to_spdx_sbom
sig { params(deps: T::Array[Formula]).returns(T::Array[T::Hash[Symbol, String]]) }
def self.runtime_deps_hash(deps)
deps.map do |dep|
{
full_name: dep.full_name,

Check warning on line 72 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L72

Added line #L72 was not covered by tests
name: dep.name,
version: dep.version.to_s,
revision: dep.revision,
pkg_version: dep.pkg_version.to_s,
declared_directly: true,
license: SPDX.license_expression_to_string(dep.license),
bottle: dep.bottle_hash,
}
end
end

sig { params(formula: Formula).returns(T::Boolean) }
def self.exist?(formula)
spdxfile(formula).exist?
end

schema_string, _, status = Utils::Curl.curl_output(SCHEMA)
sig { returns(T::Hash[String, String]) }
def self.fetch_schema!
return @schema if @schema.present?

url = SCHEMA_URL
target = SCHEMA_CACHE_TARGET
quieter = target.exist? && !target.empty?

curl_args = Utils::Curl.curl_args(retries: 0)
curl_args += ["--silent", "--time-cond", target.to_s] if quieter

begin
unless quieter
oh1 "Fetching SBOM schema"
ohai "Downloading #{url}"
end
Utils::Curl.curl_download(*curl_args, url, to: target, retries: 0)
FileUtils.touch(target, mtime: Time.now)
rescue ErrorDuringExecution
target.unlink if target.exist? && target.empty?

if target.exist?

Check warning on line 110 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L110

Added line #L110 was not covered by tests
opoo "SBOM schema update failed, falling back to cached version."
else
opoo "Failed to fetch SBOM schema, cannot perform SBOM validation!"

return {}

Check warning on line 115 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L115

Added line #L115 was not covered by tests
end
end

opoo "Failed to fetch schema!" unless status.success?
@schema = begin
JSON.parse(target.read, freeze: true)
rescue JSON::ParserError
target.unlink
opoo "Failed to fetch SBOM schema, cached version corrupted, cannot perform SBOM validation!"
{}

Check warning on line 124 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L122-L124

Added lines #L122 - L124 were not covered by tests
end
end

require "json_schemer"
sig { returns(T::Boolean) }
def valid?
unless require? "json_schemer"
error_message = "Cannot require json_schemer, run `brew install-bundler-gems`!"
ENV["HOMEBREW_ENFORCE_SBOM"] ? odie(error_message) : opoo(error_message)
return false

Check warning on line 133 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L133

Added line #L133 was not covered by tests
end

schemer = JSONSchemer.schema(schema_string)
schema = SBOM.fetch_schema!
if schema.blank?
error_message = "Could not fetch SBOM schema!"
ENV["HOMEBREW_ENFORCE_SBOM"] ? odie(error_message) : opoo(error_message)
return false

Check warning on line 140 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L140

Added line #L140 was not covered by tests
end

schemer = JSONSchemer.schema(schema)
data = to_spdx_sbom
return true if schemer.valid?(data)

opoo "SBOM validation errors:"
schemer.validate(data).to_a.each do |error|
ohai error["error"]
puts error["error"]

Check warning on line 149 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L149

Added line #L149 was not covered by tests
end

odie "Failed to validate SBOM agains schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]
odie "Failed to validate SBOM against schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]

false
end

sig { void }
def write
sig { params(validate: T::Boolean).void }
def write(validate: true)
# If this is a new installation, the cache of installed formulae
# will no longer be valid.
Formula.clear_cache unless spdxfile.exist?

self.class.cache[spdxfile] = self

unless valid?
if validate && !valid?
opoo "SBOM is not valid, not writing to disk!"
return
end

spdxfile.atomic_write(JSON.pretty_generate(to_spdx_sbom))
end

private

attr_accessor :homebrew_version, :spdxfile, :built_as_bottle, :installed_as_dependency, :installed_on_request,
:changed_files, :poured_from_bottle, :loaded_from_api, :time, :stdlib, :aliases, :arch, :source,
:built_on, :license, :name
attr_writer :compiler, :runtime_dependencies, :source_modified_time

sig { params(attributes: Hash).void }
def initialize(attributes = {})
attributes.each { |key, value| instance_variable_set(:"@#{key}", value) }
end

sig { params(runtime_dependency_declaration: T::Array[Hash], compiler_declaration: Hash).returns(T::Array[Hash]) }
def generate_relations_json(runtime_dependency_declaration, compiler_declaration)
runtime = runtime_dependency_declaration.map do |dependency|
Expand Down Expand Up @@ -294,24 +368,6 @@ def to_spdx_sbom
}
end

sig { params(deps: T::Array[Formula]).returns(T::Array[T::Hash[Symbol, String]]) }
def self.runtime_deps_hash(deps)
deps.map do |dep|
{
full_name: dep.full_name,
name: dep.name,
version: dep.version.to_s,
revision: dep.revision,
pkg_version: dep.pkg_version.to_s,
declared_directly: true,
license: SPDX.license_expression_to_string(dep.license),
bottle: dep.bottle_hash,
}
end
end

private

sig { params(base: T.nilable(T::Hash[String, Hash])).returns(T.nilable(T::Hash[String, String])) }
def get_bottle_info(base)
return unless base.present?
Expand Down
1 change: 1 addition & 0 deletions Library/Homebrew/test/formula_installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ class #{Formulary.class_s(f_name)} < Formula

it "shows audit problems if HOMEBREW_DEVELOPER is set" do
ENV["HOMEBREW_DEVELOPER"] = "1"
expect(SBOM).to receive(:fetch_schema!).and_return({})
formula_installer.fetch
formula_installer.install
expect(formula_installer).to receive(:audit_installed).and_call_original
Expand Down

0 comments on commit 1db103d

Please sign in to comment.