Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SBOM improvements #17254

Merged
merged 1 commit into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions Library/Homebrew/dev-cmd/bottle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ class Bottle < AbstractCommand

sig { override.void }
def run
Homebrew.install_bundler_gems!(groups: ["bottle"])

if args.merge?
Homebrew.install_bundler_gems!(groups: ["ast"])
return merge
end

Homebrew.install_bundler_gems!(groups: ["bottle"])

gnu_tar_formula_ensure_installed_if_needed!

args.named.to_resolved_formulae(uniq: false).each do |formula|
Expand Down Expand Up @@ -508,7 +508,7 @@ def bottle_formula(formula)
tab.write
end

sbom = SBOM.create(formula)
sbom = SBOM.create(formula, tab)
sbom.write

keg.consistent_reproducible_symlink_permissions!
Expand Down
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 and aren't bottling)
if !build_bottle? && !SBOM.exist?(formula)
sbom = SBOM.create(formula, tab)
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
219 changes: 121 additions & 98 deletions Library/Homebrew/sbom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,25 @@
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_URL = "https://spdx.github.io/spdx-3-model/model.jsonld"
SCHEMA_FILENAME = "sbom.spdx.schema.3.json"
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) }
def self.create(formula, compiler: nil, stdlib: nil)
runtime_deps = formula.runtime_formula_dependencies(undeclared: false)

sig { params(formula: Formula, tab: Tab).returns(T.attached_class) }
def self.create(formula, tab)
attributes = {
name: formula.name,
homebrew_version: HOMEBREW_VERSION,
spdxfile: formula.prefix/FILENAME,
built_as_bottle: formula.build.bottle?,
installed_as_dependency: false,
installed_on_request: false,
poured_from_bottle: false,
loaded_from_api: false,
time: Time.now.to_i,
source_modified_time: formula.source_modified_time.to_i,
compiler:,
stdlib:,
aliases: formula.aliases,
runtime_dependencies: SBOM.runtime_deps_hash(runtime_deps),
arch: Hardware::CPU.arch,
license: SPDX.license_expression_to_string(formula.license),
built_on: DevelopmentTools.build_system_info,
source: {
name: formula.name,
homebrew_version: HOMEBREW_VERSION,
spdxfile: SBOM.spdxfile(formula),
time: Time.now.to_i,
source_modified_time: tab.source_modified_time.to_i,
compiler: tab.compiler,
stdlib: tab.stdlib,
runtime_dependencies: SBOM.runtime_deps_hash(Array(tab.runtime_dependencies)),
license: SPDX.license_expression_to_string(formula.license),
built_on: DevelopmentTools.build_system_info,
source: {
path: formula.specified_path.to_s,
tap: formula.tap&.name,
tap_git_head: nil, # Filled in later if possible
Expand All @@ -63,51 +51,126 @@
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[T::Hash[String, String]]).returns(T::Array[T::Hash[String, String]]) }
def self.runtime_deps_hash(deps)
deps.map do |dep|
full_name = dep.fetch("full_name")
dep_formula = Formula[full_name]

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L62-L63

Added lines #L62 - L63 were not covered by tests
{
"full_name" => full_name,

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L65

Added line #L65 was not covered by tests
"pkg_version" => dep.fetch("pkg_version"),
"name" => dep_formula.name,
"license" => SPDX.license_expression_to_string(dep_formula.license),
"bottle" => dep_formula.bottle_hash,
"formula_pkg_version" => dep_formula.pkg_version.to_s,
}
end
end

schema_string, _, status = Utils::Curl.curl_output(SCHEMA)
sig { params(formula: Formula).returns(T::Boolean) }
def self.exist?(formula)
spdxfile(formula).exist?
end

opoo "Failed to fetch schema!" unless status.success?
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 101 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L101

Added line #L101 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 106 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L106

Added line #L106 was not covered by tests
end
end

require "json_schemer"
@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 115 in Library/Homebrew/sbom.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L113-L115

Added lines #L113 - L115 were not covered by tests
end
end

sig { returns(T::Boolean) }
def valid?
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 false

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L124

Added line #L124 was not covered by tests
end

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

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L131

Added line #L131 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 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

odie "Failed to validate SBOM agains schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]
odie "Failed to validate SBOM against JSON 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_reader :name, :homebrew_version, :time, :stdlib, :source, :built_on, :license
attr_accessor :spdxfile

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 @@ -139,7 +202,7 @@
]

