From 76fdf66f27e5da9ac69ff122abb5bbff40ff2157 Mon Sep 17 00:00:00 2001 From: Patrick Linnane Date: Sat, 10 Oct 2026 11:25:49 -0700 Subject: [PATCH] advisory-match: add --rejected-candidates Skip new candidates whose record IDs are listed in the given rejection file before querying other platforms or walking history. Ingest deletes these candidates after matching anyway. Existing records, aliases, --no-history and reconciliation are unchanged. Signed-off-by: Patrick Linnane --- Library/Homebrew/dev-cmd/advisory-match.rb | 33 ++++ .../dsl/homebrew/dev_cmd/advisory_match.rbi | 3 + .../test/dev-cmd/advisory-match_spec.rb | 187 ++++++++++++++++++ completions/bash/brew | 1 + completions/fish/brew.fish | 1 + completions/zsh/_brew | 1 + docs/Advisory-Matching.md | 7 + 7 files changed, 233 insertions(+) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index ff6c22dfccf65..f4f2457ec6c86 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -37,6 +37,9 @@ class AdvisoryMatch < AbstractCommand " instead of the published `data/repology.json`." flag "--overrides=", description: "Load reviewed formula and advisory matching overrides from ." + flag "--rejected-candidates=", + depends_on: "--output=", + description: "Skip new comparable candidates whose record IDs are listed in , before history." switch "--no-history", description: "Skip `FormulaVersions` walks for new ranges; use " \ "zero/current `pkg_version` as unverified boundaries." @@ -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 @@ -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) @@ -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 diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi index 11ba6c176e653..1409b4e3cc5c9 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi @@ -38,6 +38,9 @@ class Homebrew::DevCmd::AdvisoryMatch::Args < Homebrew::CLI::Args sig { returns(T::Boolean) } def reconcile_history?; end + sig { returns(T.nilable(String)) } + def rejected_candidates; end + sig { returns(T.nilable(String)) } def repology; end end diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index 86b479fc5246a..7c87761b0ab5a 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -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 diff --git a/completions/bash/brew b/completions/bash/brew index 0d46ed0133d59..b0a317dcb75f1 100644 --- a/completions/bash/brew +++ b/completions/bash/brew @@ -339,6 +339,7 @@ _brew_advisory_match() { --overrides --quiet --reconcile-history + --rejected-candidates --repology --verbose " diff --git a/completions/fish/brew.fish b/completions/fish/brew.fish index 6a31e7016df43..b081608a005d0 100644 --- a/completions/fish/brew.fish +++ b/completions/fish/brew.fish @@ -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)' diff --git a/completions/zsh/_brew b/completions/zsh/_brew index df8b1c2188839..dad448dce3efe 100644 --- a/completions/zsh/_brew +++ b/completions/zsh/_brew @@ -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 \ diff --git a/docs/Advisory-Matching.md b/docs/Advisory-Matching.md index 8c882b64726a5..443bc9b4af040 100644 --- a/docs/Advisory-Matching.md +++ b/docs/Advisory-Matching.md @@ -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=` 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.