mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 09:34:42 +08:00
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:
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user