From 7f4027825899e4542b04a87297408a0404ddf5f8 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 15 Jul 2026 13:35:57 -0700 Subject: [PATCH 01/28] vulns: extract Identify from Scanner Move repo_url, tag, FORGES and TAG_PATTERNS into a new Homebrew::Vulns::Identify module so the upcoming advisory-match dev-cmd can share the URL-parsing helpers without pulling in Scanner. Scanner requires the new module and calls it directly. No behaviour change; specs moved to identify_spec.rb. --- Library/Homebrew/test/vulns/identify_spec.rb | 90 ++++++++++++++++++++ Library/Homebrew/test/vulns/scanner_spec.rb | 84 ------------------ Library/Homebrew/vulns/identify.rb | 54 ++++++++++++ Library/Homebrew/vulns/scanner.rb | 53 ++---------- 4 files changed, 149 insertions(+), 132 deletions(-) create mode 100644 Library/Homebrew/test/vulns/identify_spec.rb create mode 100644 Library/Homebrew/vulns/identify.rb diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb new file mode 100644 index 0000000000000..98da6ae169504 --- /dev/null +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -0,0 +1,90 @@ +# typed: false +# frozen_string_literal: true + +require "vulns/identify" + +RSpec.describe Homebrew::Vulns::Identify do + describe ".repo_url" do + it "extracts a GitHub repo from an archive/refs/tags URL" do + url = "https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz" + expect(described_class.repo_url(url)).to eq "https://github.com/nektos/act" + end + + it "extracts a GitHub repo from a releases/download URL" do + url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" + expect(described_class.repo_url(url)).to eq "https://github.com/owner/repo" + end + + it "extracts a GitHub repo from a .git URL" do + expect(described_class.repo_url("https://github.com/AomediaOrg/aom.git")) + .to eq "https://github.com/AomediaOrg/aom" + end + + it "extracts a GitLab repo, stripping the /-/ path segment" do + url = "https://gitlab.com/owner/repo/-/archive/v1.2.3/repo-v1.2.3.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/owner/repo" + end + + it "extracts a Codeberg repo" do + url = "https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz" + expect(described_class.repo_url(url)).to eq "https://codeberg.org/owner/repo" + end + + it "falls back to the head URL when the stable URL is not a supported forge" do + stable = "https://aomedia.googlesource.com/aom.git" + head = "https://github.com/AomediaOrg/aom.git" + expect(described_class.repo_url(stable, head)).to eq "https://github.com/AomediaOrg/aom" + end + + it "falls back to the homepage when neither stable nor head is a supported forge" do + stable = "https://libssh2.org/download/libssh2-1.11.0.tar.gz" + homepage = "https://github.com/libssh2/libssh2" + expect(described_class.repo_url(stable, nil, homepage)).to eq "https://github.com/libssh2/libssh2" + end + + it "returns nil for unsupported hosts" do + expect(described_class.repo_url("https://example.com/source.tar.gz")).to be_nil + end + + it "returns nil for nil input" do + expect(described_class.repo_url(nil)).to be_nil + expect(described_class.repo_url(nil, nil)).to be_nil + end + end + + describe ".tag" do + it "extracts from archive/refs/tags .tar.gz" do + expect(described_class.tag("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")) + .to eq "v0.2.84" + end + + it "extracts a tag without a v prefix" do + url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20250814.1.tar.gz" + expect(described_class.tag(url)).to eq "20250814.1" + end + + it "extracts from archive/refs/tags .zip" do + expect(described_class.tag("https://github.com/owner/repo/archive/refs/tags/v1.0.0.zip")) + .to eq "v1.0.0" + end + + it "extracts from archive/.tar.gz" do + expect(described_class.tag("https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz")) + .to eq "v1.2.3" + end + + it "extracts from releases/download//" do + url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" + expect(described_class.tag(url)).to eq "v1.2.3" + end + + it "extracts from tarball/" do + expect(described_class.tag("https://github.com/owner/repo/tarball/v1.2.3")).to eq "v1.2.3" + end + + it "returns nil when no tag pattern matches" do + expect(described_class.tag("https://example.com/source.tar.gz")).to be_nil + expect(described_class.tag(nil)).to be_nil + end + end +end diff --git a/Library/Homebrew/test/vulns/scanner_spec.rb b/Library/Homebrew/test/vulns/scanner_spec.rb index 77c3e737fe35d..f0134b7df1610 100644 --- a/Library/Homebrew/test/vulns/scanner_spec.rb +++ b/Library/Homebrew/test/vulns/scanner_spec.rb @@ -4,90 +4,6 @@ require "vulns/scanner" RSpec.describe Homebrew::Vulns::Scanner do - describe ".repo_url" do - it "extracts a GitHub repo from an archive/refs/tags URL" do - url = "https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz" - expect(described_class.repo_url(url)).to eq "https://github.com/nektos/act" - end - - it "extracts a GitHub repo from a releases/download URL" do - url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" - expect(described_class.repo_url(url)).to eq "https://github.com/owner/repo" - end - - it "extracts a GitHub repo from a .git URL" do - expect(described_class.repo_url("https://github.com/AomediaOrg/aom.git")) - .to eq "https://github.com/AomediaOrg/aom" - end - - it "extracts a GitLab repo, stripping the /-/ path segment" do - url = "https://gitlab.com/owner/repo/-/archive/v1.2.3/repo-v1.2.3.tar.gz" - expect(described_class.repo_url(url)).to eq "https://gitlab.com/owner/repo" - end - - it "extracts a Codeberg repo" do - url = "https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz" - expect(described_class.repo_url(url)).to eq "https://codeberg.org/owner/repo" - end - - it "falls back to the head URL when the stable URL is not a supported forge" do - stable = "https://aomedia.googlesource.com/aom.git" - head = "https://github.com/AomediaOrg/aom.git" - expect(described_class.repo_url(stable, head)).to eq "https://github.com/AomediaOrg/aom" - end - - it "falls back to the homepage when neither stable nor head is a supported forge" do - stable = "https://libssh2.org/download/libssh2-1.11.0.tar.gz" - homepage = "https://github.com/libssh2/libssh2" - expect(described_class.repo_url(stable, nil, homepage)).to eq "https://github.com/libssh2/libssh2" - end - - it "returns nil for unsupported hosts" do - expect(described_class.repo_url("https://example.com/source.tar.gz")).to be_nil - end - - it "returns nil for nil input" do - expect(described_class.repo_url(nil)).to be_nil - expect(described_class.repo_url(nil, nil)).to be_nil - end - end - - describe ".tag" do - it "extracts from archive/refs/tags .tar.gz" do - expect(described_class.tag("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")) - .to eq "v0.2.84" - end - - it "extracts a tag without a v prefix" do - url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20250814.1.tar.gz" - expect(described_class.tag(url)).to eq "20250814.1" - end - - it "extracts from archive/refs/tags .zip" do - expect(described_class.tag("https://github.com/owner/repo/archive/refs/tags/v1.0.0.zip")) - .to eq "v1.0.0" - end - - it "extracts from archive/.tar.gz" do - expect(described_class.tag("https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz")) - .to eq "v1.2.3" - end - - it "extracts from releases/download//" do - url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" - expect(described_class.tag(url)).to eq "v1.2.3" - end - - it "extracts from tarball/" do - expect(described_class.tag("https://github.com/owner/repo/tarball/v1.2.3")).to eq "v1.2.3" - end - - it "returns nil when no tag pattern matches" do - expect(described_class.tag("https://example.com/source.tar.gz")).to be_nil - expect(described_class.tag(nil)).to be_nil - end - end - describe ".resolved_ids" do it "collects security-type resolves across all patches, uppercased and deduplicated" do patches = [ diff --git a/Library/Homebrew/vulns/identify.rb b/Library/Homebrew/vulns/identify.rb new file mode 100644 index 0000000000000..2cea18e0c4fe0 --- /dev/null +++ b/Library/Homebrew/vulns/identify.rb @@ -0,0 +1,54 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module Vulns + # Derives OSV.dev query keys (forge repo URL, release tag) from formula + # source URLs. Shared between {Scanner} and the advisory-matching pipeline. + module Identify + FORGES = %w[github.com gitlab.com codeberg.org].freeze + private_constant :FORGES + + TAG_PATTERNS = T.let( + [ + %r{/archive/refs/tags/([^/]+)\.tar\.gz$}, + %r{/archive/refs/tags/([^/]+)\.zip$}, + %r{/archive/([^/]+)\.tar\.gz$}, + %r{/archive/([^/]+)\.zip$}, + %r{/releases/download/([^/]+)/}, + %r{/tarball/([^/]+)$}, + ].freeze, + T::Array[Regexp], + ) + private_constant :TAG_PATTERNS + + sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } + def self.repo_url(*urls) + urls.each do |url| + next if url.nil? + + forge = FORGES.find { |f| url.include?(f) } + next if forge.nil? + + match = url.match(%r{https?://#{Regexp.escape(forge)}/([^/]+/[^/]+)}) + next if match.nil? + + repo_path = T.must(match[1]).sub(/\.git$/, "").sub(%r{/-/.*}, "") + return "https://#{forge}/#{repo_path}" + end + nil + end + + sig { params(url: T.nilable(String)).returns(T.nilable(String)) } + def self.tag(url) + return if url.nil? + + TAG_PATTERNS.each do |pattern| + match = url.match(pattern) + return match[1] if match + end + nil + end + end + end +end diff --git a/Library/Homebrew/vulns/scanner.rb b/Library/Homebrew/vulns/scanner.rb index 4e43d15be3f41..ecb429623f225 100644 --- a/Library/Homebrew/vulns/scanner.rb +++ b/Library/Homebrew/vulns/scanner.rb @@ -2,67 +2,24 @@ # frozen_string_literal: true require "sbom" +require "vulns/identify" require "vulns/osv" require "vulns/vulnerability" module Homebrew module Vulns class Scanner - FORGES = %w[github.com gitlab.com codeberg.org].freeze - private_constant :FORGES - - TAG_PATTERNS = T.let( - [ - %r{/archive/refs/tags/([^/]+)\.tar\.gz$}, - %r{/archive/refs/tags/([^/]+)\.zip$}, - %r{/archive/([^/]+)\.tar\.gz$}, - %r{/archive/([^/]+)\.zip$}, - %r{/releases/download/([^/]+)/}, - %r{/tarball/([^/]+)$}, - ].freeze, - T::Array[Regexp], - ) - private_constant :TAG_PATTERNS - - sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } - def self.repo_url(*urls) - urls.each do |url| - next if url.nil? - - forge = FORGES.find { |f| url.include?(f) } - next if forge.nil? - - match = url.match(%r{https?://#{Regexp.escape(forge)}/([^/]+/[^/]+)}) - next if match.nil? - - repo_path = T.must(match[1]).sub(/\.git$/, "").sub(%r{/-/.*}, "") - return "https://#{forge}/#{repo_path}" - end - nil - end - sig { params(source_url: T.nilable(String), head_url: T.nilable(String), homepage: T.nilable(String)).returns(T.nilable(String)) } def self.target_repo_url(source_url, head_url, homepage) - url = repo_url(source_url, head_url, homepage) - url ||= source_url if tag(source_url) + url = Identify.repo_url(source_url, head_url, homepage) + url ||= source_url if Identify.tag(source_url) url ||= head_url url end - sig { params(url: T.nilable(String)).returns(T.nilable(String)) } - def self.tag(url) - return if url.nil? - - TAG_PATTERNS.each do |pattern| - match = url.match(pattern) - return match[1] if match - end - nil - end - SBOM_SRC_SPDXID = /\ASPDXRef-Archive-.*-src\z/ private_constant :SBOM_SRC_SPDXID @@ -199,7 +156,7 @@ def build_target(formula) homepage = formula.homepage stable_repo_url = self.class.target_repo_url(stable_url, head_url, homepage) - stable_tag = self.class.tag(stable_url) || stable&.specs&.[](:tag) || stable&.version&.to_s + stable_tag = Identify.tag(stable_url) || stable&.specs&.[](:tag) || stable&.version&.to_s if (prefix = formula.any_installed_prefix) installed_pkg_version = formula.any_installed_version @@ -209,7 +166,7 @@ def build_target(formula) if (sbom = self.class.source_from_sbom(prefix)) sbom_url, sbom_version = sbom repo_url = self.class.target_repo_url(sbom_url, head_url, homepage) - tag = self.class.tag(sbom_url) || sbom_version || installed_version.presence + tag = Identify.tag(sbom_url) || sbom_version || installed_version.presence if repo_url && tag return Target.new(repo_url:, tag:, version: installed_version, from_installed_sbom: true, current_recipe_applies:) From 3dfee791d340533a23b3551b23617e2359882418 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 15 Jul 2026 16:10:35 -0700 Subject: [PATCH 02/28] vulns: add Purl builder and Identify.registry_package Homebrew::Vulns::Purl is a minimal purl-spec builder covering the ten registry types we derive from formula source URLs, with per-type name normalisation and RFC 3986 percent-encoding. Homebrew::Vulns::Identify.registry_package(url) parses download URLs from PyPI, npm, crates.io, RubyGems, Hackage, Hex, CPAN, Maven Central, CRAN and NuGet into {ecosystem, name, version, purl}. Version extraction is per-registry rather than a shared heuristic so names containing hyphen-digit segments (es5-shim, base64-bytestring, Perl6-Junction, iso-639) and RubyGems platform suffixes are handled correctly. Nothing calls it yet; it is groundwork for the advisory-match dev-cmd. --- Library/Homebrew/test/vulns/identify_spec.rb | 302 +++++++++++++++++++ Library/Homebrew/test/vulns/purl_spec.rb | 160 ++++++++++ Library/Homebrew/vulns/identify.rb | 143 +++++++++ Library/Homebrew/vulns/purl.rb | 90 ++++++ 4 files changed, 695 insertions(+) create mode 100644 Library/Homebrew/test/vulns/purl_spec.rb create mode 100644 Library/Homebrew/vulns/purl.rb diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index 98da6ae169504..8782cff5931eb 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -87,4 +87,306 @@ expect(described_class.tag(nil)).to be_nil end end + + describe ".registry_package" do + def result(url) + described_class.registry_package(url)&.to_h + end + + context "with a PyPI sdist URL" do + it "parses a simple package" do + url = "https://files.pythonhosted.org/packages/00/2a/e8/jmespath-1.0.1.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "jmespath", version: "1.0.1", + purl: "pkg:pypi/jmespath@1.0.1") + end + + it "normalises an underscored name" do + url = "https://files.pythonhosted.org/packages/00/07/d1/types_setuptools-80.9.0.20251223.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "types-setuptools", + version: "80.9.0.20251223", + purl: "pkg:pypi/types-setuptools@80.9.0.20251223") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/iso-639-2025.2.18.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "iso-639", version: "2025.2.18", + purl: "pkg:pypi/iso-639@2025.2.18") + end + + it "PEP 503-normalises a dotted name for OSV while preserving the dot in the purl" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/ruamel.yaml-0.18.6.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "ruamel-yaml", version: "0.18.6", + purl: "pkg:pypi/ruamel.yaml@0.18.6") + end + + it "returns nil for a wheel" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/foo-1.0-py3-none-any.whl" + expect(result(url)).to be_nil + end + end + + context "with an npm tarball URL" do + it "parses a scoped package" do + url = "https://registry.npmjs.org/@angular/cli/-/cli-22.0.3.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "@angular/cli", version: "22.0.3", + purl: "pkg:npm/%40angular/cli@22.0.3") + end + + it "parses an unscoped package" do + url = "https://registry.npmjs.org/reveal-md/-/reveal-md-6.1.4.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "reveal-md", version: "6.1.4", + purl: "pkg:npm/reveal-md@6.1.4") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "es5-shim", version: "4.6.7", + purl: "pkg:npm/es5-shim@4.6.7") + end + + it "handles a semver prerelease version" do + url = "https://registry.npmjs.org/react/-/react-19.0.0-rc.1.tgz" + expect(result(url)) + .to eq(ecosystem: "npm", name: "react", version: "19.0.0-rc.1", + purl: "pkg:npm/react@19.0.0-rc.1") + end + + it "decodes a percent-encoded scope" do + url = "https://registry.npmjs.org/%40angular/cli/-/cli-22.0.3.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "@angular/cli", version: "22.0.3", + purl: "pkg:npm/%40angular/cli@22.0.3") + end + + it "returns nil when the tarball filename does not match the path name" do + expect(result("https://registry.npmjs.org/foo/-/bar-1.0.0.tgz")).to be_nil + end + end + + context "with a crates.io URL" do + it "parses the crate name from the path and version from the filename" do + url = "https://static.crates.io/crates/cargo-llvm-cov/cargo-llvm-cov-0.8.7.crate" + expect(result(url)).to eq(ecosystem: "crates.io", name: "cargo-llvm-cov", version: "0.8.7", + purl: "pkg:cargo/cargo-llvm-cov@0.8.7") + end + + it "returns nil when the filename does not match the path name" do + expect(result("https://static.crates.io/crates/foo/bar-1.0.0.crate")).to be_nil + end + end + + context "with a RubyGems URL" do + it "parses a /downloads/ URL" do + url = "https://rubygems.org/downloads/activesupport-8.1.1.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "activesupport", version: "8.1.1", + purl: "pkg:gem/activesupport@8.1.1") + end + + it "parses a /gems/ URL" do + url = "https://rubygems.org/gems/addressable-2.8.6.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "addressable", version: "2.8.6", + purl: "pkg:gem/addressable@2.8.6") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://rubygems.org/downloads/iso-639-0.3.6.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "iso-639", version: "0.3.6", + purl: "pkg:gem/iso-639@0.3.6") + end + + it "strips a trailing platform suffix" do + url = "https://rubygems.org/downloads/nokogiri-1.16.0-arm64-darwin.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "nokogiri", version: "1.16.0", + purl: "pkg:gem/nokogiri@1.16.0") + end + + it "strips a platform suffix ending in a numeric OS version" do + url = "https://rubygems.org/downloads/couchbase-3.5.1-arm64-darwin-22.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "couchbase", version: "3.5.1", + purl: "pkg:gem/couchbase@3.5.1") + end + + it "strips a bare-word platform suffix" do + url = "https://rubygems.org/downloads/jrubyfx-2.0.0-java.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "jrubyfx", version: "2.0.0", + purl: "pkg:gem/jrubyfx@2.0.0") + end + + it "strips a musl platform suffix" do + url = "https://rubygems.org/downloads/ffi-1.17.4-x86_64-linux-musl.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "ffi", version: "1.17.4", + purl: "pkg:gem/ffi@1.17.4") + end + + it "strips a mingw-ucrt platform suffix" do + url = "https://rubygems.org/downloads/ruby-prof-2.0.4-x64-mingw-ucrt.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "ruby-prof", version: "2.0.4", + purl: "pkg:gem/ruby-prof@2.0.4") + end + + it "strips a platform suffix with an unenumerated CPU" do + url = "https://rubygems.org/downloads/sass-embedded-1.97.2-riscv64-linux-gnu.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "sass-embedded", version: "1.97.2", + purl: "pkg:gem/sass-embedded@1.97.2") + end + + it "strips a platform suffix with a dotted OS version" do + url = "https://rubygems.org/downloads/concurrent-ruby-0.7.1-x86-solaris-2.11.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "concurrent-ruby", version: "0.7.1", + purl: "pkg:gem/concurrent-ruby@0.7.1") + end + + it "keeps a prerelease segment in the version" do + url = "https://rubygems.org/downloads/rails-8.0.0.beta1.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "rails", version: "8.0.0.beta1", + purl: "pkg:gem/rails@8.0.0.beta1") + end + end + + context "with a Hackage URL" do + it "parses a package identifier from the path" do + url = "https://hackage.haskell.org/package/Allure-0.11.0.0/Allure-0.11.0.0.tar.gz" + expect(result(url)).to eq(ecosystem: "Hackage", name: "Allure", version: "0.11.0.0", + purl: "pkg:hackage/Allure@0.11.0.0") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://hackage.haskell.org/package/base64-bytestring-1.2.1.0/" \ + "base64-bytestring-1.2.1.0.tar.gz" + expect(result(url)).to eq(ecosystem: "Hackage", name: "base64-bytestring", + version: "1.2.1.0", + purl: "pkg:hackage/base64-bytestring@1.2.1.0") + end + end + + context "with a Hex URL" do + it "parses name and version, keeping a semver prerelease" do + url = "https://repo.hex.pm/tarballs/phoenix-1.7.0-rc.0.tar" + expect(result(url)).to eq(ecosystem: "Hex", name: "phoenix", version: "1.7.0-rc.0", + purl: "pkg:hex/phoenix@1.7.0-rc.0") + end + end + + context "with a CPAN URL" do + it "uses the distribution alone as the CPANSA name and includes the author in the purl" do + url = "https://cpan.metacpan.org/authors/id/A/AB/ABIGAIL/Regexp-Common-2024080801.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Regexp-Common", + version: "2024080801", + purl: "pkg:cpan/ABIGAIL/Regexp-Common@2024080801") + end + + it "handles a distribution name containing a digit-led segment" do + url = "https://cpan.metacpan.org/authors/id/C/CF/CFRANKS/Perl6-Junction-1.60000.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Perl6-Junction", + version: "1.60000", + purl: "pkg:cpan/CFRANKS/Perl6-Junction@1.60000") + end + + it "handles a v-prefixed version" do + url = "https://cpan.metacpan.org/authors/id/L/LE/LEONT/ExtUtils-HasCompiler-v0.25.0.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "ExtUtils-HasCompiler", + version: "v0.25.0", + purl: "pkg:cpan/LEONT/ExtUtils-HasCompiler@v0.25.0") + end + + it "handles a subdirectory below the author directory" do + url = "https://cpan.metacpan.org/authors/id/A/AM/AMBS/BibTeX/Text-BibTeX-0.91.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Text-BibTeX", version: "0.91", + purl: "pkg:cpan/AMBS/Text-BibTeX@0.91") + end + + it "keeps a developer _NN suffix in the version" do + url = "https://cpan.metacpan.org/authors/id/E/ET/ETHER/Moose-2.2207_01.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Moose", version: "2.2207_01", + purl: "pkg:cpan/ETHER/Moose@2.2207_01") + end + + it "strips a -TRIAL suffix from the version" do + url = "https://cpan.metacpan.org/authors/id/E/ET/ETHER/Moose-2.2200-TRIAL.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Moose", version: "2.2200", + purl: "pkg:cpan/ETHER/Moose@2.2200") + end + end + + context "with a Maven URL" do + it "parses groupId, artifactId and version from repo.maven.apache.org" do + url = "https://repo.maven.apache.org/maven2/com/github/spotbugs/spotbugs/4.10.2/" \ + "spotbugs-4.10.2.tgz" + expect(result(url)).to eq(ecosystem: "Maven", name: "com.github.spotbugs:spotbugs", + version: "4.10.2", + purl: "pkg:maven/com.github.spotbugs/spotbugs@4.10.2") + end + + it "parses a search.maven.org remotecontent URL" do + url = "https://search.maven.org/remotecontent?filepath=org/gradle/profiler/" \ + "gradle-profiler/0.24.0/gradle-profiler-0.24.0.zip" + expect(result(url)).to eq(ecosystem: "Maven", name: "org.gradle.profiler:gradle-profiler", + version: "0.24.0", + purl: "pkg:maven/org.gradle.profiler/gradle-profiler@0.24.0") + end + + it "returns nil for a maven-metadata.xml URL" do + url = "https://repo.maven.apache.org/maven2/com/madgag/bfg/maven-metadata.xml" + expect(result(url)).to be_nil + end + + it "returns nil for a third-party Maven repository (Central-only by design)" do + url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/" \ + "fabric-installer-1.1.1.jar" + expect(result(url)).to be_nil + end + + it "returns nil for a non-Central host with a /maven2/ path" do + url = "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/" \ + "8.0.0/gradle-8.0.0.jar" + expect(result(url)).to be_nil + end + end + + context "with a CRAN URL" do + it "parses name and version from a src/contrib URL" do + url = "https://cran.r-project.org/src/contrib/data.table_1.15.4.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "data.table", version: "1.15.4", + purl: "pkg:cran/data.table@1.15.4") + end + + it "parses an Archive/ URL" do + url = "https://cran.r-project.org/src/contrib/Archive/rlang/rlang_1.1.3.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "rlang", version: "1.1.3", + purl: "pkg:cran/rlang@1.1.3") + end + + it "parses a cloud.r-project.org URL" do + url = "https://cloud.r-project.org/src/contrib/IRkernel_1.3.2.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "IRkernel", version: "1.3.2", + purl: "pkg:cran/IRkernel@1.3.2") + end + end + + context "with a NuGet URL" do + it "parses a v3 flatcontainer URL" do + url = "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.3/" \ + "newtonsoft.json.13.0.3.nupkg" + expect(result(url)).to eq(ecosystem: "NuGet", name: "newtonsoft.json", version: "13.0.3", + purl: "pkg:nuget/newtonsoft.json@13.0.3") + end + + it "parses a v2 API URL" do + url = "https://www.nuget.org/api/v2/package/Newtonsoft.Json/13.0.3" + expect(result(url)).to eq(ecosystem: "NuGet", name: "Newtonsoft.Json", version: "13.0.3", + purl: "pkg:nuget/Newtonsoft.Json@13.0.3") + end + end + + it "returns nil for a non-registry URL" do + expect(result("https://example.com/foo-1.0.tar.gz")).to be_nil + end + + it "returns nil for a supported forge URL" do + expect(result("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")).to be_nil + end + + it "returns nil for nil input" do + expect(result(nil)).to be_nil + end + end end diff --git a/Library/Homebrew/test/vulns/purl_spec.rb b/Library/Homebrew/test/vulns/purl_spec.rb new file mode 100644 index 0000000000000..acd1a594a95e3 --- /dev/null +++ b/Library/Homebrew/test/vulns/purl_spec.rb @@ -0,0 +1,160 @@ +# typed: false +# frozen_string_literal: true + +require "vulns/purl" + +RSpec.describe Homebrew::Vulns::Purl do + describe "#initialize" do + it "raises when type is empty" do + expect { described_class.new(type: "", name: "rails") }.to raise_error(ArgumentError, /type/) + end + + it "raises when name is empty" do + expect { described_class.new(type: "gem", name: "") }.to raise_error(ArgumentError, /name/) + end + + it "lowercases the type and treats empty namespace/version as absent" do + purl = described_class.new(type: "PyPI", name: "requests", namespace: "", version: "") + expect(purl.type).to eq "pypi" + expect(purl.namespace).to be_nil + expect(purl.version).to be_nil + end + + it "freezes the stored components" do + purl = described_class.new(type: "npm", namespace: (+"@babel"), name: (+"core"), version: (+"7.0.0")) + expect([purl.type, purl.namespace, purl.name, purl.version]).to all be_frozen + end + end + + describe "per-type normalisation" do + it "lowercases a PyPI name and replaces underscores with hyphens" do + purl = described_class.new(type: "pypi", name: "Types_Setuptools") + expect(purl.name).to eq "types-setuptools" + end + + it "leaves PyPI dots and existing hyphens intact" do + purl = described_class.new(type: "pypi", name: "backports.zoneinfo") + expect(purl.name).to eq "backports.zoneinfo" + end + + it "lowercases a Hex name and namespace" do + purl = described_class.new(type: "hex", namespace: "Acme", name: "Phoenix") + expect(purl.namespace).to eq "acme" + expect(purl.name).to eq "phoenix" + end + + it "uppercases a CPAN namespace and preserves the distribution name" do + purl = described_class.new(type: "cpan", namespace: "abigail", name: "Regexp-Common") + expect(purl.namespace).to eq "ABIGAIL" + expect(purl.name).to eq "Regexp-Common" + end + + it "does not alter case for cargo, gem, hackage, cran or npm" do + %w[cargo gem hackage cran npm].each do |type| + expect(described_class.new(type:, name: "MixedCase").name).to eq "MixedCase" + end + end + end + + describe ".encode" do + it "leaves the RFC 3986 unreserved set and : untouched" do + expect(described_class.encode("Az09-._~:")).to eq "Az09-._~:" + end + + it "percent-encodes @, /, + and space per the purl spec" do + expect(described_class.encode("@a/b+c d")).to eq "%40a%2Fb%2Bc%20d" + end + + it "percent-encodes each byte of a multibyte UTF-8 character" do + expect(described_class.encode("café")).to eq "caf%C3%A9" + end + end + + describe "#to_s" do + it "builds pkg:gem with and without a version, returning a frozen string" do + bare = described_class.new(type: "gem", name: "rails").to_s + expect(bare).to eq "pkg:gem/rails" + expect(bare).to be_frozen + expect(described_class.new(type: "gem", name: "rails", version: "7.0.0").to_s) + .to eq "pkg:gem/rails@7.0.0" + end + + it "builds pkg:npm with an encoded scope namespace" do + purl = described_class.new(type: "npm", namespace: "@angular", name: "cli", version: "22.0.3") + expect(purl.to_s).to eq "pkg:npm/%40angular/cli@22.0.3" + end + + it "builds pkg:pypi with the normalised name" do + purl = described_class.new(type: "pypi", name: "types_setuptools", version: "80.9.0.20251223") + expect(purl.to_s).to eq "pkg:pypi/types-setuptools@80.9.0.20251223" + end + + it "builds pkg:cargo" do + purl = described_class.new(type: "cargo", name: "cargo-llvm-cov", version: "0.8.7") + expect(purl.to_s).to eq "pkg:cargo/cargo-llvm-cov@0.8.7" + end + + it "builds pkg:hackage preserving case" do + purl = described_class.new(type: "hackage", name: "Allure", version: "0.11.0.0") + expect(purl.to_s).to eq "pkg:hackage/Allure@0.11.0.0" + end + + it "builds pkg:hex with a lowercased name" do + purl = described_class.new(type: "hex", name: "Phoenix", version: "1.7.0-rc.0") + expect(purl.to_s).to eq "pkg:hex/phoenix@1.7.0-rc.0" + end + + it "builds pkg:cpan with an uppercased author namespace" do + purl = described_class.new(type: "cpan", namespace: "ABIGAIL", name: "Regexp-Common", + version: "2024080801") + expect(purl.to_s).to eq "pkg:cpan/ABIGAIL/Regexp-Common@2024080801" + end + + it "builds pkg:maven with a groupId namespace" do + purl = described_class.new(type: "maven", namespace: "com.github.spotbugs", name: "spotbugs", + version: "4.10.2") + expect(purl.to_s).to eq "pkg:maven/com.github.spotbugs/spotbugs@4.10.2" + end + + it "builds pkg:cran" do + purl = described_class.new(type: "cran", name: "data.table", version: "1.15.4") + expect(purl.to_s).to eq "pkg:cran/data.table@1.15.4" + end + + it "builds pkg:nuget" do + purl = described_class.new(type: "nuget", name: "Newtonsoft.Json", version: "13.0.3") + expect(purl.to_s).to eq "pkg:nuget/Newtonsoft.Json@13.0.3" + end + + it "encodes semver build metadata + in the version" do + purl = described_class.new(type: "cargo", name: "foo", version: "1.0.0+build.1") + expect(purl.to_s).to eq "pkg:cargo/foo@1.0.0%2Bbuild.1" + end + + it "encodes each namespace segment separately, preserving the / separator" do + purl = described_class.new(type: "golang", namespace: "github.com/gorilla", name: "mux", + version: "v1.8.1") + expect(purl.to_s).to eq "pkg:golang/github.com/gorilla/mux@v1.8.1" + end + end + + describe "#== and #hash" do + it "considers two purls equal when their canonical strings match" do + a = described_class.new(type: "PyPI", name: "Foo_Bar", version: "1.0") + b = described_class.new(type: "pypi", name: "foo-bar", version: "1.0") + expect(a).to eq b + expect(a.hash).to eq b.hash + end + + it "is not equal to a purl with a different version" do + a = described_class.new(type: "gem", name: "rails", version: "7.0.0") + b = described_class.new(type: "gem", name: "rails", version: "7.0.1") + expect(a).not_to eq b + end + + it "is not equal to a plain string" do + purl = described_class.new(type: "gem", name: "rails") + expect(purl == "pkg:gem/rails").to be false + end + end +end diff --git a/Library/Homebrew/vulns/identify.rb b/Library/Homebrew/vulns/identify.rb index 2cea18e0c4fe0..841577a64a878 100644 --- a/Library/Homebrew/vulns/identify.rb +++ b/Library/Homebrew/vulns/identify.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "vulns/purl" + module Homebrew module Vulns # Derives OSV.dev query keys (forge repo URL, release tag) from formula @@ -49,6 +51,147 @@ def self.tag(url) end nil end + + # `ecosystem` is the OSV.dev ecosystem identifier for `name`, or `"CPAN"` + # for CPAN distributions (queried via CPANSA, not OSV). + RegistryPackage = Struct.new(:ecosystem, :name, :version, :purl, keyword_init: true) + + ARCHIVE_EXTENSIONS = /\.(?:tar\.gz|tar\.bz2|tar\.xz|tgz|zip|gem|crate|tar|nupkg)\z/i + private_constant :ARCHIVE_EXTENSIONS + + # Cabal package versions are dot-separated non-negative integers only. + HACKAGE_PKGID = /\A(.+)-(\d+(?:\.\d+)*)\z/ + private_constant :HACKAGE_PKGID + + # Simplified from CPAN::DistnameInfo: greedy name, version is digits/ + # dots/underscores optionally `v`-prefixed. A -TRIAL suffix is stripped. + # Does not handle the rare `_`-separated form (e.g. `libao-perl_0.03-1`); + # no homebrew-core formula currently uses it. + CPAN_DISTNAME = /\A(.+)-(v?\d[\d._]*)(?:-TRIAL\d*)?\z/ + private_constant :CPAN_DISTNAME + + # Recognise a `Gem::Platform` suffix by its OS token; the CPU token is + # open-ended (riscv64, s390x, ppc64le, ...) so is matched generically. + GEM_PLATFORM_SUFFIX = / + -(?: + java|jruby|truffleruby|dalvik|dotnet|mswin\d+(?:_\d+)?| + \w+- + (?:aix|cygwin|darwin|freebsd|linux|macruby|mingw\w*|mswin\d*| + netbsd\w*|openbsd|bitrig|solaris|wasi) + (?:[-_][\w.]+)? + )\z + /x + private_constant :GEM_PLATFORM_SUFFIX + + sig { params(url: T.nilable(String)).returns(T.nilable(RegistryPackage)) } + def self.registry_package(url) + return if url.nil? + + ecosystem, purl = registry_purl(url) + return if purl.nil? + + name = case purl.type + when "maven" then "#{purl.namespace}:#{purl.name}" + # OSV keys PyPI packages by their PEP 503 normalised name. + when "pypi" then purl.name.gsub(/[-_.]+/, "-") + # CPANSA is keyed on the distribution name alone, without the author. + when "cpan" then purl.name + else purl.namespace ? "#{purl.namespace}/#{purl.name}" : purl.name + end + RegistryPackage.new(ecosystem:, name:, version: purl.version, purl: purl.to_s).freeze + end + + sig { params(url: String).returns(T.nilable([String, Purl])) } + def self.registry_purl(url) + basename = decode(File.basename(url)).sub(ARCHIVE_EXTENSIONS, "") + + case url + when %r{\Ahttps://files\.pythonhosted\.org/packages/(?:[^/]+/){3}(?![^/]+\.whl\z)} + # PEP 440 canonical versions contain no hyphen, so the last one delimits. + name, _, version = basename.rpartition("-") + return if name.empty? + + ["PyPI", Purl.new(type: "pypi", name:, version:)] + when %r{\Ahttps://registry\.npmjs\.org/(?:((?:@|%40)[^/]+)/)?([^/@%][^/]*)/-/} + namespace = Regexp.last_match(1) + name = T.must(Regexp.last_match(2)) + namespace &&= "@#{decode(namespace).delete_prefix("@")}" + name = decode(name) + return unless (version = version_after_prefix(basename, name)) + + ["npm", Purl.new(type: "npm", namespace:, name:, version:)] + when %r{\Ahttps://static\.crates\.io/crates/([^/]+)/} + name = decode(T.must(Regexp.last_match(1))) + return unless (version = version_after_prefix(basename, name)) + + ["crates.io", Purl.new(type: "cargo", name:, version:)] + when %r{\Ahttps://rubygems\.org/(?:downloads|gems)/} + name, version = gem_name_version(basename) + return if name.nil? + + ["RubyGems", Purl.new(type: "gem", name:, version:)] + when %r{\Ahttps://hackage\.haskell\.org/package/([^/]+)} + match = T.must(Regexp.last_match(1)).match(HACKAGE_PKGID) + return if match.nil? + + ["Hackage", Purl.new(type: "hackage", name: T.must(match[1]), version: match[2])] + when %r{\Ahttps://repo\.hex\.pm/tarballs/} + # Hex package names are `[a-z][a-z0-9_]*` so the first hyphen delimits. + name, sep, version = basename.partition("-") + return if sep.empty? + + ["Hex", Purl.new(type: "hex", name:, version:)] + when %r{/authors/id/[A-Z]/[A-Z]{2}/([A-Z][A-Z0-9-]+)/} + author = T.must(Regexp.last_match(1)) + match = basename.match(CPAN_DISTNAME) + return if match.nil? + + ["CPAN", Purl.new(type: "cpan", namespace: author, name: T.must(match[1]), version: match[2])] + # Maven Central only: OSV's bare `Maven` ecosystem is Central-scoped, + # so third-party repositories (Google, fabricmc, jfrog, ...) are skipped. + when %r{\Ahttps://repo1?\.maven\.(?:apache\.)?org/maven2/(.+)/([^/]+)/([^/]+)/\2-\3[.-][^/]+\z}, + %r{\Ahttps://search\.maven\.org/remotecontent\?filepath=(.+)/([^/]+)/([^/]+)/\2-\3[.-][^/]+\z} + group_id = T.must(Regexp.last_match(1)).tr("/", ".") + artifact_id = T.must(Regexp.last_match(2)) + version = Regexp.last_match(3) + ["Maven", Purl.new(type: "maven", namespace: group_id, name: artifact_id, version:)] + when %r{\Ahttps://(?:cran|cloud)\.r-project\.org/src/contrib/(?:Archive/[^/]+/)?([^/_]+)_([^/]+)\.tar\.gz\z} + ["CRAN", Purl.new(type: "cran", name: T.must(Regexp.last_match(1)), version: Regexp.last_match(2))] + when %r{\Ahttps://(?:api|www)\.nuget\.org/(?:v3-flatcontainer|api/v2/package)/([^/]+)/([^/]+)(?:/|\z)} + ["NuGet", Purl.new(type: "nuget", name: T.must(Regexp.last_match(1)), version: Regexp.last_match(2))] + end + end + + # Percent-decode a URL path segment. Unlike `decode_www_form_component` + # this leaves `+` alone and unlike `decode_uri_component` (missing from + # Sorbet's stdlib RBI) it never raises on malformed input. + sig { params(component: String).returns(String) } + def self.decode(component) + return component unless component.include?("%") + + component.b.gsub(/%[0-9A-Fa-f]{2}/) { |m| Integer(m[1, 2], 16).chr } + .force_encoding(component.encoding) + end + + sig { params(basename: String, name: String).returns(T.nilable(String)) } + def self.version_after_prefix(basename, name) + prefix = "#{name}-" + return unless basename.start_with?(prefix) + + version = basename[prefix.length..] + version.presence + end + + # Split a `.gem` basename into name and version, discarding any trailing + # {Gem::Platform} suffix (e.g. `nokogiri-1.16.0-arm64-darwin-22`). + sig { params(basename: String).returns([T.nilable(String), T.nilable(String)]) } + def self.gem_name_version(basename) + deplatformed = basename.sub(GEM_PLATFORM_SUFFIX, "") + name, sep, version = deplatformed.rpartition("-") + return [nil, nil] if sep.empty? || !version.match?(/\A\d[\w.]*\z/) + + [name, version] + end end end end diff --git a/Library/Homebrew/vulns/purl.rb b/Library/Homebrew/vulns/purl.rb new file mode 100644 index 0000000000000..f92f1e1f5f070 --- /dev/null +++ b/Library/Homebrew/vulns/purl.rb @@ -0,0 +1,90 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module Vulns + # A package URL per https://github.com/package-url/purl-spec. + # + # Minimal builder for the registry types Homebrew derives from formula + # source URLs. Applies the spec's per-type name normalisation and RFC 3986 + # percent-encoding when serialised. Parsing, qualifiers and subpath are + # intentionally omitted. + class Purl + sig { returns(String) } + attr_reader :type, :name + + sig { returns(T.nilable(String)) } + attr_reader :namespace, :version + + sig { + params(type: String, name: String, namespace: T.nilable(String), version: T.nilable(String)).void + } + def initialize(type:, name:, namespace: nil, version: nil) + raise ArgumentError, "type is required" if type.empty? + raise ArgumentError, "name is required" if name.empty? + + @type = T.let(type.downcase.freeze, String) + namespace = nil if namespace && namespace.empty? + namespace, name = self.class.normalize(@type, namespace, name) + @namespace = T.let(namespace && -namespace, T.nilable(String)) + @name = T.let(-name, String) + version = nil if version && version.empty? + @version = T.let(version && -version, T.nilable(String)) + end + + sig { returns(String) } + def to_s + purl = "pkg:#{@type}/" + if @namespace + purl << @namespace.split("/").reject(&:empty?).map { |s| self.class.encode(s) }.join("/") + purl << "/" + end + purl << self.class.encode(@name) + purl << "@#{self.class.encode(@version)}" if @version + purl.freeze + end + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + case other + when Purl + type == other.type && namespace == other.namespace && + name == other.name && version == other.version + else false + end + end + alias eql? == + + sig { returns(Integer) } + def hash + [type, namespace, name, version].hash + end + + # Percent-encode a single purl component. The spec permits the RFC 3986 + # unreserved set plus `:` unencoded; `+` for space is forbidden so + # `URI.encode_www_form_component` is unsuitable. + sig { params(component: String).returns(String) } + def self.encode(component) + component.b.gsub(/[^A-Za-z0-9\-._~:]/n) { |c| format("%%%02X", c.ord) } + end + + # Per-type normalisation from purl-spec PURL-TYPES.rst for the types we emit. + sig { + params(type: String, namespace: T.nilable(String), name: String) + .returns([T.nilable(String), String]) + } + def self.normalize(type, namespace, name) + case type + when "pypi" + [namespace, name.downcase.tr("_", "-")] + when "hex" + [namespace&.downcase, name.downcase] + when "cpan" + [namespace&.upcase, name] + else + [namespace, name] + end + end + end + end +end From 093fd5ff5c8d5a71dd8cea5cf9f754f808a5ac69 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 15 Jul 2026 18:33:25 -0700 Subject: [PATCH 03/28] vulns: extend Identify::FORGES with OSV-indexed GitLab hosts Live querybatch probes against api.osv.dev confirm gitlab.gnome.org, gitlab.freedesktop.org and invent.kde.org are indexed in the GIT ecosystem (libxml2 56 hits, poppler 75, karchive 2); sr.ht, salsa.debian.org and bitbucket.org returned zero and are not added. FORGES becomes a {host => path_regex} hash. GitHub and Codeberg keep the two-segment owner/repo capture; GitLab-family hosts (including gitlab.com) use a lazy multi-segment capture bounded by .git, /-/, /uploads/, /wikis/ or an optional trailing slash so nested subgroups such as xorg/lib/libx11 resolve while host-level /-/ and /api/ routes are rejected. repo_url now anchors on the host and strips a Wayback Machine snapshot prefix so archived homepages still resolve. --- Library/Homebrew/test/vulns/identify_spec.rb | 57 ++++++++++++++++++++ Library/Homebrew/vulns/identify.rb | 39 +++++++++++--- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index 8782cff5931eb..fefc32727e8be 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -30,6 +30,63 @@ expect(described_class.repo_url(url)).to eq "https://codeberg.org/owner/repo" end + it "extracts a gitlab.gnome.org repo from an archive URL" do + url = "https://gitlab.gnome.org/Archive/pangox-compat/-/archive/0.0.2/pangox-compat-0.0.2.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.gnome.org/Archive/pangox-compat" + end + + it "extracts a gitlab.freedesktop.org repo with a nested subgroup path" do + url = "https://gitlab.freedesktop.org/xorg/lib/libx11/-/archive/libX11-1.8.7/" \ + "libx11-libX11-1.8.7.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.freedesktop.org/xorg/lib/libx11" + end + + it "extracts a gitlab.freedesktop.org repo from a bare .git URL" do + expect(described_class.repo_url("https://gitlab.freedesktop.org/cairo/cairo.git")) + .to eq "https://gitlab.freedesktop.org/cairo/cairo" + end + + it "extracts an invent.kde.org repo" do + expect(described_class.repo_url("https://invent.kde.org/frameworks/karchive.git")) + .to eq "https://invent.kde.org/frameworks/karchive" + end + + it "extracts a gitlab.com repo with a nested subgroup path" do + url = "https://gitlab.com/gitlab-org/security/gitlab/-/archive/v16.0.0/gitlab-v16.0.0.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/gitlab-org/security/gitlab" + end + + it "extracts a GitLab repo from a legacy /uploads/ URL" do + url = "https://gitlab.com/akkuscm/akku/uploads/9a82f6a11e35c67f0e0086/akku-1.1.0.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/akkuscm/akku" + end + + it "extracts a GitLab repo from a /wikis/ URL" do + expect(described_class.repo_url("https://gitlab.gnome.org/GNOME/gjs/wikis/Home")) + .to eq "https://gitlab.gnome.org/GNOME/gjs" + end + + it "extracts a GitLab repo from a URL with a trailing slash" do + expect(described_class.repo_url("https://gitlab.com/gsasl/libntlm/")) + .to eq "https://gitlab.com/gsasl/libntlm" + end + + it "rejects a GitLab host-level /-/ route and falls back to a later URL" do + stable = "https://gitlab.freedesktop.org/-/project/62/uploads/54a0f9/spice-0.16.0.tar.bz2" + head = "https://gitlab.freedesktop.org/spice/spice.git" + expect(described_class.repo_url(stable, head)).to eq "https://gitlab.freedesktop.org/spice/spice" + end + + it "returns nil for a GitLab /api/ route" do + expect(described_class.repo_url("https://gitlab.freedesktop.org/api/v4/projects/1205/releases")) + .to be_nil + end + + it "unwraps a Wayback Machine snapshot URL" do + url = "https://web.archive.org/web/20180102081127/https://github.com/satori-com/tcpkali" + expect(described_class.repo_url(url)).to eq "https://github.com/satori-com/tcpkali" + end + it "falls back to the head URL when the stable URL is not a supported forge" do stable = "https://aomedia.googlesource.com/aom.git" head = "https://github.com/AomediaOrg/aom.git" diff --git a/Library/Homebrew/vulns/identify.rb b/Library/Homebrew/vulns/identify.rb index 841577a64a878..c4b72aa7a9cc4 100644 --- a/Library/Homebrew/vulns/identify.rb +++ b/Library/Homebrew/vulns/identify.rb @@ -8,7 +8,27 @@ module Vulns # Derives OSV.dev query keys (forge repo URL, release tag) from formula # source URLs. Shared between {Scanner} and the advisory-matching pipeline. module Identify - FORGES = %w[github.com gitlab.com codeberg.org].freeze + TWO_SEGMENT_PATH = %r{/([^/]+/[^/]+)} + private_constant :TWO_SEGMENT_PATH + + # GitLab supports nested subgroups (e.g. `xorg/lib/libx11`); the path is + # bounded by `.git`, the `/-/` route marker, the legacy `/uploads/` and + # `/wikis/` routes, or the end of the URL. Host-level `/-/` and `/api/` + # routes are rejected via the leading negative lookahead. + GITLAB_PATH = %r{/(?!-|api/)([^/]+(?:/[^/]+)+?)(?:\.git)?(?=/-/|/uploads/|/wikis/|/?\z)} + private_constant :GITLAB_PATH + + FORGES = T.let( + { + "github.com" => TWO_SEGMENT_PATH, + "codeberg.org" => TWO_SEGMENT_PATH, + "gitlab.com" => GITLAB_PATH, + "gitlab.gnome.org" => GITLAB_PATH, + "gitlab.freedesktop.org" => GITLAB_PATH, + "invent.kde.org" => GITLAB_PATH, + }.freeze, + T::Hash[String, Regexp], + ) private_constant :FORGES TAG_PATTERNS = T.let( @@ -24,19 +44,22 @@ module Identify ) private_constant :TAG_PATTERNS + WAYBACK_PREFIX = %r{\Ahttps?://web\.archive\.org/web/\d+[a-z_*]*/} + private_constant :WAYBACK_PREFIX + sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } def self.repo_url(*urls) urls.each do |url| next if url.nil? - forge = FORGES.find { |f| url.include?(f) } - next if forge.nil? - - match = url.match(%r{https?://#{Regexp.escape(forge)}/([^/]+/[^/]+)}) - next if match.nil? + url = url.sub(WAYBACK_PREFIX, "") + FORGES.each do |host, path_pattern| + match = url.match(%r{\Ahttps?://#{Regexp.escape(host)}#{path_pattern}}) + next if match.nil? - repo_path = T.must(match[1]).sub(/\.git$/, "").sub(%r{/-/.*}, "") - return "https://#{forge}/#{repo_path}" + repo_path = T.must(match[1]).sub(/\.git$/, "") + return "https://#{host}/#{repo_path}" + end end nil end From e650a474fa546d91bc0ad8dfa0832c13344bc443 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 16 Jul 2026 07:30:21 -0700 Subject: [PATCH 04/28] vulns: add CPANSec loader for cpan-security-advisory.json Fetches the compiled cpan-security-advisory.json from briandfoy/cpan-security-advisory into HOMEBREW_CACHE/vulns/ and exposes advisories per CPAN distribution. Refresh downloads to a per-process sibling Tempfile, validates the JSON, then atomically renames over the cache; on network or validation failure it warns and falls back to the stale copy. Range evaluation of affected_versions is left to the future Vulns::Match consumer. --- .../test/support/fixtures/vulns/cpansa.json | 56 ++++++ Library/Homebrew/test/vulns/cpan_sec_spec.rb | 167 ++++++++++++++++++ Library/Homebrew/vulns/cpan_sec.rb | 121 +++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 Library/Homebrew/test/support/fixtures/vulns/cpansa.json create mode 100644 Library/Homebrew/test/vulns/cpan_sec_spec.rb create mode 100644 Library/Homebrew/vulns/cpan_sec.rb diff --git a/Library/Homebrew/test/support/fixtures/vulns/cpansa.json b/Library/Homebrew/test/support/fixtures/vulns/cpansa.json new file mode 100644 index 0000000000000..eab487e70ddc5 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/vulns/cpansa.json @@ -0,0 +1,56 @@ +{ + "meta": { + "repo": "https://github.com/briandfoy/cpan-security-advisory.git", + "commit": "abc123", + "epoch": 1784142497 + }, + "module2dist": { + "DBI": "DBI", + "Image::ExifTool": "Image-ExifTool" + }, + "dists": { + "DBI": { + "main_module": "DBI", + "versions": [{"version": "1.643", "date": "2020-01-31T18:02:00"}], + "advisories": [ + { + "id": "CPANSA-DBI-2020-01", + "cves": ["CVE-2020-14393"], + "affected_versions": ["<1.643"], + "fixed_versions": [">=1.643"], + "severity": "high", + "description": "Buffer overflow in DBI.xs.\n", + "references": ["https://metacpan.org/changes/distribution/DBI"], + "reported": "2020-09-16", + "distribution": "DBI" + }, + { + "id": "CPANSA-DBI-2014-01", + "cves": ["CVE-2014-10402", "CVE-2014-10401"], + "affected_versions": [">=0.64,<1.632"], + "fixed_versions": [">=1.632"], + "severity": "medium", + "distribution": "DBI" + } + ] + }, + "Image-ExifTool": { + "main_module": "Image::ExifTool", + "advisories": [ + { + "id": "CPANSA-Image-ExifTool-2021-22204", + "cves": ["CVE-2021-22204"], + "affected_versions": [">=7.44,<12.24"], + "fixed_versions": [">=12.24"], + "severity": "critical", + "description": "Improper neutralization in DjVu.\n", + "references": [ + "https://github.com/exiftool/exiftool/commit/cf0f4e7dcd024ca99615bfd1102a841a25dde031" + ], + "reported": "2021-04-23", + "distribution": "Image-ExifTool" + } + ] + } + } +} diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb new file mode 100644 index 0000000000000..01e012f3e5910 --- /dev/null +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -0,0 +1,167 @@ +# typed: false +# frozen_string_literal: true + +require "vulns/cpan_sec" + +RSpec.describe Homebrew::Vulns::CPANSec do + let(:fixture) { TEST_FIXTURE_DIR/"vulns/cpansa.json" } + let(:cpansa) { described_class.from_file(fixture) } + + describe ".from_file" do + it "raises Error on unparseable JSON" do + Dir.mktmpdir do |dir| + bad = Pathname(dir)/"cpansa.json" + bad.write "not json" + expect { described_class.from_file(bad) } + .to raise_error(described_class::Error, /Failed to parse CPANSA data/) + end + end + end + + describe "#initialize" do + it "raises Error when the dists key is missing" do + expect { described_class.new({ "meta" => {} }) } + .to raise_error(described_class::Error, /missing 'dists' key/) + end + + it "raises Error when the top-level value is not a JSON object" do + expect { described_class.new([]) }.to raise_error(described_class::Error, /not a JSON object/) + expect { described_class.new(nil) }.to raise_error(described_class::Error, /not a JSON object/) + end + + it "treats a null or absent meta as an empty hash" do + expect(described_class.new({ "dists" => {}, "meta" => nil }).meta).to eq({}) + expect(described_class.new({ "dists" => {} }).meta).to eq({}) + end + end + + describe "#meta" do + it "returns the upstream build metadata" do + expect(cpansa.meta).to include("commit" => "abc123", "epoch" => 1784142497) + end + end + + describe "#distributions" do + it "lists all distribution names" do + expect(cpansa.distributions).to contain_exactly("DBI", "Image-ExifTool") + end + end + + describe "#advisories_for" do + it "returns Advisory structs with all fields populated" do + first = cpansa.advisories_for("DBI").first + expect(first).to have_attributes( + id: "CPANSA-DBI-2020-01", + cves: ["CVE-2020-14393"], + affected_versions: ["<1.643"], + fixed_versions: [">=1.643"], + severity: "high", + description: "Buffer overflow in DBI.xs.\n", + references: ["https://metacpan.org/changes/distribution/DBI"], + reported: "2020-09-16", + ) + end + + it "coerces cves and affected_versions to string arrays and defaults absent optional fields" do + second = cpansa.advisories_for("DBI")[1] + expect(second.id).to eq "CPANSA-DBI-2014-01" + expect(second.cves).to eq ["CVE-2014-10402", "CVE-2014-10401"] + expect(second.affected_versions).to eq [">=0.64,<1.632"] + expect(second.description).to be_nil + expect(second.references).to eq [] + end + + it "returns all advisories for a distribution in file order" do + expect(cpansa.advisories_for("DBI").map(&:id)) + .to eq ["CPANSA-DBI-2020-01", "CPANSA-DBI-2014-01"] + end + + it "returns an empty array for an unknown distribution" do + expect(cpansa.advisories_for("No-Such-Dist")).to eq [] + end + + it "returns frozen advisories" do + expect(cpansa.advisories_for("Image-ExifTool").first).to be_frozen + end + end + + describe ".load" do + it "reads a fresh cache file without downloading" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + FileUtils.cp fixture, cache/"cpansa.json" + expect(Utils::Curl).not_to receive(:curl_download) + loaded = described_class.load(cache:) + expect(loaded.distributions).to include "DBI" + end + end + + it "downloads to a temp file and atomically replaces a stale cache" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + stale.write '{"dists": {}}' + FileUtils.touch stale, mtime: Time.now - 100_000 + expect(Utils::Curl).to receive(:curl_download) do |*_args, to:| + expect(to).not_to eq stale + FileUtils.cp fixture, to + end + expect(described_class.load(cache:).distributions).to include "DBI" + expect(stale.read).to eq fixture.read + expect(cache.children.map { |c| c.basename.to_s }).to eq ["cpansa.json"] + end + end + + it "downloads when the cache file is absent" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + expect(Utils::Curl).to receive(:curl_download) do |*_args, to:| + FileUtils.cp fixture, to + end + expect(described_class.load(cache:).advisories_for("Image-ExifTool").length).to eq 1 + end + end + + it "falls back to a stale cache when the download fails, leaving it intact" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - 100_000 + original = stale.read + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) + loaded = nil + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh CPANSA data/).to_stderr + expect(loaded.distributions).to include "DBI" + expect(stale.read).to eq original + end + end + + it "falls back to a stale cache when the fetched file is invalid, leaving it intact" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - 100_000 + original = stale.read + expect(Utils::Curl).to receive(:curl_download) { |*_args, to:| to.write "not json" } + loaded = nil + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh CPANSA data/).to_stderr + expect(loaded.distributions).to include "DBI" + expect(stale.read).to eq original + expect(cache.children).to eq [stale] + end + end + + it "raises when the download fails and no cache exists" do + Dir.mktmpdir do |dir| + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 6)) + expect { described_class.load(cache: Pathname(dir)) }.to raise_error(ErrorDuringExecution) + end + end + end +end diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb new file mode 100644 index 0000000000000..9fc33b40159f7 --- /dev/null +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -0,0 +1,121 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "tempfile" +require "utils/curl" + +module Homebrew + module Vulns + # Loader for the CPAN Security Advisory database. + # Source: https://github.com/briandfoy/cpan-security-advisory + # + # The upstream repository ships a compiled `cpan-security-advisory.json` + # keyed on CPAN distribution name. This class fetches and caches that file + # and exposes advisories per distribution. Evaluating `affected_versions` + # range strings against a formula version is left to {Vulns::Match}. + class CPANSec + extend Utils::Output::Mixin + + DATA_URL = "https://raw.githubusercontent.com/briandfoy/cpan-security-advisory/" \ + "master/cpan-security-advisory.json" + CACHE_FILENAME = "cpansa.json" + DEFAULT_MAX_AGE = 86_400 + private_constant :CACHE_FILENAME, :DEFAULT_MAX_AGE + + class Error < RuntimeError; end + + Advisory = Struct.new( + :id, :cves, :affected_versions, :fixed_versions, + :severity, :description, :references, :reported, + keyword_init: true + ) + + sig { params(cache: Pathname, max_age: Integer).returns(T.attached_class) } + def self.load(cache: HOMEBREW_CACHE/"vulns", max_age: DEFAULT_MAX_AGE) + cache_file = cache/CACHE_FILENAME + return from_file(cache_file) if cache_file.exist? && (Time.now - cache_file.mtime) <= max_age + + refresh(cache_file) + rescue ErrorDuringExecution, Error => e + raise unless cache_file.exist? + + opoo "Failed to refresh CPANSA data (#{e.message.lines.first&.strip}); " \ + "using cached copy from #{cache_file.mtime}." + from_file(cache_file) + end + + # Download to a per-process sibling temp file and validate before + # atomically replacing the cache so a failed, truncated or concurrent + # fetch cannot corrupt the stale copy. + sig { params(cache_file: Pathname).returns(T.attached_class) } + def self.refresh(cache_file) + cache_file.dirname.mkpath + Tempfile.create([CACHE_FILENAME, ".download"], cache_file.dirname.to_s) do |tmp| + tmp.close + path = Pathname(tmp.path) + Utils::Curl.curl_download("--fail", "--silent", DATA_URL, to: path) + loaded = from_file(path) + File.rename(path, cache_file) + return loaded + end + end + + sig { params(path: Pathname).returns(T.attached_class) } + def self.from_file(path) + new(JSON.parse(path.read)) + rescue JSON::ParserError => e + raise Error, "Failed to parse CPANSA data at #{path}: #{e.message}" + end + + sig { params(data: T.anything).void } + def initialize(data) + raise Error, "CPANSA data is not a JSON object" unless (top = as_hash(data)) + raise Error, "CPANSA data missing 'dists' key" unless (dists = as_hash(top["dists"])) + + @dists = T.let(dists, T::Hash[String, T.untyped]) + @meta = T.let(as_hash(top["meta"]) || {}, T::Hash[String, T.untyped]) + end + + sig { params(value: T.anything).returns(T.nilable(T::Hash[String, T.untyped])) } + def as_hash(value) + case value + when Hash then value + end + end + + sig { returns(T::Hash[String, T.untyped]) } + attr_reader :meta + + sig { returns(T::Array[String]) } + def distributions + @dists.keys + end + + sig { params(distribution: String).returns(T::Array[Advisory]) } + def advisories_for(distribution) + entry = @dists[distribution] + return [] unless entry.is_a?(Hash) + + Array(entry["advisories"]).filter_map { |a| build_advisory(a) if a.is_a?(Hash) } + end + + sig { params(raw: T::Hash[String, T.untyped]).returns(T.nilable(Advisory)) } + def build_advisory(raw) + id = raw["id"] + return if id.nil? + + Advisory.new( + id:, + cves: Array(raw["cves"]).map(&:to_s), + affected_versions: Array(raw["affected_versions"]).map(&:to_s), + fixed_versions: Array(raw["fixed_versions"]).map(&:to_s), + severity: raw["severity"], + description: raw["description"], + references: Array(raw["references"]).map(&:to_s), + reported: raw["reported"], + ).freeze + end + end + end +end From 8eab7afb988df306404b3383fae4623a493484b5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 16 Jul 2026 14:56:52 -0700 Subject: [PATCH 05/28] vulns: extract CachedFeed base class from CPANSec Shared load/refresh/from_file for JSON feeds cached under HOMEBREW_CACHE/vulns/: mtime-gated freshness check, download to a per-process sibling Tempfile, validate, atomic rename, and stale-cache fallback with a warning on failure. Subclasses implement .data_url, .cache_filename and #initialize(data) and may override .default_max_age. CPANSec now inherits it; behaviour unchanged apart from the diagnostic wording naming the cache filename. --- Library/Homebrew/test/vulns/cpan_sec_spec.rb | 6 +- Library/Homebrew/vulns/cached_feed.rb | 80 ++++++++++++++++++++ Library/Homebrew/vulns/cpan_sec.rb | 64 +++------------- 3 files changed, 92 insertions(+), 58 deletions(-) create mode 100644 Library/Homebrew/vulns/cached_feed.rb diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb index 01e012f3e5910..261fff8c308e4 100644 --- a/Library/Homebrew/test/vulns/cpan_sec_spec.rb +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -13,7 +13,7 @@ bad = Pathname(dir)/"cpansa.json" bad.write "not json" expect { described_class.from_file(bad) } - .to raise_error(described_class::Error, /Failed to parse CPANSA data/) + .to raise_error(described_class::Error, /Failed to parse cpansa\.json/) end end end @@ -133,7 +133,7 @@ .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) loaded = nil expect { loaded = described_class.load(cache:) } - .to output(/Failed to refresh CPANSA data/).to_stderr + .to output(/Failed to refresh cpansa\.json/).to_stderr expect(loaded.distributions).to include "DBI" expect(stale.read).to eq original end @@ -149,7 +149,7 @@ expect(Utils::Curl).to receive(:curl_download) { |*_args, to:| to.write "not json" } loaded = nil expect { loaded = described_class.load(cache:) } - .to output(/Failed to refresh CPANSA data/).to_stderr + .to output(/Failed to refresh cpansa\.json/).to_stderr expect(loaded.distributions).to include "DBI" expect(stale.read).to eq original expect(cache.children).to eq [stale] diff --git a/Library/Homebrew/vulns/cached_feed.rb b/Library/Homebrew/vulns/cached_feed.rb new file mode 100644 index 0000000000000..adfab0772e23f --- /dev/null +++ b/Library/Homebrew/vulns/cached_feed.rb @@ -0,0 +1,80 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "tempfile" +require "utils/curl" + +module Homebrew + module Vulns + # Base class for read-only loaders of a single upstream JSON feed cached + # under `HOMEBREW_CACHE/vulns/`. Subclasses implement {.data_url}, + # {.cache_filename} and `#initialize(data)` (which validates the parsed + # payload) and may override {.default_max_age}. {.load} handles freshness, + # atomic refresh and stale-cache fallback uniformly. + class CachedFeed + extend T::Helpers + extend Utils::Output::Mixin + + abstract! + + class Error < RuntimeError; end + + sig { abstract.returns(String) } + def self.data_url; end + + sig { abstract.returns(String) } + def self.cache_filename; end + + sig { overridable.returns(Integer) } + def self.default_max_age = 86_400 + + sig { overridable.params(data: T.anything).void } + def initialize(data); end + + sig { params(cache: Pathname, max_age: Integer).returns(T.attached_class) } + def self.load(cache: HOMEBREW_CACHE/"vulns", max_age: default_max_age) + cache_file = cache/cache_filename + return from_file(cache_file) if cache_file.exist? && (Time.now - cache_file.mtime) <= max_age + + refresh(cache_file) + rescue ErrorDuringExecution, Error => e + raise unless cache_file.exist? + + opoo "Failed to refresh #{cache_filename} (#{e.message.lines.first&.strip}); " \ + "using cached copy from #{cache_file.mtime}." + from_file(cache_file) + end + + # Download to a per-process sibling temp file and validate before + # atomically replacing the cache so a failed, truncated or concurrent + # fetch cannot corrupt the stale copy. + sig { params(cache_file: Pathname).returns(T.attached_class) } + def self.refresh(cache_file) + cache_file.dirname.mkpath + Tempfile.create([cache_filename, ".download"], cache_file.dirname.to_s) do |tmp| + tmp.close + path = Pathname(tmp.path) + Utils::Curl.curl_download("--fail", "--silent", data_url, to: path) + loaded = from_file(path) + File.rename(path, cache_file) + return loaded + end + end + + sig { params(path: Pathname).returns(T.attached_class) } + def self.from_file(path) + new(JSON.parse(path.read)) + rescue JSON::ParserError => e + raise Error, "Failed to parse #{cache_filename} at #{path}: #{e.message}" + end + + sig { params(value: T.anything).returns(T.nilable(T::Hash[String, T.untyped])) } + def as_hash(value) + case value + when Hash then value + end + end + end + end +end diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb index 9fc33b40159f7..8687e5612879a 100644 --- a/Library/Homebrew/vulns/cpan_sec.rb +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -1,9 +1,7 @@ # typed: strict # frozen_string_literal: true -require "json" -require "tempfile" -require "utils/curl" +require "vulns/cached_feed" module Homebrew module Vulns @@ -14,16 +12,15 @@ module Vulns # keyed on CPAN distribution name. This class fetches and caches that file # and exposes advisories per distribution. Evaluating `affected_versions` # range strings against a formula version is left to {Vulns::Match}. - class CPANSec - extend Utils::Output::Mixin - + class CPANSec < CachedFeed DATA_URL = "https://raw.githubusercontent.com/briandfoy/cpan-security-advisory/" \ "master/cpan-security-advisory.json" - CACHE_FILENAME = "cpansa.json" - DEFAULT_MAX_AGE = 86_400 - private_constant :CACHE_FILENAME, :DEFAULT_MAX_AGE - class Error < RuntimeError; end + sig { override.returns(String) } + def self.data_url = DATA_URL + + sig { override.returns(String) } + def self.cache_filename = "cpansa.json" Advisory = Struct.new( :id, :cves, :affected_versions, :fixed_versions, @@ -31,45 +28,9 @@ class Error < RuntimeError; end keyword_init: true ) - sig { params(cache: Pathname, max_age: Integer).returns(T.attached_class) } - def self.load(cache: HOMEBREW_CACHE/"vulns", max_age: DEFAULT_MAX_AGE) - cache_file = cache/CACHE_FILENAME - return from_file(cache_file) if cache_file.exist? && (Time.now - cache_file.mtime) <= max_age - - refresh(cache_file) - rescue ErrorDuringExecution, Error => e - raise unless cache_file.exist? - - opoo "Failed to refresh CPANSA data (#{e.message.lines.first&.strip}); " \ - "using cached copy from #{cache_file.mtime}." - from_file(cache_file) - end - - # Download to a per-process sibling temp file and validate before - # atomically replacing the cache so a failed, truncated or concurrent - # fetch cannot corrupt the stale copy. - sig { params(cache_file: Pathname).returns(T.attached_class) } - def self.refresh(cache_file) - cache_file.dirname.mkpath - Tempfile.create([CACHE_FILENAME, ".download"], cache_file.dirname.to_s) do |tmp| - tmp.close - path = Pathname(tmp.path) - Utils::Curl.curl_download("--fail", "--silent", DATA_URL, to: path) - loaded = from_file(path) - File.rename(path, cache_file) - return loaded - end - end - - sig { params(path: Pathname).returns(T.attached_class) } - def self.from_file(path) - new(JSON.parse(path.read)) - rescue JSON::ParserError => e - raise Error, "Failed to parse CPANSA data at #{path}: #{e.message}" - end - - sig { params(data: T.anything).void } + sig { override.params(data: T.anything).void } def initialize(data) + super raise Error, "CPANSA data is not a JSON object" unless (top = as_hash(data)) raise Error, "CPANSA data missing 'dists' key" unless (dists = as_hash(top["dists"])) @@ -77,13 +38,6 @@ def initialize(data) @meta = T.let(as_hash(top["meta"]) || {}, T::Hash[String, T.untyped]) end - sig { params(value: T.anything).returns(T.nilable(T::Hash[String, T.untyped])) } - def as_hash(value) - case value - when Hash then value - end - end - sig { returns(T::Hash[String, T.untyped]) } attr_reader :meta From 04209921f11329d222f6aa37c6897b61c53c5532 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 16 Jul 2026 15:02:06 -0700 Subject: [PATCH 06/28] vulns: add Repology reader for advisory-database's distro index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetches data/repology.json published by Homebrew/advisory-database's RepologyIndex (7-day TTL via CachedFeed) and exposes #distro_packages_for(formula_name) returning {osv_ecosystem => [srcname, ...]} with an @-versioned-name fallback. .lookup(formula_name) is a live single-project API fallback for formulae the published index doesn't yet cover: it fetches each name_candidates variant, keeps projects whose Homebrew entries include the formula (or its base) without grouping unrelated formulae, partitions contributions into exact-name and base-name pools, and resolves each by preferred Homebrew status the same way RepologyIndex#resolve does — so the fallback is consistent with the published index for the projects it can reach. .fetch_project raises on transport/HTTP/JSON failures; only an empty (HTTP 200) response means the project doesn't exist. The OSV_DISTROS mapping and distil/homebrew_entries/PREFERRED_STATUSES mirror RepologyIndex; a follow-up will consolidate the API client with utils/repology.rb (which currently lacks a User-Agent). --- .../test/support/fixtures/vulns/repology.json | 30 ++ Library/Homebrew/test/vulns/repology_spec.rb | 323 ++++++++++++++++++ Library/Homebrew/vulns/repology.rb | 219 ++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 Library/Homebrew/test/support/fixtures/vulns/repology.json create mode 100644 Library/Homebrew/test/vulns/repology_spec.rb create mode 100644 Library/Homebrew/vulns/repology.rb diff --git a/Library/Homebrew/test/support/fixtures/vulns/repology.json b/Library/Homebrew/test/support/fixtures/vulns/repology.json new file mode 100644 index 0000000000000..5b79f0f37e117 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/vulns/repology.json @@ -0,0 +1,30 @@ +{ + "meta": { + "source": "https://repology.org/api/v1", + "osv_distros": ["AlmaLinux", "Alpine", "Debian", "FreeBSD", "Mageia", "Red Hat", "Rocky Linux", "Ubuntu", "openEuler", "openSUSE"], + "ambiguous_projects": { + "antlr": ["antlr", "antlr4-cpp-runtime"] + }, + "colliding_formulae": {} + }, + "formulae": { + "curl": { + "Alpine": ["curl"], + "Debian": ["curl"], + "FreeBSD": ["curl"], + "Ubuntu": ["curl"], + "openSUSE": ["curl"] + }, + "libgee": { + "Alpine": ["libgee"], + "Debian": ["libgee-0.8"] + }, + "ack": { + "Ubuntu": ["ack", "ack-grep"] + }, + "postgresql": { + "Debian": ["postgresql-17"], + "Alpine": ["postgresql17"] + } + } +} diff --git a/Library/Homebrew/test/vulns/repology_spec.rb b/Library/Homebrew/test/vulns/repology_spec.rb new file mode 100644 index 0000000000000..20d9bf195ffea --- /dev/null +++ b/Library/Homebrew/test/vulns/repology_spec.rb @@ -0,0 +1,323 @@ +# typed: false +# frozen_string_literal: true + +require "vulns/repology" + +RSpec.describe Homebrew::Vulns::Repology do + let(:fixture) { TEST_FIXTURE_DIR/"vulns/repology.json" } + let(:index) { described_class.from_file(fixture) } + + describe "#initialize" do + it "raises Error when the top-level value is not a JSON object" do + expect { described_class.new([]) } + .to raise_error(described_class::Error, /not a JSON object/) + end + + it "raises Error when the formulae key is missing" do + expect { described_class.new({ "meta" => {} }) } + .to raise_error(described_class::Error, /missing 'formulae' key/) + end + end + + describe "#meta and #formulae" do + it "exposes the meta block and formula names" do + expect(index.meta["osv_distros"]).to include "Debian" + expect(index.meta["ambiguous_projects"]).to eq({ "antlr" => ["antlr", "antlr4-cpp-runtime"] }) + expect(index.formulae).to contain_exactly("curl", "libgee", "ack", "postgresql") + end + end + + describe "#distro_packages_for" do + it "returns the ecosystem => srcnames map for a known formula" do + expect(index.distro_packages_for("curl")).to eq( + "Alpine" => ["curl"], "Debian" => ["curl"], "FreeBSD" => ["curl"], + "Ubuntu" => ["curl"], "openSUSE" => ["curl"] + ) + end + + it "returns multiple candidate srcnames per ecosystem" do + expect(index.distro_packages_for("ack")).to eq("Ubuntu" => ["ack", "ack-grep"]) + end + + it "falls back to the base name for an @-versioned formula" do + expect(index.distro_packages_for("postgresql@16")) + .to eq("Debian" => ["postgresql-17"], "Alpine" => ["postgresql17"]) + end + + it "returns an empty hash for an unknown formula" do + expect(index.distro_packages_for("no-such-formula")).to eq({}) + end + + it "returns frozen values" do + result = index.distro_packages_for("libgee") + expect(result).to be_frozen + expect(result["Debian"]).to be_frozen + end + + it "drops malformed entries when coercing" do + idx = described_class.new({ "formulae" => { "x" => { "Debian" => ["ok"], 123 => ["bad"], + "Empty" => [] } } }) + expect(idx.distro_packages_for("x")).to eq("Debian" => ["ok"]) + end + end + + describe ".name_candidates" do + it "generates deduplicated normalisation variants" do + expect(described_class.name_candidates("libmatio")) + .to eq ["libmatio", "matio"] + end + + it "strips an @-version suffix and applies affix variants to the base" do + expect(described_class.name_candidates("gnu-complexity@1")) + .to eq ["gnu-complexity@1", "gnu-complexity", "complexity"] + end + + it "strips a trailing 2" do + expect(described_class.name_candidates("qscintilla2")).to eq ["qscintilla2", "qscintilla"] + end + + it "returns just the name when no variant applies" do + expect(described_class.name_candidates("curl")).to eq ["curl"] + end + + it "does not yield an empty candidate for a bare 'lib' name" do + expect(described_class.name_candidates("lib")).to eq ["lib"] + end + end + + describe ".distil" do + let(:entries) do + [ + { "repo" => "debian_12", "srcname" => "curl", "status" => "outdated" }, + { "repo" => "debian_13", "srcname" => "curl", "status" => "newest" }, + { "repo" => "alpine_3_17", "srcname" => "old-curl", "status" => "legacy" }, + { "repo" => "alpine_3_22", "srcname" => "curl", "status" => "newest" }, + { "repo" => "freebsd", "srcname" => "ftp/curl", "binname" => "curl", "status" => "newest" }, + { "repo" => "opensuse_games_tumbleweed", "srcname" => "wrong" }, + { "repo" => "scoop", "binname" => "curl" }, + ] + end + + it "collapses versioned repos, drops legacy, uses binname for FreeBSD, ignores unmapped repos" do + expect(described_class.distil(entries)) + .to eq("Alpine" => ["curl"], "Debian" => ["curl"], "FreeBSD" => ["curl"]) + end + + it "collects all distinct srcnames per ecosystem, sorted" do + multi = [ + { "repo" => "ubuntu_22_04", "srcname" => "ack" }, + { "repo" => "ubuntu_18_04", "srcname" => "ack-grep" }, + ] + expect(described_class.distil(multi)).to eq("Ubuntu" => ["ack", "ack-grep"]) + end + + it "returns an empty hash for no mappable entries" do + expect(described_class.distil([{ "repo" => "scoop" }])).to eq({}) + end + end + + describe ".lookup" do + def project(homebrew:, distros:, status: "newest") + homebrew.map { |n| { "repo" => "homebrew", "srcname" => n, "status" => status } } + + distros.map { |repo, n| { "repo" => repo, "srcname" => n } } + end + + it "tries name candidates until one contains the requested formula in its Homebrew entries" do + allow(described_class).to receive(:fetch_project).with("libmatio").and_return([]) + allow(described_class).to receive(:fetch_project).with("matio").and_return( + project(homebrew: ["libmatio"], distros: [["debian_12", "matio"]]), + ) + expect(described_class.lookup("libmatio")).to eq("Debian" => ["matio"]) + end + + it "rejects a candidate whose Homebrew entries do not include the requested formula" do + allow(described_class).to receive(:fetch_project).with("libc").and_return([]) + allow(described_class).to receive(:fetch_project).with("c").and_return( + project(homebrew: ["c"], distros: [["freebsd", "c"]]), + ) + expect(described_class.lookup("libc")).to eq({}) + end + + it "accepts a candidate that lists the @-stripped base name" do + allow(described_class).to receive(:fetch_project).with("node@20").and_return([]) + allow(described_class).to receive(:fetch_project).with("node").and_return( + project(homebrew: ["node", "node@22"], distros: [["debian_12", "nodejs"]]), + ) + expect(described_class.lookup("node@20")).to eq("Debian" => ["nodejs"]) + end + + it "rejects an ambiguous project (multiple unrelated Homebrew formulae)" do + allow(described_class).to receive(:fetch_project).with("antlr").and_return( + project(homebrew: ["antlr", "antlr4-cpp-runtime"], + distros: [["debian_12", "antlr4"], ["debian_12", "antlr4-cpp-runtime"]]), + ) + expect(described_class.lookup("antlr")).to eq({}) + end + + it "continues past an ambiguous candidate to a later valid one" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo", "libfoo-utils"], distros: [["debian_12", "wrong"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "foo"]]), + ) + expect(described_class.lookup("libfoo")).to eq("Debian" => ["foo"]) + end + + it "continues past a candidate with no mapped OSV distros" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["scoop", "foo"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["alpine_3_22", "foo"]]), + ) + expect(described_class.lookup("libfoo")).to eq("Alpine" => ["foo"]) + end + + it "resolves two matching candidates by preferred Homebrew status" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "libfoo4"]], status: "rolling"), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "libfoo5"]], status: "newest"), + ) + expect(described_class.lookup("libfoo")).to eq("Debian" => ["libfoo5"]) + end + + it "prefers a sole exact-name contribution over a preferred base-name contribution" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + [{ "repo" => "homebrew", "srcname" => "libfoo@1", "status" => "rolling" }, + { "repo" => "homebrew", "srcname" => "libfoo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "exact" }], + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "base"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return([]) + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["exact"]) + end + + it "falls back to a resolved base pool when the exact pool is an unresolvable collision" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "b"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "base"]]), + ) + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["base"]) + end + + it "contributes a project listing both exact and base names to both pools" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + project(homebrew: ["libfoo@1", "libfoo"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "b"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return([]) + # Exact pool: [a/true, b/true] collides. Base pool: [a/true] (from the + # first project, which also lists `libfoo`) resolves. + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["a"]) + end + + it "returns {} when two matching candidates both have preferred status (unresolvable)" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "b"]]), + ) + expect(described_class.lookup("libfoo")).to eq({}) + end + + it "returns an empty hash when no candidate resolves" do + allow(described_class).to receive(:fetch_project).and_return([]) + expect(described_class.lookup("no-such")).to eq({}) + end + + it "propagates fetch errors rather than treating them as a miss" do + allow(described_class).to receive(:fetch_project) + .and_raise(described_class::Error, "Repology API request failed") + expect { described_class.lookup("curl") }.to raise_error(described_class::Error) + end + end + + describe ".fetch_project" do + it "returns the parsed array on success" do + body = '[{"repo":"debian_12","srcname":"curl"}]' + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: body), + ) + expect(described_class.fetch_project("curl")).to eq [{ "repo" => "debian_12", "srcname" => "curl" }] + end + + it "sends a User-Agent header (Repology rejects requests without one)" do + expect(Utils::Curl).to receive(:curl_output) do |*args| + expect(args).to include("--user-agent", described_class::USER_AGENT) + instance_double(SystemCommand::Result, success?: true, stdout: "[]") + end + described_class.fetch_project("curl") + end + + it "returns [] for a nonexistent project (HTTP 200 with empty array)" do + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: "[]"), + ) + expect(described_class.fetch_project("no-such")).to eq [] + end + + it "raises Error on curl failure" do + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: false, exit_status: 22, + stderr: "The requested URL returned error: 503"), + ) + expect { described_class.fetch_project("curl") } + .to raise_error(described_class::Error, /curl exit 22.*503/) + end + + it "raises Error on invalid JSON" do + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: "not json"), + ) + expect { described_class.fetch_project("curl") } + .to raise_error(described_class::Error, /invalid JSON/) + end + + it "raises Error on an unexpected response shape" do + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: '{"oops":true}'), + ) + expect { described_class.fetch_project("curl") } + .to raise_error(described_class::Error, /unexpected shape/) + end + end + + describe ".load" do + it "reads a fresh cache file without downloading" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + FileUtils.cp fixture, cache/"repology.json" + expect(Utils::Curl).not_to receive(:curl_download) + expect(described_class.load(cache:).formulae).to include "curl" + end + end + + it "falls back to a stale cache when the download fails" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"repology.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - (described_class.default_max_age + 1) + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) + loaded = nil + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh repology\.json/).to_stderr + expect(loaded.formulae).to include "curl" + end + end + end +end diff --git a/Library/Homebrew/vulns/repology.rb b/Library/Homebrew/vulns/repology.rb new file mode 100644 index 0000000000000..63098be586a4d --- /dev/null +++ b/Library/Homebrew/vulns/repology.rb @@ -0,0 +1,219 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/cached_feed" + +module Homebrew + module Vulns + # Reader for the Repology-derived formula → distro-package index published + # by Homebrew/advisory-database (`data/repology.json`, built by that + # repository's `RepologyIndex` via `rake repology:build`). + # + # The index maps each formula name to its source-package names in + # OSV.dev-covered distro ecosystems so {Vulns::Match} can query those + # ecosystems' advisories. {.lookup} provides a live single-project API + # fallback for formulae the published index doesn't yet cover. + class Repology < CachedFeed + DATA_URL = "https://raw.githubusercontent.com/Homebrew/advisory-database/" \ + "main/data/repology.json" + + sig { override.returns(String) } + def self.data_url = DATA_URL + + sig { override.returns(String) } + def self.cache_filename = "repology.json" + + sig { override.returns(Integer) } + def self.default_max_age = 7 * 86_400 + + DistroMap = T.type_alias { T::Hash[String, T::Array[String]] } + + sig { params(name: String).returns(String) } + def self.base_name(name) = name.sub(/@.+\z/, "") + + sig { override.params(data: T.anything).void } + def initialize(data) + super + raise Error, "Repology index is not a JSON object" unless (top = as_hash(data)) + raise Error, "Repology index missing 'formulae' key" unless (formulae = as_hash(top["formulae"])) + + @formulae = T.let(formulae, T::Hash[String, T.untyped]) + @meta = T.let(as_hash(top["meta"]) || {}, T::Hash[String, T.untyped]) + end + + sig { returns(T::Hash[String, T.untyped]) } + attr_reader :meta + + sig { returns(T::Array[String]) } + def formulae + @formulae.keys + end + + # Returns `{osv_ecosystem => [srcname, ...]}` for `formula_name`, or an + # empty hash if the index has no entry. The index is keyed on the + # Homebrew formula name as Repology records it, so `@`-versioned + # variants (`postgresql@16`) are looked up under their base name too. + sig { params(formula_name: String).returns(DistroMap) } + def distro_packages_for(formula_name) + entry = @formulae[formula_name] || @formulae[self.class.base_name(formula_name)] + return {} unless entry.is_a?(Hash) + + entry.filter_map do |eco, names| + next unless eco.is_a?(String) + + list = Array(names).grep(String) + [eco, list.freeze] if list.any? + end.to_h.freeze + end + + API_BASE = "https://repology.org/api/v1" + USER_AGENT = "Homebrew/brew (dev-cmd/advisory-match; +https://github.com/Homebrew/brew)" + + # Repology repo-name prefix => `{ecosystem:, name_field:}`. Kept in step + # with `RepologyIndex::OSV_DISTROS` in Homebrew/advisory-database; only + # the fields {.distil} needs are duplicated here. + OSV_DISTROS = T.let( + { + "debian_" => { ecosystem: "Debian" }, + "ubuntu_" => { ecosystem: "Ubuntu" }, + "alpine_" => { ecosystem: "Alpine" }, + "opensuse_leap_" => { ecosystem: "openSUSE" }, + "opensuse_tumbleweed" => { ecosystem: "openSUSE" }, + "rocky_" => { ecosystem: "Rocky Linux" }, + "almalinux_" => { ecosystem: "AlmaLinux" }, + "mageia_" => { ecosystem: "Mageia" }, + "openeuler_" => { ecosystem: "openEuler" }, + "ubi_" => { ecosystem: "Red Hat" }, + "freebsd" => { ecosystem: "FreeBSD", name_field: "binname" }, + }.freeze, + T::Hash[String, { ecosystem: String, name_field: T.nilable(String) }], + ) + private_constant :OSV_DISTROS + + # Kept in step with `RepologyIndex::PREFERRED_STATUSES`. + PREFERRED_STATUSES = %w[newest outdated devel unique noscheme].freeze + private_constant :PREFERRED_STATUSES + + # Live single-project fallback for a formula the published index does + # not (yet) cover — typically a new formula in a homebrew-core PR before + # the next nightly index build. + # + # Fetches each project in {.name_candidates}, keeps those whose Homebrew + # entries include `formula_name` (or its `@`-stripped base) and don't + # group unrelated formulae, then applies the same preferred-status + # resolution as `RepologyIndex#resolve` across the survivors. This makes + # the fallback consistent with the published index for the projects it + # can reach; it cannot detect collisions with projects outside + # {.name_candidates} (e.g. `allegro4`), which only the full crawl sees. + sig { params(formula_name: String).returns(DistroMap) } + def self.lookup(formula_name) + base = base_name(formula_name) + exact = [] + by_base = [] + name_candidates(formula_name).each do |candidate| + entries = fetch_project(candidate) + next if entries.empty? + + brew = homebrew_entries(entries) + next if brew.keys.map { |n| base_name(n) }.uniq.size > 1 + + distros = distil(entries) + next if distros.empty? + + # A project listing both the exact and base names contributes to both + # pools, matching the producer's per-key contributions. + exact << { preferred: brew.fetch(formula_name), distros: } if brew.key?(formula_name) + by_base << { preferred: brew.fetch(base), distros: } if base != formula_name && brew.key?(base) + end + + # Resolve the exact-name pool first, mirroring + # `#distro_packages_for`'s `@formulae[name] || @formulae[base]` + # precedence over the producer's per-key resolved index. + resolve_contributions(exact) || resolve_contributions(by_base) || {} + end + + sig { + params(contributions: T::Array[{ preferred: T::Boolean, distros: DistroMap }]) + .returns(T.nilable(DistroMap)) + } + def self.resolve_contributions(contributions) + chosen = contributions.one? ? contributions : contributions.select { |c| c.fetch(:preferred) } + chosen.fetch(0).fetch(:distros) if chosen.one? + end + + sig { params(entries: T::Array[T::Hash[String, T.untyped]]).returns(T::Hash[String, T::Boolean]) } + def self.homebrew_entries(entries) + result = {} + entries.each do |e| + next if e["repo"] != "homebrew" + + name = (e["srcname"] || e["binname"]).to_s + next if name.empty? + + result[name] ||= false + result[name] = true if PREFERRED_STATUSES.include?(e["status"]) + end + result + end + + sig { params(formula_name: String).returns(T::Array[String]) } + def self.name_candidates(formula_name) + base = base_name(formula_name) + [ + formula_name, + base, + base.delete_prefix("lib"), + base.delete_prefix("gnu-"), + base.delete_suffix("2"), + ].uniq.reject(&:empty?) + end + + # Fetch one Repology project. A nonexistent project returns HTTP 200 with + # `[]`, so an empty array is the only "try next candidate" signal; + # transport failures, HTTP errors, malformed JSON and unexpected shapes + # all raise so callers don't mistake an outage for "no packages". + sig { params(project: String).returns(T::Array[T::Hash[String, T.untyped]]) } + def self.fetch_project(project) + result = Utils::Curl.curl_output( + "--fail", "--silent", "--location", + "--user-agent", USER_AGENT, + "#{API_BASE}/project/#{ERB::Util.url_encode(project)}" + ) + unless result.success? + raise Error, "Repology API request for #{project.inspect} failed " \ + "(curl exit #{result.exit_status}): #{result.stderr.strip}" + end + + parsed = JSON.parse(result.stdout) + if !parsed.is_a?(Array) || !parsed.all?(Hash) + raise Error, "Repology API returned unexpected shape for #{project.inspect}" + end + + parsed + rescue JSON::ParserError => e + raise Error, "Repology API returned invalid JSON for #{project.inspect}: #{e.message}" + end + + sig { params(entries: T::Array[T::Hash[String, T.untyped]]).returns(DistroMap) } + def self.distil(entries) + result = Hash.new { |h, k| h[k] = [] } + entries.each do |entry| + repo = entry["repo"] + next unless repo.is_a?(String) + + distro = OSV_DISTROS.find { |prefix, _| repo.start_with?(prefix) }&.last + next unless distro + next if entry["status"] == "legacy" + + name = entry[distro[:name_field] || "srcname"] || entry["binname"] + next unless name.is_a?(String) + + result[distro.fetch(:ecosystem)] << name + end + result.transform_values! { |names| names.uniq.sort.freeze } + result.default = nil + result.sort.to_h.freeze + end + end + end +end From 54412271a3116468dddf70733d4a1f04d43c953b Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 27 Jul 2026 22:49:47 +0100 Subject: [PATCH 07/28] vulns/repology: delegate fetch_project to shared ::Repology client utils/repology.rb gains an API_BASE constant, URL-encodes the project name and pagination cursor, passes --fail, and raises with the curl exit status on HTTP failure (still rescued to nil for brew bump, but the detail now reaches the debug log instead of surfacing as a JSON parse error on an HTML error body). Vulns::Repology.fetch_project calls ::Repology.single_package_query and raises Error on nil, dropping its duplicate curl invocation, API_BASE and USER_AGENT constants. curl_args already sends HOMEBREW_USER_AGENT_CURL so the custom UA was redundant. --- Library/Homebrew/test/utils/repology_spec.rb | 49 +++++++++++++++++++- Library/Homebrew/test/vulns/repology_spec.rb | 44 ++++-------------- Library/Homebrew/utils/repology.rb | 12 +++-- Library/Homebrew/vulns/repology.rb | 23 +++------ 4 files changed, 71 insertions(+), 57 deletions(-) diff --git a/Library/Homebrew/test/utils/repology_spec.rb b/Library/Homebrew/test/utils/repology_spec.rb index 97c22e8864f0a..c6933dac582ad 100644 --- a/Library/Homebrew/test/utils/repology_spec.rb +++ b/Library/Homebrew/test/utils/repology_spec.rb @@ -1,4 +1,51 @@ -# typed: strict +# typed: false # frozen_string_literal: true require "utils/repology" + +RSpec.describe Repology do + before do + allow(Utils::Curl).to receive(:curl_supports_tls13?).and_return(true) + allow(Homebrew::EnvConfig).to receive(:developer?).and_return(false) + end + + describe ".single_package_query" do + def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) + instance_double(SystemCommand::Result, success?: success, stdout:, stderr:, exit_status:) + end + + it "URL-encodes the project name and passes --fail" do + expect(Utils::Curl).to receive(:curl_output) do |*args, **| + expect(args).to include("--fail", "#{described_class::API_BASE}/project/gtk%2B3") + stub_curl(success: true, stdout: "[]") + end + expect(described_class.single_package_query("gtk+3", repository: described_class::HOMEBREW_CORE)) + .to eq({ "gtk+3" => [] }) + end + + it "returns nil (rather than raising) on HTTP failure" do + allow(Utils::Curl).to receive(:curl_output).and_return( + stub_curl(success: false, exit_status: 22, stderr: "The requested URL returned error: 503"), + ) + expect(described_class.single_package_query("curl", repository: described_class::HOMEBREW_CORE)) + .to be_nil + end + + it "returns nil on invalid JSON" do + allow(Utils::Curl).to receive(:curl_output).and_return(stub_curl(success: true, stdout: "not json")) + expect(described_class.single_package_query("curl", repository: described_class::HOMEBREW_CORE)) + .to be_nil + end + end + + describe ".query_api" do + it "URL-encodes the pagination cursor" do + expect(Utils::Curl).to receive(:curl_output) do |*args, **| + expect(args.last).to eq "#{described_class::API_BASE}/projects/gtk%2B3/" \ + "?inrepo=#{described_class::HOMEBREW_CORE}&outdated=1" + instance_double(SystemCommand::Result, stdout: "{}") + end + described_class.query_api("gtk+3", repository: described_class::HOMEBREW_CORE) + end + end +end diff --git a/Library/Homebrew/test/vulns/repology_spec.rb b/Library/Homebrew/test/vulns/repology_spec.rb index 20d9bf195ffea..db9f80bd7309a 100644 --- a/Library/Homebrew/test/vulns/repology_spec.rb +++ b/Library/Homebrew/test/vulns/repology_spec.rb @@ -246,50 +246,26 @@ def project(homebrew:, distros:, status: "newest") end describe ".fetch_project" do - it "returns the parsed array on success" do - body = '[{"repo":"debian_12","srcname":"curl"}]' - allow(Utils::Curl).to receive(:curl_output).and_return( - instance_double(SystemCommand::Result, success?: true, stdout: body), - ) - expect(described_class.fetch_project("curl")).to eq [{ "repo" => "debian_12", "srcname" => "curl" }] - end - - it "sends a User-Agent header (Repology rejects requests without one)" do - expect(Utils::Curl).to receive(:curl_output) do |*args| - expect(args).to include("--user-agent", described_class::USER_AGENT) - instance_double(SystemCommand::Result, success?: true, stdout: "[]") - end - described_class.fetch_project("curl") + it "returns the entries array from ::Repology.single_package_query" do + entries = [{ "repo" => "debian_12", "srcname" => "curl" }] + allow(Repology).to receive(:single_package_query) + .with("curl", repository: Repology::HOMEBREW_CORE).and_return({ "curl" => entries }) + expect(described_class.fetch_project("curl")).to eq entries end it "returns [] for a nonexistent project (HTTP 200 with empty array)" do - allow(Utils::Curl).to receive(:curl_output).and_return( - instance_double(SystemCommand::Result, success?: true, stdout: "[]"), - ) + allow(Repology).to receive(:single_package_query).and_return({ "no-such" => [] }) expect(described_class.fetch_project("no-such")).to eq [] end - it "raises Error on curl failure" do - allow(Utils::Curl).to receive(:curl_output).and_return( - instance_double(SystemCommand::Result, success?: false, exit_status: 22, - stderr: "The requested URL returned error: 503"), - ) + it "raises Error when the underlying query fails (returns nil)" do + allow(Repology).to receive(:single_package_query).and_return(nil) expect { described_class.fetch_project("curl") } - .to raise_error(described_class::Error, /curl exit 22.*503/) - end - - it "raises Error on invalid JSON" do - allow(Utils::Curl).to receive(:curl_output).and_return( - instance_double(SystemCommand::Result, success?: true, stdout: "not json"), - ) - expect { described_class.fetch_project("curl") } - .to raise_error(described_class::Error, /invalid JSON/) + .to raise_error(described_class::Error, /request for "curl" failed/) end it "raises Error on an unexpected response shape" do - allow(Utils::Curl).to receive(:curl_output).and_return( - instance_double(SystemCommand::Result, success?: true, stdout: '{"oops":true}'), - ) + allow(Repology).to receive(:single_package_query).and_return({ "curl" => { "oops" => true } }) expect { described_class.fetch_project("curl") } .to raise_error(described_class::Error, /unexpected shape/) end diff --git a/Library/Homebrew/utils/repology.rb b/Library/Homebrew/utils/repology.rb index 0cb63b9cfb3fb..4ca00867016fb 100644 --- a/Library/Homebrew/utils/repology.rb +++ b/Library/Homebrew/utils/repology.rb @@ -8,16 +8,17 @@ module Repology extend Utils::Output::Mixin + API_BASE = "https://repology.org/api/v1" HOMEBREW_CORE = "homebrew" HOMEBREW_CASK = "homebrew_casks" sig { params(last_package_in_response: T.nilable(String), repository: String).returns(T::Hash[String, T.untyped]) } def self.query_api(last_package_in_response = "", repository:) - last_package_in_response += "/" if last_package_in_response.present? - url = "https://repology.org/api/v1/projects/#{last_package_in_response}?inrepo=#{repository}&outdated=1" + cursor = last_package_in_response.present? ? "#{ERB::Util.url_encode(last_package_in_response)}/" : "" + url = "#{API_BASE}/projects/#{cursor}?inrepo=#{repository}&outdated=1" result = Utils::Curl.curl_output( - "--silent", url.to_s, + "--fail", "--silent", url, use_homebrew_curl: !Utils::Curl.curl_supports_tls13? ) JSON.parse(result.stdout) @@ -33,12 +34,13 @@ def self.query_api(last_package_in_response = "", repository:) sig { params(name: String, repository: String).returns(T.nilable(T::Hash[String, T.untyped])) } def self.single_package_query(name, repository:) - url = "https://repology.org/api/v1/project/#{name}" + url = "#{API_BASE}/project/#{ERB::Util.url_encode(name)}" result = Utils::Curl.curl_output( - "--location", "--silent", url.to_s, + "--fail", "--location", "--silent", url, use_homebrew_curl: !Utils::Curl.curl_supports_tls13? ) + raise "curl exit #{result.exit_status}: #{result.stderr.strip}" unless result.success? data = JSON.parse(result.stdout) { name => data } diff --git a/Library/Homebrew/vulns/repology.rb b/Library/Homebrew/vulns/repology.rb index 63098be586a4d..0d5620e1100b3 100644 --- a/Library/Homebrew/vulns/repology.rb +++ b/Library/Homebrew/vulns/repology.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require "utils/repology" require "vulns/cached_feed" module Homebrew @@ -66,9 +67,6 @@ def distro_packages_for(formula_name) end.to_h.freeze end - API_BASE = "https://repology.org/api/v1" - USER_AGENT = "Homebrew/brew (dev-cmd/advisory-match; +https://github.com/Homebrew/brew)" - # Repology repo-name prefix => `{ecosystem:, name_field:}`. Kept in step # with `RepologyIndex::OSV_DISTROS` in Homebrew/advisory-database; only # the fields {.distil} needs are duplicated here. @@ -174,24 +172,15 @@ def self.name_candidates(formula_name) # all raise so callers don't mistake an outage for "no packages". sig { params(project: String).returns(T::Array[T::Hash[String, T.untyped]]) } def self.fetch_project(project) - result = Utils::Curl.curl_output( - "--fail", "--silent", "--location", - "--user-agent", USER_AGENT, - "#{API_BASE}/project/#{ERB::Util.url_encode(project)}" - ) - unless result.success? - raise Error, "Repology API request for #{project.inspect} failed " \ - "(curl exit #{result.exit_status}): #{result.stderr.strip}" - end + result = ::Repology.single_package_query(project, repository: ::Repology::HOMEBREW_CORE) + raise Error, "Repology API request for #{project.inspect} failed" if result.nil? - parsed = JSON.parse(result.stdout) - if !parsed.is_a?(Array) || !parsed.all?(Hash) + entries = result.fetch(project) + if !entries.is_a?(Array) || !entries.all?(Hash) raise Error, "Repology API returned unexpected shape for #{project.inspect}" end - parsed - rescue JSON::ParserError => e - raise Error, "Repology API returned invalid JSON for #{project.inspect}: #{e.message}" + entries end sig { params(entries: T::Array[T::Hash[String, T.untyped]]).returns(DistroMap) } From e3a61648bbab93b3af5c78b83c649a31143eff11 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 27 Jul 2026 23:00:09 +0100 Subject: [PATCH 08/28] vulns/osv: generalise query_batch to arbitrary ecosystems query_batch now takes {ecosystem:, name:, version:} with version nilable (a nil version queries all known vulnerabilities for the package). Scanner builds the GIT-ecosystem shape itself. This lets Vulns::Match issue PyPI/ npm/Debian/etc. queries through the same batching and pagination path. --- Library/Homebrew/test/vulns/osv_spec.rb | 19 ++++++++++++------- Library/Homebrew/test/vulns/scanner_spec.rb | 10 +++++----- Library/Homebrew/vulns/osv.rb | 16 ++++++++-------- Library/Homebrew/vulns/scanner.rb | 2 +- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Library/Homebrew/test/vulns/osv_spec.rb b/Library/Homebrew/test/vulns/osv_spec.rb index 2c07a93cde228..a9175b6687e18 100644 --- a/Library/Homebrew/test/vulns/osv_spec.rb +++ b/Library/Homebrew/test/vulns/osv_spec.rb @@ -15,9 +15,9 @@ def stub_curl(*results) describe ".query_batch" do let(:packages) do [ - { repo_url: "https://github.com/a/a", version: "v1" }, - { repo_url: "https://github.com/b/b", version: "v2" }, - { repo_url: "https://github.com/c/c", version: "v3" }, + { ecosystem: "GIT", name: "https://github.com/a/a", version: "v1" }, + { ecosystem: "GIT", name: "https://github.com/b/b", version: "v2" }, + { ecosystem: "GIT", name: "https://github.com/c/c", version: "v3" }, ] end @@ -39,7 +39,12 @@ def stub_curl(*results) expect(results[2].map { |v| v["id"] }).to eq ["CVE-2024-2222", "CVE-2024-3333"] end - it "posts each package as a GIT-ecosystem query" do + it "posts each package under its given ecosystem, omitting version when nil" do + mixed = [ + { ecosystem: "GIT", name: "https://github.com/a/a", version: "v1" }, + { ecosystem: "PyPI", name: "requests", version: "2.31.0" }, + { ecosystem: "Debian", name: "curl", version: nil }, + ] posted = nil expect(Utils::Curl).to receive(:curl_output) do |*args| expect(args.last).to eq "https://api.osv.dev/v1/querybatch" @@ -47,12 +52,12 @@ def stub_curl(*results) curl_result(stdout: { results: [{}, {}, {}] }.to_json) end - described_class.query_batch(packages) + described_class.query_batch(mixed) expect(posted["queries"]).to eq [ { "package" => { "name" => "https://github.com/a/a", "ecosystem" => "GIT" }, "version" => "v1" }, - { "package" => { "name" => "https://github.com/b/b", "ecosystem" => "GIT" }, "version" => "v2" }, - { "package" => { "name" => "https://github.com/c/c", "ecosystem" => "GIT" }, "version" => "v3" }, + { "package" => { "name" => "requests", "ecosystem" => "PyPI" }, "version" => "2.31.0" }, + { "package" => { "name" => "curl", "ecosystem" => "Debian" } }, ] end diff --git a/Library/Homebrew/test/vulns/scanner_spec.rb b/Library/Homebrew/test/vulns/scanner_spec.rb index f0134b7df1610..6c33169b60dc4 100644 --- a/Library/Homebrew/test/vulns/scanner_spec.rb +++ b/Library/Homebrew/test/vulns/scanner_spec.rb @@ -256,7 +256,7 @@ def osv_record(id, severity: "HIGH", **extra) it "skips formulae without a queryable repo URL and tag" do allow(Homebrew::Vulns::OSV).to receive(:query_batch).with( - [{ repo_url: "https://github.com/nektos/act", version: "v0.2.84" }], + [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.84" }], ).and_return([[]]) results = described_class.new([act, unsupported]).scan @@ -364,8 +364,8 @@ def osv_record(id, severity: "HIGH", **extra) described_class.new([core_thing, tap_thing]).scan expect(queried).to eq [ - { repo_url: "https://github.com/owner-a/thing", version: "v1.0.0" }, - { repo_url: "https://github.com/owner-b/thing", version: "v2.0.0" }, + { ecosystem: "GIT", name: "https://github.com/owner-a/thing", version: "v1.0.0" }, + { ecosystem: "GIT", name: "https://github.com/owner-b/thing", version: "v2.0.0" }, ] end @@ -451,7 +451,7 @@ def osv_record(id, severity: "HIGH", **extra) described_class.new([act]).scan - expect(queried).to eq [{ repo_url: "https://github.com/nektos/act", version: "v0.2.80" }] + expect(queried).to eq [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.80" }] end it "reports the installed version in findings" do @@ -487,7 +487,7 @@ def osv_record(id, severity: "HIGH", **extra) results = described_class.new([act]).scan - expect(queried).to eq [{ repo_url: "https://github.com/nektos/act", version: "v0.2.84" }] + expect(queried).to eq [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.84" }] expect(results.outdated_without_sbom).to eq ["act"] end end diff --git a/Library/Homebrew/vulns/osv.rb b/Library/Homebrew/vulns/osv.rb index b022fda701748..0480337c3a597 100644 --- a/Library/Homebrew/vulns/osv.rb +++ b/Library/Homebrew/vulns/osv.rb @@ -15,12 +15,12 @@ module OSV class Error < RuntimeError; end class ApiError < Error; end + Package = T.type_alias { { ecosystem: String, name: String, version: T.nilable(String) } } + # POST /v1/querybatch. Returns one array of vuln hashes per input package, # in the same order. Follows per-result `next_page_token` continuations. - sig { - params(packages: T::Array[{ repo_url: String, version: String }]) - .returns(T::Array[T::Array[T::Hash[String, T.untyped]]]) - } + # A `nil` version queries all known vulnerabilities for the package. + sig { params(packages: T::Array[Package]).returns(T::Array[T::Array[T::Hash[String, T.untyped]]]) } def self.query_batch(packages) return [] if packages.empty? @@ -29,10 +29,10 @@ def self.query_batch(packages) packages.each_slice(BATCH_SIZE).with_index do |batch, batch_index| offset = batch_index * BATCH_SIZE pending = batch.map.with_index do |pkg, index| - { - slot: offset + index, - query: { package: { name: pkg.fetch(:repo_url), ecosystem: "GIT" }, version: pkg.fetch(:version) }, - } + query = T.let({ package: { name: pkg.fetch(:name), ecosystem: pkg.fetch(:ecosystem) } }, + T::Hash[Symbol, T.untyped]) + query[:version] = pkg.fetch(:version) if pkg.fetch(:version) + { slot: offset + index, query: } end page = 0 diff --git a/Library/Homebrew/vulns/scanner.rb b/Library/Homebrew/vulns/scanner.rb index ecb429623f225..4f016d6b2dadb 100644 --- a/Library/Homebrew/vulns/scanner.rb +++ b/Library/Homebrew/vulns/scanner.rb @@ -112,7 +112,7 @@ def scan end targets = queryable.map { |f| T.must(target_for(f)) } - batch = OSV.query_batch(targets.map { |t| { repo_url: t.repo_url, version: t.tag } }) + batch = OSV.query_batch(targets.map { |t| { ecosystem: "GIT", name: t.repo_url, version: t.tag } }) findings = queryable.each_with_index.filter_map do |formula, index| target = targets.fetch(index) From 6dff42170862851440f3db4a1c0d6fa9149523d5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 27 Jul 2026 23:56:52 +0100 Subject: [PATCH 09/28] vulns: add Match with identify and advisories_for Match#identify composes Identify (git repo/tag, registry package for the primary URL and each resource) with the Repology index (distro source package names, live lookup fallback) into a single Identity per formula. Match#advisories_for builds one OSV querybatch across GIT, language- registry and versionless distro-ecosystem queries, routes CPAN packages through CPANSec, fetches full records once per id (cached across calls), and collapses hits sharing a CVE alias into one Hit whose strategy is the highest-precision path that reached it, keeping every path as evidence. Repology and CPANSec feeds are loaded once per Match instance and are injectable for tests. This is authoring-time code for advisory-database CI and the homebrew-core PR bot; it never runs on a user's machine. --- Library/Homebrew/test/vulns/match_spec.rb | 257 ++++++++++++++++++++++ Library/Homebrew/vulns/match.rb | 236 ++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 Library/Homebrew/test/vulns/match_spec.rb create mode 100644 Library/Homebrew/vulns/match.rb diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb new file mode 100644 index 0000000000000..8f873fde5da53 --- /dev/null +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -0,0 +1,257 @@ +# typed: false +# frozen_string_literal: true + +require "vulns/match" + +RSpec.describe Homebrew::Vulns::Match do + let(:repology) do + Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => { + "requests" => { "Debian" => ["requests"], "Alpine" => ["py3-requests"] }, + } }) + end + let(:cpan_sec) do + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { + "Image-ExifTool" => { "advisories" => [ + { "id" => "CPANSA-Image-ExifTool-2021-22204", "cves" => ["CVE-2021-22204"], + "affected_versions" => ["<12.24"], "fixed_versions" => ["12.24"] }, + ] }, + } }) + end + let(:matcher) { described_class.new(repology:, cpan_sec:) } + + def stub_repology_lookup(result = {}) + allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return(result) + end + + describe "#identify" do + it "derives git repo/tag, primary registry package, resources and distro packages" do + f = formula("requests") do + T.bind(self, T.class_of(Formula)) + homepage "https://requests.readthedocs.io" + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + head "https://github.com/psf/requests.git" + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz" + end + resource "vendored-c" do + url "https://example.com/blob-1.0.tar.gz" + end + end + + identity = matcher.identify(f) + + expect(identity.git_repo).to eq "https://github.com/psf/requests" + expect(identity.git_tag).to eq "2.31.0" + expect(identity.primary_package.ecosystem).to eq "PyPI" + expect(identity.primary_package.name).to eq "requests" + expect(identity.primary_package.version).to eq "2.31.0" + expect(identity.resource_packages.keys).to eq ["certifi"] + expect(identity.resource_packages["certifi"].purl).to eq "pkg:pypi/certifi@2024.2.2" + expect(identity.distro_packages) + .to eq("Debian" => ["requests"], "Alpine" => ["py3-requests"]) + expect(identity.any?).to be true + end + + it "falls back to Repology.lookup when the index has no entry" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + stub_repology_lookup({ "Debian" => ["newthing"] }) + + expect(matcher.identify(f).distro_packages).to eq("Debian" => ["newthing"]) + end + + it "swallows a Repology lookup error to an empty distro map" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + allow(Homebrew::Vulns::Repology).to receive(:lookup) + .and_raise(Homebrew::Vulns::CachedFeed::Error, "boom") + + expect(matcher.identify(f).distro_packages).to eq({}) + end + + it "reports any? false when nothing is derivable" do + f = formula("mystery") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/mystery-1.0.tar.gz" + end + stub_repology_lookup + + expect(matcher.identify(f).any?).to be false + end + end + + describe "#build_osv_queries" do + def pkg(ecosystem:, name:, version:, purl:) + Homebrew::Vulns::Identify::RegistryPackage.new(ecosystem:, name:, version:, purl:) + end + + it "emits GIT, registry (primary + resource) and distro queries with matching evidence" do + identity = described_class::Identity.new( + git_repo: "https://github.com/psf/requests", + git_tag: "v2.31.0", + primary_package: pkg(ecosystem: "PyPI", name: "requests", version: "2.31.0", + purl: "pkg:pypi/requests@2.31.0"), + resource_packages: { "certifi" => pkg(ecosystem: "PyPI", name: "certifi", version: "2024.2.2", + purl: "pkg:pypi/certifi@2024.2.2") }, + distro_packages: { "Debian" => ["requests"], "Alpine" => ["py3-requests"] }, + ) + + queries = matcher.build_osv_queries(identity) + + expect(queries.map(&:first)).to eq [ + { ecosystem: "GIT", name: "https://github.com/psf/requests", version: "v2.31.0" }, + { ecosystem: "PyPI", name: "requests", version: "2.31.0" }, + { ecosystem: "PyPI", name: "certifi", version: "2024.2.2" }, + { ecosystem: "Debian", name: "requests", version: nil }, + { ecosystem: "Alpine", name: "py3-requests", version: nil }, + ] + expect(queries.map { |_, e| [e.strategy, e.key, e.resource] }).to eq [ + [:git, "https://github.com/psf/requests", nil], + [:registry, "pkg:pypi/requests@2.31.0", nil], + [:registry, "pkg:pypi/certifi@2024.2.2", "certifi"], + [:distro, "Debian/requests", nil], + [:distro, "Alpine/py3-requests", nil], + ] + end + + it "excludes CPAN packages from OSV queries and omits GIT when no repo derived" do + identity = described_class::Identity.new( + git_repo: nil, + git_tag: "13.55", + primary_package: pkg(ecosystem: "CPAN", name: "Image-ExifTool", version: "13.55", + purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@13.55"), + resource_packages: { "extra" => pkg(ecosystem: "CPAN", name: "Try-Tiny", version: "0.31", + purl: "pkg:cpan/ETHER/Try-Tiny@0.31") }, + distro_packages: {}, + ) + + expect(matcher.build_osv_queries(identity)).to eq [] + end + end + + describe "#cpan_advisory_ids" do + it "returns CVE ids for CPAN primary and resource packages via CPANSec" do + identity = described_class::Identity.new( + git_repo: nil, git_tag: nil, + primary_package: Homebrew::Vulns::Identify::RegistryPackage.new( + ecosystem: "CPAN", name: "Image-ExifTool", version: "12.00", + purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@12.00" + ), + resource_packages: {}, distro_packages: {} + ) + + ids = matcher.cpan_advisory_ids(identity) + + expect(ids.map(&:first)).to eq ["CVE-2021-22204"] + expect(ids.first.last.strategy).to eq :cpansa + end + end + + describe "#advisories_for" do + let(:exiftool) do + formula("exiftool") do + T.bind(self, T.class_of(Formula)) + url "https://cpan.metacpan.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-12.00.tar.gz" + head "https://github.com/exiftool/exiftool.git" + end + end + + before { stub_repology_lookup({ "Debian" => ["libimage-exiftool-perl"] }) } + + it "queries every strategy in one batch, fetches full records, and dedups by CVE alias" do + expect(Homebrew::Vulns::OSV).to receive(:query_batch).with( + [ + { ecosystem: "GIT", name: "https://github.com/exiftool/exiftool", version: "12.00" }, + { ecosystem: "Debian", name: "libimage-exiftool-perl", version: nil }, + ], + ).and_return( + [ + [{ "id" => "CVE-2021-22204" }], + [{ "id" => "DSA-4910-1" }, { "id" => "DSA-0000-0" }], + ], + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-22204").and_return( + { "id" => "CVE-2021-22204", "aliases" => ["GHSA-xxxx-yyyy-zzzz"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-4910-1").and_return( + { "id" => "DSA-4910-1", "aliases" => ["CVE-2021-22204"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-0000-0").and_return( + { "id" => "DSA-0000-0", "aliases" => [] }, + ) + + hits = matcher.advisories_for(exiftool) + + expect(hits.map(&:canonical_id).sort).to eq ["CVE-2021-22204", "DSA-0000-0"] + merged = hits.find { |h| h.canonical_id == "CVE-2021-22204" } + expect(merged.strategy).to eq :git + expect(merged.evidence.map(&:strategy)).to eq [:git, :cpansa, :distro] + expect(merged.vulnerability.id).to eq "CVE-2021-22204" + expect(hits.find { |h| h.canonical_id == "DSA-0000-0" }.strategy).to eq :distro + end + + it "drops ids whose full record cannot be fetched" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return( + [[{ "id" => "CVE-9999-0000" }], []], + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability) + .and_raise(Homebrew::Vulns::OSV::ApiError, "404") + + expect(matcher.advisories_for(exiftool)).to eq [] + end + + it "returns [] without hitting OSV when nothing is identifiable" do + f = formula("mystery") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/mystery-1.0.tar.gz" + end + stub_repology_lookup + expect(Homebrew::Vulns::OSV).not_to receive(:query_batch) + + expect(matcher.advisories_for(f)).to eq [] + end + + it "caches OSV.vulnerability lookups across calls" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch) + .and_return([[{ "id" => "CVE-2021-22204" }], []]) + expect(Homebrew::Vulns::OSV).to receive(:vulnerability).once + .and_return({ "id" => "CVE-2021-22204" }) + + matcher.advisories_for(exiftool) + matcher.advisories_for(exiftool) + end + end + + describe described_class::Hit do + def vuln(id, aliases: []) + Homebrew::Vulns::Vulnerability.new({ "id" => id, "aliases" => aliases }) + end + + def ev(strategy, key: "k") + Homebrew::Vulns::Match::Evidence.new(strategy:, key:) + end + + it "sorts evidence by descending strategy precision and reports the highest as #strategy" do + hit = described_class.new(vulnerability: vuln("CVE-1"), + evidence: [ev(:distro), ev(:git), ev(:registry)]) + expect(hit.evidence.map(&:strategy)).to eq [:git, :registry, :distro] + expect(hit.strategy).to eq :git + end + + it "uses the lowest CVE alias as canonical_id, or the record id when there is none" do + expect(described_class.new(vulnerability: vuln("GHSA-x", aliases: ["CVE-2024-2", "CVE-2024-1"]), + evidence: [ev(:git)]).canonical_id).to eq "CVE-2024-1" + expect(described_class.new(vulnerability: vuln("GHSA-y"), + evidence: [ev(:git)]).canonical_id).to eq "GHSA-y" + end + + it "rejects empty evidence" do + expect { described_class.new(vulnerability: vuln("CVE-1"), evidence: []) } + .to raise_error(ArgumentError) + end + end +end diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb new file mode 100644 index 0000000000000..d47870abfe486 --- /dev/null +++ b/Library/Homebrew/vulns/match.rb @@ -0,0 +1,236 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/cpan_sec" +require "vulns/identify" +require "vulns/osv" +require "vulns/repology" +require "vulns/vulnerability" + +module Homebrew + module Vulns + # Authoring-time advisory matcher. For a given {Formula} it derives every + # OSV.dev query key it can (forge repository, language-registry package for + # the primary URL and each `resource`, distro source packages via + # {Repology}, CPAN distribution via {CPANSec}) and returns the deduplicated + # set of vulnerabilities any of them hit, tagged with the strategy that + # reached each one. + # + # Runs in `Homebrew/advisory-database` CI and the homebrew-core PR bot to + # produce candidate `BREW-*` records for human review; never on a user's + # machine, so request volume and false-positive rate are traded for recall. + class Match + include Utils::Output::Mixin + + # Descending precision. When several strategies reach the same CVE the + # highest is reported as the hit's primary strategy; the rest are kept as + # supporting evidence. + STRATEGY_PRECISION = T.let( + { git: 4, registry: 3, cpansa: 2, distro: 1 }.freeze, + T::Hash[Symbol, Integer], + ) + + Identity = Struct.new( + :git_repo, :git_tag, :primary_package, :resource_packages, :distro_packages, + keyword_init: true + ) do + extend T::Sig + + sig { returns(T::Boolean) } + def any? + !git_repo.nil? || !primary_package.nil? || resource_packages.any? || distro_packages.any? + end + end + + Evidence = Struct.new(:strategy, :key, :resource, keyword_init: true) + + class Hit + sig { returns(Vulnerability) } + attr_reader :vulnerability + + sig { returns(T::Array[Evidence]) } + attr_reader :evidence + + sig { params(vulnerability: Vulnerability, evidence: T::Array[Evidence]).void } + def initialize(vulnerability:, evidence:) + raise ArgumentError, "Hit requires at least one Evidence" if evidence.empty? + + @vulnerability = T.let(vulnerability, Vulnerability) + @evidence = T.let( + evidence.sort_by { |e| -STRATEGY_PRECISION.fetch(e.strategy) }.freeze, + T::Array[Evidence], + ) + end + + sig { returns(Symbol) } + def strategy + evidence.fetch(0).strategy + end + + sig { returns(T.nilable(String)) } + def resource + evidence.fetch(0).resource + end + + sig { returns(String) } + def canonical_id + vulnerability.cve_ids.min || vulnerability.id + end + end + + sig { params(repology: T.nilable(Repology), cpan_sec: T.nilable(CPANSec)).void } + def initialize(repology: nil, cpan_sec: nil) + @repology = T.let(repology, T.nilable(Repology)) + @cpan_sec = T.let(cpan_sec, T.nilable(CPANSec)) + @vuln_cache = T.let({}, T::Hash[String, T.nilable(T::Hash[String, T.untyped])]) + end + + sig { returns(Repology) } + def repology + @repology ||= Repology.load + end + + sig { returns(CPANSec) } + def cpan_sec + @cpan_sec ||= CPANSec.load + end + + sig { params(formula: Formula).returns(Identity) } + def identify(formula) + stable = formula.stable + stable_url = stable&.url + Identity.new( + git_repo: Identify.repo_url(stable_url, formula.head&.url, formula.homepage), + git_tag: Identify.tag(stable_url) || stable&.specs&.dig(:tag) || stable&.version&.to_s, + primary_package: Identify.registry_package(stable_url), + resource_packages: formula.resources.filter_map do |r| + pkg = Identify.registry_package(r.url) + [r.name, pkg] if pkg + end.to_h.freeze, + distro_packages: distro_packages_for(formula.name), + ).freeze + end + + # Returns one {Hit} per distinct vulnerability (grouped by CVE alias) + # reached by any strategy. Each hit's `evidence` lists every path that + # reached it, highest-precision first. + sig { params(formula: Formula).returns(T::Array[Hit]) } + def advisories_for(formula) + identity = identify(formula) + return [] unless identity.any? + + labelled = build_osv_queries(identity) + id_evidence = T.let({}, T::Hash[String, T::Array[Evidence]]) + + if labelled.any? + OSV.query_batch(labelled.map(&:first)).each_with_index do |stubs, i| + evidence = labelled.fetch(i).last + stubs.each { |stub| (id_evidence[stub.fetch("id")] ||= []) << evidence } + end + end + + cpan_advisory_ids(identity).each { |id, evidence| (id_evidence[id] ||= []) << evidence } + + hits = id_evidence.filter_map do |id, evidence| + record = fetch_vulnerability(id) + Hit.new(vulnerability: Vulnerability.new(record), evidence:) if record + end + + dedup_by_cve(hits) + end + + sig { params(identity: Identity).returns(T::Array[[OSV::Package, Evidence]]) } + def build_osv_queries(identity) + queries = T.let([], T::Array[[OSV::Package, Evidence]]) + + if (repo = identity.git_repo) && (tag = identity.git_tag) + queries << [{ ecosystem: "GIT", name: repo, version: tag }, + Evidence.new(strategy: :git, key: repo).freeze] + end + + if (pkg = identity.primary_package) && pkg.ecosystem != "CPAN" + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: pkg.version }, + Evidence.new(strategy: :registry, key: pkg.purl).freeze] + end + + identity.resource_packages.each do |resource, pkg| + next if pkg.ecosystem == "CPAN" + + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: pkg.version }, + Evidence.new(strategy: :registry, key: pkg.purl, resource:).freeze] + end + + identity.distro_packages.each do |ecosystem, srcnames| + srcnames.each do |srcname| + queries << [{ ecosystem:, name: srcname, version: nil }, + Evidence.new(strategy: :distro, key: "#{ecosystem}/#{srcname}").freeze] + end + end + + queries + end + + sig { params(identity: Identity).returns(T::Array[[String, Evidence]]) } + def cpan_advisory_ids(identity) + result = T.let([], T::Array[[String, Evidence]]) + cpan_packages(identity).each do |pkg, resource| + evidence = Evidence.new(strategy: :cpansa, key: pkg.purl, resource:).freeze + cpan_sec.advisories_for(pkg.name).each do |adv| + ids = adv.cves.presence || [adv.id.to_s] + ids.each { |id| result << [id, evidence] } + end + end + result + end + + sig { + params(identity: Identity).returns(T::Array[[Identify::RegistryPackage, T.nilable(String)]]) + } + def cpan_packages(identity) + result = T.let([], T::Array[[Identify::RegistryPackage, T.nilable(String)]]) + primary = identity.primary_package + result << [primary, nil] if primary&.ecosystem == "CPAN" + identity.resource_packages.each do |resource, pkg| + result << [pkg, resource] if pkg.ecosystem == "CPAN" + end + result + end + + sig { params(name: String).returns(Repology::DistroMap) } + def distro_packages_for(name) + indexed = repology.distro_packages_for(name) + return indexed if indexed.any? + + Repology.lookup(name) + rescue CachedFeed::Error => e + odebug "Repology lookup for #{name} failed: #{e.message}" + {} + end + + # OSV `querybatch` returns id/modified stubs; the full record is fetched + # once per id and cached across formulae. + sig { params(id: String).returns(T.nilable(T::Hash[String, T.untyped])) } + def fetch_vulnerability(id) + @vuln_cache.fetch(id) do + @vuln_cache[id] = begin + OSV.vulnerability(id) + rescue OSV::Error => e + odebug "OSV.vulnerability(#{id}) failed: #{e.message}" + nil + end + end + end + + sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } + def dedup_by_cve(hits) + hits.group_by(&:canonical_id).map do |_, group| + next group.fetch(0) if group.one? + + primary = T.must(group.max_by { |h| STRATEGY_PRECISION.fetch(h.strategy) }) + Hit.new(vulnerability: primary.vulnerability, + evidence: group.flat_map(&:evidence).uniq) + end + end + end + end +end From 7edb93f677283c9a56db367c08f8e95858df36ab Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 00:12:58 +0100 Subject: [PATCH 10/28] vulns/match: add to_brew_record and first_fixed_version Match#to_brew_record emits a candidate BREW-* OSV hash for a (formula, Hit) pair with database_specific: {source: matched, strategy, confidence, upstream_evidence}. When the current formula (or resource, for a resource hit) version is at or past the lowest comparable upstream fixed version, the record carries {fixed: pkg_version} and ecosystem_specific.fix: bump; otherwise no fixed event and fix: null. Resource hits record the resource name and derived purl in ecosystem_specific. Distro-strategy fixed versions (1:x.y-z, +dfsg-n) are not compared. Match#first_fixed_version walks FormulaVersions history newest-first and returns the pkg_version at the oldest revision where the subject version was still at or past the threshold, caching the rev-list and per-revision loads per formula. The dev-cmd passes this as first_fixed for new records; OsvExport.merge_existing preserves hand-corrected ranges thereafter. --- Library/Homebrew/test/vulns/match_spec.rb | 210 +++++++++++++++++++++- Library/Homebrew/vulns/match.rb | 161 ++++++++++++++++- 2 files changed, 360 insertions(+), 11 deletions(-) diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 8f873fde5da53..80e72f8659ca0 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -49,7 +49,7 @@ def stub_repology_lookup(result = {}) expect(identity.resource_packages["certifi"].purl).to eq "pkg:pypi/certifi@2024.2.2" expect(identity.distro_packages) .to eq("Debian" => ["requests"], "Alpine" => ["py3-requests"]) - expect(identity.any?).to be true + expect(identity.identifiable?).to be true end it "falls back to Repology.lookup when the index has no entry" do @@ -73,14 +73,14 @@ def stub_repology_lookup(result = {}) expect(matcher.identify(f).distro_packages).to eq({}) end - it "reports any? false when nothing is derivable" do + it "reports identifiable? false when nothing is derivable" do f = formula("mystery") do T.bind(self, T.class_of(Formula)) url "https://example.com/mystery-1.0.tar.gz" end stub_repology_lookup - expect(matcher.identify(f).any?).to be false + expect(matcher.identify(f).identifiable?).to be false end end @@ -219,13 +219,215 @@ def pkg(ecosystem:, name:, version:, purl:) allow(Homebrew::Vulns::OSV).to receive(:query_batch) .and_return([[{ "id" => "CVE-2021-22204" }], []]) expect(Homebrew::Vulns::OSV).to receive(:vulnerability).once - .and_return({ "id" => "CVE-2021-22204" }) + .and_return({ "id" => "CVE-2021-22204" }) matcher.advisories_for(exiftool) matcher.advisories_for(exiftool) end end + describe "#to_brew_record and helpers" do + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz" + end + end + end + let(:now) { Time.utc(2026, 7, 27, 12, 0, 0) } + + def make_hit(id:, aliases: [], fixed: [], strategy: :registry, key: "pkg:pypi/requests@2.31.0", + resource: nil, extra_evidence: []) + affected = fixed.any? ? [{ "ranges" => [{ "events" => fixed.map { |f| { "fixed" => f } } }] }] : [] + vuln = Homebrew::Vulns::Vulnerability.new({ "id" => id, "aliases" => aliases, "affected" => affected, + "summary" => "s", "details" => "d" }) + described_class::Hit.new( + vulnerability: vuln, + evidence: [described_class::Evidence.new(strategy:, key:, resource:), *extra_evidence], + ) + end + + describe "#upstream_fix_shipped?" do + it "is true when the subject version is at or past the lowest upstream fixed version" do + hit = make_hit(id: "CVE-1", fixed: ["2.28.1", "2.30.0"]) + expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be true + end + + it "is false when the subject version is below every upstream fixed version" do + hit = make_hit(id: "CVE-1", fixed: ["2.32.0"]) + expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be false + end + + it "is false when there are no fixed versions or no subject version" do + expect(matcher.upstream_fix_shipped?(requests.version, make_hit(id: "CVE-1", fixed: []))).to be false + expect(matcher.upstream_fix_shipped?(nil, make_hit(id: "CVE-1", fixed: ["1.0"]))).to be false + end + + it "ignores distro-strategy fixed versions" do + hit = make_hit(id: "CVE-1", fixed: ["1:2.28.1-1+deb12u1"], strategy: :distro, key: "Debian/requests") + expect(matcher.comparable_fix_threshold(hit)).to be_nil + expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be false + end + + it "strips a leading v from upstream fixed versions before comparing" do + hit = make_hit(id: "CVE-1", fixed: ["v2.28.1"]) + expect(matcher.comparable_fix_threshold(hit)).to eq Version.new("2.28.1") + end + end + + describe "#subject_version" do + it "returns the resource's pinned version for a resource hit" do + hit = make_hit(id: "CVE-1", key: "pkg:pypi/certifi@2024.2.2", resource: "certifi") + expect(matcher.subject_version(requests, hit)).to eq Version.new("2024.2.2") + end + + it "returns the formula version for a primary hit" do + expect(matcher.subject_version(requests, make_hit(id: "CVE-1"))).to eq Version.new("2.31.0") + end + + it "returns nil when the resource no longer exists in the formula" do + hit = make_hit(id: "CVE-1", resource: "gone") + expect(matcher.subject_version(requests, hit)).to be_nil + end + end + + describe "#first_fixed_version" do + def stub_history(versions_newest_first) + fv = instance_double(FormulaVersions) + revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] } + allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } } + versions_newest_first.each_with_index do |v, i| + old = if v + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{v}.tar.gz" + end + end + allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b| + old && b.call(old) + end + end + allow(FormulaVersions).to receive(:new).and_return(fv) + end + + it "returns the pkg_version at the oldest revision still at or past the threshold" do + stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"]) + hit = make_hit(id: "CVE-1", fixed: ["2.28.1"]) + + expect(matcher.first_fixed_version(requests, hit)).to eq "2.28.1" + end + + it "stops at an unloadable revision and returns the last known fixed pkg_version" do + stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) + hit = make_hit(id: "CVE-1", fixed: ["2.28.1"]) + + expect(matcher.first_fixed_version(requests, hit)).to eq "2.30.0" + end + + it "returns nil when the current version is not yet fixed" do + hit = make_hit(id: "CVE-1", fixed: ["2.32.0"]) + expect(FormulaVersions).not_to receive(:new) + + expect(matcher.first_fixed_version(requests, hit)).to be_nil + end + + it "returns nil for a distro-strategy hit (no comparable threshold)" do + hit = make_hit(id: "CVE-1", fixed: ["1:2.28.1-1"], strategy: :distro, key: "Debian/requests") + expect(matcher.first_fixed_version(requests, hit)).to be_nil + end + + it "caches the rev-list per formula across hits" do + fv = instance_double(FormulaVersions) + expect(fv).to receive(:rev_list).once { |_, &b| b.call("r0", "p") } + allow(fv).to receive(:formula_at_revision).and_return(nil) + allow(FormulaVersions).to receive(:new).once.and_return(fv) + + matcher.first_fixed_version(requests, make_hit(id: "CVE-1", fixed: ["1.0"])) + matcher.first_fixed_version(requests, make_hit(id: "CVE-2", fixed: ["1.0"])) + end + end + + describe "#to_brew_record" do + before do + allow(matcher).to receive(:fetch_vulnerability).and_return( + { "id" => "CVE-2024-1234", "severity" => [{ "type" => "CVSS_V3", "score" => "..." }], + "references" => [{ "type" => "ADVISORY", "url" => "https://x" }] }, + ) + end + + it "emits a matched OSV record with fixed=pkg_version when the upstream fix is shipped" do + hit = make_hit(id: "CVE-2024-1234", aliases: ["GHSA-abcd-efgh-ijkl"], fixed: ["2.28.1"]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record[:schema_version]).to eq Homebrew::Vulns::OsvExport::SCHEMA_VERSION + expect(record[:id]).to eq "BREW-requests-CVE-2024-1234" + expect(record[:published]).to eq "2026-07-27T12:00:00Z" + expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd-efgh-ijkl"] + expect(record[:summary]).to eq "s" + expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }] + expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }] + + aff = record[:affected].first + expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests") + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, + { fixed: requests.pkg_version.to_s }] }] + expect(aff[:ecosystem_specific]).to eq(fix: "bump") + + db = record[:database_specific] + expect(db[:source]).to eq "matched" + expect(db[:strategy]).to eq "registry" + expect(db[:confidence]).to eq "high" + expect(db[:upstream_evidence]).to eq [{ strategy: :registry, key: "pkg:pypi/requests@2.31.0" }] + end + + it "prefers an explicit first_fixed over the current pkg_version" do + hit = make_hit(id: "CVE-2024-1234", fixed: ["2.28.1"]) + + record = matcher.to_brew_record(requests, hit, first_fixed: "2.28.1_1", now:) + + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }, { fixed: "2.28.1_1" }] + end + + it "omits the fixed event and sets fix: nil when no upstream fix is shipped" do + hit = make_hit(id: "CVE-2024-1234", fixed: ["2.32.0"]) + + record = matcher.to_brew_record(requests, hit, now:) + + aff = record[:affected].first + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }] + expect(aff[:ecosystem_specific]).to eq(fix: nil) + end + + it "records resource name and purl and compares against the resource's pinned version" do + hit = make_hit(id: "CVE-2024-1234", fixed: ["2024.2.2"], key: "pkg:pypi/certifi@2024.2.2", + resource: "certifi") + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:affected, 0, :ecosystem_specific)) + .to eq(fix: "bump", resource: "certifi", resource_purl: "pkg:pypi/certifi@2024.2.2") + expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s) + end + + it "reports distro strategy at low confidence with all evidence listed" do + hit = make_hit(id: "CVE-2024-1234", fixed: ["1:2.28.1-1"], strategy: :distro, key: "Debian/requests", + extra_evidence: [described_class::Evidence.new(strategy: :distro, key: "Alpine/py3-requests")]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:database_specific, :strategy)).to eq "distro" + expect(record.dig(:database_specific, :confidence)).to eq "low" + expect(record.dig(:database_specific, :upstream_evidence)) + .to eq [{ strategy: :distro, key: "Debian/requests" }, + { strategy: :distro, key: "Alpine/py3-requests" }] + expect(record.dig(:affected, 0, :ecosystem_specific, :fix)).to be_nil + end + end + end + describe described_class::Hit do def vuln(id, aliases: []) Homebrew::Vulns::Vulnerability.new({ "id" => id, "aliases" => aliases }) diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index d47870abfe486..5bdf12a16f018 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -1,9 +1,11 @@ # typed: strict # frozen_string_literal: true +require "formula_versions" require "vulns/cpan_sec" require "vulns/identify" require "vulns/osv" +require "vulns/osv_export" require "vulns/repology" require "vulns/vulnerability" @@ -30,14 +32,18 @@ class Match T::Hash[Symbol, Integer], ) + # Recorded in `database_specific.confidence` for the reviewer. + CONFIDENCE = T.let( + { git: "high", registry: "high", cpansa: "medium", distro: "low" }.freeze, + T::Hash[Symbol, String], + ) + Identity = Struct.new( :git_repo, :git_tag, :primary_package, :resource_packages, :distro_packages, keyword_init: true ) do - extend T::Sig - sig { returns(T::Boolean) } - def any? + def identifiable? !git_repo.nil? || !primary_package.nil? || resource_packages.any? || distro_packages.any? end end @@ -55,7 +61,7 @@ class Hit def initialize(vulnerability:, evidence:) raise ArgumentError, "Hit requires at least one Evidence" if evidence.empty? - @vulnerability = T.let(vulnerability, Vulnerability) + @vulnerability = vulnerability @evidence = T.let( evidence.sort_by { |e| -STRATEGY_PRECISION.fetch(e.strategy) }.freeze, T::Array[Evidence], @@ -80,9 +86,11 @@ def canonical_id sig { params(repology: T.nilable(Repology), cpan_sec: T.nilable(CPANSec)).void } def initialize(repology: nil, cpan_sec: nil) - @repology = T.let(repology, T.nilable(Repology)) - @cpan_sec = T.let(cpan_sec, T.nilable(CPANSec)) + @repology = repology + @cpan_sec = cpan_sec @vuln_cache = T.let({}, T::Hash[String, T.nilable(T::Hash[String, T.untyped])]) + @formula_versions = T.let({}, T::Hash[String, FormulaVersions]) + @formula_rev_lists = T.let({}, T::Hash[String, T::Array[[String, String]]]) end sig { returns(Repology) } @@ -117,7 +125,7 @@ def identify(formula) sig { params(formula: Formula).returns(T::Array[Hit]) } def advisories_for(formula) identity = identify(formula) - return [] unless identity.any? + return [] unless identity.identifiable? labelled = build_osv_queries(identity) id_evidence = T.let({}, T::Hash[String, T::Array[Evidence]]) @@ -221,6 +229,145 @@ def fetch_vulnerability(id) end end + # Emit a candidate `BREW-*` OSV record for `hit` against `formula`. + # + # `first_fixed` is the {PkgVersion} at which Homebrew first shipped a fix + # (from {#first_fixed_version} or a hand-set value); when absent, the + # record marks the current `pkg_version` as fixed if + # {#upstream_fix_shipped?} says so, otherwise it carries no `fixed` event + # and `ecosystem_specific.fix` is null. As with {OsvExport.record_for}, + # {OsvExport.merge_existing} preserves the on-disk `ranges` on rewrite so + # a hand-corrected boundary sticks. + sig { + params(formula: Formula, hit: Hit, first_fixed: T.nilable(String), now: Time) + .returns(T::Hash[Symbol, T.untyped]) + } + def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) + vuln = hit.vulnerability + raw = fetch_vulnerability(vuln.id) || {} + timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + + fixed = first_fixed + fixed ||= formula.pkg_version.to_s if upstream_fix_shipped?(subject_version(formula, hit), hit) + events = T.let([{ introduced: "0" }], T::Array[T::Hash[Symbol, String]]) + events << { fixed: } if fixed + + record = T.let({ + schema_version: OsvExport::SCHEMA_VERSION, + id: "#{OsvExport::ID_PREFIX}-#{formula.name}-#{hit.canonical_id}", + published: timestamp, + modified: timestamp, + upstream: vuln.identifiers.uniq, + affected: [affected_entry(formula, hit, events, fixed)], + database_specific: { + source: "matched", + strategy: hit.strategy.to_s, + confidence: CONFIDENCE.fetch(hit.strategy), + upstream_evidence: hit.evidence.map { |e| e.to_h.compact }, + }, + }, T::Hash[Symbol, T.untyped]) + + record[:summary] = vuln.summary if vuln.summary + record[:details] = vuln.details if vuln.details + record[:severity] = raw["severity"] if raw["severity"] + if (refs = raw["references"]) + record[:references] = refs.uniq { |r| [r["type"], URI::RFC2396_PARSER.unescape(r["url"].to_s)] } + end + + record + end + + sig { + params(formula: Formula, hit: Hit, events: T::Array[T::Hash[Symbol, String]], + fixed: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) + } + def affected_entry(formula, hit, events, fixed) + eco = T.let({ fix: fixed ? "bump" : nil }, T::Hash[Symbol, T.nilable(String)]) + if (resource = hit.resource) + eco[:resource] = resource + eco[:resource_purl] = hit.evidence.find { |e| e.resource == resource }&.key + end + { + package: { + ecosystem: OsvExport::ECOSYSTEM, + name: formula.name, + purl: OsvExport.purl(formula.name), + }, + ranges: [{ type: "ECOSYSTEM", events: }], + ecosystem_specific: eco, + } + end + + # For a resource hit, the fix-shipped test compares the resource's pinned + # version (not the formula's) against the upstream threshold; the emitted + # `fixed:` boundary is still the formula's `pkg_version` since that is + # what {ecosystem: Homebrew} range checks match on. + sig { params(formula: Formula, hit: Hit).returns(T.nilable(Version)) } + def subject_version(formula, hit) + if (r = hit.resource) + begin + formula.resource(r)&.version + rescue ResourceMissingError + nil + end + else + formula.version + end + end + + # True when `version` is at or past any upstream fixed version. + # Distro-strategy fixed versions are distro-specific strings + # (`1:8.5.0-2`, `+dfsg-1`) and are not compared. Uses {Version}, not + # {Semver}, since formula versions are not required to be strict semver. + sig { params(version: T.nilable(Version), hit: Hit).returns(T::Boolean) } + def upstream_fix_shipped?(version, hit) + return false if version.nil? + + threshold = comparable_fix_threshold(hit) + return false if threshold.nil? + + version >= threshold + end + + sig { params(hit: Hit).returns(T.nilable(Version)) } + def comparable_fix_threshold(hit) + return if hit.strategy == :distro + + hit.vulnerability.fixed_versions + .filter_map { |v| Version.new(v.sub(/\Av/i, "")) if v.present? } + .min + end + + # Walk homebrew-core git history (newest first) via {FormulaVersions} and + # return the `pkg_version` at the oldest revision where the formula + # version was still at or past the upstream fix threshold. Returns nil + # when there is no comparable threshold or the current version is not yet + # fixed. The rev-list and per-revision loads are cached per formula so + # subsequent hits for the same formula reuse both. + sig { params(formula: Formula, hit: Hit).returns(T.nilable(String)) } + def first_fixed_version(formula, hit) + threshold = comparable_fix_threshold(hit) + return if threshold.nil? + return unless upstream_fix_shipped?(subject_version(formula, hit), hit) + + fv = @formula_versions[formula.name] ||= FormulaVersions.new(formula) + revs = @formula_rev_lists[formula.name] ||= + [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } + + last_fixed = T.let(formula.pkg_version.to_s, T.nilable(String)) + revs.each do |rev, entry| + old_fixed = fv.formula_at_revision(rev, entry) do |old| + old.pkg_version.to_s if upstream_fix_shipped?(subject_version(old, hit), hit) + end + # `nil` from formula_at_revision means the revision failed to load; + # a `nil` block result means the version dropped below the threshold. + return last_fixed if old_fixed.nil? + + last_fixed = old_fixed + end + last_fixed + end + sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } def dedup_by_cve(hits) hits.group_by(&:canonical_id).map do |_, group| From 2e00163a789541186b48beba19b527b96349fb8c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 09:22:10 +0100 Subject: [PATCH 11/28] vulns/vulnerability: include OSV upstream field in identifiers Distro-ecosystem OSV records (DEBIAN-CVE-*, RHSA-*, OESA-*, etc.) carry the underlying CVE in the schema's upstream field rather than aliases. Including it in identifiers lets Match#dedup_by_cve collapse a distro record onto the same CVE reached via GIT/registry, and lets Scanner#partition_patched match a resolves annotation against a distro-id result. related is read but excluded from identifiers since it links to different vulnerabilities and would over-merge. --- Library/Homebrew/test/vulns/vulnerability_spec.rb | 7 +++++++ Library/Homebrew/vulns/vulnerability.rb | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index 4e0dadb4194b6..4488ece83b5de 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -162,6 +162,13 @@ def semver_range(*events) 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 + 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"] + end + it "extracts CVE ids from id and aliases" do v = vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-5678"]) expect(v.cve_ids).to contain_exactly("CVE-2024-1234", "CVE-2024-5678") diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index 701c281b19038..f29776e45c9fe 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -33,7 +33,7 @@ class Vulnerability attr_reader :severity sig { returns(T::Array[String]) } - attr_reader :aliases + attr_reader :aliases, :upstream, :related sig { returns(T::Array[T::Hash[String, T.untyped]]) } attr_reader :references, :affected @@ -44,6 +44,8 @@ def initialize(data) @summary = T.let(data["summary"], T.nilable(String)) @details = T.let(data["details"], T.nilable(String)) @aliases = T.let(Array(data["aliases"]), T::Array[String]) + @upstream = T.let(Array(data["upstream"]), T::Array[String]) + @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 = T.let(extract_severity(data), T.nilable(Symbol)) @@ -67,9 +69,14 @@ def severity_level 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}. sig { returns(T::Array[String]) } def identifiers - [id, *aliases].compact + [id, *aliases, *upstream].uniq end sig { returns(T::Array[String]) } From a03c8140c0e8735308f57876eb4b15c9e10193a7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 09:22:11 +0100 Subject: [PATCH 12/28] dev-cmd/advisory-match: wire Vulns::Match to a CLI Three modes: brew advisory-match ... [--json] [--output=DIR] [--no-history] Run every strategy against each named formula and emit candidate BREW-* records. Text mode prints a per-hit summary; --json prints the OSV hashes; --output writes one file per record via OsvExport.merge_existing so existing published/ranges are preserved. brew advisory-match --all [--json] [--output=DIR] [--no-history] As above for every formula in homebrew/core. brew advisory-match --index Emit the formula-identity index (name -> Identify keys) as JSON. --no-history skips the FormulaVersions walk and uses the current pkg_version as the fixed boundary. Hidden from the manpage; this is authoring-time tooling for advisory-database CI and the homebrew-core PR bot, not a user command. --- Library/Homebrew/dev-cmd/advisory-match.rb | 152 ++++++++++++++++++ .../dsl/homebrew/dev_cmd/advisory_match.rbi | 28 ++++ .../test/dev-cmd/advisory-match_spec.rb | 124 ++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 Library/Homebrew/dev-cmd/advisory-match.rb create mode 100644 Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi create mode 100644 Library/Homebrew/test/dev-cmd/advisory-match_spec.rb diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb new file mode 100644 index 0000000000000..cd2c9eb4ffe40 --- /dev/null +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -0,0 +1,152 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "formula" +require "vulns/match" + +module Homebrew + module DevCmd + class AdvisoryMatch < AbstractCommand + cmd_args do + description <<~EOS + Match against OSV.dev (GIT, language-registry and distro + ecosystems) and CPANSA to produce candidate `BREW-*` advisory records + for . + + This is authoring-time tooling for the advisory-database CI and the + `homebrew-core` PR bot; use `brew vulns` to scan installed formulae. + EOS + switch "--all", + description: "Match every formula in `homebrew/core`." + switch "--index", + description: "Emit the formula-identity index as JSON and exit." + switch "--json", + description: "Output candidate records as a JSON array." + flag "--output=", + description: "Write each record to as " \ + "`BREW--.json`, preserving existing " \ + "`published`/`ranges` fields." + switch "--no-history", + description: "Skip the `FormulaVersions` walk for the `fixed` " \ + "boundary; use the current `pkg_version` instead." + conflicts "--all", "--index" + conflicts "--index", "--json" + conflicts "--index", "--output" + + named_args [:formula] + + hide_from_man_page! + end + + sig { override.void } + def run + Formulary.enable_factory_cache! + Homebrew.with_no_api_env do + latest_macos = MacOSVersion.new((HOMEBREW_MACOS_NEWEST_UNSUPPORTED.to_i - 1).to_s).to_sym + Homebrew::SimulateSystem.with(os: latest_macos, arch: :arm) do + matcher = Homebrew::Vulns::Match.new + next emit_index(matcher) if args.index? + + records = each_formula.flat_map { |f| records_for(matcher, f) } + emit(records) + end + end + end + + sig { returns(T::Enumerator[Formula]) } + def each_formula + return args.named.to_resolved_formulae.each unless args.all? + + raise UsageError, "`--all` does not take named arguments" if args.named.any? + + tap = CoreTap.instance + raise TapUnavailableError, tap.name unless tap.installed? + + Enumerator.new do |y| + tap.formula_names.each do |name| + y << Formulary.factory(name) + rescue => e + onoe "Error loading formula '#{name}': #{e}" + end + end + end + + sig { params(matcher: Homebrew::Vulns::Match, formula: Formula).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def records_for(matcher, formula) + hits = matcher.advisories_for(formula) + report(formula, hits) unless args.json? || args.output + hits.map do |hit| + first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? + matcher.to_brew_record(formula, hit, first_fixed:) + end + rescue Homebrew::Vulns::OSV::Error => e + onoe "OSV query for #{formula.name} failed: #{e.message}" + Homebrew.failed = true + [] + end + + sig { params(formula: Formula, hits: T::Array[Homebrew::Vulns::Match::Hit]).void } + def report(formula, hits) + ohai "#{formula.name} #{formula.pkg_version}" + if hits.empty? + puts " No advisories matched." + return + end + hits.sort_by { |h| [-h.vulnerability.severity_level, h.canonical_id] }.each do |hit| + v = hit.vulnerability + fixed = v.fixed_versions.first + puts " #{hit.canonical_id} [#{hit.strategy}, " \ + "#{Homebrew::Vulns::Match::CONFIDENCE.fetch(hit.strategy)}] " \ + "#{v.severity_display} #{v.summary&.slice(0, 60)}" \ + "#{" (upstream fixed #{fixed})" if fixed}" \ + "#{" (resource: #{hit.resource})" if hit.resource}" + end + end + + sig { params(records: T::Array[T::Hash[Symbol, T.untyped]]).void } + def emit(records) + if (dir = args.output) + FileUtils.mkdir_p(dir) + written = 0 + records.each do |record| + path = File.join(dir, "#{record.fetch(:id)}.json") + merged = Homebrew::Vulns::OsvExport.merge_existing(path, record) + next if merged.nil? + + File.write(path, "#{JSON.pretty_generate(merged)}\n") + puts " wrote #{path}" if args.verbose? + written += 1 + end + ohai "#{written} records written to #{dir} (#{records.size - written} unchanged)" + elsif args.json? + puts JSON.pretty_generate(records) + else + ohai "#{records.size} candidate records" + end + end + + sig { params(matcher: Homebrew::Vulns::Match).void } + def emit_index(matcher) + tap = CoreTap.instance + raise TapUnavailableError, tap.name unless tap.installed? + + index = tap.formula_names.each_with_object({}) do |name, h| + identity = matcher.identify(Formulary.factory(name)) + next unless identity.identifiable? + + h[name] = { + git_repo: identity.git_repo, + git_tag: identity.git_tag, + primary_package: identity.primary_package&.to_h, + resource_packages: identity.resource_packages.transform_values(&:to_h), + distro_packages: identity.distro_packages, + }.compact + rescue => e + onoe "Error loading formula '#{name}': #{e}" + end + puts JSON.pretty_generate(index) + end + end + 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 new file mode 100644 index 0000000000000..724de13fb70a1 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi @@ -0,0 +1,28 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::DevCmd::AdvisoryMatch`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::DevCmd::AdvisoryMatch`. + + +class Homebrew::DevCmd::AdvisoryMatch + sig { returns(Homebrew::DevCmd::AdvisoryMatch::Args) } + def args; end +end + +class Homebrew::DevCmd::AdvisoryMatch::Args < Homebrew::CLI::Args + sig { returns(T::Boolean) } + def all?; end + + sig { returns(T::Boolean) } + def index?; end + + sig { returns(T::Boolean) } + def json?; end + + sig { returns(T::Boolean) } + def no_history?; end + + sig { returns(T.nilable(String)) } + def output; end +end diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb new file mode 100644 index 0000000000000..939dfd2ea12d8 --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -0,0 +1,124 @@ +# typed: false +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" +require "dev-cmd/advisory-match" + +RSpec.describe Homebrew::DevCmd::AdvisoryMatch do + it_behaves_like "parseable arguments" + + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + head "https://github.com/psf/requests.git" + end + end + + before do + allow(Formulary).to receive(:enable_factory_cache!) + allow(Homebrew::Vulns::Repology).to receive(:load).and_return( + Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => {} }), + ) + allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return({}) + allow(Homebrew::Vulns::CPANSec).to receive(:load).and_return( + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {} }), + ) + end + + def cmd_for(*argv, formulae: [requests]) + cmd = described_class.new(argv) + allow(cmd.args.named).to receive(:to_resolved_formulae).and_return(formulae) + cmd + end + + def stub_osv_hit(cve, fixed:) + allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => cve }], []]) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with(cve).and_return( + { "id" => cve, "summary" => "s", + "affected" => [{ "ranges" => [{ "events" => [{ "fixed" => fixed }] }] }] }, + ) + end + + it "writes matched records to --output= with merge_existing semantics" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + Dir.mktmpdir do |dir| + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/1 records written/).to_stdout + + path = File.join(dir, "BREW-requests-CVE-2024-1234.json") + record = JSON.parse(File.read(path)) + expect(record.dig("affected", 0, "package")) + .to eq("ecosystem" => "Homebrew", "name" => "requests", "purl" => "pkg:brew/requests") + expect(record.dig("affected", 0, "ranges", 0, "events", 1)) + .to eq("fixed" => requests.pkg_version.to_s) + expect(record.dig("database_specific", "source")).to eq "matched" + expect(record.dig("database_specific", "strategy")).to eq "git" + + # A second run with the same output should report 0 written / 1 unchanged. + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/0 records written to #{Regexp.escape(dir)} \(1 unchanged\)/).to_stdout + end + end + + it "emits records as JSON with --json" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + records = JSON.parse(capture_stdout { cmd_for("requests", "--json", "--no-history").run }) + expect(records.length).to eq 1 + expect(records.first["id"]).to eq "BREW-requests-CVE-2024-1234" + end + + it "prints a per-hit summary in text mode" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + expect { cmd_for("requests", "--no-history").run } + .to output(%r{requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*1 candidate records}m).to_stdout + end + + it "reports and continues past an OSV outage without raising" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch) + .and_raise(Homebrew::Vulns::OSV::ApiError, "503") + + expect { cmd_for("requests", "--json").run } + .to output("[]\n").to_stdout.and output(/OSV query for requests failed/).to_stderr + expect(Homebrew.failed?).to be true + end + + it "iterates every core formula with --all" do + requests + core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", + formula_names: ["requests", "broken"]) + allow(CoreTap).to receive(:instance).and_return(core_tap) + allow(Formulary).to receive(:factory).with("requests").and_return(requests) + allow(Formulary).to receive(:factory).with("broken").and_raise(RuntimeError, "boom") + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + expect { described_class.new(["--all", "--json", "--no-history"]).run } + .to output(/BREW-requests-CVE-2024-1234/).to_stdout + .and output(/Error loading formula 'broken': boom/).to_stderr + end + + it "emits the formula-identity index with --index" do + requests + core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", formula_names: ["requests"]) + allow(CoreTap).to receive(:instance).and_return(core_tap) + allow(Formulary).to receive(:factory).with("requests").and_return(requests) + + output = capture_stdout { described_class.new(["--index"]).run } + index = JSON.parse(output) + expect(index.dig("requests", "git_repo")).to eq "https://github.com/psf/requests" + expect(index.dig("requests", "primary_package", "ecosystem")).to eq "PyPI" + end + + def capture_stdout + out = StringIO.new + old = $stdout + $stdout = out + yield + out.string + ensure + $stdout = old + end +end From e2c5553eb5551537d763407e3984f4bd986e6852 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 09:28:41 +0100 Subject: [PATCH 13/28] Address style and Copilot review - dev-cmd/advisory-match: extract text_mode? to satisfy Style/UnlessLogicalOperators. - test/dev-cmd/advisory-match_spec: reorder let/before above the shared example, combine Repology stubs via receive_messages, use // regex. - utils/repology: explicit require "erb" for ERB::Util.url_encode. - test/vulns/identify_spec: cover multi-byte percent decoding (Integer#chr returns ASCII-8BIT for 128-255 so no encoding error; the Copilot suggestion to pass Encoding::ASCII_8BIT is unnecessary). --- Library/Homebrew/dev-cmd/advisory-match.rb | 7 ++++++- Library/Homebrew/test/dev-cmd/advisory-match_spec.rb | 12 ++++++------ Library/Homebrew/test/vulns/identify_spec.rb | 5 +++++ Library/Homebrew/utils/repology.rb | 1 + 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index cd2c9eb4ffe40..fd36f25584983 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -75,7 +75,7 @@ def each_formula sig { params(matcher: Homebrew::Vulns::Match, formula: Formula).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def records_for(matcher, formula) hits = matcher.advisories_for(formula) - report(formula, hits) unless args.json? || args.output + report(formula, hits) if text_mode? hits.map do |hit| first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? matcher.to_brew_record(formula, hit, first_fixed:) @@ -86,6 +86,11 @@ def records_for(matcher, formula) [] end + sig { returns(T::Boolean) } + def text_mode? + !args.json? && args.output.nil? + end + sig { params(formula: Formula, hits: T::Array[Homebrew::Vulns::Match::Hit]).void } def report(formula, hits) ohai "#{formula.name} #{formula.pkg_version}" diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index 939dfd2ea12d8..e29f44810dc48 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -5,8 +5,6 @@ require "dev-cmd/advisory-match" RSpec.describe Homebrew::DevCmd::AdvisoryMatch do - it_behaves_like "parseable arguments" - let(:requests) do formula("requests") do T.bind(self, T.class_of(Formula)) @@ -17,15 +15,17 @@ before do allow(Formulary).to receive(:enable_factory_cache!) - allow(Homebrew::Vulns::Repology).to receive(:load).and_return( - Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => {} }), + allow(Homebrew::Vulns::Repology).to receive_messages( + load: Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => {} }), + lookup: {}, ) - allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return({}) allow(Homebrew::Vulns::CPANSec).to receive(:load).and_return( Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {} }), ) end + it_behaves_like "parseable arguments" + def cmd_for(*argv, formulae: [requests]) cmd = described_class.new(argv) allow(cmd.args.named).to receive(:to_resolved_formulae).and_return(formulae) @@ -74,7 +74,7 @@ def stub_osv_hit(cve, fixed:) stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") expect { cmd_for("requests", "--no-history").run } - .to output(%r{requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*1 candidate records}m).to_stdout + .to output(/requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*1 candidate records/m).to_stdout end it "reports and continues past an OSV outage without raising" do diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index fefc32727e8be..b7701aeda05a8 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -214,6 +214,11 @@ def result(url) purl: "pkg:npm/%40angular/cli@22.0.3") end + it "decodes multi-byte percent escapes without an encoding error" do + expect(described_class.decode("caf%C3%A9")).to eq "café" + expect(described_class.decode("%80").bytes).to eq [0x80] + end + it "returns nil when the tarball filename does not match the path name" do expect(result("https://registry.npmjs.org/foo/-/bar-1.0.0.tgz")).to be_nil end diff --git a/Library/Homebrew/utils/repology.rb b/Library/Homebrew/utils/repology.rb index 4ca00867016fb..61a593fb53144 100644 --- a/Library/Homebrew/utils/repology.rb +++ b/Library/Homebrew/utils/repology.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require "erb" require "utils/curl" require "utils/output" From 6525bc11ffcb76d38539b89079f35bd2de721d47 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 09:54:34 +0100 Subject: [PATCH 14/28] vulns: raise spec files to typed: true Sorbet rejects described_class::CONST at typed: true, so those references are spelled out. The expect { loaded = ... } capture pattern gets an explicit T.let so the block reassignment does not narrow to nil. match_spec.rb stays typed: false pending its rewrite for per-evidence range evaluation. --- .../test/dev-cmd/advisory-match_spec.rb | 2 +- Library/Homebrew/test/utils/repology_spec.rb | 16 ++++++++-------- Library/Homebrew/test/vulns/cpan_sec_spec.rb | 18 +++++++++--------- Library/Homebrew/test/vulns/identify_spec.rb | 2 +- Library/Homebrew/test/vulns/purl_spec.rb | 2 +- Library/Homebrew/test/vulns/repology_spec.rb | 18 +++++++++--------- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index e29f44810dc48..3da6a2516427b 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "cmd/shared_examples/args_parse" diff --git a/Library/Homebrew/test/utils/repology_spec.rb b/Library/Homebrew/test/utils/repology_spec.rb index c6933dac582ad..288da0d1fd798 100644 --- a/Library/Homebrew/test/utils/repology_spec.rb +++ b/Library/Homebrew/test/utils/repology_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "utils/repology" @@ -16,10 +16,10 @@ def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) it "URL-encodes the project name and passes --fail" do expect(Utils::Curl).to receive(:curl_output) do |*args, **| - expect(args).to include("--fail", "#{described_class::API_BASE}/project/gtk%2B3") + expect(args).to include("--fail", "#{Repology::API_BASE}/project/gtk%2B3") stub_curl(success: true, stdout: "[]") end - expect(described_class.single_package_query("gtk+3", repository: described_class::HOMEBREW_CORE)) + expect(described_class.single_package_query("gtk+3", repository: Repology::HOMEBREW_CORE)) .to eq({ "gtk+3" => [] }) end @@ -27,13 +27,13 @@ def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) allow(Utils::Curl).to receive(:curl_output).and_return( stub_curl(success: false, exit_status: 22, stderr: "The requested URL returned error: 503"), ) - expect(described_class.single_package_query("curl", repository: described_class::HOMEBREW_CORE)) + expect(described_class.single_package_query("curl", repository: Repology::HOMEBREW_CORE)) .to be_nil end it "returns nil on invalid JSON" do allow(Utils::Curl).to receive(:curl_output).and_return(stub_curl(success: true, stdout: "not json")) - expect(described_class.single_package_query("curl", repository: described_class::HOMEBREW_CORE)) + expect(described_class.single_package_query("curl", repository: Repology::HOMEBREW_CORE)) .to be_nil end end @@ -41,11 +41,11 @@ def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) describe ".query_api" do it "URL-encodes the pagination cursor" do expect(Utils::Curl).to receive(:curl_output) do |*args, **| - expect(args.last).to eq "#{described_class::API_BASE}/projects/gtk%2B3/" \ - "?inrepo=#{described_class::HOMEBREW_CORE}&outdated=1" + expect(args.last).to eq "#{Repology::API_BASE}/projects/gtk%2B3/" \ + "?inrepo=#{Repology::HOMEBREW_CORE}&outdated=1" instance_double(SystemCommand::Result, stdout: "{}") end - described_class.query_api("gtk+3", repository: described_class::HOMEBREW_CORE) + described_class.query_api("gtk+3", repository: Repology::HOMEBREW_CORE) end end end diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb index 261fff8c308e4..7368decccc461 100644 --- a/Library/Homebrew/test/vulns/cpan_sec_spec.rb +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "vulns/cpan_sec" @@ -13,7 +13,7 @@ bad = Pathname(dir)/"cpansa.json" bad.write "not json" expect { described_class.from_file(bad) } - .to raise_error(described_class::Error, /Failed to parse cpansa\.json/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /Failed to parse cpansa\.json/) end end end @@ -21,12 +21,12 @@ describe "#initialize" do it "raises Error when the dists key is missing" do expect { described_class.new({ "meta" => {} }) } - .to raise_error(described_class::Error, /missing 'dists' key/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /missing 'dists' key/) end it "raises Error when the top-level value is not a JSON object" do - expect { described_class.new([]) }.to raise_error(described_class::Error, /not a JSON object/) - expect { described_class.new(nil) }.to raise_error(described_class::Error, /not a JSON object/) + expect { described_class.new([]) }.to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) + expect { described_class.new(nil) }.to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) end it "treats a null or absent meta as an empty hash" do @@ -131,10 +131,10 @@ original = stale.read expect(Utils::Curl).to receive(:curl_download) .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) - loaded = nil + loaded = T.let(nil, T.nilable(Homebrew::Vulns::CPANSec)) expect { loaded = described_class.load(cache:) } .to output(/Failed to refresh cpansa\.json/).to_stderr - expect(loaded.distributions).to include "DBI" + expect(loaded&.distributions).to include "DBI" expect(stale.read).to eq original end end @@ -147,10 +147,10 @@ FileUtils.touch stale, mtime: Time.now - 100_000 original = stale.read expect(Utils::Curl).to receive(:curl_download) { |*_args, to:| to.write "not json" } - loaded = nil + loaded = T.let(nil, T.nilable(Homebrew::Vulns::CPANSec)) expect { loaded = described_class.load(cache:) } .to output(/Failed to refresh cpansa\.json/).to_stderr - expect(loaded.distributions).to include "DBI" + expect(loaded&.distributions).to include "DBI" expect(stale.read).to eq original expect(cache.children).to eq [stale] end diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index b7701aeda05a8..12f17edcd405c 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "vulns/identify" diff --git a/Library/Homebrew/test/vulns/purl_spec.rb b/Library/Homebrew/test/vulns/purl_spec.rb index acd1a594a95e3..40b5ff291acd1 100644 --- a/Library/Homebrew/test/vulns/purl_spec.rb +++ b/Library/Homebrew/test/vulns/purl_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "vulns/purl" diff --git a/Library/Homebrew/test/vulns/repology_spec.rb b/Library/Homebrew/test/vulns/repology_spec.rb index db9f80bd7309a..feff989a7e4c7 100644 --- a/Library/Homebrew/test/vulns/repology_spec.rb +++ b/Library/Homebrew/test/vulns/repology_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "vulns/repology" @@ -10,12 +10,12 @@ describe "#initialize" do it "raises Error when the top-level value is not a JSON object" do expect { described_class.new([]) } - .to raise_error(described_class::Error, /not a JSON object/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) end it "raises Error when the formulae key is missing" do expect { described_class.new({ "meta" => {} }) } - .to raise_error(described_class::Error, /missing 'formulae' key/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /missing 'formulae' key/) end end @@ -240,8 +240,8 @@ def project(homebrew:, distros:, status: "newest") it "propagates fetch errors rather than treating them as a miss" do allow(described_class).to receive(:fetch_project) - .and_raise(described_class::Error, "Repology API request failed") - expect { described_class.lookup("curl") }.to raise_error(described_class::Error) + .and_raise(Homebrew::Vulns::CachedFeed::Error, "Repology API request failed") + expect { described_class.lookup("curl") }.to raise_error(Homebrew::Vulns::CachedFeed::Error) end end @@ -261,13 +261,13 @@ def project(homebrew:, distros:, status: "newest") it "raises Error when the underlying query fails (returns nil)" do allow(Repology).to receive(:single_package_query).and_return(nil) expect { described_class.fetch_project("curl") } - .to raise_error(described_class::Error, /request for "curl" failed/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /request for "curl" failed/) end it "raises Error on an unexpected response shape" do allow(Repology).to receive(:single_package_query).and_return({ "curl" => { "oops" => true } }) expect { described_class.fetch_project("curl") } - .to raise_error(described_class::Error, /unexpected shape/) + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /unexpected shape/) end end @@ -289,10 +289,10 @@ def project(homebrew:, distros:, status: "newest") FileUtils.touch stale, mtime: Time.now - (described_class.default_max_age + 1) expect(Utils::Curl).to receive(:curl_download) .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) - loaded = nil + loaded = T.let(nil, T.nilable(Homebrew::Vulns::Repology)) expect { loaded = described_class.load(cache:) } .to output(/Failed to refresh repology\.json/).to_stderr - expect(loaded.formulae).to include "curl" + expect(loaded&.formulae).to include "curl" end end end From d7e7161f24eaebbab9cce917cde41b39c764837b Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 10:28:26 +0100 Subject: [PATCH 15/28] 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. --- Library/Homebrew/test/vulns/cpan_sec_spec.rb | 42 +++++++ .../Homebrew/test/vulns/vulnerability_spec.rb | 107 ++++++++++++++++- Library/Homebrew/vulns/cpan_sec.rb | 50 ++++++++ Library/Homebrew/vulns/vulnerability.rb | 109 ++++++++++++++++-- 4 files changed, 297 insertions(+), 11 deletions(-) diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb index 7368decccc461..d895ed98a7d97 100644 --- a/Library/Homebrew/test/vulns/cpan_sec_spec.rb +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -85,6 +85,48 @@ 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| diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index 4488ece83b5de..dc908e1b2bce5 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -152,6 +152,107 @@ def semver_range(*events) 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 @@ def semver_range(*events) 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 diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb index 8687e5612879a..0b376ee120829 100644 --- a/Library/Homebrew/vulns/cpan_sec.rb +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "vulns/cached_feed" +require "vulns/vulnerability" module Homebrew module Vulns @@ -54,6 +55,55 @@ def advisories_for(distribution) 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"] diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index f29776e45c9fe..a8425ccc46f03 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -36,7 +36,7 @@ class Vulnerability 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 @@ def initialize(data) @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 @@ def severity_level 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 @@ def fixed_versions 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 @@ def normalize_version(version) 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) From f5b8b6a5b6b6e6a8b768b6a5abe4251773099243 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 10:29:12 +0100 Subject: [PATCH 16/28] vulns/match: query versionlessly and evaluate ranges per evidence All OSV queries are now versionless, so bump-fixed advisories are returned. Evidence carries the {ecosystem, name} that was queried plus the subject_version to check ranges against (formula version, pinned resource version, or nil for distro), and the CPANSA Advisory for :cpansa evidence. resolve_upstream re-attributes each distro-ecosystem hit to the bare CVE ids named in its upstream (Debian/Ubuntu/RH/openSUSE/...) or related (AlmaLinux) fields, ignoring distro-prefixed intermediate ids so USN -> [CVE-x, UBUNTU-CVE-x] does not produce a stray UBUNTU-CVE hit. A multi-CVE advisory splits into one hit per CVE; a record naming no CVE is kept as a low-confidence hit. Each resolved hit gains synthesised own-identity evidence so range_status can check the CVE record's affected[] against our version. range_status(hit) walks each evidence in precision order and returns the first Vulnerability::RangeStatus (or CPANSec.range_status result) a comparable range yields. A GIT-SHA-only record, or a distro-resolved CVE whose affected[] does not match our identity, returns nil. to_brew_record derives fixed/fix: from range_status: not-affected sets {fixed: pkg_version, fix: bump}; affected or uncomparable emits no fixed event and fix: null, with confidence demoted for uncomparable. first_fixed_version now uses the range_status fixed_in as the threshold for the FormulaVersions walk. bulk mode (Match.new(bulk: true), used by --all/--index) skips the live Repology.lookup fallback so a full sweep never hits the rate-limited per-project API for the ~3,200 formulae the published index does not cover; single-formula runs still fall back for a formula the nightly index has not seen. --- Library/Homebrew/dev-cmd/advisory-match.rb | 30 +- .../test/dev-cmd/advisory-match_spec.rb | 9 +- Library/Homebrew/test/vulns/match_spec.rb | 525 ++++++++++-------- Library/Homebrew/vulns/match.rb | 334 +++++++---- 4 files changed, 530 insertions(+), 368 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index fd36f25584983..711d693886e2a 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -45,7 +45,7 @@ def run Homebrew.with_no_api_env do latest_macos = MacOSVersion.new((HOMEBREW_MACOS_NEWEST_UNSUPPORTED.to_i - 1).to_s).to_sym Homebrew::SimulateSystem.with(os: latest_macos, arch: :arm) do - matcher = Homebrew::Vulns::Match.new + matcher = Homebrew::Vulns::Match.new(bulk: args.all? || args.index?) next emit_index(matcher) if args.index? records = each_formula.flat_map { |f| records_for(matcher, f) } @@ -75,7 +75,7 @@ def each_formula sig { params(matcher: Homebrew::Vulns::Match, formula: Formula).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def records_for(matcher, formula) hits = matcher.advisories_for(formula) - report(formula, hits) if text_mode? + report(matcher, formula, hits) if text_mode? hits.map do |hit| first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? matcher.to_brew_record(formula, hit, first_fixed:) @@ -91,8 +91,11 @@ def text_mode? !args.json? && args.output.nil? end - sig { params(formula: Formula, hits: T::Array[Homebrew::Vulns::Match::Hit]).void } - def report(formula, hits) + sig { + params(matcher: Homebrew::Vulns::Match, formula: Formula, + hits: T::Array[Homebrew::Vulns::Match::Hit]).void + } + def report(matcher, formula, hits) ohai "#{formula.name} #{formula.pkg_version}" if hits.empty? puts " No advisories matched." @@ -100,12 +103,19 @@ def report(formula, hits) end hits.sort_by { |h| [-h.vulnerability.severity_level, h.canonical_id] }.each do |hit| v = hit.vulnerability - fixed = v.fixed_versions.first - puts " #{hit.canonical_id} [#{hit.strategy}, " \ - "#{Homebrew::Vulns::Match::CONFIDENCE.fetch(hit.strategy)}] " \ - "#{v.severity_display} #{v.summary&.slice(0, 60)}" \ - "#{" (upstream fixed #{fixed})" if fixed}" \ - "#{" (resource: #{hit.resource})" if hit.resource}" + status = matcher.range_status(hit) + state = if status.nil? + "uncomparable" + elsif status.affected? + status.fixed_in ? "AFFECTED, upstream fix #{status.fixed_in}" : "AFFECTED, no upstream fix" + else + "fixed (upstream #{status.fixed_in || "?"})" + end + summary = v.summary&.slice(0, 60) + puts " #{hit.canonical_id} [#{hit.strategy}, #{matcher.confidence_for(hit, status)}] " \ + "#{v.severity_display} #{state}" \ + "#{" (resource: #{hit.resource})" if hit.resource}" \ + "#{" — #{summary}" if summary}" 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 3da6a2516427b..bd89d2ab9aa72 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -36,7 +36,11 @@ def stub_osv_hit(cve, fixed:) allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => cve }], []]) allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with(cve).and_return( { "id" => cve, "summary" => "s", - "affected" => [{ "ranges" => [{ "events" => [{ "fixed" => fixed }] }] }] }, + "affected" => [{ + "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => fixed }] }], + }] }, ) end @@ -74,7 +78,8 @@ def stub_osv_hit(cve, fixed:) stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") expect { cmd_for("requests", "--no-history").run } - .to output(/requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*1 candidate records/m).to_stdout + .to output(/requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*fixed \(upstream 2\.28\.1\).*1 candidate/m) + .to_stdout end it "reports and continues past an OSV outage without raising" do diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 80e72f8659ca0..e42a9d4e407fc 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -1,4 +1,4 @@ -# typed: false +# typed: true # frozen_string_literal: true require "vulns/match" @@ -13,7 +13,7 @@ Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { "Image-ExifTool" => { "advisories" => [ { "id" => "CPANSA-Image-ExifTool-2021-22204", "cves" => ["CVE-2021-22204"], - "affected_versions" => ["<12.24"], "fixed_versions" => ["12.24"] }, + "affected_versions" => ["<12.24"], "fixed_versions" => [">=12.24"] }, ] }, } }) end @@ -23,6 +23,19 @@ def stub_repology_lookup(result = {}) allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return(result) end + def vuln(data) + Homebrew::Vulns::Vulnerability.new(data) + end + + def ev(strategy, ecosystem: nil, name: nil, subject_version: nil, key: "k", resource: nil, advisory: nil) + Homebrew::Vulns::Match::Evidence.new(strategy:, ecosystem:, name:, subject_version:, key:, + resource:, advisory:) + end + + def make_hit(vulnerability, *evidence) + Homebrew::Vulns::Match::Hit.new(vulnerability:, evidence:) + end + describe "#identify" do it "derives git repo/tag, primary registry package, resources and distro packages" do f = formula("requests") do @@ -44,7 +57,6 @@ def stub_repology_lookup(result = {}) expect(identity.git_tag).to eq "2.31.0" expect(identity.primary_package.ecosystem).to eq "PyPI" expect(identity.primary_package.name).to eq "requests" - expect(identity.primary_package.version).to eq "2.31.0" expect(identity.resource_packages.keys).to eq ["certifi"] expect(identity.resource_packages["certifi"].purl).to eq "pkg:pypi/certifi@2024.2.2" expect(identity.distro_packages) @@ -52,7 +64,7 @@ def stub_repology_lookup(result = {}) expect(identity.identifiable?).to be true end - it "falls back to Repology.lookup when the index has no entry" do + it "falls back to Repology.lookup when the index has no entry (single-formula mode)" do f = formula("newthing") do T.bind(self, T.class_of(Formula)) url "https://example.com/newthing-1.0.tar.gz" @@ -62,6 +74,17 @@ def stub_repology_lookup(result = {}) expect(matcher.identify(f).distro_packages).to eq("Debian" => ["newthing"]) end + it "does not fall back to Repology.lookup in bulk mode" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + expect(Homebrew::Vulns::Repology).not_to receive(:lookup) + + bulk = described_class.new(repology:, cpan_sec:, bulk: true) + expect(bulk.identify(f).distro_packages).to eq({}) + end + it "swallows a Repology lookup error to an empty distro map" do f = formula("newthing") do T.bind(self, T.class_of(Formula)) @@ -89,65 +112,171 @@ def pkg(ecosystem:, name:, version:, purl:) Homebrew::Vulns::Identify::RegistryPackage.new(ecosystem:, name:, version:, purl:) end - it "emits GIT, registry (primary + resource) and distro queries with matching evidence" do - identity = described_class::Identity.new( + it "emits versionless GIT/registry/distro queries with subject_version carried on the evidence" do + identity = Homebrew::Vulns::Match::Identity.new( git_repo: "https://github.com/psf/requests", git_tag: "v2.31.0", primary_package: pkg(ecosystem: "PyPI", name: "requests", version: "2.31.0", purl: "pkg:pypi/requests@2.31.0"), resource_packages: { "certifi" => pkg(ecosystem: "PyPI", name: "certifi", version: "2024.2.2", purl: "pkg:pypi/certifi@2024.2.2") }, - distro_packages: { "Debian" => ["requests"], "Alpine" => ["py3-requests"] }, + distro_packages: { "Debian" => ["requests"] }, ) - queries = matcher.build_osv_queries(identity) + queries = matcher.build_osv_queries(identity, "2.31.0") expect(queries.map(&:first)).to eq [ - { ecosystem: "GIT", name: "https://github.com/psf/requests", version: "v2.31.0" }, - { ecosystem: "PyPI", name: "requests", version: "2.31.0" }, - { ecosystem: "PyPI", name: "certifi", version: "2024.2.2" }, + { ecosystem: "GIT", name: "https://github.com/psf/requests", version: nil }, + { ecosystem: "PyPI", name: "requests", version: nil }, + { ecosystem: "PyPI", name: "certifi", version: nil }, { ecosystem: "Debian", name: "requests", version: nil }, - { ecosystem: "Alpine", name: "py3-requests", version: nil }, ] - expect(queries.map { |_, e| [e.strategy, e.key, e.resource] }).to eq [ - [:git, "https://github.com/psf/requests", nil], - [:registry, "pkg:pypi/requests@2.31.0", nil], - [:registry, "pkg:pypi/certifi@2024.2.2", "certifi"], - [:distro, "Debian/requests", nil], - [:distro, "Alpine/py3-requests", nil], + expect(queries.map { |_, e| [e.strategy, e.ecosystem, e.name, e.subject_version, e.resource] }).to eq [ + [:git, "GIT", "https://github.com/psf/requests", "v2.31.0", nil], + [:registry, "PyPI", "requests", "2.31.0", nil], + [:registry, "PyPI", "certifi", "2024.2.2", "certifi"], + [:distro, "Debian", "requests", nil, nil], ] end it "excludes CPAN packages from OSV queries and omits GIT when no repo derived" do - identity = described_class::Identity.new( + identity = Homebrew::Vulns::Match::Identity.new( git_repo: nil, git_tag: "13.55", primary_package: pkg(ecosystem: "CPAN", name: "Image-ExifTool", version: "13.55", purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@13.55"), - resource_packages: { "extra" => pkg(ecosystem: "CPAN", name: "Try-Tiny", version: "0.31", - purl: "pkg:cpan/ETHER/Try-Tiny@0.31") }, - distro_packages: {}, + resource_packages: {}, distro_packages: {} ) - expect(matcher.build_osv_queries(identity)).to eq [] + expect(matcher.build_osv_queries(identity, "13.55")).to eq [] end end - describe "#cpan_advisory_ids" do - it "returns CVE ids for CPAN primary and resource packages via CPANSec" do - identity = described_class::Identity.new( - git_repo: nil, git_tag: nil, - primary_package: Homebrew::Vulns::Identify::RegistryPackage.new( - ecosystem: "CPAN", name: "Image-ExifTool", version: "12.00", - purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@12.00" - ), - resource_packages: {}, distro_packages: {} + describe "#range_status" do + it "returns the registry-entry status when GIT ranges are uncomparable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + ]) + hit = make_hit(v, + ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1"), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")) + + expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "2.28.1") + end + + it "returns nil when the only matching entry has GIT-type ranges" do + v = vuln("id" => "CVE-2026-32316", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + ]) + hit = make_hit(v, ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1")) + + expect(matcher.range_status(hit)).to be_nil + end + + it "evaluates CPANSA constraint strings for :cpansa evidence" do + adv = Homebrew::Vulns::CPANSec::Advisory.new(id: "CPANSA-X", cves: ["CVE-1"], + affected_versions: ["<12.24"], + fixed_versions: [">=12.24"]) + hit = make_hit(vuln("id" => "CVE-1"), + ev(:cpansa, ecosystem: "CPAN", name: "Image-ExifTool", + subject_version: "13.55", advisory: adv)) + + expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "12.24") + end + + it "checks a distro-resolved upstream CVE against attached own-identity evidence" do + v = vuln("id" => "CVE-2015-8863", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "SEMVER", + "events" => [{ "introduced" => "0" }, { "fixed" => "1.6" }] }] }, + ]) + hit = make_hit(v, + ev(:distro, ecosystem: "Debian", name: "jq"), + ev(:distro, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1", key: "upstream:...")) + + expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "1.6") + end + + it "skips evidence with no subject_version" do + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "jq")) + expect(matcher.range_status(hit)).to be_nil + end + end + + describe "#resolve_upstream" do + let(:identity) do + Homebrew::Vulns::Match::Identity.new( + git_repo: "https://github.com/jqlang/jq", git_tag: "1.8.1", + primary_package: nil, resource_packages: {}, distro_packages: {} + ) + end + + it "splits a multi-CVE distro advisory into one hit per upstream CVE with own-identity evidence" do + allow(matcher).to receive(:fetch_vulnerability).with("RHSA-2026:1").and_return( + vuln("id" => "RHSA-2026:1", "upstream" => ["CVE-2026-0001", "CVE-2026-0002"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0001") + .and_return(vuln("id" => "CVE-2026-0001")) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0002") + .and_return(vuln("id" => "CVE-2026-0002")) + + hits = matcher.resolve_upstream( + { "RHSA-2026:1" => [ev(:distro, ecosystem: "Red Hat", name: "jq")] }, identity + ) + + expect(hits.map { |h| h.vulnerability.id }.sort).to eq ["CVE-2026-0001", "CVE-2026-0002"] + expect(hits.first.evidence.map(&:ecosystem)).to include("Red Hat", "GIT") + end + + it "follows only bare CVE ids from upstream/related, ignoring distro-prefixed intermediate ids" do + allow(matcher).to receive(:fetch_vulnerability).with("USN-4787-1").and_return( + vuln("id" => "USN-4787-1", "upstream" => ["CVE-2016-4074", "UBUNTU-CVE-2016-4074"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("ALSA-1").and_return( + vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2016-4074") + .and_return(vuln("id" => "CVE-2016-4074")) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) + + hits = matcher.resolve_upstream( + { "USN-4787-1" => [ev(:distro)], "ALSA-1" => [ev(:distro)] }, identity ) - ids = matcher.cpan_advisory_ids(identity) + expect(hits.map { |h| h.vulnerability.id }.sort).to eq ["CVE-2016-4074", "CVE-2024-0001"] + end - expect(ids.map(&:first)).to eq ["CVE-2021-22204"] - expect(ids.first.last.strategy).to eq :cpansa + it "keeps a record whose id/aliases already include a CVE as-is" do + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001").and_return( + vuln("id" => "CVE-2024-0001", "upstream" => ["CVE-2024-0099"]), + ) + hits = matcher.resolve_upstream({ "CVE-2024-0001" => [ev(:git)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + end + + it "keeps a record with no CVE anywhere as a low-confidence hit rather than dropping it" do + allow(matcher).to receive(:fetch_vulnerability).with("ALBA-2022:1788").and_return( + vuln("id" => "ALBA-2022:1788", "upstream" => [], "related" => ["RHBA-2022:1788"]), + ) + hits = matcher.resolve_upstream({ "ALBA-2022:1788" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["ALBA-2022:1788"] + end + + it "drops a record whose upstream CVE cannot be fetched" do + allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return( + vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0404"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0404").and_return(nil) + expect(matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity)).to eq [] end end @@ -155,53 +284,45 @@ def pkg(ecosystem:, name:, version:, purl:) let(:exiftool) do formula("exiftool") do T.bind(self, T.class_of(Formula)) - url "https://cpan.metacpan.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-12.00.tar.gz" + url "https://cpan.metacpan.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-13.55.tar.gz" head "https://github.com/exiftool/exiftool.git" end end before { stub_repology_lookup({ "Debian" => ["libimage-exiftool-perl"] }) } - it "queries every strategy in one batch, fetches full records, and dedups by CVE alias" do + it "queries versionlessly, resolves distro upstream to CVEs, and dedups by CVE alias" do expect(Homebrew::Vulns::OSV).to receive(:query_batch).with( [ - { ecosystem: "GIT", name: "https://github.com/exiftool/exiftool", version: "12.00" }, + { ecosystem: "GIT", name: "https://github.com/exiftool/exiftool", version: nil }, { ecosystem: "Debian", name: "libimage-exiftool-perl", version: nil }, ], ).and_return( [ [{ "id" => "CVE-2021-22204" }], - [{ "id" => "DSA-4910-1" }, { "id" => "DSA-0000-0" }], + [{ "id" => "DEBIAN-CVE-2021-22204" }, { "id" => "DSA-4910-1" }], ], ) allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-22204").and_return( - { "id" => "CVE-2021-22204", "aliases" => ["GHSA-xxxx-yyyy-zzzz"] }, + { "id" => "CVE-2021-22204", "aliases" => ["GHSA-xxxx"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DEBIAN-CVE-2021-22204").and_return( + { "id" => "DEBIAN-CVE-2021-22204", "upstream" => ["CVE-2021-22204"] }, ) allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-4910-1").and_return( - { "id" => "DSA-4910-1", "aliases" => ["CVE-2021-22204"] }, + { "id" => "DSA-4910-1", "upstream" => ["CVE-2021-22204", "CVE-2021-99999"] }, ) - allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-0000-0").and_return( - { "id" => "DSA-0000-0", "aliases" => [] }, + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-99999").and_return( + { "id" => "CVE-2021-99999" }, ) hits = matcher.advisories_for(exiftool) - expect(hits.map(&:canonical_id).sort).to eq ["CVE-2021-22204", "DSA-0000-0"] + expect(hits.map(&:canonical_id).sort).to eq ["CVE-2021-22204", "CVE-2021-99999"] merged = hits.find { |h| h.canonical_id == "CVE-2021-22204" } - expect(merged.strategy).to eq :git - expect(merged.evidence.map(&:strategy)).to eq [:git, :cpansa, :distro] - expect(merged.vulnerability.id).to eq "CVE-2021-22204" - expect(hits.find { |h| h.canonical_id == "DSA-0000-0" }.strategy).to eq :distro - end - - it "drops ids whose full record cannot be fetched" do - allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return( - [[{ "id" => "CVE-9999-0000" }], []], - ) - allow(Homebrew::Vulns::OSV).to receive(:vulnerability) - .and_raise(Homebrew::Vulns::OSV::ApiError, "404") - - expect(matcher.advisories_for(exiftool)).to eq [] + expect(T.must(merged).strategy).to eq :git + expect(T.must(merged).evidence.map(&:strategy).uniq.sort).to eq [:cpansa, :distro, :git] + expect(T.must(merged).evidence.find { |e| e.strategy == :cpansa }&.advisory).not_to be_nil end it "returns [] without hitting OSV when nothing is identifiable" do @@ -226,7 +347,7 @@ def pkg(ecosystem:, name:, version:, purl:) end end - describe "#to_brew_record and helpers" do + describe "#to_brew_record" do let(:requests) do formula("requests") do T.bind(self, T.class_of(Formula)) @@ -238,221 +359,157 @@ def pkg(ecosystem:, name:, version:, purl:) end let(:now) { Time.utc(2026, 7, 27, 12, 0, 0) } - def make_hit(id:, aliases: [], fixed: [], strategy: :registry, key: "pkg:pypi/requests@2.31.0", - resource: nil, extra_evidence: []) - affected = fixed.any? ? [{ "ranges" => [{ "events" => fixed.map { |f| { "fixed" => f } } }] }] : [] - vuln = Homebrew::Vulns::Vulnerability.new({ "id" => id, "aliases" => aliases, "affected" => affected, - "summary" => "s", "details" => "d" }) - described_class::Hit.new( - vulnerability: vuln, - evidence: [described_class::Evidence.new(strategy:, key:, resource:), *extra_evidence], + def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, name: "requests") + make_hit( + vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-abcd"], "summary" => "s", + "severity" => [{ "type" => "CVSS_V3", "score" => "..." }], + "references" => [{ "type" => "ADVISORY", "url" => "https://x" }], + "affected" => [{ "package" => { "ecosystem" => "PyPI", "name" => name }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => affected_events }] }]), + ev(:registry, ecosystem: "PyPI", name:, subject_version:, + key: "pkg:pypi/#{name}@#{subject_version}", resource:), ) end - describe "#upstream_fix_shipped?" do - it "is true when the subject version is at or past the lowest upstream fixed version" do - hit = make_hit(id: "CVE-1", fixed: ["2.28.1", "2.30.0"]) - expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be true - end - - it "is false when the subject version is below every upstream fixed version" do - hit = make_hit(id: "CVE-1", fixed: ["2.32.0"]) - expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be false - end - - it "is false when there are no fixed versions or no subject version" do - expect(matcher.upstream_fix_shipped?(requests.version, make_hit(id: "CVE-1", fixed: []))).to be false - expect(matcher.upstream_fix_shipped?(nil, make_hit(id: "CVE-1", fixed: ["1.0"]))).to be false - end - - it "ignores distro-strategy fixed versions" do - hit = make_hit(id: "CVE-1", fixed: ["1:2.28.1-1+deb12u1"], strategy: :distro, key: "Debian/requests") - expect(matcher.comparable_fix_threshold(hit)).to be_nil - expect(matcher.upstream_fix_shipped?(requests.version, hit)).to be false - end - - it "strips a leading v from upstream fixed versions before comparing" do - hit = make_hit(id: "CVE-1", fixed: ["v2.28.1"]) - expect(matcher.comparable_fix_threshold(hit)).to eq Version.new("2.28.1") - end + it "emits fixed=pkg_version and fix: bump when the range says the shipped version is not affected" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record[:id]).to eq "BREW-requests-CVE-2024-1234" + expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd"] + expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }] + expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }] + aff = record[:affected].first + expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests") + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", + events: [{ introduced: "0" }, { fixed: requests.pkg_version.to_s }] }] + expect(aff[:ecosystem_specific]).to eq(fix: "bump", upstream_fixed_in: "2.28.1") + expect(record.dig(:database_specific, :source)).to eq "matched" + expect(record.dig(:database_specific, :strategy)).to eq "registry" + expect(record.dig(:database_specific, :confidence)).to eq "high" end - describe "#subject_version" do - it "returns the resource's pinned version for a resource hit" do - hit = make_hit(id: "CVE-1", key: "pkg:pypi/certifi@2024.2.2", resource: "certifi") - expect(matcher.subject_version(requests, hit)).to eq Version.new("2024.2.2") - end + it "emits no fixed event and fix: nil when the range says the shipped version is still affected" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.32.0" }]) - it "returns the formula version for a primary hit" do - expect(matcher.subject_version(requests, make_hit(id: "CVE-1"))).to eq Version.new("2.31.0") - end + record = matcher.to_brew_record(requests, hit, now:) - it "returns nil when the resource no longer exists in the formula" do - hit = make_hit(id: "CVE-1", resource: "gone") - expect(matcher.subject_version(requests, hit)).to be_nil - end + aff = record[:affected].first + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }] + expect(aff[:ecosystem_specific]).to eq(fix: nil, upstream_fixed_in: "2.32.0") end - describe "#first_fixed_version" do - def stub_history(versions_newest_first) - fv = instance_double(FormulaVersions) - revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] } - allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } } - versions_newest_first.each_with_index do |v, i| - old = if v - formula("requests") do - T.bind(self, T.class_of(Formula)) - url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{v}.tar.gz" - end - end - allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b| - old && b.call(old) - end - end - allow(FormulaVersions).to receive(:new).and_return(fv) - end - - it "returns the pkg_version at the oldest revision still at or past the threshold" do - stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"]) - hit = make_hit(id: "CVE-1", fixed: ["2.28.1"]) - - expect(matcher.first_fixed_version(requests, hit)).to eq "2.28.1" - end - - it "stops at an unloadable revision and returns the last known fixed pkg_version" do - stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) - hit = make_hit(id: "CVE-1", fixed: ["2.28.1"]) - - expect(matcher.first_fixed_version(requests, hit)).to eq "2.30.0" - end - - it "returns nil when the current version is not yet fixed" do - hit = make_hit(id: "CVE-1", fixed: ["2.32.0"]) - expect(FormulaVersions).not_to receive(:new) - - expect(matcher.first_fixed_version(requests, hit)).to be_nil - end - - it "returns nil for a distro-strategy hit (no comparable threshold)" do - hit = make_hit(id: "CVE-1", fixed: ["1:2.28.1-1"], strategy: :distro, key: "Debian/requests") - expect(matcher.first_fixed_version(requests, hit)).to be_nil - end + it "emits fix: nil and demotes confidence when no comparable range exists (GIT-only)" do + hit = make_hit( + vuln("id" => "CVE-2026-32316", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + ]), + ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", subject_version: "1.8.1"), + ) - it "caches the rev-list per formula across hits" do - fv = instance_double(FormulaVersions) - expect(fv).to receive(:rev_list).once { |_, &b| b.call("r0", "p") } - allow(fv).to receive(:formula_at_revision).and_return(nil) - allow(FormulaVersions).to receive(:new).once.and_return(fv) + record = matcher.to_brew_record(requests, hit, now:) - matcher.first_fixed_version(requests, make_hit(id: "CVE-1", fixed: ["1.0"])) - matcher.first_fixed_version(requests, make_hit(id: "CVE-2", fixed: ["1.0"])) - end + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }] + expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil) + expect(record.dig(:database_specific, :confidence)).to eq "medium" end - describe "#to_brew_record" do - before do - allow(matcher).to receive(:fetch_vulnerability).and_return( - { "id" => "CVE-2024-1234", "severity" => [{ "type" => "CVSS_V3", "score" => "..." }], - "references" => [{ "type" => "ADVISORY", "url" => "https://x" }] }, - ) - end + it "prefers an explicit first_fixed over the derived value" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }]) - it "emits a matched OSV record with fixed=pkg_version when the upstream fix is shipped" do - hit = make_hit(id: "CVE-2024-1234", aliases: ["GHSA-abcd-efgh-ijkl"], fixed: ["2.28.1"]) - - record = matcher.to_brew_record(requests, hit, now:) - - expect(record[:schema_version]).to eq Homebrew::Vulns::OsvExport::SCHEMA_VERSION - expect(record[:id]).to eq "BREW-requests-CVE-2024-1234" - expect(record[:published]).to eq "2026-07-27T12:00:00Z" - expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd-efgh-ijkl"] - expect(record[:summary]).to eq "s" - expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }] - expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }] - - aff = record[:affected].first - expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests") - expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, - { fixed: requests.pkg_version.to_s }] }] - expect(aff[:ecosystem_specific]).to eq(fix: "bump") - - db = record[:database_specific] - expect(db[:source]).to eq "matched" - expect(db[:strategy]).to eq "registry" - expect(db[:confidence]).to eq "high" - expect(db[:upstream_evidence]).to eq [{ strategy: :registry, key: "pkg:pypi/requests@2.31.0" }] - end + record = matcher.to_brew_record(requests, hit, first_fixed: "2.28.1_1", now:) - it "prefers an explicit first_fixed over the current pkg_version" do - hit = make_hit(id: "CVE-2024-1234", fixed: ["2.28.1"]) - - record = matcher.to_brew_record(requests, hit, first_fixed: "2.28.1_1", now:) + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }, { fixed: "2.28.1_1" }] + end - expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }, { fixed: "2.28.1_1" }] - end + it "records resource name and purl and evaluates against the resource's pinned version" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2024.2.2" }], + subject_version: "2024.2.2", resource: "certifi", name: "certifi") - it "omits the fixed event and sets fix: nil when no upstream fix is shipped" do - hit = make_hit(id: "CVE-2024-1234", fixed: ["2.32.0"]) + record = matcher.to_brew_record(requests, hit, now:) - record = matcher.to_brew_record(requests, hit, now:) + expect(record.dig(:affected, 0, :ecosystem_specific)) + .to eq(fix: "bump", upstream_fixed_in: "2024.2.2", resource: "certifi", + resource_purl: "pkg:pypi/certifi@2024.2.2") + expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s) + end + end - aff = record[:affected].first - expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }] - expect(aff[:ecosystem_specific]).to eq(fix: nil) + describe "#first_fixed_version" do + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" end + end - it "records resource name and purl and compares against the resource's pinned version" do - hit = make_hit(id: "CVE-2024-1234", fixed: ["2024.2.2"], key: "pkg:pypi/certifi@2024.2.2", - resource: "certifi") - - record = matcher.to_brew_record(requests, hit, now:) - - expect(record.dig(:affected, 0, :ecosystem_specific)) - .to eq(fix: "bump", resource: "certifi", resource_purl: "pkg:pypi/certifi@2024.2.2") - expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s) + def stub_history(versions_newest_first) + fv = instance_double(FormulaVersions) + revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] } + allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } } + versions_newest_first.each_with_index do |v, i| + old = if v + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{v}.tar.gz" + end + end + allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b| + old && b.call(old) + end end + allow(FormulaVersions).to receive(:new).and_return(fv) + end - it "reports distro strategy at low confidence with all evidence listed" do - hit = make_hit(id: "CVE-2024-1234", fixed: ["1:2.28.1-1"], strategy: :distro, key: "Debian/requests", - extra_evidence: [described_class::Evidence.new(strategy: :distro, key: "Alpine/py3-requests")]) + def hit_fixed_at(fixed) + make_hit( + vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => fixed }] }] }, + ]), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ) + end - record = matcher.to_brew_record(requests, hit, now:) + it "returns the pkg_version at the oldest revision still at or past upstream fixed_in" do + stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.28.1" + end - expect(record.dig(:database_specific, :strategy)).to eq "distro" - expect(record.dig(:database_specific, :confidence)).to eq "low" - expect(record.dig(:database_specific, :upstream_evidence)) - .to eq [{ strategy: :distro, key: "Debian/requests" }, - { strategy: :distro, key: "Alpine/py3-requests" }] - expect(record.dig(:affected, 0, :ecosystem_specific, :fix)).to be_nil - end + it "stops at an unloadable revision and returns the last known fixed pkg_version" do + stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.30.0" end - end - describe described_class::Hit do - def vuln(id, aliases: []) - Homebrew::Vulns::Vulnerability.new({ "id" => id, "aliases" => aliases }) + it "returns nil when the current version is still affected" do + expect(FormulaVersions).not_to receive(:new) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.32.0"))).to be_nil end - def ev(strategy, key: "k") - Homebrew::Vulns::Match::Evidence.new(strategy:, key:) + it "returns nil when there is no comparable range" do + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "requests")) + expect(matcher.first_fixed_version(requests, hit)).to be_nil end + end + describe Homebrew::Vulns::Match::Hit do it "sorts evidence by descending strategy precision and reports the highest as #strategy" do - hit = described_class.new(vulnerability: vuln("CVE-1"), - evidence: [ev(:distro), ev(:git), ev(:registry)]) + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro), ev(:git), ev(:registry)) expect(hit.evidence.map(&:strategy)).to eq [:git, :registry, :distro] expect(hit.strategy).to eq :git end it "uses the lowest CVE alias as canonical_id, or the record id when there is none" do - expect(described_class.new(vulnerability: vuln("GHSA-x", aliases: ["CVE-2024-2", "CVE-2024-1"]), - evidence: [ev(:git)]).canonical_id).to eq "CVE-2024-1" - expect(described_class.new(vulnerability: vuln("GHSA-y"), - evidence: [ev(:git)]).canonical_id).to eq "GHSA-y" + expect(make_hit(vuln("id" => "GHSA-x", "aliases" => ["CVE-2024-2", "CVE-2024-1"]), + ev(:git)).canonical_id).to eq "CVE-2024-1" + expect(make_hit(vuln("id" => "GHSA-y"), ev(:git)).canonical_id).to eq "GHSA-y" end it "rejects empty evidence" do - expect { described_class.new(vulnerability: vuln("CVE-1"), evidence: []) } + expect { described_class.new(vulnerability: vuln("id" => "CVE-1"), evidence: []) } .to raise_error(ArgumentError) end end diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index 5bdf12a16f018..dc03707891da6 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -14,13 +14,14 @@ module Vulns # Authoring-time advisory matcher. For a given {Formula} it derives every # OSV.dev query key it can (forge repository, language-registry package for # the primary URL and each `resource`, distro source packages via - # {Repology}, CPAN distribution via {CPANSec}) and returns the deduplicated - # set of vulnerabilities any of them hit, tagged with the strategy that - # reached each one. + # {Repology}, CPAN distribution via {CPANSec}), issues *versionless* queries + # against each, resolves distro advisories to their upstream CVEs, and + # evaluates each hit's affected range against the version we ship. # # Runs in `Homebrew/advisory-database` CI and the homebrew-core PR bot to # produce candidate `BREW-*` records for human review; never on a user's - # machine, so request volume and false-positive rate are traded for recall. + # machine, so request volume is traded for recall and every candidate + # carries a strategy/confidence label for the reviewer. class Match include Utils::Output::Mixin @@ -48,7 +49,16 @@ def identifiable? end end - Evidence = Struct.new(:strategy, :key, :resource, keyword_init: true) + # `ecosystem`/`name` are the OSV `package` fields queried, so a hit's + # `affected[]` entry can be matched back to this evidence. + # `subject_version` is the version to evaluate that entry's ranges + # against: the formula version for the primary source, the pinned + # resource version for a resource, `nil` for distro (whose versions are + # not comparable to ours). `advisory` carries the CPANSA record for + # `:cpansa` evidence so its constraint strings survive to + # {#range_status}. + Evidence = Struct.new(:strategy, :ecosystem, :name, :subject_version, :key, :resource, + :advisory, keyword_init: true) class Hit sig { returns(Vulnerability) } @@ -68,14 +78,19 @@ def initialize(vulnerability:, evidence:) ) end + sig { returns(Evidence) } + def primary_evidence + evidence.fetch(0) + end + sig { returns(Symbol) } def strategy - evidence.fetch(0).strategy + primary_evidence.strategy end sig { returns(T.nilable(String)) } def resource - evidence.fetch(0).resource + primary_evidence.resource end sig { returns(String) } @@ -84,11 +99,12 @@ def canonical_id end end - sig { params(repology: T.nilable(Repology), cpan_sec: T.nilable(CPANSec)).void } - def initialize(repology: nil, cpan_sec: nil) + sig { params(repology: T.nilable(Repology), cpan_sec: T.nilable(CPANSec), bulk: T::Boolean).void } + def initialize(repology: nil, cpan_sec: nil, bulk: false) @repology = repology @cpan_sec = cpan_sec - @vuln_cache = T.let({}, T::Hash[String, T.nilable(T::Hash[String, T.untyped])]) + @bulk = bulk + @vuln_cache = T.let({}, T::Hash[String, T.nilable(Vulnerability)]) @formula_versions = T.let({}, T::Hash[String, FormulaVersions]) @formula_rev_lists = T.let({}, T::Hash[String, T::Array[[String, String]]]) end @@ -120,14 +136,17 @@ def identify(formula) end # Returns one {Hit} per distinct vulnerability (grouped by CVE alias) - # reached by any strategy. Each hit's `evidence` lists every path that - # reached it, highest-precision first. + # reached by any strategy. Distro-ecosystem records are resolved to their + # `upstream` CVE(s) so multi-CVE advisories split into per-CVE hits and + # collapse onto the same CVE reached via GIT/registry. All queries are + # versionless so historic bump-fixed advisories are returned; + # {#range_status} evaluates each hit against the shipped version. sig { params(formula: Formula).returns(T::Array[Hit]) } def advisories_for(formula) identity = identify(formula) return [] unless identity.identifiable? - labelled = build_osv_queries(identity) + labelled = build_osv_queries(identity, formula.version.to_s) id_evidence = T.let({}, T::Hash[String, T::Array[Evidence]]) if labelled.any? @@ -137,77 +156,139 @@ def advisories_for(formula) end end - cpan_advisory_ids(identity).each { |id, evidence| (id_evidence[id] ||= []) << evidence } - - hits = id_evidence.filter_map do |id, evidence| - record = fetch_vulnerability(id) - Hit.new(vulnerability: Vulnerability.new(record), evidence:) if record + cpan_evidence(identity).each do |ev| + cpan_sec.advisories_for(ev.name).each do |adv| + annotated = Evidence.new(**ev.to_h, advisory: adv).freeze + (adv.cves.presence || [adv.id.to_s]).each { |id| (id_evidence[id] ||= []) << annotated } + end end + hits = resolve_upstream(id_evidence, identity) dedup_by_cve(hits) end - sig { params(identity: Identity).returns(T::Array[[OSV::Package, Evidence]]) } - def build_osv_queries(identity) + sig { + params(identity: Identity, formula_version: String).returns(T::Array[[OSV::Package, Evidence]]) + } + def build_osv_queries(identity, formula_version) queries = T.let([], T::Array[[OSV::Package, Evidence]]) - if (repo = identity.git_repo) && (tag = identity.git_tag) - queries << [{ ecosystem: "GIT", name: repo, version: tag }, - Evidence.new(strategy: :git, key: repo).freeze] + if (repo = identity.git_repo) + queries << [{ ecosystem: "GIT", name: repo, version: nil }, + Evidence.new(strategy: :git, ecosystem: "GIT", name: repo, + subject_version: identity.git_tag || formula_version, + key: repo).freeze] end if (pkg = identity.primary_package) && pkg.ecosystem != "CPAN" - queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: pkg.version }, - Evidence.new(strategy: :registry, key: pkg.purl).freeze] + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: nil }, + Evidence.new(strategy: :registry, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: pkg.purl).freeze] end identity.resource_packages.each do |resource, pkg| next if pkg.ecosystem == "CPAN" - queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: pkg.version }, - Evidence.new(strategy: :registry, key: pkg.purl, resource:).freeze] + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: nil }, + Evidence.new(strategy: :registry, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: pkg.purl, resource:).freeze] end identity.distro_packages.each do |ecosystem, srcnames| srcnames.each do |srcname| queries << [{ ecosystem:, name: srcname, version: nil }, - Evidence.new(strategy: :distro, key: "#{ecosystem}/#{srcname}").freeze] + Evidence.new(strategy: :distro, ecosystem:, name: srcname, + key: "#{ecosystem}/#{srcname}").freeze] end end queries end - sig { params(identity: Identity).returns(T::Array[[String, Evidence]]) } - def cpan_advisory_ids(identity) - result = T.let([], T::Array[[String, Evidence]]) - cpan_packages(identity).each do |pkg, resource| - evidence = Evidence.new(strategy: :cpansa, key: pkg.purl, resource:).freeze - cpan_sec.advisories_for(pkg.name).each do |adv| - ids = adv.cves.presence || [adv.id.to_s] - ids.each { |id| result << [id, evidence] } - end + sig { params(identity: Identity).returns(T::Array[Evidence]) } + def cpan_evidence(identity) + result = T.let([], T::Array[Evidence]) + primary = identity.primary_package + if primary&.ecosystem == "CPAN" + result << Evidence.new(strategy: :cpansa, ecosystem: "CPAN", name: primary.name, + subject_version: primary.version, key: primary.purl) + end + identity.resource_packages.each do |resource, pkg| + next if pkg.ecosystem != "CPAN" + + result << Evidence.new(strategy: :cpansa, ecosystem: "CPAN", name: pkg.name, + subject_version: pkg.version, key: pkg.purl, resource:) end result end + CVE_ID = /\ACVE-\d{4}-\d+\z/ + private_constant :CVE_ID + + # Turn `id => [Evidence, ...]` into `[Hit, ...]`, resolving each record to + # the canonical CVE(s) it references. Distro advisories name their CVEs in + # `upstream` (Debian/Ubuntu/RH/openSUSE/...) or `related` (AlmaLinux), + # often mixed with distro-prefixed ids (`DEBIAN-CVE-*`) that would need + # another hop; only bare `CVE-YYYY-N` ids are followed. A record that is + # already a CVE (by id or alias) is kept as-is; one that names no CVE at + # all is kept as a low-confidence hit rather than dropped. Each resolved + # hit gains synthesised evidence pointing at our own identity so + # {#range_status} can check the CVE record's `affected[]` against our + # version. sig { - params(identity: Identity).returns(T::Array[[Identify::RegistryPackage, T.nilable(String)]]) + params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity) + .returns(T::Array[Hit]) } - def cpan_packages(identity) - result = T.let([], T::Array[[Identify::RegistryPackage, T.nilable(String)]]) - primary = identity.primary_package - result << [primary, nil] if primary&.ecosystem == "CPAN" - identity.resource_packages.each do |resource, pkg| - result << [pkg, resource] if pkg.ecosystem == "CPAN" + def resolve_upstream(id_evidence, identity) + own = own_evidence(identity) + hits = T.let([], T::Array[Hit]) + + id_evidence.each do |id, evidence| + record = fetch_vulnerability(id) + next if record.nil? + + upstream_cves = (record.upstream + record.related).grep(CVE_ID).uniq + if record.cve_ids.any? || upstream_cves.empty? + hits << Hit.new(vulnerability: record, evidence:) + next + end + + upstream_cves.each do |cve| + upstream_record = fetch_vulnerability(cve) + next if upstream_record.nil? + + hits << Hit.new(vulnerability: upstream_record, evidence: evidence + own) + end + end + + hits + end + + # Evidence rows pointing at our own identity keys (git repo, primary + # registry package) with the formula/package version as subject. Attached + # to distro-resolved upstream hits so {#range_status} can evaluate the + # upstream CVE record's `affected[]` against something comparable. + sig { params(identity: Identity).returns(T::Array[Evidence]) } + def own_evidence(identity) + result = T.let([], T::Array[Evidence]) + if (repo = identity.git_repo) + result << Evidence.new(strategy: :distro, ecosystem: "GIT", name: repo, + subject_version: identity.git_tag, key: "upstream:#{repo}").freeze + end + if (pkg = identity.primary_package) + result << Evidence.new(strategy: :distro, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: "upstream:#{pkg.purl}").freeze end result end + # Bulk mode (the `--all` sweep) trusts the published index; only a + # single-formula run (the PR bot, or an explicit named check) may hit the + # live Repology API for a formula the index doesn't yet cover. sig { params(name: String).returns(Repology::DistroMap) } def distro_packages_for(name) indexed = repology.distro_packages_for(name) - return indexed if indexed.any? + return indexed if indexed.any? || @bulk Repology.lookup(name) rescue CachedFeed::Error => e @@ -217,11 +298,11 @@ def distro_packages_for(name) # OSV `querybatch` returns id/modified stubs; the full record is fetched # once per id and cached across formulae. - sig { params(id: String).returns(T.nilable(T::Hash[String, T.untyped])) } + sig { params(id: String).returns(T.nilable(Vulnerability)) } def fetch_vulnerability(id) @vuln_cache.fetch(id) do @vuln_cache[id] = begin - OSV.vulnerability(id) + Vulnerability.new(OSV.vulnerability(id)) rescue OSV::Error => e odebug "OSV.vulnerability(#{id}) failed: #{e.message}" nil @@ -229,26 +310,60 @@ def fetch_vulnerability(id) end end + sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } + def dedup_by_cve(hits) + hits.group_by(&:canonical_id).map do |_, group| + next group.fetch(0) if group.one? + + primary = T.must(group.max_by { |h| STRATEGY_PRECISION.fetch(h.strategy) }) + Hit.new(vulnerability: primary.vulnerability, + evidence: group.flat_map(&:evidence).uniq) + end + end + + # Evaluate `hit` against the version we ship, trying each evidence in + # precision order. Returns the first {Vulnerability::RangeStatus} that a + # comparable range yields, or `nil` if no evidence produced a checkable + # answer (e.g. a GIT-only record with commit-SHA ranges, or a distro-only + # hit whose upstream CVE has no `affected[]` matching our identity). + sig { params(hit: Hit).returns(T.nilable(Vulnerability::RangeStatus)) } + def range_status(hit) + hit.evidence.each do |ev| + status = case ev.strategy + when :cpansa + adv = ev.advisory + CPANSec.range_status(adv, ev.subject_version) if adv && ev.subject_version + else + next unless ev.subject_version + + hit.vulnerability.range_status(ev.ecosystem, ev.name, ev.subject_version) + end + return status if status + end + nil + end + # Emit a candidate `BREW-*` OSV record for `hit` against `formula`. # # `first_fixed` is the {PkgVersion} at which Homebrew first shipped a fix - # (from {#first_fixed_version} or a hand-set value); when absent, the - # record marks the current `pkg_version` as fixed if - # {#upstream_fix_shipped?} says so, otherwise it carries no `fixed` event - # and `ecosystem_specific.fix` is null. As with {OsvExport.record_for}, - # {OsvExport.merge_existing} preserves the on-disk `ranges` on rewrite so - # a hand-corrected boundary sticks. + # (from {#first_fixed_version} or a hand-set value). Otherwise + # {#range_status} is consulted: `affected? == false` sets + # `fixed: pkg_version` and `ecosystem_specific.fix: "bump"`; + # `affected? == true` (or no comparable range) emits no `fixed` event and + # `fix: null`. As with {OsvExport.record_for}, {OsvExport.merge_existing} + # preserves on-disk `ranges` on rewrite so a hand-corrected boundary + # sticks. sig { params(formula: Formula, hit: Hit, first_fixed: T.nilable(String), now: Time) .returns(T::Hash[Symbol, T.untyped]) } def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) vuln = hit.vulnerability - raw = fetch_vulnerability(vuln.id) || {} timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + status = range_status(hit) fixed = first_fixed - fixed ||= formula.pkg_version.to_s if upstream_fix_shipped?(subject_version(formula, hit), hit) + fixed ||= formula.pkg_version.to_s if status && !status.affected? events = T.let([{ introduced: "0" }], T::Array[T::Hash[Symbol, String]]) events << { fixed: } if fixed @@ -257,32 +372,45 @@ def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) id: "#{OsvExport::ID_PREFIX}-#{formula.name}-#{hit.canonical_id}", published: timestamp, modified: timestamp, - upstream: vuln.identifiers.uniq, - affected: [affected_entry(formula, hit, events, fixed)], + upstream: vuln.identifiers, + affected: [affected_entry(formula, hit, events, fixed, status)], database_specific: { source: "matched", strategy: hit.strategy.to_s, - confidence: CONFIDENCE.fetch(hit.strategy), - upstream_evidence: hit.evidence.map { |e| e.to_h.compact }, + confidence: confidence_for(hit, status), + upstream_evidence: hit.evidence.map { |e| e.to_h.except(:advisory).compact }, }, }, T::Hash[Symbol, T.untyped]) record[:summary] = vuln.summary if vuln.summary record[:details] = vuln.details if vuln.details - record[:severity] = raw["severity"] if raw["severity"] - if (refs = raw["references"]) + record[:severity] = vuln.severity_entries if vuln.severity_entries.any? + if (refs = vuln.references).any? record[:references] = refs.uniq { |r| [r["type"], URI::RFC2396_PARSER.unescape(r["url"].to_s)] } end record end + sig { + params(hit: Hit, status: T.nilable(Vulnerability::RangeStatus)).returns(String) + } + def confidence_for(hit, status) + base = CONFIDENCE.fetch(hit.strategy) + return base if status + + # No comparable range: the reviewer must set the boundary by hand. + (base == "high") ? "medium" : "low" + end + sig { params(formula: Formula, hit: Hit, events: T::Array[T::Hash[Symbol, String]], - fixed: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) + fixed: T.nilable(String), status: T.nilable(Vulnerability::RangeStatus)) + .returns(T::Hash[Symbol, T.untyped]) } - def affected_entry(formula, hit, events, fixed) + def affected_entry(formula, hit, events, fixed, status) eco = T.let({ fix: fixed ? "bump" : nil }, T::Hash[Symbol, T.nilable(String)]) + eco[:upstream_fixed_in] = status.fixed_in if status&.fixed_in if (resource = hit.resource) eco[:resource] = resource eco[:resource_purl] = hit.evidence.find { |e| e.resource == resource }&.key @@ -298,58 +426,19 @@ def affected_entry(formula, hit, events, fixed) } end - # For a resource hit, the fix-shipped test compares the resource's pinned - # version (not the formula's) against the upstream threshold; the emitted - # `fixed:` boundary is still the formula's `pkg_version` since that is - # what {ecosystem: Homebrew} range checks match on. - sig { params(formula: Formula, hit: Hit).returns(T.nilable(Version)) } - def subject_version(formula, hit) - if (r = hit.resource) - begin - formula.resource(r)&.version - rescue ResourceMissingError - nil - end - else - formula.version - end - end - - # True when `version` is at or past any upstream fixed version. - # Distro-strategy fixed versions are distro-specific strings - # (`1:8.5.0-2`, `+dfsg-1`) and are not compared. Uses {Version}, not - # {Semver}, since formula versions are not required to be strict semver. - sig { params(version: T.nilable(Version), hit: Hit).returns(T::Boolean) } - def upstream_fix_shipped?(version, hit) - return false if version.nil? - - threshold = comparable_fix_threshold(hit) - return false if threshold.nil? - - version >= threshold - end - - sig { params(hit: Hit).returns(T.nilable(Version)) } - def comparable_fix_threshold(hit) - return if hit.strategy == :distro - - hit.vulnerability.fixed_versions - .filter_map { |v| Version.new(v.sub(/\Av/i, "")) if v.present? } - .min - end - # Walk homebrew-core git history (newest first) via {FormulaVersions} and - # return the `pkg_version` at the oldest revision where the formula - # version was still at or past the upstream fix threshold. Returns nil - # when there is no comparable threshold or the current version is not yet - # fixed. The rev-list and per-revision loads are cached per formula so - # subsequent hits for the same formula reuse both. + # return the `pkg_version` at the oldest revision where the subject was + # still at or past `upstream_fixed_in`. Returns nil when the current + # version is not yet fixed. The rev-list and per-revision loads are + # cached per formula so subsequent hits reuse both. sig { params(formula: Formula, hit: Hit).returns(T.nilable(String)) } def first_fixed_version(formula, hit) - threshold = comparable_fix_threshold(hit) - return if threshold.nil? - return unless upstream_fix_shipped?(subject_version(formula, hit), hit) + status = range_status(hit) + return if status.nil? || status.affected? + return unless (upstream_fixed = status.fixed_in) + threshold = Version.new(upstream_fixed) + resource = hit.resource fv = @formula_versions[formula.name] ||= FormulaVersions.new(formula) revs = @formula_rev_lists[formula.name] ||= [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } @@ -357,10 +446,9 @@ def first_fixed_version(formula, hit) last_fixed = T.let(formula.pkg_version.to_s, T.nilable(String)) revs.each do |rev, entry| old_fixed = fv.formula_at_revision(rev, entry) do |old| - old.pkg_version.to_s if upstream_fix_shipped?(subject_version(old, hit), hit) + subject = subject_version(old, resource) + old.pkg_version.to_s if subject && subject >= threshold end - # `nil` from formula_at_revision means the revision failed to load; - # a `nil` block result means the version dropped below the threshold. return last_fixed if old_fixed.nil? last_fixed = old_fixed @@ -368,14 +456,16 @@ def first_fixed_version(formula, hit) last_fixed end - sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } - def dedup_by_cve(hits) - hits.group_by(&:canonical_id).map do |_, group| - next group.fetch(0) if group.one? - - primary = T.must(group.max_by { |h| STRATEGY_PRECISION.fetch(h.strategy) }) - Hit.new(vulnerability: primary.vulnerability, - evidence: group.flat_map(&:evidence).uniq) + sig { params(formula: Formula, resource: T.nilable(String)).returns(T.nilable(Version)) } + def subject_version(formula, resource) + if resource + begin + formula.resource(resource)&.version + rescue ResourceMissingError + nil + end + else + formula.version end end end From ac858185a3c103fd85b5811fa0908d9ab78cdf3d Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 11:33:57 +0100 Subject: [PATCH 17/28] vulns/match: batch OSV queries across formulae and stream output each_advisory_batch builds labelled queries for a chunk of formulae at once, sends them through one OSV.query_batch (which slices at BATCH_SIZE), and yields (formula, hits) in input order. advisories_for becomes a single-element wrapper. The vulnerability cache still spans chunks so a CVE fetched for one formula is reused for the next. dev-cmd/advisory-match streams via an Emitter: --output writes each record as it is produced and only accumulates counts; text mode counts; --json still builds the array (single-formula / PR-bot use, so bounded). This lets --all iterate the whole tap without holding every record in memory or issuing one querybatch per formula. --- Library/Homebrew/dev-cmd/advisory-match.rb | 125 +++++++++++++----- .../test/dev-cmd/advisory-match_spec.rb | 4 +- Library/Homebrew/test/vulns/match_spec.rb | 28 ++++ Library/Homebrew/vulns/match.rb | 58 ++++++-- 4 files changed, 171 insertions(+), 44 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index 711d693886e2a..9e371998d2991 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -48,8 +48,20 @@ def run matcher = Homebrew::Vulns::Match.new(bulk: args.all? || args.index?) next emit_index(matcher) if args.index? - records = each_formula.flat_map { |f| records_for(matcher, f) } - emit(records) + emitter = build_emitter + begin + matcher.each_advisory_batch(each_formula) do |formula, hits| + report(matcher, formula, hits) if text_mode? + hits.each do |hit| + first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? + emitter << matcher.to_brew_record(formula, hit, first_fixed:) + end + end + rescue Homebrew::Vulns::OSV::Error => e + onoe "OSV query failed: #{e.message}" + Homebrew.failed = true + end + emitter.finish end end end @@ -72,20 +84,6 @@ def each_formula end end - sig { params(matcher: Homebrew::Vulns::Match, formula: Formula).returns(T::Array[T::Hash[Symbol, T.untyped]]) } - def records_for(matcher, formula) - hits = matcher.advisories_for(formula) - report(matcher, formula, hits) if text_mode? - hits.map do |hit| - first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? - matcher.to_brew_record(formula, hit, first_fixed:) - end - rescue Homebrew::Vulns::OSV::Error => e - onoe "OSV query for #{formula.name} failed: #{e.message}" - Homebrew.failed = true - [] - end - sig { returns(T::Boolean) } def text_mode? !args.json? && args.output.nil? @@ -119,25 +117,90 @@ def report(matcher, formula, hits) end end - sig { params(records: T::Array[T::Hash[Symbol, T.untyped]]).void } - def emit(records) - if (dir = args.output) + # `--output` and text mode write per-record and only accumulate counts; + # `--json` accumulates the array (single-formula / PR-bot use, so bounded). + class Emitter + sig { params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record); end + + sig { void } + def finish; end + end + + class DirEmitter < Emitter + sig { params(dir: String, verbose: T::Boolean).void } + def initialize(dir, verbose:) + super() FileUtils.mkdir_p(dir) - written = 0 - records.each do |record| - path = File.join(dir, "#{record.fetch(:id)}.json") - merged = Homebrew::Vulns::OsvExport.merge_existing(path, record) - next if merged.nil? - - File.write(path, "#{JSON.pretty_generate(merged)}\n") - puts " wrote #{path}" if args.verbose? - written += 1 + @dir = dir + @verbose = verbose + @written = T.let(0, Integer) + @unchanged = T.let(0, Integer) + end + + sig { override.params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record) + path = File.join(@dir, "#{record.fetch(:id)}.json") + merged = Homebrew::Vulns::OsvExport.merge_existing(path, record) + if merged.nil? + @unchanged += 1 + return end - ohai "#{written} records written to #{dir} (#{records.size - written} unchanged)" + File.write(path, "#{JSON.pretty_generate(merged)}\n") + puts " wrote #{path}" if @verbose + @written += 1 + end + + sig { override.void } + def finish + Utils::Output.ohai "#{@written} records written to #{@dir} (#{@unchanged} unchanged)" + end + end + + class JsonEmitter < Emitter + sig { void } + def initialize + super + @records = T.let([], T::Array[T::Hash[Symbol, T.untyped]]) + end + + sig { override.params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record) + @records << record + end + + sig { override.void } + def finish + puts JSON.pretty_generate(@records) + end + end + + class CountEmitter < Emitter + sig { void } + def initialize + super + @count = T.let(0, Integer) + end + + sig { override.params(_record: T::Hash[Symbol, T.untyped]).void } + def <<(_record) + @count += 1 + end + + sig { override.void } + def finish + Utils::Output.ohai "#{@count} candidate records" + end + end + + sig { returns(Emitter) } + def build_emitter + if (dir = args.output) + DirEmitter.new(dir, verbose: args.verbose?) elsif args.json? - puts JSON.pretty_generate(records) + JsonEmitter.new else - ohai "#{records.size} candidate records" + CountEmitter.new 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 bd89d2ab9aa72..996940dd17371 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -82,12 +82,12 @@ def stub_osv_hit(cve, fixed:) .to_stdout end - it "reports and continues past an OSV outage without raising" do + it "reports an OSV outage and finishes the emitter without raising" do allow(Homebrew::Vulns::OSV).to receive(:query_batch) .and_raise(Homebrew::Vulns::OSV::ApiError, "503") expect { cmd_for("requests", "--json").run } - .to output("[]\n").to_stdout.and output(/OSV query for requests failed/).to_stderr + .to output("[]\n").to_stdout.and output(/OSV query failed: 503/).to_stderr expect(Homebrew.failed?).to be true end diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index e42a9d4e407fc..2fdeb4971d8bc 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -280,6 +280,34 @@ def pkg(ecosystem:, name:, version:, purl:) end end + describe "#each_advisory_batch" do + it "sends every formula's queries through one OSV.query_batch and yields per-formula hits" do + a = formula("aa") do + T.bind(self, T.class_of(Formula)) + url "https://github.com/owner/aa/archive/refs/tags/v1.0.tar.gz" + end + b = formula("bb") do + T.bind(self, T.class_of(Formula)) + url "https://github.com/owner/bb/archive/refs/tags/v2.0.tar.gz" + end + bulk = described_class.new(repology:, cpan_sec:, bulk: true) + + expect(Homebrew::Vulns::OSV).to receive(:query_batch).once.with( + [ + { ecosystem: "GIT", name: "https://github.com/owner/aa", version: nil }, + { ecosystem: "GIT", name: "https://github.com/owner/bb", version: nil }, + ], + ).and_return([[{ "id" => "CVE-2024-0001" }], []]) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-0001") + .and_return({ "id" => "CVE-2024-0001" }) + + yielded = T.let([], T::Array[[String, T::Array[String]]]) + bulk.each_advisory_batch([a, b]) { |f, hits| yielded << [f.name, hits.map(&:canonical_id)] } + + expect(yielded).to eq [["aa", ["CVE-2024-0001"]], ["bb", []]] + end + end + describe "#advisories_for" do let(:exiftool) do formula("exiftool") do diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index dc03707891da6..c296710e682be 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -143,28 +143,64 @@ def identify(formula) # {#range_status} evaluates each hit against the shipped version. sig { params(formula: Formula).returns(T::Array[Hit]) } def advisories_for(formula) - identity = identify(formula) - return [] unless identity.identifiable? + result = T.let([], T::Array[Hit]) + each_advisory_batch([formula]) { |_, hits| result = hits } + result + end - labelled = build_osv_queries(identity, formula.version.to_s) - id_evidence = T.let({}, T::Hash[String, T::Array[Evidence]]) + BULK_CHUNK = 200 + private_constant :BULK_CHUNK - if labelled.any? - OSV.query_batch(labelled.map(&:first)).each_with_index do |stubs, i| - evidence = labelled.fetch(i).last - stubs.each { |stub| (id_evidence[stub.fetch("id")] ||= []) << evidence } + # Bulk form of {#advisories_for}: builds the labelled queries for a chunk + # of formulae at once, sends them through a single {OSV.query_batch} + # (which itself slices at `BATCH_SIZE`), then yields `(formula, hits)` in + # input order. Per-formula query counts vary widely (one distro entry per + # ecosystem×srcname), so chunking bounds memory without accumulating the + # whole tap's queries or records; the `@vuln_cache` still spans chunks. + sig { + params(formulae: T::Enumerable[Formula], + _blk: T.proc.params(formula: Formula, hits: T::Array[Hit]).void).void + } + def each_advisory_batch(formulae, &_blk) + formulae.each_slice(BULK_CHUNK) do |chunk| + identities = chunk.map { |f| [f, identify(f)] } + labelled = T.let([], T::Array[[OSV::Package, [Formula, Evidence]]]) + identities.each do |f, identity| + next unless identity.identifiable? + + build_osv_queries(identity, f.version.to_s).each do |query, evidence| + labelled << [query, [f, evidence]] + end + end + + by_formula = T.let({}, T::Hash[Formula, T::Hash[String, T::Array[Evidence]]]) + if labelled.any? + OSV.query_batch(labelled.map(&:first)).each_with_index do |stubs, i| + formula, evidence = labelled.fetch(i).last + id_evidence = by_formula[formula] ||= {} + stubs.each { |stub| (id_evidence[stub.fetch("id")] ||= []) << evidence } + end + end + + identities.each do |f, identity| + next yield f, [] unless identity.identifiable? + + yield f, hits_from(by_formula[f] || {}, identity) end end + end + sig { + params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity).returns(T::Array[Hit]) + } + def hits_from(id_evidence, identity) cpan_evidence(identity).each do |ev| cpan_sec.advisories_for(ev.name).each do |adv| annotated = Evidence.new(**ev.to_h, advisory: adv).freeze (adv.cves.presence || [adv.id.to_s]).each { |id| (id_evidence[id] ||= []) << annotated } end end - - hits = resolve_upstream(id_evidence, identity) - dedup_by_cve(hits) + dedup_by_cve(resolve_upstream(id_evidence, identity)) end sig { From 269b3ae4f084cfa5e40ce54e1d37062a95035152 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 11:41:05 +0100 Subject: [PATCH 18/28] vulns: raise let-free spec files to typed: strict utils/repology_spec, identify_spec and purl_spec go to typed: strict with sigs added on their helper methods. cpan_sec_spec, vulns/repology_spec, match_spec and dev-cmd/advisory-match_spec stay at typed: true because they use let, which generates a sig-less method that strict rejects (no brew spec at typed: strict uses let). --- Library/Homebrew/test/utils/repology_spec.rb | 6 +++++- Library/Homebrew/test/vulns/identify_spec.rb | 3 ++- Library/Homebrew/test/vulns/purl_spec.rb | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/test/utils/repology_spec.rb b/Library/Homebrew/test/utils/repology_spec.rb index 288da0d1fd798..df0de5a65dce2 100644 --- a/Library/Homebrew/test/utils/repology_spec.rb +++ b/Library/Homebrew/test/utils/repology_spec.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "utils/repology" @@ -10,6 +10,10 @@ end describe ".single_package_query" do + sig { + params(success: T::Boolean, stdout: String, stderr: String, exit_status: Integer) + .returns(T.untyped) + } def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) instance_double(SystemCommand::Result, success?: success, stdout:, stderr:, exit_status:) end diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index 12f17edcd405c..65fff1bffa4a3 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "vulns/identify" @@ -146,6 +146,7 @@ end describe ".registry_package" do + sig { params(url: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, T.untyped])) } def result(url) described_class.registry_package(url)&.to_h end diff --git a/Library/Homebrew/test/vulns/purl_spec.rb b/Library/Homebrew/test/vulns/purl_spec.rb index 40b5ff291acd1..1630dae0c9726 100644 --- a/Library/Homebrew/test/vulns/purl_spec.rb +++ b/Library/Homebrew/test/vulns/purl_spec.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "vulns/purl" From 8aa628fb3dd513d733e4dc44992ed432f197e3e9 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 12:05:55 +0100 Subject: [PATCH 19/28] vulns: three-state range status over the union of matching entries Vulnerability::RangeStatus.state is :affected, :fixed, or :not_applicable (below every introduced boundary; distinct from :fixed so a version the vulnerability never applied to is not recorded as a bump fix). range_status now iterates every affected[] entry whose package matches {ecosystem, name} rather than the first, so records that split one package across several disjoint ranges (GHSA-jfh8-c2jp-5v3q's three log4j-core entries) evaluate correctly. A last_affected boundary is not reported as fixed_in for an affected version, and the not-affected side of a last_affected interval requires target > boundary (not >=) to count as :fixed. CPANSec.range_status returns the same three states, using the highest fixed lower-bound at or below the target for :fixed and reporting :not_applicable when the version satisfies no affected constraint and sits below every fixed bound. --- .../Homebrew/test/vulns/vulnerability_spec.rb | 47 +++++++++-- Library/Homebrew/vulns/cpan_sec.rb | 14 ++-- Library/Homebrew/vulns/vulnerability.rb | 83 +++++++++++-------- 3 files changed, 99 insertions(+), 45 deletions(-) diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index dc908e1b2bce5..3e112ceb2f3a9 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -199,6 +199,43 @@ def range(type, *events) .to have_attributes(affected?: true, fixed_in: "1.8.2") end + it "unions all affected entries for the same package (Log4Shell has three)" do + log4j = lambda do |intro, fixed| + affected("Maven", "org.apache.logging.log4j:log4j-core", + range("ECOSYSTEM", { "introduced" => intro }, { "fixed" => fixed })) + end + v = vuln("id" => "GHSA-jfh8-c2jp-5v3q", "affected" => [ + log4j.call("2.0-beta9", "2.3.2"), + log4j.call("2.4", "2.12.4"), + log4j.call("2.13.0", "2.17.0"), + ]) + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.3.0")) + .to have_attributes(state: :affected, fixed_in: "2.3.2") + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.10.0")) + .to have_attributes(state: :affected, fixed_in: "2.12.4") + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.17.0")) + .to have_attributes(state: :fixed, fixed_in: "2.17.0") + end + + it "reports :not_applicable when the target is below every introduced boundary" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })), + ]) + expect(v.range_status("PyPI", "requests", "2.31.0")) + .to have_attributes(state: :not_applicable, fixed_in: nil) + end + + it "does not report a last_affected boundary as fixed_in" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", + range("ECOSYSTEM", { "introduced" => "0" }, { "last_affected" => "2.0" })), + ]) + expect(v.range_status("PyPI", "requests", "2.0")) + .to have_attributes(state: :affected, fixed_in: nil) + expect(v.range_status("PyPI", "requests", "2.1")) + .to have_attributes(state: :fixed, fixed_in: "2.0") + 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", @@ -212,7 +249,7 @@ def range(type, *events) .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 + it "reports :fixed with the highest fixed boundary at or below the target" do v = vuln("id" => "CVE-1", "affected" => [ affected("PyPI", "requests", range("ECOSYSTEM", @@ -220,9 +257,9 @@ def range(type, *events) { "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") + .to have_attributes(state: :fixed, fixed_in: "2.28.1") expect(v.range_status("PyPI", "requests", "3.1.0")) - .to have_attributes(affected?: false, fixed_in: "3.0.4") + .to have_attributes(state: :fixed, fixed_in: "3.0.4") end it "reports affected with no fixed_in for an open-ended interval" do @@ -247,9 +284,9 @@ def range(type, *events) 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) + .to have_attributes(state: :affected, fixed_in: nil) expect(v.range_status("PyPI", "requests", "2.32.0")) - .to have_attributes(affected?: false, fixed_in: nil) + .to have_attributes(state: :not_applicable, fixed_in: nil) end end diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb index 0b376ee120829..b9ee6b4dee84a 100644 --- a/Library/Homebrew/vulns/cpan_sec.rb +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -66,11 +66,15 @@ 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 + bounds = advisory.fixed_versions.flat_map { |c| lower_bounds(c) } + 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) + Vulnerability::RangeStatus.new(state: :fixed, fixed_in:).freeze + else + Vulnerability::RangeStatus.new(state: :not_applicable, fixed_in: nil).freeze + end end CONSTRAINT = /\A\s*(<=|>=|==|<|>|=)?\s*v?(\d[\w.]*)\s*\z/ diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index a8425ccc46f03..a1f78c66f6c62 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -104,13 +104,24 @@ def fixed_versions end.uniq end - RangeStatus = Struct.new(:affected, :fixed_in, keyword_init: true) do + # `state` is `:affected` (in an interval), `:fixed` (past the closing + # boundary of at least one interval), or `:not_applicable` (below the + # `introduced` of every interval; the vulnerability never applied to this + # version). `fixed_in` is the boundary that closed the containing + # interval (`:affected`) or the highest boundary at-or-below the version + # (`:fixed`). + RangeStatus = Struct.new(:state, :fixed_in, keyword_init: true) do sig { returns(T::Boolean) } - def affected? = self[:affected] + def affected? = state == :affected + + sig { returns(T::Boolean) } + def fixed? = state == :fixed end - # Evaluates `version` against the `affected[]` entry whose `package` - # matches `{ecosystem, name}`, honouring range `type`: + # Evaluates `version` against every `affected[]` entry whose `package` + # matches `{ecosystem, name}` (an advisory can carry several disjoint + # entries for the same package, e.g. GHSA-jfh8-c2jp-5v3q's three + # log4j-core ranges), honouring range `type`: # # - `SEMVER` ranges compare with {Semver}. # - `ECOSYSTEM` ranges compare with {Version} (best-effort; the record's @@ -119,53 +130,55 @@ def affected? = self[:affected] # - `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. + # exists in the matching entries, so callers can distinguish + # `:not_applicable`/`:fixed` from "could not check". 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? + entries = affected_entries_for(ecosystem, name) + return if entries.empty? 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 + past_fixes = T.let([], T::Array[String]) + + entries.each do |entry| + Array(entry["ranges"]).each do |range| + type = range["type"] + next if type == "GIT" + + cmp = comparator_for(type) + intervals(Array(range["events"])).each do |lower, upper, upper_inclusive| + checked = true + if in_interval?(target, lower, upper, upper_inclusive, cmp) + return RangeStatus.new(state: :affected, fixed_in: upper_inclusive ? nil : upper).freeze + end + next unless upper + + rel = cmp.call(target, upper) + past_fixes << upper if upper_inclusive ? rel.positive? : rel >= 0 + rescue Uncomparable + checked ||= false end - - candidate_fixes << upper if upper && cmp.call(target, upper) >= 0 - rescue Uncomparable - checked ||= false end - end - versions = Array(entry["versions"]) - if versions.any? + versions = Array(entry["versions"]) + next if versions.empty? + checked = true - return RangeStatus.new(affected: true, fixed_in: nil).freeze if versions.any? do |v| - normalize_version(v.to_s) == target + if versions.any? { |v| normalize_version(v.to_s) == target } + return RangeStatus.new(state: :affected, fixed_in: nil).freeze end end return unless checked - RangeStatus.new(affected: false, fixed_in: candidate_fixes.max_by { |v| Version.new(v) }).freeze + state = past_fixes.any? ? :fixed : :not_applicable + RangeStatus.new(state:, fixed_in: past_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| + sig { params(ecosystem: String, name: String).returns(T::Array[T::Hash[String, T.untyped]]) } + def affected_entries_for(ecosystem, name) + affected.select do |aff| pkg = aff["package"] pkg.is_a?(Hash) && pkg["ecosystem"] == ecosystem && pkg["name"] == name end From 3f0f00fcf7d64fa420c67d64c429d3cdecc2d9a7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 12:06:26 +0100 Subject: [PATCH 20/28] vulns/match: transitive upstream, CPANSA-only hits, threaded prefetch resolve_upstream walks upstream transitively with a per-walk visited set and hop budget so USN -> UBUNTU-CVE-* -> CVE-* resolves; related is consulted for bare CVE ids only when upstream is empty (AlmaLinux ALSA records). A record that reaches no CVE is kept as-is rather than dropped. CPANSA advisories with no CVE alias (102 in the current feed) become hits directly via a synthesised Vulnerability instead of being dropped when their id 404s at OSV. CVE-bearing advisories keep the OSV record as canonical, falling back to the synthesised one only if the fetch fails. prefetch_vulnerabilities warms the record cache for a chunk's stub ids in bounded-concurrency batches before per-formula processing, so resolve_upstream reads mostly from cache instead of issuing serial GETs. range_status returns [status, evidence] so first_fixed_version can re-run the same evidence's range check against each historical subject version, preserving last_affected and exclusive-bound semantics instead of collapsing to a >= threshold. to_brew_record only sets fixed on state == :fixed and records range_state in ecosystem_specific. dev-cmd/advisory-match: --all now conflicts with --json (JsonEmitter accumulates); the text report shows the three-state result. --- Library/Homebrew/dev-cmd/advisory-match.rb | 14 +- .../test/dev-cmd/advisory-match_spec.rb | 15 +- Library/Homebrew/test/vulns/match_spec.rb | 112 +++++++++-- Library/Homebrew/vulns/match.rb | 181 ++++++++++++------ 4 files changed, 234 insertions(+), 88 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index 9e371998d2991..6076ee93b1a52 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -31,6 +31,7 @@ class AdvisoryMatch < AbstractCommand description: "Skip the `FormulaVersions` walk for the `fixed` " \ "boundary; use the current `pkg_version` instead." conflicts "--all", "--index" + conflicts "--all", "--json" conflicts "--index", "--json" conflicts "--index", "--output" @@ -101,13 +102,12 @@ def report(matcher, formula, hits) end hits.sort_by { |h| [-h.vulnerability.severity_level, h.canonical_id] }.each do |hit| v = hit.vulnerability - status = matcher.range_status(hit) - state = if status.nil? - "uncomparable" - elsif status.affected? - status.fixed_in ? "AFFECTED, upstream fix #{status.fixed_in}" : "AFFECTED, no upstream fix" - else - "fixed (upstream #{status.fixed_in || "?"})" + status, = matcher.range_status(hit) + state = case status&.state + when nil then "uncomparable" + when :affected then "AFFECTED#{", upstream fix #{status&.fixed_in}" if status&.fixed_in}" + when :fixed then "fixed (upstream #{status&.fixed_in || "?"})" + else "not applicable" end summary = v.summary&.slice(0, 60) puts " #{hit.canonical_id} [#{hit.strategy}, #{matcher.confidence_for(hit, status)}] " \ diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index 996940dd17371..c44347a5c4100 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -91,7 +91,7 @@ def stub_osv_hit(cve, fixed:) expect(Homebrew.failed?).to be true end - it "iterates every core formula with --all" do + it "iterates every core formula with --all and streams to --output" do requests core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", formula_names: ["requests", "broken"]) @@ -100,9 +100,16 @@ def stub_osv_hit(cve, fixed:) allow(Formulary).to receive(:factory).with("broken").and_raise(RuntimeError, "boom") stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") - expect { described_class.new(["--all", "--json", "--no-history"]).run } - .to output(/BREW-requests-CVE-2024-1234/).to_stdout - .and output(/Error loading formula 'broken': boom/).to_stderr + Dir.mktmpdir do |dir| + expect { described_class.new(["--all", "--output", dir, "--no-history"]).run } + .to output(/1 records written/).to_stdout + .and output(/Error loading formula 'broken': boom/).to_stderr + expect(File).to exist(File.join(dir, "BREW-requests-CVE-2024-1234.json")) + end + end + + it "rejects --all with --json" do + expect { described_class.new(["--all", "--json"]) }.to raise_error(UsageError, /mutually exclusive/) end it "emits the formula-identity index with --index" do diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 2fdeb4971d8bc..17bead652e568 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -166,7 +166,9 @@ def pkg(ecosystem:, name:, version:, purl:) subject_version: "1.8.1"), ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")) - expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "2.28.1") + status, evidence = matcher.range_status(hit) + expect(status).to have_attributes(state: :fixed, fixed_in: "2.28.1") + expect(evidence.strategy).to eq :registry end it "returns nil when the only matching entry has GIT-type ranges" do @@ -188,7 +190,7 @@ def pkg(ecosystem:, name:, version:, purl:) ev(:cpansa, ecosystem: "CPAN", name: "Image-ExifTool", subject_version: "13.55", advisory: adv)) - expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "12.24") + expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "12.24") end it "checks a distro-resolved upstream CVE against attached own-identity evidence" do @@ -202,7 +204,7 @@ def pkg(ecosystem:, name:, version:, purl:) ev(:distro, ecosystem: "GIT", name: "https://github.com/jqlang/jq", subject_version: "1.8.1", key: "upstream:...")) - expect(matcher.range_status(hit)).to have_attributes(affected?: false, fixed_in: "1.6") + expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "1.6") end it "skips evidence with no subject_version" do @@ -211,6 +213,37 @@ def pkg(ecosystem:, name:, version:, purl:) end end + describe "#hits_from" do + let(:cpan_sec) do + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { + "No-CVE-Dist" => { "advisories" => [ + { "id" => "CPANSA-No-CVE-Dist-2020-01", "cves" => [], + "affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"], + "description" => "d", "references" => ["https://x"] }, + ] }, + } }) + 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, + primary_package: Homebrew::Vulns::Identify::RegistryPackage.new( + ecosystem: "CPAN", name: "No-CVE-Dist", version: "0.9", purl: "pkg:cpan/X/No-CVE-Dist@0.9", + ), + resource_packages: {}, distro_packages: {} + ) + expect(Homebrew::Vulns::OSV).not_to receive(:vulnerability) + + hits = matcher.hits_from({}, identity) + + expect(hits.length).to eq 1 + expect(hits.first.vulnerability.id).to eq "CPANSA-No-CVE-Dist-2020-01" + expect(hits.first.vulnerability.references).to eq [{ "type" => "WEB", "url" => "https://x" }] + expect(matcher.range_status(hits.first)&.first) + .to have_attributes(state: :affected, fixed_in: "1.0") + end + end + describe "#resolve_upstream" do let(:identity) do Homebrew::Vulns::Match::Identity.new( @@ -236,23 +269,40 @@ def pkg(ecosystem:, name:, version:, purl:) expect(hits.first.evidence.map(&:ecosystem)).to include("Red Hat", "GIT") end - it "follows only bare CVE ids from upstream/related, ignoring distro-prefixed intermediate ids" do - allow(matcher).to receive(:fetch_vulnerability).with("USN-4787-1").and_return( - vuln("id" => "USN-4787-1", "upstream" => ["CVE-2016-4074", "UBUNTU-CVE-2016-4074"]), + it "follows upstream transitively (USN -> UBUNTU-CVE-* -> CVE-*) with cycle protection" do + allow(matcher).to receive(:fetch_vulnerability).with("USN-8202-1").and_return( + vuln("id" => "USN-8202-1", "upstream" => ["UBUNTU-CVE-2024-0001"]), ) + allow(matcher).to receive(:fetch_vulnerability).with("UBUNTU-CVE-2024-0001").and_return( + vuln("id" => "UBUNTU-CVE-2024-0001", "upstream" => ["CVE-2024-0001", "USN-8202-1"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) + + hits = matcher.resolve_upstream({ "USN-8202-1" => [ev(:distro)] }, identity) + 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 allow(matcher).to receive(:fetch_vulnerability).with("ALSA-1").and_return( - vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001"]), + vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001", "RHSA-2024:1"]), ) - allow(matcher).to receive(:fetch_vulnerability).with("CVE-2016-4074") - .and_return(vuln("id" => "CVE-2016-4074")) allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") .and_return(vuln("id" => "CVE-2024-0001")) - hits = matcher.resolve_upstream( - { "USN-4787-1" => [ev(:distro)], "ALSA-1" => [ev(:distro)] }, identity + hits = matcher.resolve_upstream({ "ALSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + 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"]), ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) - expect(hits.map { |h| h.vulnerability.id }.sort).to eq ["CVE-2016-4074", "CVE-2024-0001"] + hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] end it "keeps a record whose id/aliases already include a CVE as-is" do @@ -271,12 +321,13 @@ def pkg(ecosystem:, name:, version:, purl:) expect(hits.map { |h| h.vulnerability.id }).to eq ["ALBA-2022:1788"] end - it "drops a record whose upstream CVE cannot be fetched" do + it "keeps a record as-is when its upstream CVE cannot be fetched" do allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return( vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0404"]), ) allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0404").and_return(nil) - expect(matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity)).to eq [] + hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["DSA-1"] end end @@ -412,7 +463,7 @@ def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, nam expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests") expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed: requests.pkg_version.to_s }] }] - expect(aff[:ecosystem_specific]).to eq(fix: "bump", upstream_fixed_in: "2.28.1") + expect(aff[:ecosystem_specific]).to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2.28.1") expect(record.dig(:database_specific, :source)).to eq "matched" expect(record.dig(:database_specific, :strategy)).to eq "registry" expect(record.dig(:database_specific, :confidence)).to eq "high" @@ -425,7 +476,7 @@ def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, nam aff = record[:affected].first expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }] - expect(aff[:ecosystem_specific]).to eq(fix: nil, upstream_fixed_in: "2.32.0") + expect(aff[:ecosystem_specific]).to eq(fix: nil, range_state: "affected", upstream_fixed_in: "2.32.0") end it "emits fix: nil and demotes confidence when no comparable range exists (GIT-only)" do @@ -444,6 +495,15 @@ def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, nam expect(record.dig(:database_specific, :confidence)).to eq "medium" end + it "records not_applicable and does not emit fixed for a version below every introduced" do + hit = registry_hit(affected_events: [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }] + expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil, range_state: "not_applicable") + end + it "prefers an explicit first_fixed over the derived value" do hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }]) @@ -459,8 +519,8 @@ def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, nam record = matcher.to_brew_record(requests, hit, now:) expect(record.dig(:affected, 0, :ecosystem_specific)) - .to eq(fix: "bump", upstream_fixed_in: "2024.2.2", resource: "certifi", - resource_purl: "pkg:pypi/certifi@2024.2.2") + .to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2024.2.2", + resource: "certifi", resource_purl: "pkg:pypi/certifi@2024.2.2") expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s) end end @@ -491,22 +551,32 @@ def stub_history(versions_newest_first) allow(FormulaVersions).to receive(:new).and_return(fv) end - def hit_fixed_at(fixed) + def hit_with_range(*events) make_hit( vuln("id" => "CVE-1", "affected" => [ { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, - "ranges" => [{ "type" => "ECOSYSTEM", - "events" => [{ "introduced" => "0" }, { "fixed" => fixed }] }] }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => events }] }, ]), ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), ) end + def hit_fixed_at(fixed) + hit_with_range({ "introduced" => "0" }, { "fixed" => fixed }) + end + it "returns the pkg_version at the oldest revision still at or past upstream fixed_in" do stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"]) expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.28.1" end + it "honours last_affected inclusivity by re-running the range per revision" do + stub_history(["2.31.0", "2.1", "2.0", "1.9"]) + hit = hit_with_range({ "introduced" => "0" }, { "last_affected" => "2.0" }) + # 2.0 is the last *affected* version so 2.1 is the first fixed pkg_version. + expect(matcher.first_fixed_version(requests, hit)).to eq "2.1" + end + it "stops at an unloadable revision and returns the last known fixed pkg_version" do stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.30.0" diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index c296710e682be..b67221b624a16 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -182,6 +182,8 @@ def each_advisory_batch(formulae, &_blk) end end + prefetch_vulnerabilities(by_formula.each_value.flat_map(&:keys)) + identities.each do |f, identity| next yield f, [] unless identity.identifiable? @@ -194,13 +196,33 @@ def each_advisory_batch(formulae, &_blk) params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity).returns(T::Array[Hit]) } def hits_from(id_evidence, identity) + hits = resolve_upstream(id_evidence, identity) cpan_evidence(identity).each do |ev| cpan_sec.advisories_for(ev.name).each do |adv| annotated = Evidence.new(**ev.to_h, advisory: adv).freeze - (adv.cves.presence || [adv.id.to_s]).each { |id| (id_evidence[id] ||= []) << annotated } + if adv.cves.any? + adv.cves.each do |cve| + record = fetch_vulnerability(cve) || cpansa_vulnerability(adv) + hits << Hit.new(vulnerability: record, evidence: [annotated]) + end + else + hits << Hit.new(vulnerability: cpansa_vulnerability(adv), evidence: [annotated]) + end end end - dedup_by_cve(resolve_upstream(id_evidence, identity)) + dedup_by_cve(hits) + end + + sig { params(adv: CPANSec::Advisory).returns(Vulnerability) } + def cpansa_vulnerability(adv) + summary = adv.description.to_s.lines.first&.strip + Vulnerability.new({ + "id" => adv.id.to_s, + "aliases" => adv.cves, + "summary" => summary, + "details" => adv.description, + "references" => adv.references.map { |u| { "type" => "WEB", "url" => u } }, + }.compact) end sig { @@ -261,16 +283,19 @@ def cpan_evidence(identity) CVE_ID = /\ACVE-\d{4}-\d+\z/ private_constant :CVE_ID + MAX_UPSTREAM_HOPS = 5 + private_constant :MAX_UPSTREAM_HOPS + # Turn `id => [Evidence, ...]` into `[Hit, ...]`, resolving each record to - # the canonical CVE(s) it references. Distro advisories name their CVEs in - # `upstream` (Debian/Ubuntu/RH/openSUSE/...) or `related` (AlmaLinux), - # often mixed with distro-prefixed ids (`DEBIAN-CVE-*`) that would need - # another hop; only bare `CVE-YYYY-N` ids are followed. A record that is - # already a CVE (by id or alias) is kept as-is; one that names no CVE at - # all is kept as a low-confidence hit rather than dropped. Each resolved - # hit gains synthesised evidence pointing at our own identity so - # {#range_status} can check the CVE record's `affected[]` against our - # version. + # the CVE(s) it derives from. `upstream` is walked transitively with a + # per-walk visited set (chains like `USN -> UBUNTU-CVE-* -> CVE-*` occur + # in practice). `related` links to different vulnerabilities per the OSV + # schema and is only consulted for its bare CVE ids when `upstream` is + # empty (AlmaLinux ALSA records use it that way). A record that is + # already a CVE by id or alias, or that reaches no CVE within the hop + # budget, is kept as-is. Each resolved hit gains synthesised evidence + # pointing at our own identity so {#range_status} can check the CVE + # record's `affected[]` against our version. sig { params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity) .returns(T::Array[Hit]) @@ -283,23 +308,41 @@ def resolve_upstream(id_evidence, identity) record = fetch_vulnerability(id) next if record.nil? - upstream_cves = (record.upstream + record.related).grep(CVE_ID).uniq - if record.cve_ids.any? || upstream_cves.empty? + resolved = resolve_to_cves(record, Set[id], MAX_UPSTREAM_HOPS) + if resolved.empty? hits << Hit.new(vulnerability: record, evidence:) next end - upstream_cves.each do |cve| - upstream_record = fetch_vulnerability(cve) - next if upstream_record.nil? - - hits << Hit.new(vulnerability: upstream_record, evidence: evidence + own) + resolved.each do |cve_record| + ev = cve_record.equal?(record) ? evidence : evidence + own + hits << Hit.new(vulnerability: cve_record, evidence: ev) end end hits end + # 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). + sig { + params(record: Vulnerability, seen: T::Set[String], budget: Integer) + .returns(T::Array[Vulnerability]) + } + def resolve_to_cves(record, seen, budget) + 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| + next [] unless seen.add?(ref) + + upstream = fetch_vulnerability(ref) + upstream ? resolve_to_cves(upstream, seen, budget - 1) : [] + end.uniq(&:id) + end + # Evidence rows pointing at our own identity keys (git repo, primary # registry package) with the formula/package version as subject. Attached # to distro-resolved upstream hits so {#range_status} can evaluate the @@ -332,18 +375,32 @@ def distro_packages_for(name) {} end - # OSV `querybatch` returns id/modified stubs; the full record is fetched - # once per id and cached across formulae. + MAX_VULN_FETCH_THREADS = 15 + private_constant :MAX_VULN_FETCH_THREADS + + # OSV `querybatch` returns id/modified stubs. Warm `@vuln_cache` with the + # full records for a chunk's stub ids before per-formula processing so + # {#resolve_upstream} reads mostly from cache. + sig { params(ids: T::Array[String]).void } + def prefetch_vulnerabilities(ids) + missing = ids.uniq.reject { |id| @vuln_cache.key?(id) } + missing.each_slice(MAX_VULN_FETCH_THREADS) do |slice| + slice.map { |id| [id, Thread.new { load_vulnerability(id) }] } + .each { |id, t| @vuln_cache[id] = t.value } + end + end + sig { params(id: String).returns(T.nilable(Vulnerability)) } def fetch_vulnerability(id) - @vuln_cache.fetch(id) do - @vuln_cache[id] = begin - Vulnerability.new(OSV.vulnerability(id)) - rescue OSV::Error => e - odebug "OSV.vulnerability(#{id}) failed: #{e.message}" - nil - end - end + @vuln_cache.fetch(id) { @vuln_cache[id] = load_vulnerability(id) } + end + + sig { params(id: String).returns(T.nilable(Vulnerability)) } + def load_vulnerability(id) + Vulnerability.new(OSV.vulnerability(id)) + rescue OSV::Error => e + odebug "OSV.vulnerability(#{id}) failed: #{e.message}" + nil end sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } @@ -358,27 +415,34 @@ def dedup_by_cve(hits) end # Evaluate `hit` against the version we ship, trying each evidence in - # precision order. Returns the first {Vulnerability::RangeStatus} that a - # comparable range yields, or `nil` if no evidence produced a checkable - # answer (e.g. a GIT-only record with commit-SHA ranges, or a distro-only - # hit whose upstream CVE has no `affected[]` matching our identity). - sig { params(hit: Hit).returns(T.nilable(Vulnerability::RangeStatus)) } + # precision order. Returns `[status, evidence]` for the first evidence + # whose range is comparable, or `nil` if none produced a checkable answer + # (e.g. a GIT-only record with commit-SHA ranges, or a distro-only hit + # whose upstream CVE has no `affected[]` matching our identity). + sig { params(hit: Hit).returns(T.nilable([Vulnerability::RangeStatus, Evidence])) } def range_status(hit) hit.evidence.each do |ev| - status = case ev.strategy - when :cpansa - adv = ev.advisory - CPANSec.range_status(adv, ev.subject_version) if adv && ev.subject_version - else - next unless ev.subject_version - - hit.vulnerability.range_status(ev.ecosystem, ev.name, ev.subject_version) - end - return status if status + status = evidence_range_status(hit.vulnerability, ev, ev.subject_version) + return [status, ev] if status end nil end + sig { + params(vulnerability: Vulnerability, evidence: Evidence, subject_version: T.nilable(String)) + .returns(T.nilable(Vulnerability::RangeStatus)) + } + def evidence_range_status(vulnerability, evidence, subject_version) + return if subject_version.nil? + + if evidence.strategy == :cpansa + adv = evidence.advisory + CPANSec.range_status(adv, subject_version) if adv + else + vulnerability.range_status(evidence.ecosystem, evidence.name, subject_version) + end + end + # Emit a candidate `BREW-*` OSV record for `hit` against `formula`. # # `first_fixed` is the {PkgVersion} at which Homebrew first shipped a fix @@ -396,10 +460,10 @@ def range_status(hit) def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) vuln = hit.vulnerability timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") - status = range_status(hit) + status, = range_status(hit) fixed = first_fixed - fixed ||= formula.pkg_version.to_s if status && !status.affected? + fixed ||= formula.pkg_version.to_s if status&.fixed? events = T.let([{ introduced: "0" }], T::Array[T::Hash[Symbol, String]]) events << { fixed: } if fixed @@ -446,6 +510,7 @@ def confidence_for(hit, status) } def affected_entry(formula, hit, events, fixed, status) eco = T.let({ fix: fixed ? "bump" : nil }, T::Hash[Symbol, T.nilable(String)]) + eco[:range_state] = status.state.to_s if status eco[:upstream_fixed_in] = status.fixed_in if status&.fixed_in if (resource = hit.resource) eco[:resource] = resource @@ -463,18 +528,22 @@ def affected_entry(formula, hit, events, fixed, status) end # Walk homebrew-core git history (newest first) via {FormulaVersions} and - # return the `pkg_version` at the oldest revision where the subject was - # still at or past `upstream_fixed_in`. Returns nil when the current - # version is not yet fixed. The rev-list and per-revision loads are - # cached per formula so subsequent hits reuse both. + # return the `pkg_version` at the oldest revision whose subject version + # still evaluates as `:fixed` against the same evidence used for the + # current version. Re-running the full range check per revision keeps + # `last_affected` and exclusive-bound semantics intact instead of + # collapsing them to a `>= threshold` test. Returns nil when the current + # version is not `:fixed`. The rev-list and per-revision loads are cached + # per formula. sig { params(formula: Formula, hit: Hit).returns(T.nilable(String)) } def first_fixed_version(formula, hit) - status = range_status(hit) - return if status.nil? || status.affected? - return unless (upstream_fixed = status.fixed_in) + result = range_status(hit) + return if result.nil? + + status, evidence = result + return unless status.fixed? - threshold = Version.new(upstream_fixed) - resource = hit.resource + resource = evidence.resource fv = @formula_versions[formula.name] ||= FormulaVersions.new(formula) revs = @formula_rev_lists[formula.name] ||= [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } @@ -482,8 +551,8 @@ def first_fixed_version(formula, hit) last_fixed = T.let(formula.pkg_version.to_s, T.nilable(String)) revs.each do |rev, entry| old_fixed = fv.formula_at_revision(rev, entry) do |old| - subject = subject_version(old, resource) - old.pkg_version.to_s if subject && subject >= threshold + subject = subject_version(old, resource)&.to_s + old.pkg_version.to_s if evidence_range_status(hit.vulnerability, evidence, subject)&.fixed? end return last_fixed if old_fixed.nil? From a67b5353652af1ef3ac4442993313c16107ff9ea Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 12:11:54 +0100 Subject: [PATCH 21/28] dev-cmd/advisory-match: leave source: generated records untouched A CVE that homebrew-core patches via a resolves annotation already has a source: generated record with fix: patch from generate-vulns-advisories. The matcher will typically also reach the same CVE via GIT/registry/ distro; overwriting drops the patch attribution for a derived fix: null/bump. --output now skips existing files whose database_specific.source is generated and reports the count. --- Library/Homebrew/dev-cmd/advisory-match.rb | 19 ++++++++++++++++++- .../test/dev-cmd/advisory-match_spec.rb | 17 ++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index 6076ee93b1a52..3fc1a20b09796 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -136,11 +136,20 @@ def initialize(dir, verbose:) @verbose = verbose @written = T.let(0, Integer) @unchanged = T.let(0, Integer) + @skipped_generated = T.let(0, Integer) end sig { override.params(record: T::Hash[Symbol, T.untyped]).void } def <<(record) path = File.join(@dir, "#{record.fetch(:id)}.json") + # A record already emitted by `generate-vulns-advisories` (a formula + # `resolves` patch annotation) is more authoritative than a matched + # candidate; overwriting it would drop `fix: "patch"` for a derived + # `fix: null`/`"bump"`. + if File.file?(path) && existing_source(path) == "generated" + @skipped_generated += 1 + return + end merged = Homebrew::Vulns::OsvExport.merge_existing(path, record) if merged.nil? @unchanged += 1 @@ -151,9 +160,17 @@ def <<(record) @written += 1 end + sig { params(path: String).returns(T.nilable(String)) } + def existing_source(path) + JSON.parse(File.read(path)).dig("database_specific", "source") + rescue JSON::ParserError + nil + end + sig { override.void } def finish - Utils::Output.ohai "#{@written} records written to #{@dir} (#{@unchanged} unchanged)" + Utils::Output.ohai "#{@written} records written to #{@dir} " \ + "(#{@unchanged} unchanged, #{@skipped_generated} generated left as-is)" 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 c44347a5c4100..1cb65074d56a0 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -62,7 +62,22 @@ def stub_osv_hit(cve, fixed:) # A second run with the same output should report 0 written / 1 unchanged. expect { cmd_for("requests", "--output", dir, "--no-history").run } - .to output(/0 records written to #{Regexp.escape(dir)} \(1 unchanged\)/).to_stdout + .to output(/0 records written to #{Regexp.escape(dir)} \(1 unchanged, 0 generated/).to_stdout + end + end + + it "does not overwrite an existing source: generated record" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + Dir.mktmpdir do |dir| + path = File.join(dir, "BREW-requests-CVE-2024-1234.json") + File.write(path, JSON.generate({ "id" => "BREW-requests-CVE-2024-1234", + "database_specific" => { "source" => "generated" }, + "affected" => [{ "ecosystem_specific" => { "fix" => "patch" } }] })) + + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/0 records written.*1 generated left as-is/).to_stdout + expect(JSON.parse(File.read(path)).dig("affected", 0, "ecosystem_specific", "fix")).to eq "patch" end end From 35a92398f01aa71987c2f604ea514028dfc59039 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 12:35:39 +0100 Subject: [PATCH 22/28] 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. --- Library/Homebrew/dev-cmd/advisory-match.rb | 6 ++++ .../test/dev-cmd/advisory-match_spec.rb | 13 +++++++ Library/Homebrew/test/vulns/cpan_sec_spec.rb | 6 ++++ Library/Homebrew/test/vulns/match_spec.rb | 35 ++++++++++++++++++- .../Homebrew/test/vulns/vulnerability_spec.rb | 8 +++++ Library/Homebrew/vulns/cpan_sec.rb | 3 +- Library/Homebrew/vulns/match.rb | 27 +++++++++----- Library/Homebrew/vulns/vulnerability.rb | 7 ++-- 8 files changed, 91 insertions(+), 14 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index 3fc1a20b09796..f7a31c1f84d99 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -54,6 +54,12 @@ def run 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 diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb index 1cb65074d56a0..f284e8ed6fe24 100644 --- a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -66,6 +66,19 @@ def stub_osv_hit(cve, fixed:) 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") diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb index d895ed98a7d97..39f1ab03b6694 100644 --- a/Library/Homebrew/test/vulns/cpan_sec_spec.rb +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -121,6 +121,12 @@ def adv(affected:, fixed:) 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) diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 17bead652e568..935a61851eece 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -224,6 +224,31 @@ def pkg(ecosystem:, name:, version:, purl:) } }) 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 @@ def pkg(ecosystem:, name:, version:, purl:) 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 @@ def pkg(ecosystem:, name:, version:, purl:) 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"]), diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index 3e112ceb2f3a9..63b8bf7e9f2cd 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -270,6 +270,14 @@ def range(type, *events) .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", diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb index b9ee6b4dee84a..d21dd6b4d19ab 100644 --- a/Library/Homebrew/vulns/cpan_sec.rb +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -70,7 +70,8 @@ def self.range_status(advisory, version) 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 diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index b67221b624a16..645062284565a 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -202,23 +202,27 @@ def hits_from(id_evidence, identity) 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 @@ def resolve_upstream(id_evidence, identity) 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 @@ def resolve_to_cves(record, seen, budget) 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) diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index a1f78c66f6c62..5d303c770a0ee 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -148,16 +148,15 @@ def range_status(ecosystem, name, version) 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 From 9cb74dc0db28a5032c88589fd1f40487473b343c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 12:54:59 +0100 Subject: [PATCH 23/28] vulns/match: keep per-evidence source records and aggregate subjects Evidence gains :source_record, set at Hit construction to the record the evidence was matched against. dedup_by_cve merges evidence from every grouped hit, and each evidence keeps pointing at its own record; a GHSA found via a PyPI query no longer loses its PyPI affected[] range when deduped onto a CVE-id record found via GIT that only carries commit-SHA ranges. range_status evaluates every evidence against its own source record and aggregates: :affected if any subject is affected (a fixed or not-applicable primary cannot hide an affected resource), else :fixed if any is fixed, else :not_applicable only when every comparable subject says so. The chosen evidence is returned so to_brew_record attributes resource/resource_purl to the subject that decided the state and first_fixed_version re-runs that evidence per revision. upstream_evidence in the emitted record excludes :source_record and :advisory (both are internal handles, not serialisable metadata). --- Library/Homebrew/test/vulns/match_spec.rb | 73 +++++++++++++++++++++++ Library/Homebrew/vulns/match.rb | 67 ++++++++++++++------- 2 files changed, 117 insertions(+), 23 deletions(-) diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 935a61851eece..4d48f0623b204 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -211,6 +211,79 @@ def pkg(ecosystem:, name:, version:, purl:) hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "jq")) expect(matcher.range_status(hit)).to be_nil end + + it "checks each evidence against its own source record after dedup merges hits" do + # CVE record from GIT query: no PyPI affected entry. + cve = vuln("id" => "CVE-2024-47081", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "abc123" }] }] }, + ]) + # GHSA record from PyPI query: carries the PyPI range. + ghsa = vuln("id" => "GHSA-9hjg-9r4m-mvj7", "aliases" => ["CVE-2024-47081"], "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.32.4" }] }] }, + ]) + merged = matcher.dedup_by_cve([ + make_hit(cve, ev(:git, ecosystem: "GIT", name: "https://github.com/psf/requests", + subject_version: "2.31.0")), + make_hit(ghsa, ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")), + ]) + + expect(merged.length).to eq 1 + status, evidence = matcher.range_status(merged.first) + expect(status).to have_attributes(state: :affected, fixed_in: "2.32.4") + expect(evidence.source_record.id).to eq "GHSA-9hjg-9r4m-mvj7" + end + + it "reports :affected when a resource subject is affected even if the primary is :not_applicable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2", + resource: "certifi")) + + status, evidence = matcher.range_status(hit) + expect(status).to have_attributes(state: :affected, fixed_in: "2025.1.1") + expect(evidence.resource).to eq "certifi" + end + + it "reports :affected when a resource is affected even if the primary is :fixed" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2", + resource: "certifi")) + + expect(matcher.range_status(hit)&.first).to have_attributes(state: :affected) + end + + it "reports :not_applicable only when every comparable subject is not_applicable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:distro, ecosystem: "Debian", name: "requests")) + + expect(matcher.range_status(hit)&.first&.state).to eq :not_applicable + end end describe "#hits_from" do diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index 645062284565a..33e0c1a87de6a 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -56,9 +56,17 @@ def identifiable? # resource version for a resource, `nil` for distro (whose versions are # not comparable to ours). `advisory` carries the CPANSA record for # `:cpansa` evidence so its constraint strings survive to - # {#range_status}. + # {#range_status}. `source_record` is the {Vulnerability} this evidence + # was matched against (attached at hit-construction time), so after + # {#dedup_by_cve} merges hits each evidence still points at the record + # whose `affected[]` it should be checked against. Evidence = Struct.new(:strategy, :ecosystem, :name, :subject_version, :key, :resource, - :advisory, keyword_init: true) + :advisory, :source_record, keyword_init: true) do + sig { params(record: Vulnerability).returns(T.untyped) } + def with_source(record) + source_record ? self : self.class.new(**to_h, source_record: record).freeze + end + end class Hit sig { returns(Vulnerability) } @@ -73,7 +81,8 @@ def initialize(vulnerability:, evidence:) @vulnerability = vulnerability @evidence = T.let( - evidence.sort_by { |e| -STRATEGY_PRECISION.fetch(e.strategy) }.freeze, + evidence.map { |e| e.with_source(vulnerability) } + .sort_by { |e| -STRATEGY_PRECISION.fetch(e.strategy) }.freeze, T::Array[Evidence], ) end @@ -425,32 +434,40 @@ def dedup_by_cve(hits) end end - # Evaluate `hit` against the version we ship, trying each evidence in - # precision order. Returns `[status, evidence]` for the first evidence - # whose range is comparable, or `nil` if none produced a checkable answer - # (e.g. a GIT-only record with commit-SHA ranges, or a distro-only hit - # whose upstream CVE has no `affected[]` matching our identity). + # Evaluate `hit` against every evidence's subject, each against the + # record that evidence was matched against, and aggregate: `:affected` if + # any subject is affected (a fixed primary must not hide an affected + # resource, or vice versa), else `:fixed` if any is fixed, else + # `:not_applicable` only when every comparable subject says so. Returns + # `[status, evidence]` where `evidence` is the one whose result was + # chosen (used by {#first_fixed_version} and for the emitted record's + # resource attribution), or `nil` if no evidence produced a checkable + # answer. sig { params(hit: Hit).returns(T.nilable([Vulnerability::RangeStatus, Evidence])) } def range_status(hit) - hit.evidence.each do |ev| - status = evidence_range_status(hit.vulnerability, ev, ev.subject_version) - return [status, ev] if status + results = hit.evidence.filter_map do |ev| + status = evidence_range_status(ev, ev.subject_version) + [status, ev] if status end - nil + return if results.empty? + + results.find { |s, _| s.affected? } || + results.find { |s, _| s.fixed? } || + results.first end sig { - params(vulnerability: Vulnerability, evidence: Evidence, subject_version: T.nilable(String)) + params(evidence: Evidence, subject_version: T.nilable(String)) .returns(T.nilable(Vulnerability::RangeStatus)) } - def evidence_range_status(vulnerability, evidence, subject_version) + def evidence_range_status(evidence, subject_version) return if subject_version.nil? if evidence.strategy == :cpansa adv = evidence.advisory CPANSec.range_status(adv, subject_version) if adv else - vulnerability.range_status(evidence.ecosystem, evidence.name, subject_version) + evidence.source_record&.range_status(evidence.ecosystem, evidence.name, subject_version) end end @@ -471,7 +488,7 @@ def evidence_range_status(vulnerability, evidence, subject_version) def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) vuln = hit.vulnerability timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") - status, = range_status(hit) + status, status_evidence = range_status(hit) fixed = first_fixed fixed ||= formula.pkg_version.to_s if status&.fixed? @@ -484,12 +501,12 @@ def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) published: timestamp, modified: timestamp, upstream: vuln.identifiers, - affected: [affected_entry(formula, hit, events, fixed, status)], + affected: [affected_entry(formula, hit, events, fixed, status, status_evidence)], database_specific: { source: "matched", strategy: hit.strategy.to_s, confidence: confidence_for(hit, status), - upstream_evidence: hit.evidence.map { |e| e.to_h.except(:advisory).compact }, + upstream_evidence: hit.evidence.map { |e| e.to_h.except(:advisory, :source_record).compact }, }, }, T::Hash[Symbol, T.untyped]) @@ -516,16 +533,20 @@ def confidence_for(hit, status) sig { params(formula: Formula, hit: Hit, events: T::Array[T::Hash[Symbol, String]], - fixed: T.nilable(String), status: T.nilable(Vulnerability::RangeStatus)) + fixed: T.nilable(String), status: T.nilable(Vulnerability::RangeStatus), + status_evidence: T.nilable(Evidence)) .returns(T::Hash[Symbol, T.untyped]) } - def affected_entry(formula, hit, events, fixed, status) + def affected_entry(formula, hit, events, fixed, status, status_evidence) eco = T.let({ fix: fixed ? "bump" : nil }, T::Hash[Symbol, T.nilable(String)]) eco[:range_state] = status.state.to_s if status eco[:upstream_fixed_in] = status.fixed_in if status&.fixed_in - if (resource = hit.resource) + # Attribute the resource whose subject decided the state, falling back + # to the highest-precision evidence when nothing was comparable. + if (resource = status_evidence&.resource || hit.resource) eco[:resource] = resource - eco[:resource_purl] = hit.evidence.find { |e| e.resource == resource }&.key + eco[:resource_purl] = (status_evidence if status_evidence&.resource)&.key || + hit.evidence.find { |e| e.resource == resource }&.key end { package: { @@ -563,7 +584,7 @@ def first_fixed_version(formula, hit) revs.each do |rev, entry| old_fixed = fv.formula_at_revision(rev, entry) do |old| subject = subject_version(old, resource)&.to_s - old.pkg_version.to_s if evidence_range_status(hit.vulnerability, evidence, subject)&.fixed? + old.pkg_version.to_s if evidence_range_status(evidence, subject)&.fixed? end return last_fixed if old_fixed.nil? From 42064914e5ba61005294c93e73ac38b13cad78d3 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 13:07:15 +0100 Subject: [PATCH 24/28] vulns/match: aggregate every subject per historical revision first_fixed_version re-evaluates every evidence at each historical revision using that revision's subject version (primary formula version or the resource's pinned version there) and applies the same aggregate rule as range_status. The walk stops at the first revision where any subject drops back to :affected, so a primary that crossed its upstream fix at formula 2.0 combined with a resource that crossed at 3.0 yields 3.0, not 2.0. --- Library/Homebrew/test/vulns/match_spec.rb | 37 ++++++++++++++++++++-- Library/Homebrew/vulns/match.rb | 38 ++++++++++++++--------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index 4d48f0623b204..af6171a4d5ec8 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -643,11 +643,17 @@ def stub_history(versions_newest_first) fv = instance_double(FormulaVersions) revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] } allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } } - versions_newest_first.each_with_index do |v, i| - old = if v + versions_newest_first.each_with_index do |entry, i| + primary, res = Array(entry) + old = if primary formula("requests") do T.bind(self, T.class_of(Formula)) - url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{v}.tar.gz" + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{primary}.tar.gz" + if res + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-#{res}.tar.gz" + end + end end end allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b| @@ -683,6 +689,31 @@ def hit_fixed_at(fixed) expect(matcher.first_fixed_version(requests, hit)).to eq "2.1" end + it "aggregates every subject per revision so a fixed primary does not mask a later-fixed resource" do + # Primary requests fixed upstream in 2.0; resource certifi fixed upstream in 100.0. + # History (formula pkg_version => [primary, certifi]): the resource crossed its + # threshold at formula 3.0; the primary crossed at 2.0. Aggregate is only :fixed + # from 3.0 onward. + stub_history([["4.0", "101.0"], ["3.0", "100.0"], ["2.5", "99.0"], ["2.0", "98.0"], ["1.0", "97.0"]]) + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "2.0" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "100.0" }] }] }, + ]) + current = formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz" + resource("certifi") { url "https://files.pythonhosted.org/packages/11/22/33/certifi-101.0.tar.gz" } + end + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "101.0", + resource: "certifi")) + + expect(matcher.first_fixed_version(current, hit)).to eq "3.0" + end + it "stops at an unloadable revision and returns the last known fixed pkg_version" do stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.30.0" diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index 33e0c1a87de6a..17d8c28789737 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -560,22 +560,18 @@ def affected_entry(formula, hit, events, fixed, status, status_evidence) end # Walk homebrew-core git history (newest first) via {FormulaVersions} and - # return the `pkg_version` at the oldest revision whose subject version - # still evaluates as `:fixed` against the same evidence used for the - # current version. Re-running the full range check per revision keeps - # `last_affected` and exclusive-bound semantics intact instead of - # collapsing them to a `>= threshold` test. Returns nil when the current - # version is not `:fixed`. The rev-list and per-revision loads are cached - # per formula. + # return the `pkg_version` at the oldest revision where the aggregate of + # every checkable subject is still `:fixed`. Re-running the full + # per-evidence range check with each revision's subject versions keeps + # `last_affected` and exclusive-bound semantics intact and stops as soon + # as any subject (primary or a resource) drops back into `:affected`, so + # a primary fixed at 2.0 with a resource fixed at 3.0 yields 3.0. Returns + # nil when the current aggregate is not `:fixed`. The rev-list and + # per-revision loads are cached per formula. sig { params(formula: Formula, hit: Hit).returns(T.nilable(String)) } def first_fixed_version(formula, hit) - result = range_status(hit) - return if result.nil? + return unless range_status(hit)&.first&.fixed? - status, evidence = result - return unless status.fixed? - - resource = evidence.resource fv = @formula_versions[formula.name] ||= FormulaVersions.new(formula) revs = @formula_rev_lists[formula.name] ||= [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } @@ -583,8 +579,7 @@ def first_fixed_version(formula, hit) last_fixed = T.let(formula.pkg_version.to_s, T.nilable(String)) revs.each do |rev, entry| old_fixed = fv.formula_at_revision(rev, entry) do |old| - subject = subject_version(old, resource)&.to_s - old.pkg_version.to_s if evidence_range_status(evidence, subject)&.fixed? + old.pkg_version.to_s if aggregate_state_at(old, hit) == :fixed end return last_fixed if old_fixed.nil? @@ -593,6 +588,19 @@ def first_fixed_version(formula, hit) last_fixed end + sig { params(formula: Formula, hit: Hit).returns(T.nilable(Symbol)) } + def aggregate_state_at(formula, hit) + results = hit.evidence.filter_map do |ev| + subject = subject_version(formula, ev.resource)&.to_s + evidence_range_status(ev, subject) + end + return if results.empty? + return :affected if results.any?(&:affected?) + return :fixed if results.any?(&:fixed?) + + :not_applicable + end + sig { params(formula: Formula, resource: T.nilable(String)).returns(T.nilable(Version)) } def subject_version(formula, resource) if resource From 1ac19d222d2e4796e747df4102ef71c9ea4a8f81 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 13:17:13 +0100 Subject: [PATCH 25/28] vulns/match: keep versionless evidence uncheckable in the history walk aggregate_state_at was substituting the historical formula version for every evidence row, including distro evidence built with subject_version: nil. That let a distro record's Debian-versioned range be compared against our formula version and report :affected, which made first_fixed_version stop at the current pkg_version for a hit whose only comparable (registry) subject was already :fixed further back. Evidence with a nil original subject_version is now skipped at every revision, matching range_status. --- Library/Homebrew/test/vulns/match_spec.rb | 24 +++++++++++++++++++++++ Library/Homebrew/vulns/match.rb | 7 +++++++ 2 files changed, 31 insertions(+) diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index af6171a4d5ec8..db20b93fe5c20 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -689,6 +689,30 @@ def hit_fixed_at(fixed) expect(matcher.first_fixed_version(requests, hit)).to eq "2.1" end + it "keeps versionless (distro) evidence uncheckable at historical revisions too" do + stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0"]) + # A distro record whose Debian range would spuriously match our formula + # version if it were compared: ensure it stays skipped in the walk. + distro_record = vuln("id" => "DEBIAN-CVE-1", "affected" => [ + { "package" => { "ecosystem" => "Debian", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "999+deb12u1" }] }] }, + ]) + registry_record = vuln("id" => "GHSA-x", "aliases" => ["CVE-1"], "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + ]) + hit = matcher.dedup_by_cve([ + make_hit(registry_record, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")), + make_hit(distro_record, + ev(:distro, ecosystem: "Debian", name: "requests", subject_version: nil)), + ]).first + + expect(matcher.first_fixed_version(requests, hit)).to eq "2.28.1" + end + it "aggregates every subject per revision so a fixed primary does not mask a later-fixed resource" do # Primary requests fixed upstream in 2.0; resource certifi fixed upstream in 100.0. # History (formula pkg_version => [primary, certifi]): the resource crossed its diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index 17d8c28789737..5930d60cd16cf 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -591,6 +591,13 @@ def first_fixed_version(formula, hit) sig { params(formula: Formula, hit: Hit).returns(T.nilable(Symbol)) } def aggregate_state_at(formula, hit) results = hit.evidence.filter_map do |ev| + # Evidence built without a subject_version (distro queries, own- + # identity rows for a formula with no derivable tag) is deliberately + # uncheckable and must stay that way at historical revisions too; + # substituting the historical formula version would compare it + # against the distro record's distro-versioned range. + next if ev.subject_version.nil? + subject = subject_version(formula, ev.resource)&.to_s evidence_range_status(ev, subject) end From 2c1daef60abeadc636a566190e3da814e392680d Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 13:31:56 +0100 Subject: [PATCH 26/28] vulns/match: return :never_affected when Homebrew skipped the interval first_fixed_version now distinguishes three outcomes for a currently- :fixed hit: the pkg_version at the :fixed -> :affected boundary (or at the last loadable revision, best-effort); :never_affected when the walk reaches :not_applicable or the start of the formula's history without seeing :affected (Homebrew jumped from below introduced straight past fixed and never shipped an affected build); and nil when the current aggregate is not :fixed. dev-cmd/advisory-match drops :never_affected candidates instead of emitting {introduced: 0, fixed: }. --- Library/Homebrew/dev-cmd/advisory-match.rb | 5 ++- Library/Homebrew/test/vulns/match_spec.rb | 25 +++++++++++++++ Library/Homebrew/vulns/match.rb | 36 ++++++++++++++++------ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index f7a31c1f84d99..4d8facf30be52 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -61,7 +61,10 @@ def run 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:) + next if first_fixed == :never_affected + + boundary = first_fixed if first_fixed.is_a?(String) + emitter << matcher.to_brew_record(formula, hit, first_fixed: boundary) end end rescue Homebrew::Vulns::OSV::Error => e diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb index db20b93fe5c20..c7e79fb6b7844 100644 --- a/Library/Homebrew/test/vulns/match_spec.rb +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -689,6 +689,31 @@ def hit_fixed_at(fixed) expect(matcher.first_fixed_version(requests, hit)).to eq "2.1" end + it "returns :never_affected when Homebrew jumped from below introduced straight past fixed" do + # Advisory {introduced: 2.0, fixed: 3.0}; Homebrew went 1.0 -> 4.0 and + # never shipped a 2.x, so no BREW record should be emitted. + stub_history(["4.0", "1.0"]) + current = formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz" + end + hit = make_hit( + vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "2.0" }, { "fixed" => "3.0" }] }] }, + ]), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"), + ) + + expect(matcher.first_fixed_version(current, hit)).to eq :never_affected + end + + it "returns :never_affected when the formula was already past fixed at its first revision" do + stub_history(["2.31.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq :never_affected + end + it "keeps versionless (distro) evidence uncheckable at historical revisions too" do stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0"]) # A distro record whose Debian range would spuriously match our formula diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb index 5930d60cd16cf..0b64434599a05 100644 --- a/Library/Homebrew/vulns/match.rb +++ b/Library/Homebrew/vulns/match.rb @@ -565,10 +565,21 @@ def affected_entry(formula, hit, events, fixed, status, status_evidence) # per-evidence range check with each revision's subject versions keeps # `last_affected` and exclusive-bound semantics intact and stops as soon # as any subject (primary or a resource) drops back into `:affected`, so - # a primary fixed at 2.0 with a resource fixed at 3.0 yields 3.0. Returns - # nil when the current aggregate is not `:fixed`. The rev-list and - # per-revision loads are cached per formula. - sig { params(formula: Formula, hit: Hit).returns(T.nilable(String)) } + # a primary fixed at 2.0 with a resource fixed at 3.0 yields 3.0. + # + # Returns: + # - `nil` when the current aggregate is not `:fixed`. + # - `:never_affected` when the walk reaches `:not_applicable` (or the + # start of the formula's history) without ever seeing `:affected`, + # i.e. Homebrew jumped from a version below `introduced` straight past + # `fixed` and never shipped an affected build. The caller drops the + # candidate rather than emitting `{introduced: "0", fixed: }`. + # - a `pkg_version` String when the walk hits `:affected`, or when it + # stops at an unloadable revision (best-effort boundary; the reviewer + # can tighten). + # + # The rev-list and per-revision loads are cached per formula. + sig { params(formula: Formula, hit: Hit).returns(T.nilable(T.any(String, Symbol))) } def first_fixed_version(formula, hit) return unless range_status(hit)&.first&.fixed? @@ -576,16 +587,21 @@ def first_fixed_version(formula, hit) revs = @formula_rev_lists[formula.name] ||= [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } - last_fixed = T.let(formula.pkg_version.to_s, T.nilable(String)) + last_fixed = T.let(formula.pkg_version.to_s, String) revs.each do |rev, entry| - old_fixed = fv.formula_at_revision(rev, entry) do |old| - old.pkg_version.to_s if aggregate_state_at(old, hit) == :fixed + state = fv.formula_at_revision(rev, entry) do |old| + [aggregate_state_at(old, hit), old.pkg_version.to_s] end - return last_fixed if old_fixed.nil? + # `nil` means the revision failed to load; can't verify further. + return last_fixed if state.nil? + + aggregate, pkg_version = state + return :never_affected if aggregate == :not_applicable + return last_fixed if aggregate != :fixed - last_fixed = old_fixed + last_fixed = pkg_version end - last_fixed + :never_affected end sig { params(formula: Formula, hit: Hit).returns(T.nilable(Symbol)) } From 4f439a92724cdfcc7ac3485c413c1da4c358c6f5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 15:00:55 +0100 Subject: [PATCH 27/28] vulns: lowercase github.com paths and accept sibling-formula Repology projects Identify.repo_url lowercases the path for github.com URLs. OSV.dev's GIT ecosystem indexes repository URLs case-sensitively but normalises github.com to lowercase (GitHub itself is case-insensitive), so a head "https://github.com/FFmpeg/FFmpeg.git" previously produced zero hits where the lowercased form returns 231. GitLab and Codeberg are case-sensitive so their paths are preserved. Vulns::Repology.lookup no longer rejects a project whose Homebrew entries include sibling formulae with a different base name (wget + wget2, sqlite + sqlite-analyzer, ffmpeg + a third-party ffmpeg-full). The sibling's distro srcnames flow through as extra low-confidence distro queries whose upstream-CVE range check will not match this formula's identity, so the cost is uncomparable candidates rather than wrong :affected/:fixed claims. The published index still excludes these (RepologyIndex in Homebrew/advisory-database applies the stricter rule), so the recall gain applies to named-formula and PR-bot runs; --all needs the same loosening in the index builder. --- Library/Homebrew/test/vulns/identify_spec.rb | 13 ++++++++--- Library/Homebrew/test/vulns/repology_spec.rb | 17 +++++++++------ Library/Homebrew/test/vulns/scanner_spec.rb | 2 +- Library/Homebrew/vulns/identify.rb | 8 +++++++ Library/Homebrew/vulns/repology.rb | 23 +++++++++++--------- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb index 65fff1bffa4a3..cb4d92f8096e7 100644 --- a/Library/Homebrew/test/vulns/identify_spec.rb +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -15,9 +15,16 @@ expect(described_class.repo_url(url)).to eq "https://github.com/owner/repo" end - it "extracts a GitHub repo from a .git URL" do + it "extracts a GitHub repo from a .git URL, lowercasing the path (OSV normalises github.com)" do expect(described_class.repo_url("https://github.com/AomediaOrg/aom.git")) - .to eq "https://github.com/AomediaOrg/aom" + .to eq "https://github.com/aomediaorg/aom" + expect(described_class.repo_url("https://github.com/FFmpeg/FFmpeg.git")) + .to eq "https://github.com/ffmpeg/ffmpeg" + end + + it "preserves path case for GitLab (case-sensitive host)" do + expect(described_class.repo_url("https://gitlab.gnome.org/GNOME/glib.git")) + .to eq "https://gitlab.gnome.org/GNOME/glib" end it "extracts a GitLab repo, stripping the /-/ path segment" do @@ -90,7 +97,7 @@ it "falls back to the head URL when the stable URL is not a supported forge" do stable = "https://aomedia.googlesource.com/aom.git" head = "https://github.com/AomediaOrg/aom.git" - expect(described_class.repo_url(stable, head)).to eq "https://github.com/AomediaOrg/aom" + expect(described_class.repo_url(stable, head)).to eq "https://github.com/aomediaorg/aom" end it "falls back to the homepage when neither stable nor head is a supported forge" do diff --git a/Library/Homebrew/test/vulns/repology_spec.rb b/Library/Homebrew/test/vulns/repology_spec.rb index feff989a7e4c7..9171dec376649 100644 --- a/Library/Homebrew/test/vulns/repology_spec.rb +++ b/Library/Homebrew/test/vulns/repology_spec.rb @@ -146,17 +146,20 @@ def project(homebrew:, distros:, status: "newest") expect(described_class.lookup("node@20")).to eq("Debian" => ["nodejs"]) end - it "rejects an ambiguous project (multiple unrelated Homebrew formulae)" do - allow(described_class).to receive(:fetch_project).with("antlr").and_return( - project(homebrew: ["antlr", "antlr4-cpp-runtime"], - distros: [["debian_12", "antlr4"], ["debian_12", "antlr4-cpp-runtime"]]), + it "accepts a project that also lists sibling formulae with a different base name" do + # Repology groups wget + wget2 under one project; the sibling's distro + # srcnames come through as extra low-confidence distro queries whose + # upstream-CVE range check will not match this formula's identity. + allow(described_class).to receive(:fetch_project).with("wget").and_return( + project(homebrew: ["wget", "wget2"], + distros: [["debian_12", "wget"], ["debian_12", "wget2"]]), ) - expect(described_class.lookup("antlr")).to eq({}) + expect(described_class.lookup("wget")).to eq("Debian" => ["wget", "wget2"]) end - it "continues past an ambiguous candidate to a later valid one" do + it "still rejects a candidate whose Homebrew entries do not include the requested formula at all" do allow(described_class).to receive(:fetch_project).with("libfoo").and_return( - project(homebrew: ["libfoo", "libfoo-utils"], distros: [["debian_12", "wrong"]]), + project(homebrew: ["libfoo-utils"], distros: [["debian_12", "wrong"]]), ) allow(described_class).to receive(:fetch_project).with("foo").and_return( project(homebrew: ["libfoo"], distros: [["debian_12", "foo"]]), diff --git a/Library/Homebrew/test/vulns/scanner_spec.rb b/Library/Homebrew/test/vulns/scanner_spec.rb index 6c33169b60dc4..97b30162b0bb5 100644 --- a/Library/Homebrew/test/vulns/scanner_spec.rb +++ b/Library/Homebrew/test/vulns/scanner_spec.rb @@ -127,7 +127,7 @@ target = described_class.new([aom]).build_target(aom) - expect(target.repo_url).to eq "https://github.com/AomediaOrg/aom" + expect(target.repo_url).to eq "https://github.com/aomediaorg/aom" expect(target.tag).to eq "v3.13.1" end diff --git a/Library/Homebrew/vulns/identify.rb b/Library/Homebrew/vulns/identify.rb index c4b72aa7a9cc4..b996ef725d7df 100644 --- a/Library/Homebrew/vulns/identify.rb +++ b/Library/Homebrew/vulns/identify.rb @@ -47,6 +47,13 @@ module Identify WAYBACK_PREFIX = %r{\Ahttps?://web\.archive\.org/web/\d+[a-z_*]*/} private_constant :WAYBACK_PREFIX + # OSV.dev's GIT ecosystem indexes repository URLs case-sensitively but + # normalises `github.com` paths to lowercase (GitHub itself is + # case-insensitive). GitLab and Codeberg are case-sensitive so their + # paths are preserved. + LOWERCASE_PATH_HOSTS = ["github.com"].freeze + private_constant :LOWERCASE_PATH_HOSTS + sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } def self.repo_url(*urls) urls.each do |url| @@ -58,6 +65,7 @@ def self.repo_url(*urls) next if match.nil? repo_path = T.must(match[1]).sub(/\.git$/, "") + repo_path = repo_path.downcase if LOWERCASE_PATH_HOSTS.include?(host) return "https://#{host}/#{repo_path}" end end diff --git a/Library/Homebrew/vulns/repology.rb b/Library/Homebrew/vulns/repology.rb index 0d5620e1100b3..c85436b0db17c 100644 --- a/Library/Homebrew/vulns/repology.rb +++ b/Library/Homebrew/vulns/repology.rb @@ -93,16 +93,21 @@ def distro_packages_for(formula_name) private_constant :PREFERRED_STATUSES # Live single-project fallback for a formula the published index does - # not (yet) cover — typically a new formula in a homebrew-core PR before - # the next nightly index build. + # not cover: a new formula in a homebrew-core PR before the next nightly + # index build, or one the index put in `meta.ambiguous_projects`. # # Fetches each project in {.name_candidates}, keeps those whose Homebrew - # entries include `formula_name` (or its `@`-stripped base) and don't - # group unrelated formulae, then applies the same preferred-status - # resolution as `RepologyIndex#resolve` across the survivors. This makes - # the fallback consistent with the published index for the projects it - # can reach; it cannot detect collisions with projects outside - # {.name_candidates} (e.g. `allegro4`), which only the full crawl sees. + # entries include `formula_name` (or its `@`-stripped base), then applies + # the same preferred-status resolution as `RepologyIndex#resolve` across + # the survivors. Unlike the index builder, a project that also lists + # sibling formulae with a different base (`wget` + `wget2`, `sqlite` + + # `sqlite-analyzer`, `ffmpeg` + a third-party `ffmpeg-full`) is *not* + # rejected: the distro srcnames for the sibling flow through as extra + # low-confidence distro queries whose upstream-CVE range check will not + # match this formula's identity, so the cost is uncomparable noise rather + # than a wrong `:affected`/`:fixed` claim. This cannot detect collisions + # with projects outside {.name_candidates} (e.g. `allegro4`), which only + # the full crawl sees. sig { params(formula_name: String).returns(DistroMap) } def self.lookup(formula_name) base = base_name(formula_name) @@ -113,8 +118,6 @@ def self.lookup(formula_name) next if entries.empty? brew = homebrew_entries(entries) - next if brew.keys.map { |n| base_name(n) }.uniq.size > 1 - distros = distil(entries) next if distros.empty? From 966b02147836a3256355f431438849c097f1911a Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 28 Jul 2026 16:18:05 +0100 Subject: [PATCH 28/28] dev-cmd/advisory-match: explicit require "fileutils" --- Library/Homebrew/dev-cmd/advisory-match.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb index 4d8facf30be52..9d117c5c25f78 100644 --- a/Library/Homebrew/dev-cmd/advisory-match.rb +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "abstract_command" +require "fileutils" require "formula" require "vulns/match"