vulns: add advisory matcher and dev-cmd/advisory-match - #23329
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR lays the groundwork for a future brew advisory-match pipeline by factoring URL/release identification into reusable components and adding cached readers for upstream advisory-related JSON feeds, without changing current brew vulns output.
Changes:
- Extracted forge repo URL + tag derivation into
Vulns::Identify, and updatedVulns::Scannerto delegate to it. - Added
Vulns::Purl+Identify.registry_packageto derive OSV ecosystem/name/version/purl from common language registry URLs. - Introduced
Vulns::CachedFeedplus new feed readers (Vulns::CPANSec,Vulns::Repology) and updated Repology API helpers + tests/fixtures.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| Library/Homebrew/vulns/scanner.rb | Delegate repo/tag derivation to Vulns::Identify. |
| Library/Homebrew/vulns/repology.rb | New cached reader + live fallback for formula→distro source-package mapping. |
| Library/Homebrew/vulns/purl.rb | New minimal purl builder with type-specific normalisation + encoding. |
| Library/Homebrew/vulns/identify.rb | New shared forge/tag/registry URL identification logic. |
| Library/Homebrew/vulns/cpan_sec.rb | New cached loader for CPAN Security Advisory JSON. |
| Library/Homebrew/vulns/cached_feed.rb | New shared JSON feed caching/refresh/stale-fallback base class. |
| Library/Homebrew/utils/repology.rb | Repology API base constant + URL-encoding for project/cursor requests. |
| Library/Homebrew/test/vulns/scanner_spec.rb | Removed tests for logic moved out of Scanner. |
| Library/Homebrew/test/vulns/repology_spec.rb | New unit tests for Vulns::Repology (including cache fallback). |
| Library/Homebrew/test/vulns/purl_spec.rb | New unit tests for Vulns::Purl. |
| Library/Homebrew/test/vulns/identify_spec.rb | New unit tests for Vulns::Identify repo/tag + registry parsing. |
| Library/Homebrew/test/vulns/cpan_sec_spec.rb | New unit tests for Vulns::CPANSec (including cache fallback). |
| Library/Homebrew/test/utils/repology_spec.rb | Updated tests for Repology API URL-encoding and failure behaviour. |
| Library/Homebrew/test/support/fixtures/vulns/repology.json | New Repology index fixture for tests. |
| Library/Homebrew/test/support/fixtures/vulns/cpansa.json | New CPANSA fixture for tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
MikeMcQuaid
approved these changes
Jul 28, 2026
MikeMcQuaid
left a comment
Member
There was a problem hiding this comment.
Makes sense to me, thanks!
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
Three modes:
brew advisory-match <formula>... [--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.
- 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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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: <first-shipped>}.
… 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.
andrew
marked this pull request as ready for review
July 28, 2026 14:27
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi: File type not supported
Comments suppressed due to low confidence (1)
Library/Homebrew/vulns/vulnerability.rb:283
Uncomparablewas previously markedprivate_constantbut is now publicly accessible. This is an internal exception type used only for range comparison; keeping it private avoids unintentionally exposing it as part of the public API surface ofVulnerability.
class Uncomparable < StandardError
end
MikeMcQuaid
approved these changes
Jul 28, 2026
This was referenced Jul 28, 2026
Merged
andrew
enabled auto-merge
July 28, 2026 15:20
andrew
added a commit
to Homebrew/advisory-database
that referenced
this pull request
Jul 28, 2026
Runs brew advisory-match --all --output advisories --no-history at 08:20 UTC (offset from Regenerate at 06:20 so that job's source: generated records are on main and get skipped rather than overwritten) and opens a matched-advisories PR when anything changes. --no-history is used because the FormulaVersions walk for every :fixed hit across ~8500 formulae would exceed the job limit; new records get fixed: <current pkg_version> which reviewers tighten per CONTRIBUTING, and existing records keep their on-disk ranges via merge_existing. Merge only after Homebrew/brew#23329 ships in a tagged release.
andrew
added a commit
to Homebrew/advisory-database
that referenced
this pull request
Aug 3, 2026
Runs brew advisory-match --all --output advisories --no-history at 08:20 UTC (offset from Regenerate at 06:20 so that job's source: generated records are on main and get skipped rather than overwritten) and opens a matched-advisories PR when anything changes. --no-history is used because the FormulaVersions walk for every :fixed hit across ~8500 formulae would exceed the job limit; new records get fixed: <current pkg_version> which reviewers tighten per CONTRIBUTING, and existing records keep their on-disk ranges via merge_existing. Merge only after Homebrew/brew#23329 ships in a tagged release.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
brew vulnscurrently queries OSV.dev'sGITecosystem only: derive a forge repo URL from a formula'sstable/head/homepage, look up{repo_url, tag}. About 27% ofhomebrew/corehas no derivable forge URL and is skipped, and vendoredresourceblocks are not checked at all.Homebrew/advisory-database publishes
BREW-*OSV records underecosystem: Homebrew(registered in ossf/osv-schema and package-url/purl-spec; osv.dev ingestion pending google/osv.dev#5660). Today those records are all derived from formularesolvespatch annotations. This PR adds discovery by matching a formula against external feeds, so the corpus can cover version-bump fixes and still-open CVEs as well.This runs in
Homebrew/advisory-databaseCI (nightly ingest) and a futurehomebrew-corePR bot to produce candidate records for human review.brew vulnson a user's machine stays at O(1) HTTP requests; the matching cost is paid once when building the corpus.Identification (
Vulns::Identify,Vulns::Purl)repo_url/tag/FORGESare extracted fromScanner(which now delegates) andFORGESgainsgitlab.gnome.org,gitlab.freedesktop.organdinvent.kde.org, all confirmed indexed by OSV.github.compaths are lowercased (OSV's GIT ecosystem indexes case-sensitively but normalises GitHub to lowercase;FFmpeg/FFmpegreturned 0 hits whereffmpeg/ffmpegreturns 231).Identify.registry_package(url)returns{ecosystem:, name:, version:, purl:}for PyPI/npm/crates/CPAN/Hackage/Hex/RubyGems/Maven/NuGet/CRAN URL patterns, so astable.urlorresourceURL that points at a registry can be queried against that ecosystem's OSV feed.Vulns::Purlbuilds spec-conformant purl strings.Cached feeds (
Vulns::CachedFeed,Vulns::CPANSec,Vulns::Repology)CachedFeedhandles fetch, atomic refresh and stale-cache fallback for a JSON feed underHOMEBREW_CACHE/vulns/.CPANSecreads the compiled cpan-security-advisory JSON (CPAN has no OSV ecosystem).RepologyreadsHomebrew/advisory-database's publisheddata/repology.json, mapping formula names to their source-package names in OSV-covered distros (Debian, Ubuntu, Alpine, openSUSE, Rocky, Alma, Mageia, openEuler, Red Hat, FreeBSD). Formulae the nightly index misses (or puts inambiguous_projects) fall back to a live single-project API call, delegating to::Repology.single_package_query, which gains URL-encoding,--failand an explicit failure message. The live lookup accepts a project that also lists sibling formulae with a different base (wget+wget2,sqlite+sqlite-analyzer); the sibling's distro srcnames come through as extra low-confidence queries whose upstream-CVE range check will not match this formula's identity.Range evaluation (
Vulnerability#range_status,CPANSec.range_status)Vulnerability#range_status(ecosystem, name, version)evaluates a version against everyaffected[]entry whosepackagematches (records like GHSA-jfh8-c2jp-5v3q carry three disjoint entries for the same package), honouring rangetype:SEMVERviaSemver,ECOSYSTEMviaVersion,GITskipped as uncomparable since commit-SHA events cannot be ordered against a version string. It returns{state:, fixed_in:}wherestateis:affected,:fixed, or:not_applicable(below everyintroduced; distinct from:fixedso a version the vulnerability never applied to is not recorded as a bump fix), ornilwhen there is no matching entry or comparable range.CPANSec.range_statusdoes the same for CPANSA's</<=/>/>=/==/bare constraint grammar.Vulnerability#identifiersisid + aliasesonly.upstreamis a directed reference (a distro advisory naming one or more source CVEs) and is followed explicitly byMatchinstead of being treated as an alias.Matching (
Vulns::Match)Match#identify(formula)composes the above into anIdentity: forge repo/tag, primary registry package,resourceregistry packages, and distro source-package names.Match#each_advisory_batch(formulae)builds versionless GIT/language-registry/distro queries for a chunk of formulae at once and sends them through oneOSV.query_batch(generalised here from{repo_url:, version:}to{ecosystem:, name:, version:}). Querying versionless means bump-fixed advisories are returned; eachEvidencecarries the queried{ecosystem, name}plus thesubject_versionto check ranges against afterwards. CPAN packages route throughCPANSecinstead, with theAdvisorycarried on the evidence.advisories_for(formula)wraps the batch method for the single-formula case.Distro records are resolved to the CVEs they derive from by walking
upstreamtransitively with cycle detection (chains likeUSN -> UBUNTU-CVE-* -> CVE-*occur in practice);relatedis consulted for bare CVE ids only forALSA-*records with noupstream(an AlmaLinux data quirk; per the schemarelatedotherwise names different vulnerabilities). A multi-CVE advisory splits into per-CVE hits. CPANSA advisories with no CVE alias, or whose CVE is absent from OSV, become hits from a per-id synthesisedVulnerability. Each resolved hit gets extra evidence pointing at our own git repo and registry package sorange_statuscan check the CVE'saffected[]against our version. Hits sharing a CVE alias then collapse into oneHitwhosestrategyis the highest-precision path (git > registry > cpansa > distro); each evidence keeps a reference to the record it was matched against so, after dedup, a GHSA's PyPI range is still checked against the GHSA record even when the merged hit's canonical record is a CVE that only carries GIT-SHA ranges. A per-chunk threaded prefetch warms the record cache before per-formula processing.Match#range_status(hit)evaluates every evidence against its own source record and aggregates::affectedif any subject is (a fixed primary cannot mask an affected resource), else:fixedif any is, else:not_applicableonly when every comparable subject says so.Match#to_brew_record(formula, hit)emits the candidate OSV hash withdatabase_specific: {source: "matched", strategy:, confidence:, upstream_evidence:}. Onlystate == :fixedwrites{fixed: pkg_version}andecosystem_specific.fix: "bump";:affectedand uncomparable emit nofixedevent, confidence is demoted for uncomparable, and:not_applicablehits are dropped before emission.Match#first_fixed_versionre-runs the full aggregate check at each historical revision (each evidence against that revision's subject version) solast_affectedand exclusive bounds are preserved and a primary fixed at 2.0 with a resource fixed at 3.0 yields 3.0. Evidence built without a subject version (distro queries) stays uncheckable in history too. If the walk reaches:not_applicableor the start of the formula's history without ever seeing:affected, Homebrew never shipped an affected build and the candidate is dropped;OsvExport.merge_existingpreserves hand-corrected boundaries on rewrite otherwise.bulk:mode (--all/--index) skips the liveRepology.lookupfallback so a full sweep never hits the rate-limited per-project API for the ~3,200 formulae the index doesn't cover; single-formula runs still fall back.brew advisory-match(dev-cmd, hidden from manpage)--outputwrites each record as it is produced viaOsvExport.merge_existing, skips existingsource: generatedrecords so a matched candidate never clobbers aresolves-derivedfix: "patch", and only accumulates counts.--jsonprints the OSV-hash array (single-formula only; conflicts with--all).--indexemits thename -> Identitymap for the whole tap.A 50-formula calibration run (
--output --no-history, 2026-07-28) took 7m08s and produced 1618 candidates across 38 formulae: 34:affected, 436:fixed, 1148 uncomparable.:affectedprecision was 31/34 (91%); the three false positives were upstream-data issues (stale CPANSA>0range for cpanminus, Perl decimal-version comparison for IO-Compress-Brotli, PYSEC open-ended vs GHSAlast_affectedfor kerberos), not matcher bugs. A:fixedspot-check went 15/15.No user-facing behaviour change:
brew vulnsoutput is unchanged apart fromgithub.comrepo URLs now being queried lowercased.brewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?Claude Code assisted with implementation and tests; each module reviewed by hand and verified via
brew lgtm, fullbrew style, and livebrew advisory-match jqruns.