if compiler_declaration["SPDXRef-Stdlib"].present?
base += {
base << {
spdxElementId: "SPDXRef-Stdlib",
relationshipType: "DEPENDENCY_OF",
relatedSpdxElement: "SPDXRef-Bottle-#{name}",
Expand All @@ -157,7 +220,7 @@
}
def generate_packages_json(runtime_dependency_declaration, compiler_declaration)
bottle = []
if get_bottle_info(source[:bottle])
if (bottle_info = get_bottle_info(source[:bottle]))
bottle << {
SPDXID: "SPDXRef-Bottle-#{name}",
name: name.to_s,
Expand All @@ -166,7 +229,7 @@
licenseDeclared: assert_value(nil),
builtDate: source_modified_time.to_s,
licenseConcluded: license,
downloadLocation: T.must(get_bottle_info(source[:bottle]))["url"],
downloadLocation: bottle_info.fetch("url"),
copyrightText: assert_value(nil),
externalRefs: [
{
Expand All @@ -178,7 +241,7 @@
checksums: [
{
algorithm: "SHA256",
checksumValue: T.must(get_bottle_info(source[:bottle]))["sha256"],
checksumValue: bottle_info.fetch("sha256"),
},
],
}
Expand Down Expand Up @@ -216,25 +279,28 @@
bottle_info = get_bottle_info(dependency["bottle"])
next unless bottle_info.present?

# Only set bottle URL if the dependency is the same version as the formula/bottle.
bottle_url = bottle_info["url"] if dependency["pkg_version"] == dependency["formula_pkg_version"]
MikeMcQuaid marked this conversation as resolved.
Show resolved Hide resolved

{
SPDXID: "SPDXRef-Package-SPDXRef-#{dependency["name"].tr("/", "-")}-#{dependency["version"]}",
SPDXID: "SPDXRef-Package-SPDXRef-#{dependency["name"].tr("/", "-")}-#{dependency["pkg_version"]}",

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L286

Added line #L286 was not covered by tests
name: dependency["name"],
versionInfo: dependency["pkg_version"],
filesAnalyzed: false,
licenseDeclared: assert_value(nil),
licenseConcluded: assert_value(dependency["license"]),
downloadLocation: assert_value(bottle_info.present? ? bottle_info["url"] : nil),
downloadLocation: assert_value(bottle_url),
copyrightText: assert_value(nil),
checksums: [
{
algorithm: "SHA256",
checksumValue: assert_value(bottle_info.present? ? bottle_info["sha256"] : nil),
checksumValue: assert_value(bottle_info["sha256"]),
},
],
externalRefs: [
{
referenceCategory: "PACKAGE-MANAGER",
referenceLocator: "pkg:brew/#{dependency["full_name"]}@#{dependency["version"]}",
referenceLocator: "pkg:brew/#{dependency["full_name"]}@#{dependency["pkg_version"]}",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I see now in testing that I didn't consider before, we might need to URL encode the full name since it can contain @ too. @woodruffw you know the purl spec better. Is pkg:brew/homebrew/core/python@3.12@3.12.3 allowed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SMillerDev merged this to get the fixes in but: I'll open up a follow-up PR to address this once @woodruffw confirms 👍🏻

referenceType: :purl,
},
],
Expand Down Expand Up @@ -294,57 +360,19 @@
}
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?
return unless base.key?("files")

T.must(base["files"])[Utils::Bottles.tag.to_sym]
end
files = base["files"].presence
return unless files

sig { returns(T::Boolean) }
def stable?
spec == :stable
files[Utils::Bottles.tag.to_sym] || files[:all]

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

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/sbom.rb#L370

Added line #L370 was not covered by tests
end

sig { returns(Symbol) }
def compiler
@compiler || DevelopmentTools.default_compiler
end

sig { returns(CxxStdlib) }
def cxxstdlib
# Older sboms won't have these values, so provide sensible defaults
lib = stdlib.to_sym if stdlib
CxxStdlib.create(lib, compiler.to_sym)
end

sig { returns(T::Boolean) }
def built_bottle?
built_as_bottle && !poured_from_bottle
end

sig { returns(T::Boolean) }
def bottle?
built_as_bottle
@compiler.presence&.to_sym || DevelopmentTools.default_compiler
end

sig { returns(T.nilable(Tap)) }
Expand All @@ -353,11 +381,6 @@
Tap.fetch(tap_name) if tap_name
end

sig { returns(Symbol) }
def spec
source[:spec].to_sym
end

sig { returns(T.nilable(Version)) }
def stable_version
source[:stable][:version]
Expand Down