vulns/match: tighten range-state edge cases

dev-cmd/advisory-match drops :not_applicable hits before to_brew_record;
emitting them as {introduced: 0} with no fixed event reads to OSV
consumers as currently affected.

CPANSec.range_status decides :fixed by evaluating the full fixed_versions
constraint with satisfies? instead of a stripped >= bound, so
affected: ["<1.0"], fixed: [">1.0"] leaves 1.0 as :not_applicable
rather than :fixed.

cpansa_vulnerability takes the single id being handled so a multi-CVE
CPANSA advisory whose CVEs are absent from OSV yields one record per CVE
instead of collapsing under the lowest.

resolve_to_cves consults related only for ALSA-* records; the OSV schema
defines related as different vulnerabilities and only AlmaLinux is known
to use it for source CVEs.

Vulnerability#range_status marks an interval checked only after a
comparison succeeds, so a target that fails every comparison in the only
range returns nil (uncomparable) rather than :not_applicable.
This commit is contained in:
Andrew Nesbitt committed 2026-07-28 12:35:39 +01:00
1 parent a67b535365
commit 35a92398f0
8 files changed
+91 -14

No files matched your search

@@ -54,6 +54,12 @@ module Homebrew
matcher.each_advisory_batch(each_formula) do |formula, hits|
report(matcher, formula, hits) if text_mode?
hits.each do |hit|
# A `:not_applicable` hit (below every `introduced`) emitted
# as `{introduced: 0}` with no `fixed` reads to OSV consumers
# as currently affected; drop it instead.
status, = matcher.range_status(hit)
next if status&.state == :not_applicable
first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history?
emitter << matcher.to_brew_record(formula, hit, first_fixed:)
end
@@ -66,6 +66,19 @@ RSpec.describe Homebrew::DevCmd::AdvisoryMatch do
end
end
it "drops :not_applicable hits instead of emitting them as open ranges" do
allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => "CVE-2024-1234" }], []])
allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-1234").and_return(
{ "id" => "CVE-2024-1234", "affected" => [{
"package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" },
"ranges" => [{ "type" => "ECOSYSTEM",
"events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }],
}] },
)
expect(JSON.parse(capture_stdout { cmd_for("requests", "--json", "--no-history").run })).to eq []
end
it "does not overwrite an existing source: generated record" do
stub_osv_hit("CVE-2024-1234", fixed: "2.28.1")
@@ -121,6 +121,12 @@ RSpec.describe Homebrew::Vulns::CPANSec do
expect(described_class.range_status(adv(affected: [], fixed: []), "1.0").affected?).to be true
end
it "does not report a version in the gap between affected and a strict >fix as :fixed" do
status = described_class.range_status(adv(affected: ["<1.0"], fixed: [">1.0"]), "1.0")
expect(status.state).to eq :not_applicable
expect(described_class.range_status(adv(affected: ["<1.0"], fixed: [">1.0"]), "1.1").state).to eq :fixed
end
it "reports affected with no fixed_in when there is no fixed_versions" do
expect(described_class.range_status(adv(affected: ["<12.24"], fixed: []), "12.00"))
.to have_attributes(affected?: true, fixed_in: nil)
+34 -1
View File
@@ -224,6 +224,31 @@ RSpec.describe Homebrew::Vulns::Match do
} })
end
it "scopes a synthesised fallback to the CVE being handled when OSV lacks it" do
cpan_sec = Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {
"Multi" => { "advisories" => [
{ "id" => "CPANSA-Multi-1", "cves" => ["CVE-2022-4988", "CVE-2022-4989"],
"affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"] },
] },
} })
m = described_class.new(repology:, cpan_sec:)
identity = Homebrew::Vulns::Match::Identity.new(
git_repo: nil, git_tag: nil,
primary_package: Homebrew::Vulns::Identify::RegistryPackage.new(
ecosystem: "CPAN", name: "Multi", version: "0.9", purl: "pkg:cpan/X/Multi@0.9",
),
resource_packages: {}, distro_packages: {}
)
allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4988")
.and_raise(Homebrew::Vulns::OSV::ApiError, "404")
allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4989")
.and_return({ "id" => "CVE-2022-4989" })
hits = m.hits_from({}, identity)
expect(hits.map(&:canonical_id).sort).to eq ["CVE-2022-4988", "CVE-2022-4989"]
end
it "builds a hit directly from a CPANSA advisory that has no CVE alias" do
identity = Homebrew::Vulns::Match::Identity.new(
git_repo: nil, git_tag: nil,
@@ -283,7 +308,7 @@ RSpec.describe Homebrew::Vulns::Match do
expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
end
it "consults related for bare CVE ids only when upstream is empty (AlmaLinux)" do
it "consults related for bare CVE ids only for ALSA-* records with no upstream" do
allow(matcher).to receive(:fetch_vulnerability).with("ALSA-1").and_return(
vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001", "RHSA-2024:1"]),
)
@@ -294,6 +319,14 @@ RSpec.describe Homebrew::Vulns::Match do
expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
end
it "does not consult related for a non-ALSA record with no upstream" do
allow(matcher).to receive(:fetch_vulnerability).with("MGASA-1").and_return(
vuln("id" => "MGASA-1", "related" => ["CVE-2024-9999"]),
)
hits = matcher.resolve_upstream({ "MGASA-1" => [ev(:distro)] }, identity)
expect(hits.map { |h| h.vulnerability.id }).to eq ["MGASA-1"]
end
it "ignores related when upstream is present" do
allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return(
vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0001"], "related" => ["CVE-9999-9999"]),
@@ -270,6 +270,14 @@ RSpec.describe Homebrew::Vulns::Vulnerability do
.to have_attributes(affected?: true, fixed_in: nil)
end
it "returns nil (not :not_applicable) when every comparison in the only range fails" do
v = vuln("id" => "CVE-1", "affected" => [
affected("crates.io", "serde",
range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.0.0" })),
])
expect(v.range_status("crates.io", "serde", "not-semver")).to be_nil
end
it "compares SEMVER ranges with strict semver ordering" do
v = vuln("id" => "CVE-1", "affected" => [
affected("crates.io", "serde",
+2 -1
View File
@@ -70,7 +70,8 @@ module Homebrew
if affected
fixed_in = bounds.select { |v| target < v }.min&.to_s
Vulnerability::RangeStatus.new(state: :affected, fixed_in:).freeze
elsif (fixed_in = bounds.select { |v| target >= v }.max&.to_s)
elsif advisory.fixed_versions.any? { |c| satisfies?(target, c) }
fixed_in = bounds.select { |v| target >= v }.max&.to_s
Vulnerability::RangeStatus.new(state: :fixed, fixed_in:).freeze
else
Vulnerability::RangeStatus.new(state: :not_applicable, fixed_in: nil).freeze
+19 -8
View File
@@ -202,23 +202,27 @@ module Homebrew
annotated = Evidence.new(**ev.to_h, advisory: adv).freeze
if adv.cves.any?
adv.cves.each do |cve|
record = fetch_vulnerability(cve) || cpansa_vulnerability(adv)
record = fetch_vulnerability(cve) || cpansa_vulnerability(adv, id: cve)
hits << Hit.new(vulnerability: record, evidence: [annotated])
end
else
hits << Hit.new(vulnerability: cpansa_vulnerability(adv), evidence: [annotated])
hits << Hit.new(vulnerability: cpansa_vulnerability(adv, id: adv.id.to_s),
evidence: [annotated])
end
end
end
dedup_by_cve(hits)
end
sig { params(adv: CPANSec::Advisory).returns(Vulnerability) }
def cpansa_vulnerability(adv)
# Synthesise a {Vulnerability} for a CPANSA advisory when OSV has no
# record. `id` is scoped to the single CVE (or CPANSA id) being handled
# so a multi-CVE advisory whose CVEs are absent from OSV yields distinct
# records instead of collapsing under the lowest CVE in dedup.
sig { params(adv: CPANSec::Advisory, id: String).returns(Vulnerability) }
def cpansa_vulnerability(adv, id:)
summary = adv.description.to_s.lines.first&.strip
Vulnerability.new({
"id" => adv.id.to_s,
"aliases" => adv.cves,
"id" => id,
"summary" => summary,
"details" => adv.description,
"references" => adv.references.map { |u| { "type" => "WEB", "url" => u } },
@@ -323,6 +327,12 @@ module Homebrew
hits
end
# AlmaLinux ALSA-* records list their source CVEs in `related` rather than
# `upstream`. That is a data-source quirk; per the OSV schema `related`
# otherwise names *different* vulnerabilities and must not be traversed.
RELATED_AS_UPSTREAM_PREFIX = "ALSA-"
private_constant :RELATED_AS_UPSTREAM_PREFIX
# Returns the set of CVE records `record` derives from. `[record]` if it
# is one already; `[]` if the walk exhausts without reaching a CVE (the
# caller then keeps `record` itself as a low-confidence hit).
@@ -334,8 +344,9 @@ module Homebrew
return [record] if record.cve_ids.any?
return [] if budget.zero?
follow = record.upstream.presence || record.related.grep(CVE_ID)
follow.uniq.flat_map do |ref|
follow = record.upstream.presence
follow ||= record.related.grep(CVE_ID) if record.id.start_with?(RELATED_AS_UPSTREAM_PREFIX)
Array(follow).uniq.flat_map do |ref|
next [] unless seen.add?(ref)
upstream = fetch_vulnerability(ref)
+3 -4
View File
@@ -148,16 +148,15 @@ module Homebrew
cmp = comparator_for(type)
intervals(Array(range["events"])).each do |lower, upper, upper_inclusive|
inside = in_interval?(target, lower, upper, upper_inclusive, cmp)
checked = true
if in_interval?(target, lower, upper, upper_inclusive, cmp)
return RangeStatus.new(state: :affected, fixed_in: upper_inclusive ? nil : upper).freeze
end
return RangeStatus.new(state: :affected, fixed_in: upper_inclusive ? nil : upper).freeze if inside
next unless upper
rel = cmp.call(target, upper)
past_fixes << upper if upper_inclusive ? rel.positive? : rel >= 0
rescue Uncomparable
checked ||= false
next
end
end