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
33 changes: 33 additions & 0 deletions Library/Homebrew/dev-cmd/advisory-match.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class AdvisoryMatch < AbstractCommand
"<file> instead of the published `data/repology.json`."
flag "--overrides=",
description: "Load reviewed formula and advisory matching overrides from <file>."
flag "--rejected-candidates=",
depends_on: "--output=",
description: "Skip new comparable candidates whose record IDs are listed in <file>, before history."
switch "--no-history",
description: "Skip `FormulaVersions` walks for new ranges; use " \
"zero/current `pkg_version` as unverified boundaries."
Expand Down Expand Up @@ -82,6 +85,15 @@ def run
formula_names = if (list = args.formula_list)
File.readlines(list, chomp: true).reject(&:empty?).uniq
end
rejected_candidates = if (path = args.rejected_candidates)
File.readlines(path, chomp: true).filter_map do |line|
id = line.sub(/#.*/, "").strip
id unless id.empty?
end.to_set
else
Set.new
end
prefiltered = T.let([], T::Array[String])
Formulary.enable_factory_cache!
Homebrew::API.with_no_api_env do
latest_macos = MacOSVersion.new((HOMEBREW_MACOS_NEWEST_UNSUPPORTED.to_i - 1).to_s).to_sym
Expand Down Expand Up @@ -127,6 +139,23 @@ def run
next
end

# Alias preparation must still see every actionable hit. Only
# absent records can be rejected before their initial history;
# existing records remain subject to the database's checks.
if rejected_candidates.any? && !args.no_history? && emitter.is_a?(DirEmitter)
actionable.reject! do |hit, status|
next false unless status

id = matcher.record_id(formula, hit)
next false unless rejected_candidates.include?(id)
next false if emitter.alias_protected?(id) || emitter.reviewed_range_state(id)
next false if emitter.alias_target_paths(id).any? { |target| File.exist?(target) }

prefiltered << id
true
end
end

new_histories = T.let({}, T::Hash[String, Homebrew::Vulns::Match::ReconciledHistory])
actionable.each do |hit, status|
record_id = matcher.record_id(formula, hit)
Expand Down Expand Up @@ -263,6 +292,10 @@ def run
Homebrew.failed = true
end
emitter.finish
if prefiltered.any?
puts " #{prefiltered.length} rejected candidates skipped before history"
prefiltered.sort.each { |id| puts " #{id}" }
end
report_history_load_failures(matcher) if args.reconcile_history?
end
end
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

187 changes: 187 additions & 0 deletions Library/Homebrew/test/dev-cmd/advisory-match_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,193 @@ def run_formula_list(contents, *extra_args, mode: "--reconcile-history")
expect(index.dig("requests", "primary_package", "ecosystem")).to eq "PyPI"
end

context "with --rejected-candidates" do
let(:dir) { Dir.mktmpdir }
let(:record_id) { "BREW-requests-CVE-2024-1234" }
let(:record_path) { File.join(dir, "#{record_id}.json") }
let(:rejected_path) { File.join(dir, "rejected-candidates.txt") }
let(:rejected_contents) { "#{record_id}\n" }
let(:history_option) { "--new-history" }
let(:command) do
cmd_for("requests", "--output", dir, history_option, "--rejected-candidates", rejected_path)
end
let(:matcher) { Homebrew::Vulns::Match.new }
let(:stored) do
hit = matcher.advisories_for(requests).fetch(0)
JSON.parse(JSON.generate(matcher.to_brew_record(requests, hit, now: Time.utc(2020))))
end

before do
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1", aliases: ["GHSA-example"])
allow(matcher).to receive(:reconcile_history).and_return(history_result)
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
File.write(rejected_path, rejected_contents)
end

after do
FileUtils.remove_entry(dir)
end

it "reports a rejected new candidate without extra platform queries or history" do
expect(matcher).not_to receive(:advisories_for)
expect(matcher).not_to receive(:reconcile_history)

output = capture_stdout { command.run }
expect([File.exist?(record_path), output]).to match [
false, a_string_matching(/1 rejected candidates skipped before history\n #{record_id}\n/)
]
end

it "processes an existing rejected record exactly as without the flag" do
stored["summary"] = "old summary"
original = JSON.generate(stored)
allow(Time).to receive(:now).and_return(Time.utc(2026))
File.write(record_path, original)
baseline_output = capture_stdout { cmd_for("requests", "--output", dir, "--new-history").run }
baseline = File.read(record_path)
File.write(record_path, original)

output = capture_stdout { command.run }
expect([File.read(record_path), output, JSON.parse(File.read(record_path))["summary"]])
.to eq [baseline, baseline_output, "s"]
end

it "does not skip an existing record with no reviewed range" do
stored.fetch("affected").fetch(0)["ranges"] = []
File.write(record_path, JSON.generate(stored))
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

output = capture_stdout { command.run }
expect([JSON.parse(File.read(record_path)).dig("affected", 0, "ranges", 0, "events"), output])
.to match [[{ "introduced" => "2.27.0" }, { "fixed" => "2.28.1" }], a_string_matching(/4 history walks/)]
end

it "does not skip an existing unreviewed alias of a rejected canonical ID" do
stored["id"] = "BREW-requests-GHSA-example"
stored.fetch("affected").fetch(0)["ranges"] = []
alias_path = File.join(dir, "#{stored.fetch("id")}.json")
File.write(alias_path, JSON.generate(stored))
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

command.run
expect(JSON.parse(File.read(alias_path)).dig("affected", 0, "ranges", 0, "events"))
.to eq [{ "introduced" => "2.27.0" }, { "fixed" => "2.28.1" }]
end

it "does not treat a listed non-canonical alias as a rejected record ID" do
File.write(rejected_path, "BREW-requests-GHSA-example\n")
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

command.run
expect(JSON.parse(File.read(record_path)).fetch("id")).to eq record_id
end

it "strips comments and whitespace and ignores blank lines and duplicate IDs" do
File.write(rejected_path,
" # comment\n\n\t#{record_id} \t # trailing comment\r\n #{record_id} # duplicate\n \t\n")
expect { command.run }
.to output(/1 rejected candidates skipped before history\n #{record_id}\n\z/).to_stdout
end

it "matches record IDs case-sensitively and without prefix matching" do
File.write(rejected_path, "#{record_id.downcase}\n#{record_id}-extra\n")
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

command.run
expect(File.exist?(record_path)).to be true
end

it "keeps the part-1 output and four-platform walk when the flag is absent" do
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

output = capture_stdout { cmd_for("requests", "--output", dir, "--new-history").run }
expect([JSON.parse(File.read(record_path)).dig("affected", 0, "ranges", 0, "events"), output])
.to match [[{ "introduced" => "2.27.0" }, { "fixed" => "2.28.1" }], a_string_matching(/4 history walks/)]
expect(output).not_to include("rejected candidates")
end

it "keeps full history for an allowed candidate when the rejected hit comes first" do
stub_osv_hit("CVE-2024-5678", fixed: "2.28.1")
ids = %w[CVE-2024-1234 CVE-2024-5678]
allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([ids.map { |id| { "id" => id } }, []])
walked = []
allow(matcher).to receive(:reconcile_history) do |_, hit|
walked << hit.canonical_id
history_result
end
expect(matcher).to receive(:advisories_for).exactly(3).times.and_call_original

command.run
expect([Dir.glob(File.join(dir, "BREW-*.json")).map { |path| File.basename(path) }, walked])
.to eq [["BREW-requests-CVE-2024-5678.json"], Array.new(4, "CVE-2024-5678")]
end

it "keeps full history for an allowed candidate when the rejected hit comes last" do
stub_osv_hit("CVE-2024-5678", fixed: "2.28.1")
ids = %w[CVE-2024-5678 CVE-2024-1234]
allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([ids.map { |id| { "id" => id } }, []])
walked = []
allow(matcher).to receive(:reconcile_history) do |_, hit|
walked << hit.canonical_id
history_result
end
expect(matcher).to receive(:advisories_for).exactly(3).times.and_call_original

command.run
expect([Dir.glob(File.join(dir, "BREW-*.json")).map { |path| File.basename(path) }, walked])
.to eq [["BREW-requests-CVE-2024-5678.json"], Array.new(4, "CVE-2024-5678")]
end

it "still reports malformed existing records during alias preparation" do
File.write(record_path, "{")
expect { command.run }.to output(/malformed alias/).to_stderr
expect([Homebrew.failed?, File.read(record_path)]).to eq [true, "{"]
end

it "fails when the rejection file is missing" do
File.unlink(rejected_path)
expect { command.run }.to raise_error(Errno::ENOENT)
end

it "fails when the rejection file is unreadable" do
allow(File).to receive(:readlines).with(rejected_path, chomp: true).and_raise(Errno::EACCES)
expect { command.run }.to raise_error(Errno::EACCES)
end

it "requires an output directory" do
expect { cmd_for("requests", "--rejected-candidates", rejected_path) }
.to raise_error(UsageError, /--rejected-candidates.*--output/)
end

context "with --no-history" do
let(:history_option) { "--no-history" }

it "leaves unchecked authoring unchanged" do
expect(matcher).not_to receive(:reconcile_history)
command.run
expect(File.exist?(record_path)).to be true
end
end

context "with --reconcile-history" do
let(:command) do
overrides_path = File.join(dir, "overrides.yml")
File.write(overrides_path, "{}")
cmd_for("requests", "--output", dir, "--reconcile-history", "--overrides", overrides_path,
"--rejected-candidates", rejected_path)
end

it "still reconciles an existing rejected record" do
File.write(record_path, JSON.generate(stored))
expect(matcher).to receive(:reconcile_history).exactly(4).times.and_return(history_result)

command.run
expect(JSON.parse(File.read(record_path)).dig("affected", 0, "ranges", 0, "events"))
.to eq [{ "introduced" => "2.27.0" }, { "fixed" => "2.28.1" }]
end
end
end

def capture_stdout
out = StringIO.new
old = $stdout
Expand Down
1 change: 1 addition & 0 deletions completions/bash/brew
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ _brew_advisory_match() {
--overrides
--quiet
--reconcile-history
--rejected-candidates
--repology
--verbose
"
Expand Down
1 change: 1 addition & 0 deletions completions/fish/brew.fish
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ __fish_brew_complete_arg 'advisory-match' -l output -d 'Write each record to dir
__fish_brew_complete_arg 'advisory-match' -l overrides -d 'Load reviewed formula and advisory matching overrides from file'
__fish_brew_complete_arg 'advisory-match' -l quiet -d 'Make some output more quiet'
__fish_brew_complete_arg 'advisory-match' -l reconcile-history -d 'Reconcile existing matched terminal ranges against complete history; requires `--overrides`'
__fish_brew_complete_arg 'advisory-match' -l rejected-candidates -d 'Skip new comparable candidates whose record IDs are listed in file, before history'
__fish_brew_complete_arg 'advisory-match' -l repology -d 'Load the formula to distro-package index from file instead of the published `data/repology.json`'
__fish_brew_complete_arg 'advisory-match' -l verbose -d 'Make some output more verbose'
__fish_brew_complete_arg 'advisory-match' -a '(__fish_brew_suggest_formulae_all)'
Expand Down
1 change: 1 addition & 0 deletions completions/zsh/_brew
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ _brew_advisory_match() {
'--overrides[Load reviewed formula and advisory matching overrides from file]' \
'--quiet[Make some output more quiet]' \
'(--new-history --no-history --json --index)--reconcile-history[Reconcile existing matched terminal ranges against complete history; requires `--overrides`]' \
'--rejected-candidates[Skip new comparable candidates whose record IDs are listed in file, before history]' \
'--repology[Load the formula to distro-package index from file instead of the published `data/repology.json`]' \
'--verbose[Make some output more verbose]' \
- formula \
Expand Down
7 changes: 7 additions & 0 deletions docs/Advisory-Matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ 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.

`--rejected-candidates=<file>` 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.
Alias preparation still sees all actionable hits, and a listed non-canonical alias does not reject the canonical ID.
Existing records, including unreviewed aliases, are processed normally so downstream validation can still reject listed records on disk.
This option does not change `--no-history` or `--reconcile-history`; an unreadable rejection file fails the command.

## Reconciling existing ranges

`--reconcile-history --output advisories --overrides data/overrides.yml` explicitly revisits existing `source: matched`, bump-fixed records with terminal introduced/fixed intervals.
Expand Down
Loading