Skip to content

Commit e554f97

Browse files
authored
Merge pull request #23610 from Homebrew/advisory-match-new-history
advisory-match: add --new-history to skip reviewed records
2 parents 519f531 + 585bcd4 commit e554f97

9 files changed

Lines changed: 190 additions & 8 deletions

File tree

Library/Homebrew/dev-cmd/advisory-match.rb

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ class AdvisoryMatch < AbstractCommand
3434
switch "--no-history",
3535
description: "Skip the `FormulaVersions` walk for the `fixed` " \
3636
"boundary; use the current `pkg_version` instead."
37+
switch "--new-history",
38+
depends_on: "--output=",
39+
description: "Walk `FormulaVersions` only for records whose " \
40+
"reviewed ranges are not already in <directory>."
3741
conflicts "--all", "--index"
3842
conflicts "--all", "--json"
3943
conflicts "--index", "--json"
4044
conflicts "--index", "--output"
45+
conflicts "--no-history", "--new-history"
4146

4247
named_args [:formula]
4348

@@ -64,7 +69,11 @@ def run
6469
status, = matcher.range_status(hit)
6570
next if status&.state == :not_applicable
6671

67-
first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history?
72+
record_id = matcher.record_id(formula, hit)
73+
walk_history = !args.no_history? && status&.fixed?
74+
walk_history &&= emitter.history_required?(record_id) if args.new_history?
75+
emitter.record_history_walk if walk_history
76+
first_fixed = matcher.first_fixed_version(formula, hit) if walk_history
6877
next if first_fixed == :never_affected
6978

7079
boundary = first_fixed if first_fixed.is_a?(String)
@@ -142,6 +151,12 @@ def report(matcher, formula, hits)
142151
# `--output` and text mode write per-record and only accumulate counts;
143152
# `--json` accumulates the array (single-formula / PR-bot use, so bounded).
144153
class Emitter
154+
sig { params(_record_id: String).returns(T::Boolean) }
155+
def history_required?(_record_id) = true
156+
157+
sig { void }
158+
def record_history_walk; end
159+
145160
sig { params(record: T::Hash[Symbol, T.untyped]).void }
146161
def <<(record); end
147162

@@ -159,11 +174,12 @@ def initialize(dir, verbose:)
159174
@written = T.let(0, Integer)
160175
@unchanged = T.let(0, Integer)
161176
@skipped_generated = T.let(0, Integer)
177+
@history_walks = T.let(0, Integer)
162178
end
163179

164180
sig { override.params(record: T::Hash[Symbol, T.untyped]).void }
165181
def <<(record)
166-
path = File.join(@dir, "#{record.fetch(:id)}.json")
182+
path = record_path(record.fetch(:id))
167183
# A record already emitted by `generate-vulns-advisories` (a formula
168184
# `resolves` patch annotation) is more authoritative than a matched
169185
# candidate; overwriting it would drop `fix: "patch"` for a derived
@@ -182,6 +198,35 @@ def <<(record)
182198
@written += 1
183199
end
184200

201+
sig { override.params(record_id: String).returns(T::Boolean) }
202+
def history_required?(record_id)
203+
path = record_path(record_id)
204+
return true unless File.file?(path)
205+
206+
existing = JSON.parse(File.read(path))
207+
return true unless existing.is_a?(Hash)
208+
return false if existing.dig("database_specific", "source") == "generated"
209+
210+
affected = existing["affected"]
211+
return true unless affected.is_a?(Array)
212+
return true if affected.empty?
213+
214+
affected.any? { |entry| !entry.is_a?(Hash) || !entry["ranges"] }
215+
rescue JSON::ParserError
216+
true
217+
end
218+
219+
sig { override.void }
220+
def record_history_walk
221+
@history_walks += 1
222+
puts " #{@history_walks} history walks" if @verbose && (@history_walks % 100).zero?
223+
end
224+
225+
sig { params(record_id: String).returns(String) }
226+
def record_path(record_id)
227+
File.join(@dir, "#{record_id}.json")
228+
end
229+
185230
sig { params(path: String).returns(T.nilable(String)) }
186231
def existing_source(path)
187232
JSON.parse(File.read(path)).dig("database_specific", "source")
@@ -192,7 +237,8 @@ def existing_source(path)
192237
sig { override.void }
193238
def finish
194239
Utils::Output.ohai "#{@written} records written to #{@dir} " \
195-
"(#{@unchanged} unchanged, #{@skipped_generated} generated left as-is)"
240+
"(#{@unchanged} unchanged, #{@skipped_generated} generated left as-is, " \
241+
"#{@history_walks} history walks)"
196242
end
197243
end
198244

Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Library/Homebrew/test/dev-cmd/advisory-match_spec.rb

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,110 @@ def stub_osv_hit(cve, fixed:)
6666
end
6767
end
6868

