378 lines
12 KiB
Ruby
378 lines
12 KiB
Ruby
![]() |
# 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
|