Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Library/Homebrew/dev-cmd/advisory-match.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [] }
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Library/Homebrew/formula_versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
39 changes: 36 additions & 3 deletions Library/Homebrew/test/dev-cmd/advisory-match_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
193 changes: 193 additions & 0 deletions Library/Homebrew/test/vulns/history_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
54 changes: 53 additions & 1 deletion Library/Homebrew/test/vulns/match_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading