From 613ae9b9496e455391800608ba7f4de20947020a Mon Sep 17 00:00:00 2001 From: Patrick Linnane Date: Sat, 10 Oct 2026 11:27:58 -0700 Subject: [PATCH] advisory-match: reuse platform-independent history Load a historical revision once and share its observations across platforms when its source is provably platform-independent: a single Formula class using only literal, allowlisted DSL. Anything else is still evaluated separately on each platform. Signed-off-by: Patrick Linnane --- Library/Homebrew/dev-cmd/advisory-match.rb | 11 +- Library/Homebrew/formula_versions.rb | 5 +- .../test/dev-cmd/advisory-match_spec.rb | 39 +++- Library/Homebrew/test/vulns/history_spec.rb | 193 +++++++++++++++++ Library/Homebrew/test/vulns/match_spec.rb | 54 ++++- .../test/vulns/platform_independence_spec.rb | 196 ++++++++++++++++++ Library/Homebrew/vulns/history.rb | 44 +++- Library/Homebrew/vulns/match.rb | 21 +- .../Homebrew/vulns/platform_independence.rb | 110 ++++++++++ docs/Advisory-Matching.md | 6 + 10 files changed, 660 insertions(+), 19 deletions(-) create mode 100644 Library/Homebrew/test/vulns/platform_independence_spec.rb create mode 100644 Library/Homebrew/vulns/platform_independence.rb diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index f4f2457ec6c86..593465dcf0697 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -309,6 +309,7 @@ def run .returns(T::Hash[String, Homebrew::Vulns::Match::ReconciledHistory]) } def new_record_histories(matcher, formula, hits, emitter, latest_macos:) + independent = Homebrew::Vulns::PlatformIndependence.formula?(formula) records = hits.to_h { |hit| [matcher.record_id(formula, hit), matcher.to_brew_record(formula, hit)] } outcomes = T.let({}, T::Hash[String, T::Array[Homebrew::Vulns::Match::ReconciledHistory]]) records.each_key { |id| outcomes[id] = [] } @@ -322,8 +323,12 @@ def new_record_histories(matcher, formula, hits, emitter, latest_macos:) Homebrew::SimulateSystem.with(os:, arch:) do begin - view = index.zero? ? formula : Formulary.factory(formula.path) - platform_hits = index.zero? ? hits : matcher.dedup_by_aliases(matcher.advisories_for(view)) + view = (index.zero? || independent) ? formula : Formulary.factory(formula.path) + platform_hits = if index.zero? || independent + hits + else + matcher.dedup_by_aliases(matcher.advisories_for(view)) + end rescue Homebrew::Vulns::OSV::Error raise rescue => e @@ -355,7 +360,7 @@ def new_record_histories(matcher, formula, hits, emitter, latest_macos:) platform_hit = family.fetch(0) end emitter.record_history_walk - outcomes.fetch(id) << matcher.reconcile_history(view, platform_hit) + outcomes.fetch(id) << matcher.reconcile_history(view, platform_hit, reuse_platforms: true) end end end diff --git a/Library/Homebrew/formula_versions.rb b/Library/Homebrew/formula_versions.rb index 66b7097509c7c..176903d212861 100644 --- a/Library/Homebrew/formula_versions.rb +++ b/Library/Homebrew/formula_versions.rb @@ -357,10 +357,11 @@ def rev_list(branch, all_history: false, &_block) .params( revision: String, formula_relative_path: String, + source: T.nilable(String), _block: T.proc.params(arg0: Formula).returns(T.type_parameter(:U)), ).returns(T.nilable(T.type_parameter(:U))) } - def formula_at_revision(revision, formula_relative_path = relative_path, &_block) + def formula_at_revision(revision, formula_relative_path = relative_path, source: nil, &_block) @load_error = nil Homebrew.raise_deprecation_exceptions = true LegacyMacOS.simulated_target = @simulated_target @@ -372,7 +373,7 @@ def formula_at_revision(revision, formula_relative_path = relative_path, &_block Formulary.from_contents( name, path, - file_contents_at_revision(revision, formula_relative_path) + (source || file_contents_at_revision(revision, formula_relative_path)) .sub(/\A(?:(?:[ \t]*#[^\n]*\n|[ \t]*\n)|(?:=begin[^\n]*(?:\n|\z).*?^=end[^\n]*(?:\n|\z)))*/m) do |header| "#{header}Formula = ScriptFileFormula = ::FormulaVersions.legacy_formula_class;" end, diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index 7c87761b0ab5a..85728f068bb23 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -1434,6 +1434,37 @@ def basis_for(evidence, ecosystem_specific = {}) end end + it "reuses an independent current formula's hits without additional OSV queries" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + allow(requests.path).to receive(:read).and_return(<<~RUBY) + class Requests < Formula + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + head "https://github.com/psf/requests.git" + end + RUBY + matcher = Homebrew::Vulns::Match.new + allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher) + expect(matcher).to receive(:reconcile_history).with(requests, anything, reuse_platforms: true) + .exactly(4).times.and_return(history_result) + expect(matcher).not_to receive(:advisories_for) + expect(Formulary).not_to receive(:factory).with(requests.path) + + cmd_for("requests", "--json").run + end + + it "still stops at the first held platform when the current source is independent" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + allow(requests.path).to receive(:read).and_return("class Requests < Formula; end") + matcher = Homebrew::Vulns::Match.new + allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher) + expect(matcher).to receive(:reconcile_history).with(requests, anything, reuse_platforms: true).once + .and_return(history_result(state: :unresolved, + reasons: [:history_unavailable])) + expect(matcher).not_to receive(:advisories_for) + + cmd_for("requests", "--json").run + end + it "queries each additional platform once for multiple new candidates" do ids = %w[CVE-2024-1234 CVE-2024-5678] ids.each { |id| stub_osv_hit(id, fixed: "2.28.1") } @@ -1502,7 +1533,8 @@ def resource_formula(version, resource_version, unknown: false) }] }) allow(Homebrew::Vulns::History).to receive(:new).and_return(history) - allow(history).to receive(:walk).with(anything, complete: true) do |_, **_, &block| + allow(history).to receive(:platform_independent?).and_return(false) + allow(history).to receive(:walk).with(anything, complete: true, reuse_platforms: true) do |_, **_, &block| linux = Homebrew::SimulateSystem.simulating_or_running_on_linux? block.call(resource_formula("2.0", if linux formerly_on_linux ? "1.0" : nil @@ -1583,7 +1615,8 @@ def resource_formula(version, resource_version, unknown: false) }] }) allow(Homebrew::Vulns::History).to receive(:new).and_return(history) - allow(history).to receive(:walk).with(anything, complete: true) do |_, **_, &block| + allow(history).to receive(:platform_independent?).and_return(false) + allow(history).to receive(:walk).with(anything, complete: true, reuse_platforms: true) do |_, **_, &block| old_formulae.each { |old| block.call(old) } nil end @@ -1604,7 +1637,7 @@ def resource_formula(version, resource_version, unknown: false) end it "holds a new record when a later platform's complete walk is unavailable" do - allow(history).to receive(:walk).with(anything, complete: true) do |_, **_, &block| + allow(history).to receive(:walk).with(anything, complete: true, reuse_platforms: true) do |_, **_, &block| old_formulae.each { |old| block.call(old) } :history_unavailable if Homebrew::SimulateSystem.simulating_or_running_on_linux? end diff --git a/Library/Homebrew/test/vulns/history_spec.rb b/Library/Homebrew/test/vulns/history_spec.rb index 309f3c37b0772..1b6f54d28eaf7 100644 --- a/Library/Homebrew/test/vulns/history_spec.rb +++ b/Library/Homebrew/test/vulns/history_spec.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "vulns/history" +require "vulns/match" RSpec.describe Homebrew::Vulns::History do subject(:history) { described_class.new } @@ -361,4 +362,196 @@ class Requests < Formula expect(history.walk(requests, complete: true) { nil }).to eq :history_unavailable end end + + context "with platform reuse" do + let(:source_path) { "Formula/r/requests.rb" } + let(:current_source) do + <<~RUBY + class Requests < Formula + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-3.0.tar.gz" + end + RUBY + end + let(:old_source) { current_source.sub("requests-3.0", "requests-1.0") } + let(:platforms) { [[:tahoe, :arm], [:tahoe, :intel], [:linux, :arm], [:linux, :intel]] } + + before do + allow(FormulaVersions).to receive(:new).and_call_original + allow_any_instance_of(FormulaVersions).to receive(:rev_list) + .and_yield("new", source_path).and_yield("old", source_path) + allow(Utils).to receive(:popen_read).and_call_original + ["new", "old"].zip([current_source, old_source]).each do |revision, source| + allow(Utils).to receive(:popen_read).with("git", "cat-file", "blob", "#{revision}:#{source_path}") + .and_return(source) + allow(Utils).to receive(:popen_read) + .with("git", "-C", requests.tap!.path.to_s, "cat-file", "blob", "#{revision}:#{source_path}", safe: true) + .and_return(source) + end + allow(Utils).to receive(:popen_read) + .with("git", "-C", requests.tap!.path.to_s, "diff-tree", "--root", "--no-commit-id", "--name-only", + "--find-renames", "--diff-filter=AR", "-r", "old", safe: true).and_return("#{source_path}\n") + end + + def walk_platforms(reuse:) + walker = described_class.new + platforms.map do |os, arch| + versions = [] + status = Homebrew::SimulateSystem.with(os:, arch:) do + walker.walk(requests, complete: true, reuse_platforms: reuse) do |old| + versions << [old.pkg_version.to_s, old.resources.map(&:name)] + nil + end + end + [status, versions] + end + end + + def reconciled_platforms(reuse:) + matcher = Homebrew::Vulns::Match.new + current = Formulary.from_contents("requests", requests.tap_path, current_source) + vulnerability = Homebrew::Vulns::Vulnerability.new({ + "id" => "CVE-2024-1234", "affected" => [{ + "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "2.0" }] }], + }] + }) + evidence = Homebrew::Vulns::Match::Evidence.new( + strategy: :registry, ecosystem: "PyPI", name: "certifi", resource: "certifi", + subject_version: "1.0", key: "pkg:pypi/certifi@1.0" + ) + hit = Homebrew::Vulns::Match::Hit.new(vulnerability:, evidence: [evidence]) + results = platforms.map do |os, arch| + Homebrew::SimulateSystem.with(os:, arch:) do + matcher.reconcile_history(current, hit, reuse_platforms: reuse) + end + end + combined = Homebrew::Vulns::Match::ReconciledHistory.combine(results) + [combined.state, combined.events, combined.reasons] + end + + it "reuses independent revisions with identical observations and fewer formula loads" do + loads = 0 + allow(Formulary).to receive(:from_contents).and_wrap_original do |original, *args, **kwargs| + loads += 1 + original.call(*args, **kwargs) + end + full = walk_platforms(reuse: false) + full_loads = loads + loads = 0 + reused = walk_platforms(reuse: true) + + expect([reused, full_loads, loads]).to eq [full, 8, 2] + end + + context "when a removed resource was Linux-only" do + let(:old_source) do + current_source.sub("requests-3.0", "requests-1.0").sub("\nend", <<~RUBY) + + on_linux do + resource "certifi" do + url "https://files.pythonhosted.org/packages/aa/bb/cc/certifi-1.0.tar.gz" + end + end + end + RUBY + end + + it "preserves the removed Linux resource's affected interval with reuse on and off" do + full = reconciled_platforms(reuse: false) + expect(Formulary).to receive(:from_contents).exactly(6).times.and_call_original + reused = reconciled_platforms(reuse: true) + + expect([full, reused]).to eq Array.new(2) { + [:range, [{ introduced: "1.0" }, { fixed: "3.0" }], []] + } + end + + it "reloads the dependent old revision despite an independent current revision" do + expect(Formulary).to receive(:from_contents).exactly(5).times.and_call_original + expect(walk_platforms(reuse: true)).to eq [ + [nil, [["3.0", []], ["1.0", []]]], [nil, [["3.0", []], ["1.0", []]]], + [nil, [["3.0", []], ["1.0", ["certifi"]]]], [nil, [["3.0", []], ["1.0", ["certifi"]]]] + ] + end + end + + context "when a removed resource was macOS-only" do + let(:old_source) do + current_source.sub("requests-3.0", "requests-1.0").sub("\nend", <<~RUBY) + + on_macos do + resource "certifi" do + url "https://files.pythonhosted.org/packages/aa/bb/cc/certifi-1.0.tar.gz" + end + end + end + RUBY + end + + it "preserves the removed macOS resource's affected interval with reuse on and off" do + full = reconciled_platforms(reuse: false) + expect(Formulary).to receive(:from_contents).exactly(6).times.and_call_original + reused = reconciled_platforms(reuse: true) + + expect([full, reused]).to eq Array.new(2) { + [:range, [{ introduced: "1.0" }, { fixed: "3.0" }], []] + } + end + + it "retains the old macOS resource while reusing only the independent revision" do + expect(Formulary).to receive(:from_contents).exactly(5).times.and_call_original + expect(walk_platforms(reuse: true)).to eq [ + [nil, [["3.0", []], ["1.0", ["certifi"]]]], [nil, [["3.0", []], ["1.0", ["certifi"]]]], + [nil, [["3.0", []], ["1.0", []]]], [nil, [["3.0", []], ["1.0", []]]] + ] + end + end + + context "when every revision has literal metadata" do + let(:old_source) do + current_source.sub("requests-3.0", "requests-1.0").sub("\nend", <<~RUBY) + + resource "certifi" do + url "https://files.pythonhosted.org/packages/aa/bb/cc/certifi-1.0.tar.gz" + end + end + RUBY + end + + it "loads the classified source without reading the same Git blob again" do + expect(Utils).not_to receive(:popen_read).with("git", "cat-file", "blob", anything) + + expect(walk_platforms(reuse: true)).to eq Array.new(4) { + [nil, [["3.0", []], ["1.0", ["certifi"]]]] + } + end + + it "does not transfer the independence proof to an equal formula object" do + proof = [] + history.walk(requests, reuse_platforms: true) do |old| + proof = [history.platform_independent?(old), history.platform_independent?(old.dup)] + :stop + end + + expect(proof).to eq [true, false] + end + + it "preserves the complete reconciled interval while loading each revision once" do + observations = 0 + allow_any_instance_of(Homebrew::Vulns::Match).to receive(:reconciliation_observation) + .and_wrap_original do |original, *args| + observations += 1 + original.call(*args) + end + full = reconciled_platforms(reuse: false) + full_observations = observations + observations = 0 + expect(Formulary).to receive(:from_contents).exactly(3).times.and_call_original + reused = reconciled_platforms(reuse: true) + + interval = [:range, [{ introduced: "1.0" }, { fixed: "3.0" }], []] + expect([full, reused, full_observations, observations]).to eq [interval, interval, 12, 6] + end + end + end end diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 8f2ae9abef6de..e2488070790bd 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -1219,7 +1219,7 @@ def prerelease_hit(introduced: "2026.2.22", fixed: "2026.2.23", resource: nil, t older = [prerelease_formula("2026.2.22-2"), prerelease_formula("2026.2.21-2")] history = instance_double(Homebrew::Vulns::History) allow(Homebrew::Vulns::History).to receive(:new).and_return(history) - allow(history).to receive(:walk).with(current, complete: true) do |&block| + allow(history).to receive(:walk).with(current, complete: true, reuse_platforms: false) do |&block| older.each { |old| block.call(old) } nil end @@ -2500,4 +2500,56 @@ def pnpm_hit .to raise_error(ArgumentError) end end + + context "with shared historical observations" do + let(:current) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-3.0.tar.gz" + end + end + let(:old) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-1.0.tar.gz" + end + end + let(:history) { instance_double(Homebrew::Vulns::History) } + + before do + allow(Homebrew::Vulns::History).to receive(:new).and_return(history) + allow(history).to receive(:walk).and_yield(old) + allow(history).to receive(:platform_independent?).with(current).and_return(false) + allow(history).to receive(:platform_independent?).with(old).and_return(true) + end + + def cached_hit(fixed: "2.0", aliases: []) + vulnerability = vuln({ + "id" => "CVE-2024-1234", "aliases" => aliases, "affected" => [{ + "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => fixed }] }], + }] + }) + make_hit(vulnerability, ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "3.0")) + end + + it "does not reuse an observation for different upstream constraints under the same ID" do + first = matcher.reconcile_history(current, cached_hit, reuse_platforms: true) + changed = matcher.reconcile_history(current, cached_hit(fixed: "0.5"), reuse_platforms: true) + + expect([first.state, changed.state]).to eq [:range, :never_affected] + end + + it "rechecks patch annotations when the upstream alias family changes" do + allow(old).to receive(:serialized_patches) + .and_return([{ "resolves" => [{ "type" => "security", "id" => "CVE-2024-5678" }] }]) + original = cached_hit + expanded = make_hit(cached_hit(aliases: ["CVE-2024-5678"]).vulnerability, *original.evidence) + first = matcher.reconcile_history(current, original, reuse_platforms: true) + changed = matcher.reconcile_history(current, expanded, reuse_platforms: true) + + expect([first.state, changed.state, changed.reasons]) + .to eq [:range, :unresolved, [:advisory_patch_requires_review]] + end + end end diff --git a/Library/Homebrew/test/vulns/platform_independence_spec.rb b/Library/Homebrew/test/vulns/platform_independence_spec.rb new file mode 100644 index 0000000000000..739dce7340e3e --- /dev/null +++ b/Library/Homebrew/test/vulns/platform_independence_spec.rb @@ -0,0 +1,196 @@ +# typed: true +# frozen_string_literal: true + +require "vulns/platform_independence" + +RSpec.describe Homebrew::Vulns::PlatformIndependence do + def independent?(body) + described_class.source?("class Example < Formula\n#{body}\nend\n") + end + + it "accepts literal matcher metadata and deferred installation and test code" do + expect(independent?(<<~'RUBY')).to be true + include Language::Python::Virtualenv + desc "Example" + homepage "https://example.test" + url "https://example.test/example-2.0.tar.gz", tag: "v2.0" + version "2.0" + revision 1 + version_scheme 1 + sha256 "abc" + license any_of: ["MIT", "BSD-2-Clause"] + head "https://example.test/example.git", branch: "main" + depends_on "python@3.14" + uses_from_macos "libffi" + pypi_packages exclude_packages: %w[certifi] + bottle do + sha256 cellar: :any_skip_relocation, arm64_tahoe: "abc", x86_64_linux: "def" + rebuild 1 + end + stable do + mirror "https://mirror.test/example-2.0.tar.gz" + resource "certifi" do + url "https://example.test/certifi-1.0.tar.gz" + patch do + url "https://example.test/fix.diff" + sha256 "def" + resolves "CVE-2024-1234" + end + end + end + def install + system "make", OS.mac? ? "mac" : "linux" + end + test do + system "#{bin}/example", "--version" + end + RUBY + end + + it "rejects on_linux resources" do + expect(independent?(<<~RUBY)).to be false + on_linux do + resource "dep" do + url "https://example.test/dep-1.0.tar.gz" + end + end + RUBY + end + + it "rejects on_macos resources" do + expect(independent?(<<~RUBY)).to be false + on_macos do + resource "dep" do + url "https://example.test/dep-1.0.tar.gz" + end + end + RUBY + end + + it "rejects class-level OS conditionals" do + expect(independent?(<<~RUBY)).to be false + if OS.mac? + url "https://example.test/mac-1.0.tar.gz" + end + RUBY + end + + it "rejects hardware-selected values" do + expect(independent?(<<~RUBY)).to be false + url Hardware::CPU.arm? ? "arm" : "intel" + RUBY + end + + it "rejects helper-derived metadata" do + expect(independent?(<<~RUBY)).to be false + def self.archive + "https://example.test/dep-1.0.tar.gz" + end + url archive + RUBY + end + + it "rejects interpolated resource URLs" do + expect(independent?(<<~'RUBY')).to be false + resource "dep" do + url "https://example.test/dep-#{version}.tar.gz" + end + RUBY + end + + it "rejects macOS-version DSL" do + expect(independent?(<<~RUBY)).to be false + on_sonoma :or_newer do + depends_on "dep" + end + RUBY + end + + it "rejects on_system DSL" do + expect(independent?(<<~RUBY)).to be false + on_system :linux, macos: :sonoma do + depends_on "dep" + end + RUBY + end + + it "rejects unknown DSL" do + expect(independent?(<<~RUBY)).to be false + mystery "literal" + RUBY + end + + it "rejects constants used as metadata" do + expect(independent?(<<~RUBY)).to be false + ARCHIVE = "https://example.test/dep-1.0.tar.gz" + url ARCHIVE + RUBY + end + + it "rejects boolean-derived values" do + expect(independent?(<<~RUBY)).to be false + url ENV["SOURCE"] || "https://example.test/dep-1.0.tar.gz" + RUBY + end + + it "rejects conditional patch annotations" do + expect(independent?(<<~RUBY)).to be false + patch do + url "https://example.test/fix.diff" + resolves "CVE-2024-1234" if OS.mac? + end + RUBY + end + + it "rejects custom metadata methods" do + expect(independent?(<<~RUBY)).to be false + def resources + [] + end + RUBY + end + + it "rejects unknown mixins" do + expect(independent?(<<~RUBY)).to be false + include CustomHelpers + RUBY + end + + it "rejects executable argument splats" do + expect(independent?(<<~RUBY)).to be false + depends_on(*packages) + RUBY + end + + it "rejects metaprogramming" do + expect(independent?(<<~'RUBY')).to be false + class_eval "url \"x\"" + RUBY + end + + it "rejects execution inside bottle blocks" do + expect(independent?(<<~RUBY)).to be false + bottle do + Formula.class_eval { def version; "0"; end } + end + RUBY + end + + it "rejects unparsable source" do + expect(described_class.source?("class Example < Formula")).to be false + end + + it "rejects statements outside the formula class" do + expect(described_class.source?("require 'helper'\nclass Example < Formula; end")).to be false + end + + it "falls back when the current source cannot be read" do + current = formula("example") do + T.bind(self, T.class_of(Formula)) + url "https://example.test/example-1.0.tar.gz" + end + allow(current.path).to receive(:read).and_raise(Errno::ENOENT) + + expect(described_class.formula?(current)).to be false + end +end diff --git a/Library/Homebrew/vulns/history.rb b/Library/Homebrew/vulns/history.rb index b10dcb198b939..73d25c4f9886f 100644 --- a/Library/Homebrew/vulns/history.rb +++ b/Library/Homebrew/vulns/history.rb @@ -3,12 +3,14 @@ require "formula_versions" require "simulate_system" +require "vulns/platform_independence" module Homebrew module Vulns # Walks a formula's tap history newest first via {FormulaVersions}. Each # instance shares Git facts across platforms for a fixed tap history, - # while loaded formulae remain specific to the simulated platform. + # while loaded formulae remain specific to the simulated platform unless + # their source proves that matching metadata is platform-independent. # # History that cannot be trusted fails closed: a shallow clone, a formula # file with no git history and a revision that cannot be loaded all end the @@ -30,6 +32,9 @@ def initialize @rev_lists = T.let({}, T::Hash[String, T::Array[[String, String]]]) @complete_history = T.let({}, T::Hash[String, T::Boolean]) @shallow_taps = T.let({}, T::Hash[String, T::Boolean]) + @platform_independence = T.let({}, T::Hash[[String, String, String], T::Boolean]) + @independent_formulae = T.let({}.compare_by_identity, T::Hash[Formula, T::Boolean]) + @shared_formulae = T.let({}, T::Hash[[String, String, String], Formula]) @load_failures = T.let({}, T::Hash[[String, String, String, String], LoadFailure]) end @@ -37,6 +42,11 @@ def initialize sig { returns(T::Array[LoadFailure]) } def load_failures = @load_failures.values + # Identity is essential: distinct platform views of the same formula + # can compare equal, but only a source-proven historical instance may be shared. + sig { params(formula: Formula).returns(T::Boolean) } + def platform_independent?(formula) = @independent_formulae.key?(formula) + # Yield each loadable historical revision of `formula`, newest first, # until the block returns a result. Returns that result, # `:history_unavailable` when the history cannot be trusted or `nil` once @@ -46,12 +56,13 @@ def load_failures = @load_failures.values # A proven absent path is not a build; skip it # in complete walks so earlier lifetimes are still observed. sig { - params(formula: Formula, - complete: T::Boolean, - _block: T.proc.params(old: Formula).returns(T.nilable(T.any(String, Symbol)))) + params(formula: Formula, + complete: T::Boolean, + reuse_platforms: T::Boolean, + _block: T.proc.params(old: Formula).returns(T.nilable(T.any(String, Symbol)))) .returns(T.nilable(T.any(String, Symbol))) } - def walk(formula, complete: false, &_block) + def walk(formula, complete: false, reuse_platforms: false, &_block) tap = formula.tap! tap_key = tap.path.to_s return :history_unavailable if @shallow_taps.fetch(tap_key) { @shallow_taps[tap_key] = tap.shallow? } @@ -83,7 +94,28 @@ def walk(formula, complete: false, &_block) revs.each do |rev, entry| # Wrap the verdict so keeping the walk going (`nil`) stays # distinguishable from a revision that failed to load. - verdict = fv.formula_at_revision(rev, entry) { |old| [yield(old)] } + key = [tap_path, rev, entry] + source = T.let(nil, T.nilable(String)) + independent = reuse_platforms && @platform_independence.fetch(key) do + @platform_independence[key] = begin + source = Utils.popen_read("git", "-C", tap_key, "cat-file", "blob", "#{rev}:#{entry}", safe: true) + PlatformIndependence.source?(source) + rescue ErrorDuringExecution + false + end + end + shared = independent ? @shared_formulae[key] : nil + verdict = if shared + [yield(shared)] + else + fv.formula_at_revision(rev, entry, **(source ? { source: } : {})) do |old| + if independent + @shared_formulae[key] = old + @independent_formulae[old] = true + end + [yield(old)] + end + end if verdict.nil? begin next if complete && fv.path_absent_at_revision?(rev, entry) diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index d3f0c08c88de9..02e707c3283f6 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -231,6 +231,10 @@ def initialize(repology: nil, cpan_sec: nil, overrides: nil, bulk: false, strict @vuln_cache = T.let({}, T::Hash[String, T.nilable(Vulnerability)]) @vuln_errors = T.let({}, T::Hash[String, OSV::Error]) @history = T.let(History.new, History) + @reconciliation_observations = T.let( + {}.compare_by_identity, + T::Hash[Formula, T::Hash[[T::Array[String], T::Array[Evidence]], HistoricalObservation]], + ) end sig { returns(T::Array[History::LoadFailure]) } @@ -948,23 +952,32 @@ def first_fixed_version(formula, hit) # transition, including below-introduced versions and removed resources. # An unresolved or protected result never supplies replacement boundaries. sig { - params(formula: Formula, hit: Hit, require_resource_presence: T::Boolean).returns(ReconciledHistory) + params(formula: Formula, hit: Hit, require_resource_presence: T::Boolean, + reuse_platforms: T::Boolean).returns(ReconciledHistory) } - def reconcile_history(formula, hit, require_resource_presence: false) + def reconcile_history(formula, hit, require_resource_presence: false, reuse_platforms: false) if preserve_homebrew_ranges?(formula.name, hit.identifiers) return ReconciledHistory.new(state: :preserved, reasons: []) end unseen = require_resource_presence ? hit.evidence.select(&:resource).dup : [] + # Matching metadata alone does not identify an observation: constraints + # and the alias family (including patch annotations) must also agree. + observation_key = [hit.identifiers, hit.evidence] observe = lambda do |build| unseen.reject! do |ev| present, version = subject_version_at(build, ev) present && !version.nil? end - reconciliation_observation(build, hit) + if reuse_platforms && @history.platform_independent?(build) + cached = @reconciliation_observations[build] ||= {} + cached[observation_key] ||= reconciliation_observation(build, hit) + else + reconciliation_observation(build, hit) + end end observations = [observe.call(formula)] - result = @history.walk(formula, complete: true) do |old| + result = @history.walk(formula, complete: true, reuse_platforms:) do |old| observations << observe.call(old) nil end diff --git a/Library/Homebrew/vulns/platform_independence.rb b/Library/Homebrew/vulns/platform_independence.rb new file mode 100644 index 0000000000000..7b18d6d2cc631 --- /dev/null +++ b/Library/Homebrew/vulns/platform_independence.rb @@ -0,0 +1,110 @@ +# typed: strict +# frozen_string_literal: true + +require "prism" + +module Homebrew + module Vulns + # Proves independence of the metadata read by Match, not of installation. + # Unknown Ruby or DSL is deliberately evaluated separately on each platform. + module PlatformIndependence + SOURCE_CALLS = [:url, :mirror, :version, :sha256, :sha1, :md5].freeze + FORMULA_CALLS = T.let((SOURCE_CALLS + [ + :desc, :homepage, :head, :revision, :version_scheme, :license, :depends_on, + :uses_from_macos, :keg_only, :conflicts_with, :option, :deprecated_option, + :deprecate!, :disable!, :pypi_packages + ]).freeze, T::Array[Symbol]) + + sig { params(formula: Formula).returns(T::Boolean) } + def self.formula?(formula) + source?(formula.path.read) + rescue SystemCallError + false + end + + sig { params(source: String).returns(T::Boolean) } + def self.source?(source) + parsed = Prism.parse(source) + return false unless parsed.success? + + statements = parsed.value.statements.body + definition = statements.first + return false if !statements.one? || !definition.is_a?(Prism::ClassNode) + return false unless definition.constant_path.is_a?(Prism::ConstantReadNode) + + parent = definition.superclass + return false if !parent.is_a?(Prism::ConstantReadNode) || parent.name != :Formula + + body?(definition.body, :formula) + end + + sig { params(body: T.nilable(Prism::Node), context: Symbol).returns(T::Boolean) } + def self.body?(body, context) + return true if body.nil? + return false unless body.is_a?(Prism::StatementsNode) + + body.body.all? do |node| + # These methods and Formula.test's block are stored, never invoked by + # loading or matching. No arbitrary helpers or metadata overrides. + if context == :formula && node.is_a?(Prism::DefNode) + next node.receiver.nil? && node.parameters.nil? && [:install, :post_install, :caveats].include?(node.name) + end + next false if !node.is_a?(Prism::CallNode) || !node.receiver.nil? + + arguments = node.arguments&.arguments || [] + block = node.block + if context == :formula && node.name == :include && block.nil? + # This mixin defines installation helpers only, with no included hook. + next arguments.one? && arguments.first&.location&.slice == "Language::Python::Virtualenv" + end + next false unless arguments.all? { |argument| literal?(argument) } + + if block + next false if !block.is_a?(Prism::BlockNode) || !block.parameters.nil? + next true if context == :formula && node.name == :test && arguments.empty? + + nested = case node.name + when :stable, :head then :source if context == :formula && arguments.empty? + when :bottle then :bottle if context == :formula && arguments.empty? + when :resource + :resource if [:formula, :source].include?(context) && arguments.one? && + arguments.first.is_a?(Prism::StringNode) + when :patch then :patch if [:formula, :source, :resource].include?(context) + end + next false unless nested + + body?(block.body, nested) + else + allowed = case context + when :formula then FORMULA_CALLS + when :source then SOURCE_CALLS + [:depends_on, :uses_from_macos, :option] + when :resource then SOURCE_CALLS + when :patch then SOURCE_CALLS + [:resolves, :apply] + when :bottle then [:sha256, :sha1, :root_url, :cellar, :prefix, :rebuild] + else [] + end + allowed.include?(node.name) + end + end + end + + sig { params(node: Prism::Node).returns(T::Boolean) } + def self.literal?(node) + case node + when Prism::StringNode, Prism::SymbolNode, Prism::IntegerNode, Prism::FloatNode, + Prism::TrueNode, Prism::FalseNode, Prism::NilNode + true + when Prism::ArrayNode + node.elements.all? { |element| literal?(element) } + when Prism::HashNode, Prism::KeywordHashNode + node.elements.all? do |element| + element.is_a?(Prism::AssocNode) && literal?(element.key) && literal?(element.value) + end + else + false + end + end + private_class_method :body?, :literal? + end + end +end diff --git a/docs/Advisory-Matching.md b/docs/Advisory-Matching.md index 443bc9b4af040..ee5a50eaa41f1 100644 --- a/docs/Advisory-Matching.md +++ b/docs/Advisory-Matching.md @@ -50,6 +50,12 @@ Existing reviewed ranges are preserved rather than replaced with guessed introdu `--no-history` explicitly uses unverified zero/current-version boundaries for new ranges and cannot be combined with `--new-history`. It is an unchecked authoring mode, not a way to validate a skipped candidate. +New-record history can reuse a revision's loaded matching metadata across platforms when parsing that revision proves that its declarations are platform-independent. +A conservative allowlist accepts literal source, resource and patch metadata; unknown DSL, helper-derived values and platform conditionals retain separate platform loads. +The current formula is checked separately before reusing its advisory queries, and older revisions are always classified individually. +Shared historical observations remain bound to the same upstream evidence and alias family. +This optimisation does not change existing-record reconciliation or the early stop for candidates requiring review. + `--rejected-candidates=` requires `--output` and reads an explicit local rejection list; it never fetches one. The file contains exact, case-sensitive record IDs, one per line, with `#` comments, surrounding whitespace and blank lines ignored. With history enabled, listed new comparable candidates are skipped before additional platform queries or history walks, and their count and IDs are reported.