Skip to content

vulns: add advisory matcher and dev-cmd/advisory-match - #23329

Merged
andrew merged 30 commits into
Homebrew:mainfrom
andrew:vulns-identify
Jul 28, 2026
Merged

vulns: add advisory matcher and dev-cmd/advisory-match#23329
andrew merged 30 commits into
Homebrew:mainfrom
andrew:vulns-identify

Conversation

@andrew

@andrew andrew commented Jul 27, 2026

Copy link
Copy Markdown
Member

brew vulns currently queries OSV.dev's GIT ecosystem only: derive a forge repo URL from a formula's stable/head/homepage, look up {repo_url, tag}. About 27% of homebrew/core has no derivable forge URL and is skipped, and vendored resource blocks are not checked at all.

Homebrew/advisory-database publishes BREW-* OSV records under ecosystem: 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 formula resolves patch 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-database CI (nightly ingest) and a future homebrew-core PR bot to produce candidate records for human review. brew vulns on 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/FORGES are extracted from Scanner (which now delegates) and FORGES gains gitlab.gnome.org, gitlab.freedesktop.org and invent.kde.org, all confirmed indexed by OSV. github.com paths are lowercased (OSV's GIT ecosystem indexes case-sensitively but normalises GitHub to lowercase; FFmpeg/FFmpeg returned 0 hits where ffmpeg/ffmpeg returns 231). Identify.registry_package(url) returns {ecosystem:, name:, version:, purl:} for PyPI/npm/crates/CPAN/Hackage/Hex/RubyGems/Maven/NuGet/CRAN URL patterns, so a stable.url or resource URL that points at a registry can be queried against that ecosystem's OSV feed. Vulns::Purl builds spec-conformant purl strings.

Cached feeds (Vulns::CachedFeed, Vulns::CPANSec, Vulns::Repology)

CachedFeed handles fetch, atomic refresh and stale-cache fallback for a JSON feed under HOMEBREW_CACHE/vulns/. CPANSec reads the compiled cpan-security-advisory JSON (CPAN has no OSV ecosystem).

Repology reads Homebrew/advisory-database's published data/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 in ambiguous_projects) fall back to a live single-project API call, delegating to ::Repology.single_package_query, which gains URL-encoding, --fail and 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 every affected[] entry whose package matches (records like GHSA-jfh8-c2jp-5v3q carry three disjoint entries for the same package), honouring range type: SEMVER via Semver, ECOSYSTEM via Version, GIT skipped as uncomparable since commit-SHA events cannot be ordered against a version string. It returns {state:, fixed_in:} where state is :affected, :fixed, or :not_applicable (below every introduced; distinct from :fixed so a version the vulnerability never applied to is not recorded as a bump fix), or nil when there is no matching entry or comparable range. CPANSec.range_status does the same for CPANSA's </<=/>/>=/==/bare constraint grammar.

Vulnerability#identifiers is id + aliases only. upstream is a directed reference (a distro advisory naming one or more source CVEs) and is followed explicitly by Match instead of being treated as an alias.

Matching (Vulns::Match)

Match#identify(formula) composes the above into an Identity: forge repo/tag, primary registry package, resource registry 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 one OSV.query_batch (generalised here from {repo_url:, version:} to {ecosystem:, name:, version:}). Querying versionless means bump-fixed advisories are returned; each Evidence carries the queried {ecosystem, name} plus the subject_version to check ranges against afterwards. CPAN packages route through CPANSec instead, with the Advisory carried 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 upstream transitively with cycle detection (chains like USN -> UBUNTU-CVE-* -> CVE-* occur in practice); related is consulted for bare CVE ids only for ALSA-* records with no upstream (an AlmaLinux data quirk; per the schema related otherwise 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 synthesised Vulnerability. Each resolved hit gets extra evidence pointing at our own git repo and registry package so range_status can check the CVE's affected[] against our version. Hits sharing a CVE alias then collapse into one Hit whose strategy is 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: :affected if any subject is (a fixed primary cannot mask an affected resource), else :fixed if any is, else :not_applicable only when every comparable subject says so. Match#to_brew_record(formula, hit) emits the candidate OSV hash with database_specific: {source: "matched", strategy:, confidence:, upstream_evidence:}. Only state == :fixed writes {fixed: pkg_version} and ecosystem_specific.fix: "bump"; :affected and uncomparable emit no fixed event, confidence is demoted for uncomparable, and :not_applicable hits are dropped before emission.

Match#first_fixed_version re-runs the full aggregate check at each historical revision (each evidence against that revision's subject version) so last_affected and 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_applicable or the start of the formula's history without ever seeing :affected, Homebrew never shipped an affected build and the candidate is dropped; OsvExport.merge_existing preserves hand-corrected boundaries on rewrite otherwise.

bulk: mode (--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 index doesn't cover; single-formula runs still fall back.

brew advisory-match (dev-cmd, hidden from manpage)

brew advisory-match <formula>... [--json] [--output=DIR] [--no-history]
brew advisory-match --all        [--json] [--output=DIR] [--no-history]
brew advisory-match --index

--output writes each record as it is produced via OsvExport.merge_existing, skips existing source: generated records so a matched candidate never clobbers a resolves-derived fix: "patch", and only accumulates counts. --json prints the OSV-hash array (single-formula only; conflicts with --all). --index emits the name -> Identity map 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. :affected precision was 31/34 (91%); the three false positives were upstream-data issues (stale CPANSA >0 range for cpanminus, Perl decimal-version comparison for IO-Compress-Brotli, PYSEC open-ended vs GHSA last_affected for kerberos), not matcher bugs. A :fixed spot-check went 15/15.

No user-facing behaviour change: brew vulns output is unchanged apart from github.com repo URLs now being queried lowercased.


  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include Hyperfine benchmarks.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?

  • AI was used to generate or assist with generating this PR.

Claude Code assisted with implementation and tests; each module reviewed by hand and verified via brew lgtm, full brew style, and live brew advisory-match jq runs.


Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 updated Vulns::Scanner to delegate to it.
  • Added Vulns::Purl + Identify.registry_package to derive OSV ecosystem/name/version/purl from common language registry URLs.
  • Introduced Vulns::CachedFeed plus 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.

Comment thread Library/Homebrew/utils/repology.rb
Comment thread Library/Homebrew/vulns/identify.rb
@andrew andrew changed the title vulns: add Identify/Purl/CachedFeed/CPANSec/Repology for advisory matching vulns: add advisory matcher and dev-cmd/advisory-match Jul 28, 2026

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me, thanks!

Comment thread Library/Homebrew/test/utils/repology_spec.rb Outdated
Comment thread Library/Homebrew/test/vulns/cpan_sec_spec.rb Outdated
andrew added 16 commits July 28, 2026 10:29
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.
andrew added 8 commits July 28, 2026 11:33
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.
andrew added 3 commits July 28, 2026 13:17
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
andrew marked this pull request as ready for review July 28, 2026 14:27
@andrew
andrew requested a review from Copilot July 28, 2026 14:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • Uncomparable was previously marked private_constant but 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 of Vulnerability.
      class Uncomparable < StandardError
      end

Comment thread Library/Homebrew/dev-cmd/advisory-match.rb
@andrew
andrew requested a review from MikeMcQuaid July 28, 2026 14:41
@andrew
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
andrew added this pull request to the merge queue Jul 28, 2026
Merged via the queue into Homebrew:main with commit 8d0b6af Jul 28, 2026
42 checks passed
@andrew
andrew deleted the vulns-identify branch July 28, 2026 16:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants