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.