69+
it "only walks history for records not already present with --new-history" do
70+
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
71+
72+
Dir.mktmpdir do |dir|
73+
path = File.join(dir, "BREW-requests-CVE-2024-1234.json")
74+
record = {
75+
"schema_version" => Homebrew::Vulns::OsvExport::SCHEMA_VERSION,
76+
"id" => "BREW-requests-CVE-2024-1234",
77+
"modified" => "2026-01-01T00:00:00Z",
78+
"affected" => [{
79+
"package" => { "ecosystem" => "Homebrew", "name" => "requests" },
80+
"ranges" => [{ "type" => "ECOSYSTEM", "events" => [
81+
{ "introduced" => "0" }, { "fixed" => "2.28.1" }
82+
] }],
83+
}],
84+
"database_specific" => { "source" => "matched" },
85+
}
86+
File.write(path, JSON.generate(record))
87+
88+
matcher = Homebrew::Vulns::Match.new
89+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
90+
expect(matcher).not_to receive(:first_fixed_version)
91+
92+
expect { cmd_for("requests", "--output", dir, "--new-history").run }
93+
.to output(/0 history walks/).to_stdout
94+
expect(JSON.parse(File.read(path)).dig("affected", 0, "ranges", 0, "events", 1))
95+
.to eq("fixed" => "2.28.1")
96+
end
97+
end
98+
99+
it "uses the historical boundary for a new record with --new-history" do
100+
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
101+
matcher = Homebrew::Vulns::Match.new
102+
expect(matcher).to receive(:first_fixed_version).and_return("2.28.1")
103+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
104+
105+
Dir.mktmpdir do |dir|
106+
expect { cmd_for("requests", "--output", dir, "--new-history").run }
107+
.to output(/1 history walks/).to_stdout
108+
path = File.join(dir, "BREW-requests-CVE-2024-1234.json")
109+
expect(JSON.parse(File.read(path)).dig("affected", 0, "ranges", 0, "events", 1))
110+
.to eq("fixed" => "2.28.1")
111+
end
112+
end
113+
114+
it "walks history when an existing matched record has no ranges" do
115+
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
116+
matcher = Homebrew::Vulns::Match.new
117+
expect(matcher).to receive(:first_fixed_version).and_return("2.28.1")
118+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
119+
120+
Dir.mktmpdir do |dir|
121+
path = File.join(dir, "BREW-requests-CVE-2024-1234.json")
122+
File.write(path, JSON.generate({
123+
"id" => "BREW-requests-CVE-2024-1234",
124+
"affected" => [{ "package" => { "ecosystem" => "Homebrew", "name" => "requests" } }],
125+
"database_specific" => { "source" => "matched" },
126+
}))
127+
128+
cmd_for("requests", "--output", dir, "--new-history").run
129+
expect(JSON.parse(File.read(path)).dig("affected", 0, "ranges", 0, "events", 1))
130+
.to eq("fixed" => "2.28.1")
131+
end
132+
end
133+
134+
it "walks history when an existing record is malformed" do
135+
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
136+
matcher = Homebrew::Vulns::Match.new
137+
expect(matcher).to receive(:first_fixed_version).and_return("2.28.1")
138+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
139+
140+
Dir.mktmpdir do |dir|
141+
path = File.join(dir, "BREW-requests-CVE-2024-1234.json")
142+
File.write(path, "{")
143+
144+
cmd_for("requests", "--output", dir, "--new-history").run
145+
expect(JSON.parse(File.read(path)).dig("affected", 0, "ranges", 0, "events", 1))
146+
.to eq("fixed" => "2.28.1")
147+
end
148+
end
149+
150+
it "does not walk history for a new record with --no-history" do
151+
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
152+
matcher = Homebrew::Vulns::Match.new
153+
expect(matcher).not_to receive(:first_fixed_version)
154+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
155+
156+
Dir.mktmpdir do |dir|
157+
cmd_for("requests", "--output", dir, "--no-history").run
158+
end
159+
end
160+
161+
it "does not count a history walk for a new record that is still affected" do
162+
stub_osv_hit("CVE-2024-1234", fixed: "2.32.0")
163+
matcher = Homebrew::Vulns::Match.new
164+
expect(matcher).not_to receive(:first_fixed_version)
165+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
166+
167+
Dir.mktmpdir do |dir|
168+
expect { cmd_for("requests", "--output", dir, "--new-history").run }
169+
.to output(/0 history walks/).to_stdout
170+
end
171+
end
172+
69173
it "drops :not_applicable hits instead of emitting them as open ranges" do
70174
allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => "CVE-2024-1234" }], []])
71175
allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-1234").and_return(
@@ -88,7 +192,11 @@ def stub_osv_hit(cve, fixed:)
88192
"database_specific" => { "source" => "generated" },
89193
"affected" => [{ "ecosystem_specific" => { "fix" => "patch" } }] }))
90194

