Module: Homebrew::Attestation Private
- Extended by:
- SystemCommand::Mixin, Utils::Output::Mixin
- Defined in:
- attestation.rb
This module is part of a private API. This module may only be used in the Homebrew/brew repository. Third parties should avoid using this module if possible, as it may be removed or changed without warning.
Defined Under Namespace
Classes: GhAuthInvalid, GhAuthNeeded, GhIncompatible, InvalidAttestationError, MissingAttestationError, RateLimitExceededError, UnsupportedTapError
Constant Summary collapse
- HOMEBREW_CORE_REPO =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
"Homebrew/homebrew-core"- BACKFILL_REPO =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
"trailofbits/homebrew-brew-verify"- BACKFILL_CUTOFF =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
No backfill attestations after this date are considered valid.
This date is shortly after the backfill operation for homebrew-core completed, as can be seen here: https://github.com/trailofbits/homebrew-brew-verify/attestations.
In effect, this means that, even if an attacker is able to compromise the backfill signing workflow, they will be unable to convince a verifier to accept their newer, malicious backfilled signatures.
DateTime.new(2024, 3, 14).freeze
- BULK_LIST_MAX_DIGESTS =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
1024- BULK_LIST_PAGE_SIZE =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
100- ATTESTATION_MAX_RETRIES =
This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.
5
Class Method Summary collapse
-
.bulk_list_bundles(organisation, digests) ⇒ Hash{String => Array<String>}
private
Returns inline bundles as JSON lines keyed by digest; a digest held only as
bundle_urlattestations maps to an empty array. -
.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) ⇒ Hash{String => T.untyped}
private
Verifies the given bottle against a cryptographic attestation of build provenance.
-
.check_core_attestation(bottle) ⇒ Hash{String => T.untyped}
private
Verifies the given bottle against a cryptographic attestation of build provenance from homebrew-core's CI, falling back on a "backfill" attestation for older bottles.
-
.check_formula_attestation(bottle) ⇒ Hash{String => T.untyped}
private
Verifies a bottle against the attestation model for the formula's tap.
-
.gh_executable ⇒ Pathname
private
Returns a path to a suitable
ghexecutable for attestation verification. -
.prefetch(formulae) ⇒ void
private
Lists the formulae's homebrew-core bottle attestations in bulk so that
check_attestationcan verify them offline. -
.prefetched_bundle_lines(bottle, digest, organisation) ⇒ Array<String>?
private
The listed bundles to verify the bottle against, or
nilto look the attestation up online. -
.prefetched_bundles ⇒ Hash{String => Hash{String => Array<String>}}
private
Listed attestation bundles as JSON lines, keyed by bottle SHA-256 and then by organisation; an empty array means the organisation holds none.
- .signing_organisation(signing_repo) ⇒ String private
-
.sort_formulae_for_install(formulae) ⇒ Array<Formula>
private
Prioritize installing
ghfirst if it's in the formula list or check for the existence of theghexecutable elsewhere.
Methods included from SystemCommand::Mixin
command_sandbox, system_command, system_command!
Methods included from Utils::Output::Mixin
issue_reporting_message, odebug, odeprecated, odie, odisabled, ofail, oh1, oh1_title, ohai, ohai_title, onoe, opoo, opoo_once, opoo_outside_github_actions, opoo_without_github_actions_annotation, pretty_cannot_install, pretty_deprecated, pretty_disabled, pretty_duration, pretty_install_status, pretty_installed, pretty_removed, pretty_uninstalled, pretty_unmarked, pretty_upgradable, pretty_warning
Class Method Details
.bulk_list_bundles(organisation, digests) ⇒ Hash{String => Array<String>}
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Returns inline bundles as JSON lines keyed by digest; a digest held only
as bundle_url attestations maps to an empty array.
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'attestation.rb', line 165 def self.bulk_list_bundles(organisation, digests) url = "#{GitHub::API_URL}/orgs/#{organisation}/attestations/bulk-list?per_page=#{BULK_LIST_PAGE_SIZE}" bundles = {} digests.each_slice(BULK_LIST_MAX_DIGESTS) do |slice| data = { subject_digests: slice.map { |digest| "sha256:#{digest}" } } page_url = url loop do response = GitHub::API.open_rest(page_url, data:, request_method: :POST) break unless response.is_a?(Hash) listed = response["attestations_subject_digests"] listed = {} unless listed.is_a?(Hash) listed.each do |subject_digest, attestations| next if !attestations.is_a?(Array) || attestations.empty? lines = attestations.filter_map do |attestation| bundle = attestation["bundle"] if attestation.is_a?(Hash) JSON.generate(bundle) if bundle.is_a?(Hash) end (bundles[subject_digest.to_s.delete_prefix("sha256:")] ||= []).concat(lines) end page_info = response["page_info"] cursor = page_info["next"] if page_info.is_a?(Hash) && page_info["has_next"] break if cursor.blank? next_url = "#{url}&after=#{URI.encode_www_form_component(cursor)}" break if next_url == page_url page_url = next_url end end bundles end |
.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) ⇒ Hash{String => T.untyped}
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Verifies the given bottle against a cryptographic attestation of build provenance.
The provenance is verified as originating from signing_repository, which is a String
that should be formatted as a GitHub owner/repository.
Callers may additionally pass in signing_workflow, which will scope the attestation
down to an exact GitHub Actions workflow, in
https://github/OWNER/REPO/.github/workflows/WORKFLOW.yml@REF format.
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'attestation.rb', line 222 def self.check_attestation(bottle, signing_repo, signing_workflow = nil, subject = nil) cmd = ["attestation", "verify", bottle.cached_download, "--repo", signing_repo, "--format", "json"] cmd += ["--cert-identity", signing_workflow] if signing_workflow.present? # Fail early if we have no credentials. The command below invariably # fails without them, so this saves us an unnecessary subshell. credentials = GitHub::API.credentials raise GhAuthNeeded, "missing credentials" if credentials.blank? digest = Downloadable.verification_cache.sha256(bottle.cached_download) lines = prefetched_bundle_lines(bottle, digest, signing_organisation(signing_repo)) bundle_path = nil begin if lines count = Utils.pluralize("listed attestation", lines.count, include_count: true) odebug "Verifying #{bottle.filename} against #{count} from #{signing_repo}" bundle_file = Tempfile.create(["attestations-", ".jsonl"], HOMEBREW_TEMP) bundle_path = Pathname(bundle_file.path) begin bundle_file.puts(lines) ensure bundle_file.close end cmd += ["--bundle", bundle_path] end result = system_command!(gh_executable, args: cmd, env: { "GH_TOKEN" => credentials, "GH_HOST" => "github.com" }, secrets: [credentials], print_stderr: false, chdir: HOMEBREW_TEMP) rescue ErrorDuringExecution => e if e.exitstatus == 1 && e.stderr.include?("unknown command") raise GhIncompatible, "gh CLI is incompatible with attestations" end # Even if we have credentials, they may be invalid or malformed. if e.exitstatus == 4 || e.stderr.include?("HTTP 401: Bad credentials") raise GhAuthInvalid, "invalid credentials" end if e.stderr.match?(/HTTP (?:403|429)\b.*(?:API rate limit exceeded|exceeded a secondary rate limit)/) raise RateLimitExceededError, "GitHub API rate limit exceeded: #{e}" end # The API used to return 404 but now can return 200 with an empty array. # We match the no attestation case precisely as there are similarly worded errors. if e.stderr.include?("HTTP 404: Not Found") || e.stderr.match?(/: no attestations found\R/) raise MissingAttestationError, "attestation not found: #{e}" end raise InvalidAttestationError, "attestation verification failed: #{e}" ensure bundle_path&.unlink end begin attestations = JSON.parse(result.stdout) rescue JSON::ParserError raise InvalidAttestationError, "attestation verification returned malformed JSON" end # `gh attestation verify` returns a JSON array of one or more results, # for all attestations that match the input's digest. We want to additionally # filter these down to just the attestation whose subject(s) contain the bottle's name. # As of 2024-12-04 GitHub's Artifact Attestation feature can put multiple subjects # in a single attestation, so we check every subject in each attestation # and select the first attestation with a matching subject. # In particular, this happens with v2.0.0 and later of the # `actions/attest` action. subject = bottle.filename.to_s if subject.blank? attestation = attestations.find do |a| a.dig("verificationResult", "statement", "subject").any? do |candidate| next false if candidate.dig("digest", "sha256") != digest if bottle.tag.to_sym == :all && signing_repo != BACKFILL_REPO # :all-tagged bottles are created by `brew bottle --merge`, and are not directly # bound to their own filename (since they're created by deduplicating other filenames). # To verify these, we parse each attestation subject and look for one with a matching # formula (name, version), but not an exact tag match. # This is sound insofar as the signature has already been verified. However, # longer term, we should also directly attest to `:all`-tagged bottles. candidate["name"].match?( /\A#{Regexp.escape("#{bottle.filename.name}--#{bottle.filename.version}")}\. [^.]+\.bottle(?:\.\d+)?\.tar\.gz\z/x, ) else candidate["name"] == subject end end end raise InvalidAttestationError, "no attestation matches subject: #{subject}" if attestation.blank? attestation end |
.check_core_attestation(bottle) ⇒ Hash{String => T.untyped}
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Verifies the given bottle against a cryptographic attestation of build provenance from homebrew-core's CI, falling back on a "backfill" attestation for older bottles.
This is a specialization of check_attestation for homebrew-core.
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 |
# File 'attestation.rb', line 395 def self.check_core_attestation(bottle) begin # Ideally, we would also constrain the signing workflow here, but homebrew-core # currently uses multiple signing workflows to produce bottles # (e.g. `dispatch-build-bottle.yml`, `dispatch-rebottle.yml`, etc.). # # We could check each of these (1) explicitly (slow), (2) by generating a pattern # to pass into `--cert-identity-regex` (requires us to build up a Go-style regex), # or (3) by checking the resulting JSON for the expected signing workflow. # # Long term, we should probably either do (3) *or* switch to a single reusable # workflow, which would then be our sole identity. However, GitHub's # attestations currently do not include reusable workflow state by default. attestation = check_attestation bottle, HOMEBREW_CORE_REPO return attestation rescue MissingAttestationError odebug "falling back on backfilled attestation for #{bottle.filename}" # Our backfilled attestation is a little unique: the subject is not just the bottle # filename, but also has the bottle's hosted URL hash prepended to it. # This was originally unintentional, but has a virtuous side effect of further # limiting domain separation on the backfilled signatures (by committing them to # their original bottle URLs). url_sha256 = if EnvConfig.bottle_domain_custom? # If our bottle is coming from a mirror, we need to recompute the expected # non-mirror URL to make the hash match. checksum = bottle.resource.checksum odie "#{bottle.resource.name} checksum is nil" if checksum.nil? path, = Utils::Bottles.path_resolved_basename HOMEBREW_BOTTLE_DEFAULT_DOMAIN, bottle.name, checksum, bottle.filename url = "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/#{path}" Digest::SHA256.hexdigest(url) else Digest::SHA256.hexdigest(bottle.url) end subject = "#{url_sha256}--#{bottle.filename}" # We don't pass in a signing workflow for backfill signatures because # some backfilled bottle signatures were signed from the 'backfill' # branch, and others from 'main' of trailofbits/homebrew-brew-verify # so the signing workflow is slightly different which causes some bottles to incorrectly # fail when checking their attestation. This shouldn't meaningfully affect security # because if somehow someone could generate false backfill attestations # from a different workflow we will still catch it because the # attestation would have been generated after our cutoff date. backfill_attestation = check_attestation bottle, BACKFILL_REPO, nil, subject = backfill_attestation.dig("verificationResult", "verifiedTimestamps", 0, "timestamp") raise InvalidAttestationError, "backfill attestation is missing verified timestamp" if .nil? if DateTime.parse() > BACKFILL_CUTOFF raise InvalidAttestationError, "backfill attestation post-dates cutoff" end end backfill_attestation rescue InvalidAttestationError @attestation_retry_count ||= T.let(Hash.new(0), T.nilable(T::Hash[Bottle, Integer])) raise if @attestation_retry_count[bottle] >= ATTESTATION_MAX_RETRIES # Retry online rather than against listed bundles that did not verify. prefetched_bundles.delete(Downloadable.verification_cache.sha256(bottle.cached_download)) sleep_time = 3 ** @attestation_retry_count[bottle] opoo "Failed to verify attestation. Retrying in #{sleep_time}s..." sleep sleep_time if ENV["HOMEBREW_TESTS"].blank? @attestation_retry_count[bottle] += 1 retry end |
.check_formula_attestation(bottle) ⇒ Hash{String => T.untyped}
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Verifies a bottle against the attestation model for the formula's tap.
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'attestation.rb', line 352 def self.check_formula_attestation(bottle) formula = bottle.resource.owner formula = formula.owner if formula.is_a?(SoftwareSpec) unless formula.is_a?(Formula) raise UnsupportedTapError, "bottle is not associated with a formula tap." end tap = formula.tap if tap.nil? raise UnsupportedTapError, "#{formula.full_name} is not from a tap with supported bottle attestations." end return check_core_attestation(bottle) if tap.core_tap? if tap.custom_remote? raise UnsupportedTapError, "#{formula.full_name} is from #{tap.name}, which uses a non-default " \ "remote and cannot be attested safely." end signing_repo = tap.remote_repository if signing_repo.blank? raise UnsupportedTapError, "#{formula.full_name} is from #{tap.name}, which does not resolve " \ "to a GitHub repository for attestation verification." end return check_core_attestation(bottle) if signing_repo == HOMEBREW_CORE_REPO check_attestation(bottle, signing_repo) end |
.gh_executable ⇒ Pathname
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Returns a path to a suitable gh executable for attestation verification.
86 87 88 89 90 91 92 93 94 95 96 |
# File 'attestation.rb', line 86 def self.gh_executable @gh_executable ||= T.let(nil, T.nilable(Pathname)) return @gh_executable if @gh_executable # NOTE: We set HOMEBREW_NO_VERIFY_ATTESTATIONS when installing `gh` itself, # to prevent a cycle during bootstrapping. This can eventually be resolved # by vendoring a pure-Ruby Sigstore verifier client. @gh_executable = with_env(HOMEBREW_NO_VERIFY_ATTESTATIONS: "1") do Utils::Executable.ensure!("gh", reason: "verifying attestations", latest: true) end end |
.prefetch(formulae) ⇒ void
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
This method returns an undefined value.
Lists the formulae's homebrew-core bottle attestations in bulk so that
check_attestation can verify them offline. Failures are only logged:
an unlisted bottle is looked up online as before.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'attestation.rb', line 131 def self.prefetch(formulae) return unless Homebrew::EnvConfig.verify_attestations? return if Homebrew::EnvConfig.no_github_api? return if GitHub::API.credentials.blank? digests = formulae.filter_map do |formula| bottle = formula.bottle if formula.tap&.core_tap? bottle.resource.checksum&.hexdigest if bottle end digests = digests.uniq - prefetched_bundles.keys return if digests.empty? [HOMEBREW_CORE_REPO, BACKFILL_REPO].each do |signing_repo| organisation = signing_organisation(signing_repo) listed = bulk_list_bundles(organisation, digests) digests.each do |digest| lines = listed[digest] # A digest held only as `bundle_url` attestations stays unrecorded and is looked up online. next if lines && lines.empty? (prefetched_bundles[digest] ||= {})[organisation] = lines || [] end digests -= listed.keys break if digests.empty? end rescue GitHub::API::Error, JSON::ParserError => e odebug "Failed to list attestations in bulk: #{e}" end |
.prefetched_bundle_lines(bottle, digest, organisation) ⇒ Array<String>?
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
The listed bundles to verify the bottle against, or nil to look the
attestation up online.
327 328 329 330 331 332 333 334 335 336 337 338 339 |
# File 'attestation.rb', line 327 def self.prefetched_bundle_lines(bottle, digest, organisation) bundles = prefetched_bundles.fetch(digest, {}) lines = bundles[organisation] return if lines.nil? return lines unless lines.empty? # Leave digests no organisation holds to the online lookup in case the # listing was incomplete. return if bundles.values.all?(&:empty?) raise MissingAttestationError, "attestation not found: none listed from #{organisation} for #{bottle.filename}" end |
.prefetched_bundles ⇒ Hash{String => Hash{String => Array<String>}}
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Listed attestation bundles as JSON lines, keyed by bottle SHA-256 and then by organisation; an empty array means the organisation holds none.
121 122 123 |
# File 'attestation.rb', line 121 def self.prefetched_bundles @prefetched_bundles ||= T.let({}, T.nilable(T::Hash[String, T::Hash[String, T::Array[String]]])) end |
.signing_organisation(signing_repo) ⇒ String
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
202 |
# File 'attestation.rb', line 202 def self.signing_organisation(signing_repo) = signing_repo.split("/").fetch(0) |
.sort_formulae_for_install(formulae) ⇒ Array<Formula>
This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.
Prioritize installing gh first if it's in the formula list
or check for the existence of the gh executable elsewhere.
This ensures that a valid version of gh is installed before
we use it to check the attestations of any other formulae we
want to install.
107 108 109 110 111 112 113 114 |
# File 'attestation.rb', line 107 def self.sort_formulae_for_install(formulae) if (gh = formulae.find { |f| f.full_name == "gh" }) [gh] | formulae else Homebrew::Attestation.gh_executable formulae end end |