mirror of
https://github.com/Homebrew/brew.git
synced 2025-07-14 16:09:03 +08:00
403 lines
13 KiB
Ruby
403 lines
13 KiB
Ruby
# typed: true # rubocop:todo Sorbet/StrictSigil
|
|
# frozen_string_literal: true
|
|
|
|
require "cxxstdlib"
|
|
require "json"
|
|
require "development_tools"
|
|
require "extend/cachable"
|
|
require "utils/curl"
|
|
|
|
# Rather than calling `new` directly, use one of the class methods like {SBOM.create}.
|
|
class SBOM
|
|
FILENAME = "sbom.spdx.json"
|
|
SCHEMA_FILE = (HOMEBREW_LIBRARY_PATH/"data/schemas/sbom.json").freeze
|
|
|
|
# Instantiates a {SBOM} for a new installation of a formula.
|
|
sig { params(formula: Formula, tab: Tab).returns(T.attached_class) }
|
|
def self.create(formula, tab)
|
|
active_spec = if formula.stable?
|
|
T.must(formula.stable)
|
|
else
|
|
T.must(formula.head)
|
|
end
|
|
active_spec_sym = formula.active_spec_sym
|
|
|
|
attributes = {
|
|
name: formula.name,
|
|
homebrew_version: HOMEBREW_VERSION,
|
|
spdxfile: SBOM.spdxfile(formula),
|
|
time: tab.time || Time.now,
|
|
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
|
|
spec: active_spec_sym.to_s,
|
|
patches: active_spec.patches,
|
|
bottle: formula.bottle_hash,
|
|
active_spec_sym => {
|
|
version: active_spec.version,
|
|
url: active_spec.url,
|
|
checksum: active_spec.checksum,
|
|
},
|
|
},
|
|
}
|
|
|
|
# We can only get `tap_git_head` if the tap is installed locally
|
|
attributes[:source][:tap_git_head] = T.must(formula.tap).git_head if formula.tap&.installed?
|
|
|
|
new(attributes)
|
|
end
|
|
|
|
sig { params(formula: Formula).returns(Pathname) }
|
|
def self.spdxfile(formula)
|
|
formula.prefix/FILENAME
|
|
end
|
|
|
|
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]
|
|
{
|
|
"full_name" => full_name,
|
|
"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
|
|
|
|
sig { params(formula: Formula).returns(T::Boolean) }
|
|
def self.exist?(formula)
|
|
spdxfile(formula).exist?
|
|
end
|
|
|
|
sig { returns(T::Hash[String, T.untyped]) }
|
|
def self.schema
|
|
@schema ||= JSON.parse(SCHEMA_FILE.read, freeze: true)
|
|
end
|
|
|
|
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 []
|
|
end
|
|
|
|
schemer = JSONSchemer.schema(SBOM.schema)
|
|
data = to_spdx_sbom(bottling:)
|
|
|
|
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:"
|
|
validation_errors.each(&:puts)
|
|
|
|
odie "Failed to validate SBOM against JSON schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]
|
|
|
|
false
|
|
end
|
|
|
|
sig { params(validate: T::Boolean, bottling: T::Boolean).void }
|
|
def write(validate: true, bottling: false)
|
|
# If this is a new installation, the cache of installed formulae
|
|
# will no longer be valid.
|
|
Formula.clear_cache unless spdxfile.exist?
|
|
|
|
if validate && !valid?(bottling:)
|
|
opoo "SBOM is not valid, not writing to disk!"
|
|
return
|
|
end
|
|
|
|
spdxfile.atomic_write(JSON.pretty_generate(to_spdx_sbom(bottling:)))
|
|
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,
|
|
bottling: T::Boolean,
|
|
).returns(T::Array[Hash])
|
|
}
|
|
def generate_relations_json(runtime_dependency_declaration, compiler_declaration, bottling:)
|
|
runtime = runtime_dependency_declaration.map do |dependency|
|
|
{
|
|
spdxElementId: dependency[:SPDXID],
|
|
relationshipType: "RUNTIME_DEPENDENCY_OF",
|
|
relatedSpdxElement: "SPDXRef-Bottle-#{name}",
|
|
}
|
|
end
|
|
|
|
patches = source[:patches].each_with_index.map do |_patch, index|
|
|
{
|
|
spdxElementId: "SPDXRef-Patch-#{name}-#{index}",
|
|
relationshipType: "PATCH_APPLIED",
|
|
relatedSpdxElement: "SPDXRef-Archive-#{name}-src",
|
|
}
|
|
end
|
|
|
|
base = T.let([{
|
|
spdxElementId: "SPDXRef-File-#{name}",
|
|
relationshipType: "PACKAGE_OF",
|
|
relatedSpdxElement: "SPDXRef-Archive-#{name}-src",
|
|
}], T::Array[Hash])
|
|
|
|
unless bottling
|
|
base << {
|
|
spdxElementId: "SPDXRef-Compiler",
|
|
relationshipType: "BUILD_TOOL_OF",
|
|
relatedSpdxElement: "SPDXRef-Package-#{name}-src",
|
|
}
|
|
|
|
if compiler_declaration["SPDXRef-Stdlib"].present?
|
|
base << {
|
|
spdxElementId: "SPDXRef-Stdlib",
|
|
relationshipType: "DEPENDENCY_OF",
|
|
relatedSpdxElement: "SPDXRef-Bottle-#{name}",
|
|
}
|
|
end
|
|
end
|
|
|
|
runtime + patches + base
|
|
end
|
|
|
|
sig {
|
|
params(runtime_dependency_declaration: T::Array[Hash],
|
|
compiler_declaration: Hash,
|
|
bottling: T::Boolean).returns(
|
|
T::Array[
|
|
T::Hash[
|
|
Symbol,
|
|
T.any(String, T::Array[T::Hash[Symbol, String]]),
|
|
],
|
|
],
|
|
)
|
|
}
|
|
def generate_packages_json(runtime_dependency_declaration, compiler_declaration, bottling:)
|
|
bottle = []
|
|
if !bottling && (bottle_info = get_bottle_info(source[:bottle])) &&
|
|
(stable_version = source.dig(:stable, :version))
|
|
bottle << {
|
|
SPDXID: "SPDXRef-Bottle-#{name}",
|
|
name: name.to_s,
|
|
versionInfo: stable_version.to_s,
|
|
filesAnalyzed: false,
|
|
licenseDeclared: assert_value(nil),
|
|
builtDate: source_modified_time.to_s,
|
|
licenseConcluded: assert_value(license),
|
|
downloadLocation: bottle_info.fetch("url"),
|
|
copyrightText: assert_value(nil),
|
|
externalRefs: [
|
|
{
|
|
referenceCategory: "PACKAGE-MANAGER",
|
|
referenceLocator: "pkg:brew/#{tap}/#{name}@#{stable_version}",
|
|
referenceType: "purl",
|
|
},
|
|
],
|
|
checksums: [
|
|
{
|
|
algorithm: "SHA256",
|
|
checksumValue: bottle_info.fetch("sha256"),
|
|
},
|
|
],
|
|
}
|
|
end
|
|
|
|
compiler_declarations = if bottling
|
|
[]
|
|
else
|
|
compiler_declaration.values
|
|
end
|
|
|
|
[
|
|
{
|
|
SPDXID: "SPDXRef-Archive-#{name}-src",
|
|
name: name.to_s,
|
|
versionInfo: spec_version.to_s,
|
|
filesAnalyzed: false,
|
|
licenseDeclared: assert_value(nil),
|
|
builtDate: source_modified_time.to_s,
|
|
licenseConcluded: assert_value(license),
|
|
downloadLocation: source[spec_symbol][:url],
|
|
copyrightText: assert_value(nil),
|
|
externalRefs: [],
|
|
checksums: [
|
|
{
|
|
algorithm: "SHA256",
|
|
checksumValue: source[spec_symbol][:checksum].to_s,
|
|
},
|
|
],
|
|
},
|
|
] + runtime_dependency_declaration + compiler_declarations + bottle
|
|
end
|
|
|
|
sig {
|
|
params(bottling: T::Boolean).returns(T::Array[T::Hash[Symbol,
|
|
T.any(T::Boolean, String,
|
|
T::Array[T::Hash[Symbol, String]])]])
|
|
}
|
|
def full_spdx_runtime_dependencies(bottling:)
|
|
return [] if bottling || @runtime_dependencies.blank?
|
|
|
|
@runtime_dependencies.compact.filter_map do |dependency|
|
|
next unless dependency.present?
|
|
|
|
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"]
|
|
|
|
dependency_json = {
|
|
SPDXID: "SPDXRef-Package-SPDXRef-#{dependency["name"].tr("/", "-")}-#{dependency["pkg_version"]}",
|
|
name: dependency["name"],
|
|
versionInfo: dependency["pkg_version"],
|
|
filesAnalyzed: false,
|
|
licenseDeclared: assert_value(nil),
|
|
licenseConcluded: assert_value(dependency["license"]),
|
|
downloadLocation: assert_value(bottle_url),
|
|
copyrightText: assert_value(nil),
|
|
checksums: [
|
|
{
|
|
algorithm: "SHA256",
|
|
checksumValue: assert_value(bottle_info["sha256"]),
|
|
},
|
|
],
|
|
externalRefs: [
|
|
{
|
|
referenceCategory: "PACKAGE-MANAGER",
|
|
referenceLocator: "pkg:brew/#{dependency["full_name"]}@#{dependency["pkg_version"]}",
|
|
referenceType: "purl",
|
|
},
|
|
],
|
|
}
|
|
dependency_json
|
|
end
|
|
end
|
|
|
|
sig { params(bottling: T::Boolean).returns(T::Hash[Symbol, T.any(String, T::Array[T::Hash[Symbol, String]])]) }
|
|
def to_spdx_sbom(bottling:)
|
|
runtime_full = full_spdx_runtime_dependencies(bottling:)
|
|
|
|
compiler_info = {
|
|
"SPDXRef-Compiler" => {
|
|
SPDXID: "SPDXRef-Compiler",
|
|
name: compiler.to_s,
|
|
versionInfo: assert_value(built_on["xcode"]),
|
|
filesAnalyzed: false,
|
|
licenseDeclared: assert_value(nil),
|
|
licenseConcluded: assert_value(nil),
|
|
copyrightText: assert_value(nil),
|
|
downloadLocation: assert_value(nil),
|
|
checksums: [],
|
|
externalRefs: [],
|
|
},
|
|
}
|
|
|
|
if stdlib.present?
|
|
compiler_info["SPDXRef-Stdlib"] = {
|
|
SPDXID: "SPDXRef-Stdlib",
|
|
name: stdlib.to_s,
|
|
versionInfo: stdlib.to_s,
|
|
filesAnalyzed: false,
|
|
licenseDeclared: assert_value(nil),
|
|
licenseConcluded: assert_value(nil),
|
|
copyrightText: assert_value(nil),
|
|
downloadLocation: assert_value(nil),
|
|
checksums: [],
|
|
externalRefs: [],
|
|
}
|
|
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:, creators: },
|
|
dataLicense: "CC0-1.0",
|
|
documentNamespace: "https://formulae.brew.sh/spdx/#{name}-#{spec_version}.json",
|
|
documentDescribes: packages.map { |dependency| dependency[:SPDXID] },
|
|
files: [],
|
|
packages:,
|
|
relationships: generate_relations_json(runtime_full, compiler_info, bottling:),
|
|
}
|
|
end
|
|
|
|
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?
|
|
|
|
files = base["files"].presence
|
|
return unless files
|
|
|
|
files[Utils::Bottles.tag.to_sym] || files[:all]
|
|
end
|
|
|
|
sig { returns(Symbol) }
|
|
def compiler
|
|
@compiler.presence&.to_sym || DevelopmentTools.default_compiler
|
|
end
|
|
|
|
sig { returns(T.nilable(Tap)) }
|
|
def tap
|
|
tap_name = source[:tap]
|
|
Tap.fetch(tap_name) if tap_name
|
|
end
|
|
|
|
sig { returns(Symbol) }
|
|
def spec_symbol
|
|
source.fetch(:spec).to_sym
|
|
end
|
|
|
|
sig { returns(T.nilable(Version)) }
|
|
def spec_version
|
|
source.fetch(spec_symbol)[:version]
|
|
end
|
|
|
|
sig { returns(Time) }
|
|
def source_modified_time
|
|
Time.at(@source_modified_time).utc
|
|
end
|
|
|
|
sig { params(val: T.untyped).returns(T.any(String, Symbol)) }
|
|
def assert_value(val)
|
|
return :NOASSERTION.to_s unless val.present?
|
|
|
|
val
|
|
end
|
|
end
|