mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 09:34:42 +08:00
vulns: add per-package range evaluation
Vulnerability#range_status(ecosystem, name, version) evaluates a version
against the single affected[] entry whose package matches, honouring
range type: SEMVER via Semver, ECOSYSTEM via Version, GIT skipped as
uncomparable (commit-SHA events cannot be ordered against a version
string). Returns {affected?, fixed_in} for the interval containing the
version, or the closing boundary of the highest interval below it, or
nil when no entry matches / no comparable range exists so callers can
tell not-affected from could-not-check.
CPANSec.range_status(advisory, version) evaluates the CPANSA
affected_versions/fixed_versions constraint grammar (comma-joined AND of
</<=/>/>=/==/=/bare terms; array entries OR) against a Version.
Vulnerability#identifiers reverts to id + aliases only. upstream is a
directed reference (a distro advisory naming one-or-more source CVEs)
and related links to different vulnerabilities; treating either as an
identity of this record over- or under-merges. Vulns::Match follows
upstream explicitly. severity_entries exposes the raw OSV severity
array for record emission.
This commit is contained in:
4 files changed
+297
-11
No files matched your search
@@ -85,6 +85,48 @@ RSpec.describe Homebrew::Vulns::CPANSec do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".range_status" do
|
||||
def adv(affected:, fixed:)
|
||||
Homebrew::Vulns::CPANSec::Advisory.new(id: "CPANSA-X", cves: [], affected_versions: affected,
|
||||
fixed_versions: fixed)
|
||||
end
|
||||
|
||||
it "reports affected with fixed_in when the version is inside a single-bound constraint" do
|
||||
status = described_class.range_status(adv(affected: ["<12.24"], fixed: [">=12.24"]), "12.00")
|
||||
expect(status).to have_attributes(affected?: true, fixed_in: "12.24")
|
||||
end
|
||||
|
||||
it "reports not-affected with fixed_in when the version is at or past the fix" do
|
||||
status = described_class.range_status(adv(affected: ["<12.24"], fixed: [">=12.24"]), "13.55")
|
||||
expect(status).to have_attributes(affected?: false, fixed_in: "12.24")
|
||||
end
|
||||
|
||||
it "evaluates comma-joined AND terms" do
|
||||
status = described_class.range_status(adv(affected: [">=0.64,<1.632"], fixed: [">=1.632"]), "1.5")
|
||||
expect(status.affected?).to be true
|
||||
expect(described_class.range_status(adv(affected: [">=0.64,<1.632"], fixed: []), "0.5").affected?)
|
||||
.to be false
|
||||
end
|
||||
|
||||
it "treats multiple array entries as OR" do
|
||||
a = adv(affected: ["<1.0", ">=2.0,<2.5"], fixed: [">=1.0,<2.0", ">=2.5"])
|
||||
expect(described_class.range_status(a, "0.9").affected?).to be true
|
||||
expect(described_class.range_status(a, "2.1")).to have_attributes(affected?: true, fixed_in: "2.5")
|
||||
expect(described_class.range_status(a, "1.5").affected?).to be false
|
||||
end
|
||||
|
||||
it "treats a bare version term as equality and an empty affected_versions as always affected" do
|
||||
expect(described_class.range_status(adv(affected: ["1.0"], fixed: []), "1.0").affected?).to be true
|
||||
expect(described_class.range_status(adv(affected: ["1.0"], fixed: []), "1.1").affected?).to be false
|
||||
expect(described_class.range_status(adv(affected: [], fixed: []), "1.0").affected?).to be true
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".load" do
|
||||
it "reads a fresh cache file without downloading" do
|
||||
Dir.mktmpdir do |dir|
|
||||
|
||||
@@ -152,6 +152,107 @@ RSpec.describe Homebrew::Vulns::Vulnerability do
|
||||
end
|
||||
end
|
||||
|
||||
describe "#range_status" do
|
||||
def affected(ecosystem, name, *ranges, versions: nil)
|
||||
{ "package" => { "ecosystem" => ecosystem, "name" => name },
|
||||
"ranges" => ranges, "versions" => versions }.compact
|
||||
end
|
||||
|
||||
def range(type, *events)
|
||||
{ "type" => type, "events" => events }
|
||||
end
|
||||
|
||||
it "matches only the affected entry for the given package" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "2.28.1" })),
|
||||
affected("PyPI", "urllib3", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "1.26.5" })),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "2.27.0"))
|
||||
.to have_attributes(affected?: true, fixed_in: "2.28.1")
|
||||
expect(v.range_status("PyPI", "urllib3", "2.27.0"))
|
||||
.to have_attributes(affected?: false, fixed_in: "1.26.5")
|
||||
end
|
||||
|
||||
it "returns nil when no affected entry matches the package" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "other", range("ECOSYSTEM", { "fixed" => "1.0" })),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "2.0")).to be_nil
|
||||
end
|
||||
|
||||
it "skips GIT ranges as uncomparable and returns nil when nothing else is checkable" do
|
||||
v = vuln("id" => "CVE-2026-32316", "affected" => [
|
||||
affected("GIT", "https://github.com/jqlang/jq",
|
||||
range("GIT", { "introduced" => "0" },
|
||||
{ "fixed" => "e47e56d226519635768e6aab2f38f0ab037c09e5" })),
|
||||
])
|
||||
expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1")).to be_nil
|
||||
end
|
||||
|
||||
it "uses a comparable range when the same entry also carries a GIT range" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("GIT", "https://github.com/jqlang/jq",
|
||||
range("GIT", { "fixed" => "e47e56d" }),
|
||||
range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.8.2" })),
|
||||
])
|
||||
expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1"))
|
||||
.to have_attributes(affected?: true, fixed_in: "1.8.2")
|
||||
end
|
||||
|
||||
it "picks the fixed boundary of the interval containing the target across disjoint branches" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "requests",
|
||||
range("ECOSYSTEM",
|
||||
{ "introduced" => "0" }, { "fixed" => "2.28.1" },
|
||||
{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "3.0.1"))
|
||||
.to have_attributes(affected?: true, fixed_in: "3.0.4")
|
||||
expect(v.range_status("PyPI", "requests", "2.27.0"))
|
||||
.to have_attributes(affected?: true, fixed_in: "2.28.1")
|
||||
end
|
||||
|
||||
it "reports not-affected with the highest fixed boundary at or below the target" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "requests",
|
||||
range("ECOSYSTEM",
|
||||
{ "introduced" => "0" }, { "fixed" => "2.28.1" },
|
||||
{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "2.31.0"))
|
||||
.to have_attributes(affected?: false, fixed_in: "2.28.1")
|
||||
expect(v.range_status("PyPI", "requests", "3.1.0"))
|
||||
.to have_attributes(affected?: false, fixed_in: "3.0.4")
|
||||
end
|
||||
|
||||
it "reports affected with no fixed_in for an open-ended interval" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "2.0.0" })),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "2.31.0"))
|
||||
.to have_attributes(affected?: true, fixed_in: nil)
|
||||
end
|
||||
|
||||
it "compares SEMVER ranges with strict semver ordering" 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", "1.0.0-rc.1"))
|
||||
.to have_attributes(affected?: true, fixed_in: "1.0.0")
|
||||
end
|
||||
|
||||
it "checks an explicit versions list when present" do
|
||||
v = vuln("id" => "CVE-1", "affected" => [
|
||||
affected("PyPI", "requests", versions: ["2.30.0", "2.31.0"]),
|
||||
])
|
||||
expect(v.range_status("PyPI", "requests", "2.31.0"))
|
||||
.to have_attributes(affected?: true, fixed_in: nil)
|
||||
expect(v.range_status("PyPI", "requests", "2.32.0"))
|
||||
.to have_attributes(affected?: false, fixed_in: nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#identifiers and #cve_ids" do
|
||||
it "returns id followed by aliases" do
|
||||
v = vuln("id" => "GHSA-xxxx-yyyy-zzzz", "aliases" => ["CVE-2024-1234", "OSV-2024-1"])
|
||||
@@ -162,11 +263,11 @@ RSpec.describe Homebrew::Vulns::Vulnerability do
|
||||
expect(vuln("id" => "CVE-2024-1234").identifiers).to eq ["CVE-2024-1234"]
|
||||
end
|
||||
|
||||
it "includes upstream ids (distro records carry the CVE there) but not related" do
|
||||
it "excludes upstream and related (directed references, not identities of this record)" do
|
||||
v = vuln("id" => "DEBIAN-CVE-2024-1234", "upstream" => ["CVE-2024-1234"],
|
||||
"related" => ["CVE-2024-9999"])
|
||||
expect(v.identifiers).to eq ["DEBIAN-CVE-2024-1234", "CVE-2024-1234"]
|
||||
expect(v.cve_ids).to eq ["CVE-2024-1234"]
|
||||
expect(v.identifiers).to eq ["DEBIAN-CVE-2024-1234"]
|
||||
expect(v.upstream).to eq ["CVE-2024-1234"]
|
||||
end
|
||||
|
||||
it "extracts CVE ids from id and aliases" do
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "vulns/cached_feed"
|
||||
require "vulns/vulnerability"
|
||||
|
||||
module Homebrew
|
||||
module Vulns
|
||||
@@ -54,6 +55,55 @@ module Homebrew
|
||||
Array(entry["advisories"]).filter_map { |a| build_advisory(a) if a.is_a?(Hash) }
|
||||
end
|
||||
|
||||
# CPANSA constraints: each `affected_versions` array entry is a
|
||||
# comma-joined AND of `<`/`<=`/`>`/`>=`/`==`/`=`/bare-version terms; the
|
||||
# array is an OR of those. `fixed_versions` uses the same grammar.
|
||||
# Compared with {Version}; Perl's decimal-vs-dotted equivalence
|
||||
# (`1.002003` == `v1.2.3`) is not modelled since homebrew-core CPAN
|
||||
# formulae uniformly use the decimal form.
|
||||
sig { params(advisory: Advisory, version: String).returns(Vulnerability::RangeStatus) }
|
||||
def self.range_status(advisory, version)
|
||||
target = Version.new(version.sub(/\Av/i, ""))
|
||||
affected = advisory.affected_versions.empty? ||
|
||||
advisory.affected_versions.any? { |c| satisfies?(target, c) }
|
||||
fixed_in = advisory.fixed_versions.flat_map { |c| lower_bounds(c) }
|
||||
.select { |v| target < v || (!affected && target == v) }
|
||||
.min&.to_s
|
||||
fixed_in ||= advisory.fixed_versions.flat_map { |c| lower_bounds(c) }.max&.to_s unless affected
|
||||
Vulnerability::RangeStatus.new(affected:, fixed_in:).freeze
|
||||
end
|
||||
|
||||
CONSTRAINT = /\A\s*(<=|>=|==|<|>|=)?\s*v?(\d[\w.]*)\s*\z/
|
||||
private_constant :CONSTRAINT
|
||||
|
||||
LOWER_BOUND_OPS = [">=", ">", "==", "=", nil].freeze
|
||||
private_constant :LOWER_BOUND_OPS
|
||||
|
||||
sig { params(target: Version, conjunction: String).returns(T::Boolean) }
|
||||
def self.satisfies?(target, conjunction)
|
||||
conjunction.split(",").all? do |term|
|
||||
match = term.match(CONSTRAINT)
|
||||
next false unless match
|
||||
|
||||
bound = Version.new(T.must(match[2]))
|
||||
case match[1]
|
||||
when "<" then target < bound
|
||||
when "<=" then target <= bound
|
||||
when ">" then target > bound
|
||||
when ">=" then target >= bound
|
||||
else target == bound
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(conjunction: String).returns(T::Array[Version]) }
|
||||
def self.lower_bounds(conjunction)
|
||||
conjunction.split(",").filter_map do |term|
|
||||
match = term.match(CONSTRAINT)
|
||||
Version.new(T.must(match[2])) if match && LOWER_BOUND_OPS.include?(match[1])
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(raw: T::Hash[String, T.untyped]).returns(T.nilable(Advisory)) }
|
||||
def build_advisory(raw)
|
||||
id = raw["id"]
|
||||
|
||||
@@ -36,7 +36,7 @@ module Homebrew
|
||||
attr_reader :aliases, :upstream, :related
|
||||
|
||||
sig { returns(T::Array[T::Hash[String, T.untyped]]) }
|
||||
attr_reader :references, :affected
|
||||
attr_reader :references, :affected, :severity_entries
|
||||
|
||||
sig { params(data: T::Hash[String, T.untyped]).void }
|
||||
def initialize(data)
|
||||
@@ -48,6 +48,7 @@ module Homebrew
|
||||
@related = T.let(Array(data["related"]), T::Array[String])
|
||||
@references = T.let(Array(data["references"]), T::Array[T::Hash[String, T.untyped]])
|
||||
@affected = T.let(Array(data["affected"]), T::Array[T::Hash[String, T.untyped]])
|
||||
@severity_entries = T.let(Array(data["severity"]), T::Array[T::Hash[String, T.untyped]])
|
||||
@severity = T.let(extract_severity(data), T.nilable(Symbol))
|
||||
end
|
||||
|
||||
@@ -69,14 +70,14 @@ module Homebrew
|
||||
SEVERITY_LEVEL.fetch(sev, 0)
|
||||
end
|
||||
|
||||
# `aliases` and `upstream` both name the same underlying vulnerability;
|
||||
# distro-ecosystem records typically carry the CVE in `upstream` rather
|
||||
# than `aliases`. `related` is excluded: it links to *different*
|
||||
# vulnerabilities (e.g. other CVEs fixed by the same DSA) and would
|
||||
# over-merge in {Match#dedup_by_cve}.
|
||||
# Only `id` and `aliases` name *this* vulnerability. `upstream` is a
|
||||
# directed reference (a distro advisory pointing at one-or-more source
|
||||
# CVEs) and `related` links to different vulnerabilities; neither is safe
|
||||
# to treat as an identity of this record. {Match} follows `upstream`
|
||||
# explicitly and re-attributes the hit to each CVE it names.
|
||||
sig { returns(T::Array[String]) }
|
||||
def identifiers
|
||||
[id, *aliases, *upstream].uniq
|
||||
[id, *aliases].uniq
|
||||
end
|
||||
|
||||
sig { returns(T::Array[String]) }
|
||||
@@ -103,6 +104,99 @@ module Homebrew
|
||||
end.uniq
|
||||
end
|
||||
|
||||
RangeStatus = Struct.new(:affected, :fixed_in, keyword_init: true) do
|
||||
sig { returns(T::Boolean) }
|
||||
def affected? = self[:affected]
|
||||
end
|
||||
|
||||
# Evaluates `version` against the `affected[]` entry whose `package`
|
||||
# matches `{ecosystem, name}`, honouring range `type`:
|
||||
#
|
||||
# - `SEMVER` ranges compare with {Semver}.
|
||||
# - `ECOSYSTEM` ranges compare with {Version} (best-effort; the record's
|
||||
# own ecosystem defines the ordering, but callers only invoke this for
|
||||
# ecosystems whose versions are broadly `Version`-comparable).
|
||||
# - `GIT` ranges are commit hashes and are skipped as uncomparable.
|
||||
#
|
||||
# Returns `nil` when no entry matches the package or no comparable range
|
||||
# exists in the matching entry, so callers can distinguish "checked and
|
||||
# not affected" from "could not check". `fixed_in` is the `fixed` (or
|
||||
# `last_affected` + note) event that closes the interval containing
|
||||
# `version`, or the lowest `fixed` above `version` when it falls outside
|
||||
# every interval.
|
||||
sig { params(ecosystem: String, name: String, version: String).returns(T.nilable(RangeStatus)) }
|
||||
def range_status(ecosystem, name, version)
|
||||
entry = affected_entry_for(ecosystem, name)
|
||||
return if entry.nil?
|
||||
|
||||
target = normalize_version(version)
|
||||
checked = T.let(false, T::Boolean)
|
||||
candidate_fixes = T.let([], T::Array[String])
|
||||
|
||||
Array(entry["ranges"]).each do |range|
|
||||
type = range["type"]
|
||||
next if type == "GIT"
|
||||
|
||||
cmp = comparator_for(type)
|
||||
Array(range["events"]).then { |ev| intervals(ev) }.each do |lower, upper, upper_inclusive|
|
||||
checked = true
|
||||
if in_interval?(target, lower, upper, upper_inclusive, cmp)
|
||||
return RangeStatus.new(affected: true, fixed_in: upper).freeze
|
||||
end
|
||||
|
||||
candidate_fixes << upper if upper && cmp.call(target, upper) >= 0
|
||||
rescue Uncomparable
|
||||
checked ||= false
|
||||
end
|
||||
end
|
||||
|
||||
versions = Array(entry["versions"])
|
||||
if versions.any?
|
||||
checked = true
|
||||
return RangeStatus.new(affected: true, fixed_in: nil).freeze if versions.any? do |v|
|
||||
normalize_version(v.to_s) == target
|
||||
end
|
||||
end
|
||||
|
||||
return unless checked
|
||||
|
||||
RangeStatus.new(affected: false, fixed_in: candidate_fixes.max_by { |v| Version.new(v) }).freeze
|
||||
end
|
||||
|
||||
sig { params(ecosystem: String, name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
|
||||
def affected_entry_for(ecosystem, name)
|
||||
affected.find do |aff|
|
||||
pkg = aff["package"]
|
||||
pkg.is_a?(Hash) && pkg["ecosystem"] == ecosystem && pkg["name"] == name
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(range_type: T.nilable(String)).returns(T.proc.params(a: String, b: String).returns(Integer)) }
|
||||
def comparator_for(range_type)
|
||||
if range_type == "SEMVER"
|
||||
->(a, b) { Semver.compare(a, b) || raise(Uncomparable) }
|
||||
else
|
||||
->(a, b) { Version.new(a) <=> Version.new(b) || raise(Uncomparable) }
|
||||
end
|
||||
end
|
||||
|
||||
sig {
|
||||
params(target: String, lower: T.nilable(String), upper: T.nilable(String),
|
||||
upper_inclusive: T::Boolean,
|
||||
cmp: T.proc.params(a: String, b: String).returns(Integer)).returns(T::Boolean)
|
||||
}
|
||||
def in_interval?(target, lower, upper, upper_inclusive, cmp)
|
||||
above = lower.nil? || cmp.call(target, lower) >= 0
|
||||
below = if upper.nil?
|
||||
true
|
||||
elsif upper_inclusive
|
||||
cmp.call(target, upper) <= 0
|
||||
else
|
||||
cmp.call(target, upper).negative?
|
||||
end
|
||||
above && below
|
||||
end
|
||||
|
||||
# OSV has already matched this record against the queried version. This
|
||||
# method only overrides that with `false` when every affected entry can
|
||||
# be evaluated locally (explicit `versions` list or `SEMVER` range) and
|
||||
@@ -174,7 +268,6 @@ module Homebrew
|
||||
|
||||
class Uncomparable < StandardError
|
||||
end
|
||||
private_constant :Uncomparable
|
||||
|
||||
sig { params(target: String, events: T::Array[T::Hash[String, T.untyped]]).returns(T::Boolean) }
|
||||
def in_semver_range?(target, events)
|
||||
|
||||
Reference in new issue
Block a user