91-
expect { cmd_for("requests", "--output", dir, "--no-history").run }
195+
matcher = Homebrew::Vulns::Match.new
196+
expect(matcher).not_to receive(:first_fixed_version)
197+
allow(Homebrew::Vulns::Match).to receive(:new).and_return(matcher)
198+
199+
expect { cmd_for("requests", "--output", dir, "--new-history").run }
92200
.to output(/0 records written.*1 generated left as-is/).to_stdout
93201
expect(JSON.parse(File.read(path)).dig("affected", 0, "ecosystem_specific", "fix")).to eq "patch"
94202
end
@@ -160,6 +268,16 @@ def stub_osv_hit(cve, fixed:)
160268
expect { described_class.new(["--all", "--json"]) }.to raise_error(UsageError, /mutually exclusive/)
161269
end
162270

271+
it "requires --output with --new-history" do
272+
expect { described_class.new(["requests", "--new-history"]) }
273+
.to raise_error(UsageError, /--new-history.*--output/)
274+
end
275+
276+
it "rejects --new-history with --no-history" do
277+
expect { described_class.new(["requests", "--output", "out", "--new-history", "--no-history"]) }
278+
.to raise_error(UsageError, /mutually exclusive/)
279+
end
280+
163281
it "emits the formula-identity index with --index" do
164282
requests
165283
core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", formula_names: ["requests"])

Library/Homebrew/test/vulns/match_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,8 @@ def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, nam
562562
record = matcher.to_brew_record(requests, hit, now:)
563563

564564
expect(record[:id]).to eq "BREW-requests-CVE-2024-1234"
565+
expect(matcher.record_id(requests, hit))
566+
.to eq Homebrew::Vulns::OsvExport.record_for(requests, "CVE-2024-1234", now:)[:id]
565567
expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd"]
566568
expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }]
567569
expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }]

Library/Homebrew/vulns/match.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc)
497497

