378 lines
12 KiB
Ruby
Raw Normal View History

# typed: true
# 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
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
# 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)
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: {
path: formula.specified_path.to_s,
tap: formula.tap&.name,
tap_git_head: nil, # Filled in later if possible
spec: formula.active_spec_sym.to_s,
patches: formula.stable&.patches,
bottle: formula.bottle_hash,
stable: {
version: formula.stable&.version,
url: formula.stable&.url,
checksum: formula.stable&.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(attributes: Hash).void }
def initialize(attributes = {})
attributes.each { |key, value| instance_variable_set(:"@#{key}", value) }
end
sig { returns(T::Boolean) }
def valid?
data = to_spdx_sbom
schema_string, _, status = Utils::Curl.curl_output(SCHEMA)
opoo "Failed to fetch schema!" unless status.success?
require "json_schemer"
schemer = JSONSchemer.schema(schema_string)
return true if schemer.valid?(data)
opoo "SBOM validation errors:"
schemer.validate(data).to_a.each do |error|
ohai error["error"]
end
odie "Failed to validate SBOM agains schema!" if ENV["HOMEBREW_ENFORCE_SBOM"]
false
end
sig { void }
def write
# 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?
opoo "SBOM is not valid, not writing to disk!"
return
end
spdxfile.atomic_write(JSON.pretty_generate(to_spdx_sbom))
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|
{
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 = [
{
spdxElementId: "SPDXRef-File-#{name}",
relationshipType: "PACKAGE_OF",
relatedSpdxElement: "SPDXRef-Archive-#{name}-src",
},
{
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
runtime + patches + base
end
sig {
params(runtime_dependency_declaration: T::Array[Hash],
compiler_declaration: Hash).returns(T::Array[T::Hash[Symbol,
T.any(String,
T::Array[T::Hash[Symbol, String]])]])
}
def generate_packages_json(runtime_dependency_declaration, compiler_declaration)
bottle = []
if get_bottle_info(source[:bottle])
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: license,
downloadLocation: T.must(get_bottle_info(source[:bottle]))["url"],
copyrightText: assert_value(nil),
externalRefs: [
{
referenceCategory: "PACKAGE-MANAGER",
referenceLocator: "pkg:brew/#{tap}/#{name}@#{stable_version}",
referenceType: "purl",
},
],
checksums: [
{
algorithm: "SHA256",
checksumValue: T.must(get_bottle_info(source[:bottle]))["sha256"],
},
],
}
end
[
{
SPDXID: "SPDXRef-Archive-#{name}-src",
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: source[:stable][:url],
copyrightText: assert_value(nil),
externalRefs: [],
checksums: [
{
algorithm: "SHA256",
checksumValue: source[:stable][:checksum].to_s,
},
],
},
] + runtime_dependency_declaration + compiler_declaration.values + bottle
end
sig { returns(T::Array[T::Hash[Symbol, T.any(T::Boolean, String, T::Array[T::Hash[Symbol, String]])]]) }
def full_spdx_runtime_dependencies
return [] unless @runtime_dependencies.present?
@runtime_dependencies.compact.filter_map do |dependency|
next unless dependency.present?
bottle_info = get_bottle_info(dependency["bottle"])
next unless bottle_info.present?
{
SPDXID: "SPDXRef-Package-SPDXRef-#{dependency["name"].tr("/", "-")}-#{dependency["version"]}",
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),
copyrightText: assert_value(nil),
checksums: [
{
algorithm: "SHA256",
checksumValue: assert_value(bottle_info.present? ? bottle_info["sha256"] : nil),
},
],
externalRefs: [
{
referenceCategory: "PACKAGE-MANAGER",
referenceLocator: "pkg:brew/#{dependency["full_name"]}@#{dependency["version"]}",
referenceType: :purl,
},
],
}
end
end
sig { returns(T::Hash[Symbol, T.any(String, T::Array[T::Hash[Symbol, String]])]) }
def to_spdx_sbom
runtime_full = full_spdx_runtime_dependencies
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,
versionInfo: stdlib,
filesAnalyzed: false,
licenseDeclared: assert_value(nil),
licenseConcluded: assert_value(nil),
copyrightText: assert_value(nil),
downloadLocation: assert_value(nil),
checksums: [],
externalRefs: [],
}
end
packages = generate_packages_json(runtime_full, compiler_info)
{
SPDXID: "SPDXRef-DOCUMENT",
spdxVersion: "SPDX-2.3",
name: "SBOM-SPDX-#{name}-#{stable_version}",
creationInfo: {
created: DateTime.now.to_s,
creators: ["Tool: https://github.com/homebrew/brew@#{homebrew_version}"],
},
dataLicense: "CC0-1.0",
documentNamespace: "https://formulae.brew.sh/spdx/#{name}-#{stable_version}.json",
documentDescribes: packages.map { |dependency| dependency[:SPDXID] },
files: [],
packages:,
relationships: generate_relations_json(runtime_full, compiler_info),
}
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
sig { returns(T::Boolean) }
def stable?
spec == :stable
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
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
source[:spec].to_sym
end
sig { returns(T.nilable(Version)) }
def stable_version
source[:stable][:version]
end
sig { returns(Time) }
def source_modified_time
Time.at(@source_modified_time || 0)
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