Skip to content

Commit

Permalink
feat: add generated SPDX file on bottling
Browse files Browse the repository at this point in the history
  • Loading branch information
SMillerDev committed Mar 30, 2024
1 parent 02a0ea8 commit 4cf1dd2
Show file tree
Hide file tree
Showing 3 changed files with 368 additions and 0 deletions.
5 changes: 5 additions & 0 deletions Library/Homebrew/dev-cmd/bottle.rb
Expand Up @@ -6,6 +6,7 @@
require "formula"
require "utils/bottles"
require "tab"
require "sbom"
require "keg"
require "formula_versions"
require "cli/parser"
Expand Down Expand Up @@ -431,6 +432,7 @@ def bottle_formula(formula)
Tab.clear_cache
Dependency.clear_cache
Requirement.clear_cache
SBOM.clear_cache
tab = Tab.for_keg(keg)
original_tab = tab.dup
tab.poured_from_bottle = false
Expand All @@ -443,6 +445,9 @@ def bottle_formula(formula)
tab.write
end

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

keg.consistent_reproducible_symlink_permissions!

cd cellar do
Expand Down
348 changes: 348 additions & 0 deletions Library/Homebrew/sbom.rb
@@ -0,0 +1,348 @@
# typed: true
# frozen_string_literal: true

require "json_schemer"
require "cxxstdlib"
require "json"
require "development_tools"
require "extend/cachable"

# 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)
build = formula.build
runtime_deps = formula.runtime_dependencies(undeclared: false)
attributes = {
"name" => formula.name,
"homebrew_version" => HOMEBREW_VERSION,
"spdxfile" => formula.prefix/FILENAME,
"built_as_bottle" => 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" => compiler,
"stdlib" => stdlib,
"aliases" => formula.aliases,
"runtime_dependencies" => SBOM.runtime_deps_hash(formula, 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 = Net::HTTP.get(URI.parse(SCHEMA))

schemer = JSONSchemer.schema(schema_string)

return true if schemer.valid?(data)

opoo "SBOM is not valid, not writing to disk!"
schemer.validate(data).to_a.each do |error|
odebug error["error"]
end

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

return unless valid?

spdxfile.atomic_write(JSON.pretty_generate(data))
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"].map do |_patch|
{
"spdxElementId" => "SPDXRef-Patch-#{name}",
"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[Hash]) }
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" => "NOASSERTION",
"builtDate" => source_modified_time.to_s,
"licenseConcluded" => license,
"downloadLocation" => T.must(get_bottle_info(source["bottle"]))["url"],
"copyrightText" => "NOASSERTION",
"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" => "NOASSERTION",
"builtDate" => source_modified_time.to_s,
"licenseConcluded" => license || "NOASSERTION",
"downloadLocation" => source["stable"]["url"],
"copyrightText" => "NOASSERTION",
"externalRefs" => [],
"checksums" => [
{
"algorithm" => "SHA256",
"checksumValue" => source["stable"]["checksum"].to_s,
},
],
},
] + runtime_dependency_declaration + compiler_declaration.values + bottle
end

sig { returns(Hash) }
def to_spdx_sbom
runtime_full = []

if @runtime_dependencies.present?
runtime_full = @runtime_dependencies.map do |dependency|
bottle_info = get_bottle_info(dependency["bottle"])
{
"SPDXID" => "SPDXRef-Package-SPDXRef-#{dependency["name"].tr("/", "-")}-#{dependency["version"]}",
"name" => dependency["name"],
"versionInfo" => dependency["pkg_version"],
"filesAnalyzed" => false,
"licenseDeclared" => "NOASSERTION",
"licenseConcluded" => dependency["license"] || "NOASSERTION",
"downloadLocation" => bottle_info.present? ? bottle_info["url"] : "NOASSERTION",
"copyrightText" => "NOASSERTION",
"checksums" => [
{
"algorithm" => "SHA256",
"checksumValue" => bottle_info.present? ? bottle_info["sha256"] : "NOASSERTION",
},
],
"externalRefs" => [
{
"referenceCategory" => "PACKAGE-MANAGER",
"referenceLocator" => "pkg:brew/#{dependency["full_name"]}@#{dependency["version"]}",
"referenceType" => "purl",
},
],
}
end
end

compiler_info = {
"SPDXRef-Compiler" => {
"SPDXID" => "SPDXRef-Compiler",
"name" => compiler.to_s,
"versionInfo" => built_on["xcode"],
"filesAnalyzed" => false,
"licenseDeclared" => "NOASSERTION",
"licenseConcluded" => "NOASSERTION",
"copyrightText" => "NOASSERTION",
"downloadLocation" => "NOASSERTION",
"checksums" => [],
"externalRefs" => [],
},
}

if stdlib.present?
compiler_info["SPDXRef-Stdlib"] = {
"SPDXID" => "SPDXRef-Stdlib",
"name" => stdlib,
"versionInfo" => stdlib,
"filesAnalyzed" => false,
"licenseDeclared" => "NOASSERTION",
"licenseConcluded" => "NOASSERTION",
"copyrightText" => "NOASSERTION",
"downloadLocation" => "NOASSERTION",
"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" => packages,
"relationships" => generate_relations_json(runtime_full, compiler_info),
}
end

sig { params(formula: Formula, deps: T::Array[Dependency]).returns(T::Array[Hash]) }
def self.runtime_deps_hash(formula, deps)
deps.map do |dep|
f = dep.to_formula
{
"full_name" => f.full_name,
"name" => f.name,
"version" => f.version.to_s,
"revision" => f.revision,
"pkg_version" => f.pkg_version.to_s,
"declared_directly" => formula.deps.include?(dep),
"license" => SPDX.license_expression_to_string(f.license),
"bottle" => f.bottle_hash,
}
end
end

private

sig { params(base: T::Hash[String, Hash]).returns(T.nilable(Hash)) }
def get_bottle_info(base)
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
end
15 changes: 15 additions & 0 deletions Library/Homebrew/test/sbom_spec.rb
@@ -0,0 +1,15 @@
# frozen_string_literal: true

require "sbom"

RSpec.describe SBOM do
subject(:sbom) { described_class.create(f) }

let(:f) { formula { url "foo-1.0" } }

describe "#stable?" do
it "returns true if the SBOM is valid" do
expect(sbom).to be_valid
end
end
end

0 comments on commit 4cf1dd2

Please sign in to comment.