498498
record = T.let({
499499
schema_version: OsvExport::SCHEMA_VERSION,
500-
id: "#{OsvExport::ID_PREFIX}-#{formula.name}-#{hit.canonical_id}",
500+
id: record_id(formula, hit),
501501
published: timestamp,
502502
modified: timestamp,
503503
upstream: vuln.identifiers,
@@ -520,6 +520,11 @@ def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc)
520520
record
521521
end
522522

523+
sig { params(formula: Formula, hit: Hit).returns(String) }
524+
def record_id(formula, hit)
525+
OsvExport.record_id(formula, hit.canonical_id)
526+
end
527+
523528
sig {
524529
params(hit: Hit, status: T.nilable(Vulnerability::RangeStatus)).returns(String)
525530
}

Library/Homebrew/vulns/osv_export.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def self.run(annotated, dir, first_fixed: nil, now: Time.now.utc)
5959
annotated.each do |formula, patches|
6060
Scanner.resolved_ids(patches).each do |vuln_id|
6161
upstream = upstream_cache.fetch(vuln_id) { upstream_cache[vuln_id] = fetch_upstream(vuln_id) }
62-
path = File.join(dir, "#{ID_PREFIX}-#{formula.name}-#{vuln_id}.json")
62+
path = File.join(dir, "#{record_id(formula, vuln_id)}.json")
6363
existing = File.file?(path)
6464
# A transient OSV outage would otherwise strip summary/severity/etc.
6565
# from an existing enriched record; leave it untouched instead.
@@ -123,7 +123,7 @@ def self.record_for(formula, vuln_id, patches: formula.serialized_patches,
123123
timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
124124
record = T.let({
125125
schema_version: SCHEMA_VERSION,
126-
id: "#{ID_PREFIX}-#{formula.name}-#{vuln_id}",
126+
id: record_id(formula, vuln_id),
127127
published: timestamp,
128128
modified: timestamp,
129129
upstream: [vuln_id],
@@ -150,6 +150,11 @@ def self.record_for(formula, vuln_id, patches: formula.serialized_patches,
150150
record
151151
end
152152

153+
sig { params(formula: Formula, vuln_id: String).returns(String) }
154+
def self.record_id(formula, vuln_id)
155+
"#{ID_PREFIX}-#{formula.name}-#{vuln_id}"
156+
end
157+
153158
sig {
154159
params(formula: Formula, vuln_id: String, patches: T::Array[T::Hash[String, T.untyped]], fixed: String)
155160
.returns(T::Hash[Symbol, T.untyped])

completions/bash/brew

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ _brew_advisory_match() {
344344
--help
345345
--index
346346
--json
347+
--new-history
347348
--no-history
348349
--output
349350
--quiet

completions/fish/brew.fish

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ __fish_brew_complete_arg 'advisory-match' -l debug -d 'Display any debugging inf
339339
__fish_brew_complete_arg 'advisory-match' -l help -d 'Show this message'
340340
__fish_brew_complete_arg 'advisory-match' -l index -d 'Emit the formula-identity index as JSON and exit'
341341
__fish_brew_complete_arg 'advisory-match' -l json -d 'Output candidate records as a JSON array'
342+
__fish_brew_complete_arg 'advisory-match' -l new-history -d 'Walk `FormulaVersions` only for records whose reviewed ranges are not already in directory'
342343
__fish_brew_complete_arg 'advisory-match' -l no-history -d 'Skip the `FormulaVersions` walk for the `fixed` boundary; use the current `pkg_version` instead'
343344
__fish_brew_complete_arg 'advisory-match' -l output -d 'Write each record to directory as `BREW-formula-id.json`, preserving existing `published`/`ranges` fields'
344345
__fish_brew_complete_arg 'advisory-match' -l quiet -d 'Make some output more quiet'

completions/zsh/_brew

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,8 @@ _brew_advisory_match() {
442442
'--help[Show this message]' \
443443
'(--all --json --output)--index[Emit the formula-identity index as JSON and exit]' \
444444
'(--all --index)--json[Output candidate records as a JSON array]' \
445-
'--no-history[Skip the `FormulaVersions` walk for the `fixed` boundary; use the current `pkg_version` instead]' \
445+
'(--no-history)--new-history[Walk `FormulaVersions` only for records whose reviewed ranges are not already in directory]' \
446+
'(--new-history)--no-history[Skip the `FormulaVersions` walk for the `fixed` boundary; use the current `pkg_version` instead]' \
446447
'(--index)--output[Write each record to directory as `BREW-formula-id.json`, preserving existing `published`/`ranges` fields]' \
447448
'--quiet[Make some output more quiet]' \
448449
'--repology[Load the formula to distro-package index from file instead of the published `data/repology.json`]' \

0 commit comments

Comments
 (0)