# Plumbing Homebrew into the vulnerability ecosystem

Back in January I [released brew-vulns](/2026/01/08/brew-vulns-cve-scanning-for-homebrew), a gem that added CVE scanning to Homebrew. It shelled out to `brew info --json=v2`, derived a source repository URL and version tag for each installed formula, and queried [osv.dev](https://osv.dev) for anything known against that repo at that version. As of Homebrew 6.0.11 that command is built in and the gem is deprecated. Getting useful answers out of `brew vulns` in between meant changing what data Homebrew publishes about its own packages, registering Homebrew with the specs that vulnerability tooling is built on, standing up an advisory database, and porting Homebrew's version comparison to Python so osv.dev can order our releases.

```
$ brew vulns
Checking 142 packages for vulnerabilities...
(18 packages skipped - no supported source URL)

openssl@3 (3.4.1)
  CVE-2024-13176 (medium) - Timing side-channel in ECDSA signature...
    Fixed in: 3.4.2

Found 1 vulnerability in 1 package

10 resolved by formula patches (not counted; pass --no-ignore-patches to include):
  glibc: CVE-2024-2961, CVE-2024-33599, CVE-2024-33600, CVE-2025-0395, ...
```

About a month after the first release Mike McQuaid invited me to transfer the repo into the Homebrew org, and from then on it was being run against all of homebrew-core rather than just my laptop. That's roughly 8,400 formulae, enough real data to find edge cases fixtures never do. OSV records with malformed `affected` ranges were passing versions through silently, so [range evaluation became fail-closed](https://github.com/Homebrew/homebrew-brew-vulns/pull/62). Vulnerability summaries occasionally contain terminal escape sequences and needed sanitising before printing. One pathological query could pull unbounded pages from the OSV API, so both pagination and the thread pool that fetches vulnerability detail got hard caps, and CI users needed a [distinct exit code](https://github.com/Homebrew/homebrew-brew-vulns/pull/79) for "the scan errored" as opposed to "the scan found something".

### Formula patches

Hundreds of formulae in homebrew-core ship with at least one patch applied on top of the upstream tarball, and some of those patches fix CVEs, so the bottle a user installs is not affected even when the upstream version number falls inside an advisory's range. A query keyed on repo and version reports those as open regardless. There was also no programmatic way to find out which formulae carried patches at all: `brew info --json` didn't serialise the patch list, formulae.brew.sh didn't show it, and formulae loaded from the API had an empty one, leaving a grep over a full homebrew-core checkout as the only option.

I opened a [discussion](https://github.com/orgs/Homebrew/discussions/6869) proposing two changes to Homebrew itself: add patches to the JSON output, and let a patch declare which CVEs it resolves. Mike said yes to both more or less immediately. Patches [went into `Formula#to_hash`](https://github.com/Homebrew/brew/pull/22459) the same day, which surfaced 1,191 of them across 809 formulae, 972 with an external URL and 219 embedded in the formula file as `__END__` data. The `patch do` DSL then [gained `resolves` and `type`](https://github.com/Homebrew/brew/pull/22466):

```ruby
patch do
  url "https://deb.debian.org/.../libquicktime_1.2.4-12.debian.tar.xz"
  sha256 "..."
  type :backport
  resolves "CVE-2016-2399", "CVE-2017-9122"
  apply "patches/CVE-2016-2399.patch", "patches/CVE-2017-9122_et_al.patch"
end
```

The vocabulary matches CycloneDX's [`pedigree.patches`](https://cyclonedx.org/docs/1.6/json/#components_items_pedigree_patches) so SBOM tooling can read it without translation, and CVE ids are auto-inferred from the patch URL and `apply` filenames, which meant glibc, unzip, zip and libquicktime populated without any formula edits.

That change sat in review for close to a month and shipped in Homebrew 6.0.4, at which point brew-vulns could [consume it](https://github.com/Homebrew/homebrew-brew-vulns/pull/92): any OSV result whose id or alias matches a `resolves` entry is reported as patched, excluded from the exit code, and marked `analysis.state: resolved` in CycloneDX output. Bo Anderson later [mapped `resolves` through the API layer](https://github.com/Homebrew/brew/pull/23114) so it survives the JSON round-trip when formulae are loaded without a local homebrew-core checkout.

### Annotation backfill

With the DSL merged, the [next job](https://github.com/Homebrew/homebrew-brew-vulns/issues/95) was populating it across those 1,191 existing patches. Of the 972 external ones, 497 point at GitHub commit URLs (in principle queryable in OSV by commit hash), 272 point at raw files hosted in homebrew-core itself, 42 come from Debian, and the rest scatter across MacPorts, FreeBSD ports, Fedora, Gentoo and gists. [Patrick Linnane](https://github.com/p-linnane) sampled 59 of the GitHub-commit patches and found zero that referenced a CVE; homebrew-core's patches are almost entirely build and portability fixes, and a heuristic that greps formula source for CVE strings risks marking things patched that aren't, since several of the CVE mentions in core are comments about *un*fixed issues.

So the approach flipped from annotating every patch by URL heuristic to running the scanner over every patched formula and only annotating where a real finding gets suppressed. That pass covered the 484 patched formulae with a resolvable source repo and turned up nine with open OSV findings; in every case the shipped patch was a build fix unrelated to the CVE, so zero new annotations came out of it.

A wider sweep querying OSV by commit hash for the GitHub-commit patches, reading DEP-3 headers on the Debian tarballs, and grepping local patch bodies found exactly one more: `nvi` carries a Debian regex patch that fixes [CVE-2015-2305](https://github.com/Homebrew/homebrew-core/pull/290337). Along the way, extending the scanner's [homepage fallback](https://github.com/Homebrew/homebrew-brew-vulns/pull/97) to cover formulae hosted outside GitHub, GitLab and Codeberg also turned up an unrelated data bug where `libart`'s homepage field pointed at [a completely different project](https://github.com/Homebrew/homebrew-core/pull/290393).

The glibc formula's Debian patch tarball resolves ten CVEs, and querying OSV by the sourceware.org repo URL returns exactly one of them. The other nine exist in OSV only as records auto-converted from NVD, and those conversions carry no `affected[].package` data: you can `GET /v1/vulns/CVE-2024-2961` and read the record, but no query by package, purl or repository will ever return it. All of libquicktime's patched CVEs were in the same state. So even a scanner with perfect repo-URL derivation has a recall ceiling set by how much of NVD has been enriched with package data upstream, and for the C libraries Homebrew ships a lot of, the answer is not much.

By this point Homebrew had structured `resolves` data, version history and patch metadata for every formula, which together are enough to act as an OSV data source. I [added an `--osv-export` flag](https://github.com/Homebrew/homebrew-brew-vulns/pull/99) to the tap that walks every formula's `resolves` list and emits one OSV-schema JSON record per CVE, under a `Homebrew` ecosystem with `pkg:brew/<name>` package URLs, `BREW-<formula>-<CVE>` ids, and the resolving patch details carried in `ecosystem_specific`. A demo repository held the initial 26 records with schema validation running in CI, and any OSV-aware tool reading that feed could answer whether a given Homebrew package version was affected by a given CVE without going near the upstream repo.

### Spec registrations

For anyone else's tooling to read those records, two specs needed a Homebrew entry and osv.dev needed code to handle it. The [package-url spec](https://github.com/package-url/purl-spec) defines the `pkg:` identifier format that OSV, CycloneDX, SPDX and most SBOM tooling use to name packages, and it maintains a registry of allowed types. A `pkg:brew` type had been [proposed in 2023](https://github.com/package-url/purl-spec/issues/254) by William Woodruff and again by others afterwards, and it finally [landed](https://github.com/package-url/purl-spec/pull/796) via Rui Chen earlier this year. One wrinkle specific to Homebrew is that formula names can contain `@` for version-pinned variants like `openssl@3` and `python@3.12`, and `@` is also purl's version separator, so `pkg:brew/openssl@3` is ambiguous and the `@` in the name has to be [percent-encoded](https://github.com/Homebrew/homebrew-brew-vulns/pull/101) as `pkg:brew/openssl%403`.

The [OSV schema](https://github.com/ossf/osv-schema) keeps its own registry of ecosystem names and id prefixes, and records that use an unregistered one fail validation. Registering [`Homebrew` and the `BREW-` prefix](https://github.com/ossf/osv-schema/pull/576) was straightforward, as was [documenting the database URL and how Homebrew versions sort](https://github.com/ossf/osv-schema/pull/580), which any tool evaluating an `affected` range needs to implement.

Getting osv.dev itself to ingest the feed is the piece [still in progress](https://github.com/google/osv.dev/issues/5659). Two additions are needed per ecosystem in the osv.dev importer: a purl-to-package mapping, which for `pkg:brew` is a couple of lines, and a version comparator so that range queries like "is `6.0_7` inside `introduced: 0, fixed: 6.0_8`" can be evaluated server-side. Homebrew versions are the upstream version string with an optional `_N` revision suffix that increments when a formula changes without an upstream release, and the upstream strings are whatever each project uses, semver or otherwise. The comparator is therefore a Python port of Homebrew's Ruby `Version#<=>` logic, which runs to a couple of hundred lines of edge cases. The [draft PR](https://github.com/google/osv.dev/pull/5660) is open; until it merges and ingestion is enabled, `brew vulns` can't query `{ecosystem: "Homebrew"}` over the API and has to fetch the advisory database's static JSON directly instead.

### Advisory database

I transferred the demo repository into the Homebrew org once the schema registration merged, initially as `Homebrew/homebrew-advisory-database`. That name lasted about a day: the `homebrew-` prefix is how the `setup-homebrew` CI action identifies taps, and CI runs started failing on an attempt to tap a directory full of JSON files, so it became [`Homebrew/advisory-database`](https://github.com/Homebrew/advisory-database) and every reference to the old name across brew and the repo's own workflows [had to follow](https://github.com/Homebrew/brew/pull/23117).

The repo holds one JSON file per advisory, currently 33 across six formulae (glibc, libquicktime, lrzsz, nvi, unzip, zip), all generated from `resolves` annotations. A daily GitHub Actions workflow regenerates them, and because the org's branch ruleset requires pull requests on `main` the workflow [opens a PR](https://github.com/Homebrew/advisory-database/pull/7) instead of pushing. Regeneration originally ran `gem install brew-vulns` followed by `brew vulns --osv-export`, which meant the tap couldn't be archived until brew had a native replacement; that arrived as [`brew generate-vulns-advisories`](https://github.com/Homebrew/brew/pull/23106), a hidden dev command, and the [workflow switched over](https://github.com/Homebrew/advisory-database/pull/4).

The `fixed` boundary in each record needed more care than "whatever version ships today". If unzip's formula gained a CVE-2014-8139 patch at revision `6.0_6` and the current revision is `6.0_8`, a record saying `fixed: 6.0_8` wrongly flags `6.0_6` and `6.0_7` as vulnerable. The generator now [walks homebrew-core's git history](https://github.com/Homebrew/brew/pull/23115) via `FormulaVersions` to find the first revision where the resolving patch appeared, and [merges into existing records](https://github.com/Homebrew/brew/pull/23112) rather than overwriting them so a hand-corrected boundary or a curated record survives the next nightly run. The [contributing guide](https://github.com/Homebrew/advisory-database/blob/main/CONTRIBUTING.md) covers the curated path for cases the generator can't express, using a `BREW-0000-0000` placeholder id that a maintainer replaces on merge.

### Merging into Homebrew/brew

Around this point I was added as a Homebrew maintainer, which made the next stretch a lot smoother since it involved landing a run of interdependent changes across brew, homebrew-core and the advisory database in quick succession. The scanner [moved into `Library/Homebrew/cmd/vulns.rb`](https://github.com/Homebrew/brew/pull/23080) with a supporting `vulns/` module directory, following the same pattern as `brew livecheck`, and the [tracking issue](https://github.com/Homebrew/homebrew-brew-vulns/issues/111) has the full design doc listing everything that changed on the way in.

User-facing commands in brew's `cmd/` directory can't lazy-load gem groups (a RuboCop cop enforces it), which was the biggest constraint on the port: anything the command needs has to be in the vendored bundle already or reimplemented, and adding five gems to every Homebrew install for one subcommand wasn't going to fly. The tap depended on [`purl`](https://rubygems.org/gems/purl), [`vers`](https://rubygems.org/gems/vers), [`cvss-suite`](https://rubygems.org/gems/cvss-suite), [`sbom`](https://rubygems.org/gems/sbom) and [`sarif-ruby`](https://rubygems.org/gems/sarif-ruby), and all five went.

The `sbom` and `sarif-ruby` gems were only used for `--cyclonedx` and `--sarif` output, so those flags are dropped for now and will return as direct JSON emission validated against the published schemas. The `purl` gem was used exactly once, to extract the ecosystem token from an OSV record's `affected.package.purl` field, and since the merged code only evaluates `SEMVER` ranges locally that lookup is never reached.

The `vers` gem became a small `Homebrew::Vulns::Semver` module implementing strict SemVer 2.0 comparison. Homebrew's own `Version` class isn't a drop-in here: it sorts `1.0.0-alpha.1` above `1.0.0-alpha.beta` where the SemVer spec says numeric prerelease identifiers sort below alphanumeric ones, and it compares `+build` metadata where SemVer says build metadata is ignored for ordering. Getting either wrong turns a range boundary into a false positive or negative.

The `cvss-suite` gem became `Homebrew::Vulns::CVSS`, which implements the CVSS v3.0/v3.1 base-score formula and buckets the result. CVSS v4.0 scoring requires a roughly 270-entry MacroVector lookup table plus interpolation between entries, so v4 vectors currently fall through to the next `severity` entry on the record; almost every record with a v4 vector also carries a v3 one, so in practice little is lost.

The tap's `--all` flag was going to become `--eval-all` to match other brew commands, until `--eval-all` was replaced across brew with a tap-trust mechanism while the PR was in review, so with no arguments `brew vulns` now scans every formula from a trusted tap and falls back to installed formulae otherwise. The tap also always read the current formula definition for the source URL and version, so an old installed keg would be checked against whatever version the formula had since been bumped to. The built-in command reads the [installed keg's SPDX SBOM](https://github.com/Homebrew/brew/pull/23140) instead, and if an outdated keg predates SBOM generation the scan warns and exits nonzero rather than reporting a version it never checked.

All of that shipped together in Homebrew 6.0.11, and the tap is now [a deprecation stub](https://github.com/Homebrew/homebrew-brew-vulns/pull/112) pointing at the built-in command.

### Coverage

The command as shipped queries OSV's GIT ecosystem by repo URL, which covers about 73% of homebrew-core (6,210 of 8,492 formulae); the other 2,282 have no derivable GitHub, GitLab or Codeberg URL and are skipped, with a count reported at the end of the run. Closing that gap means finding other keys that join a formula to an advisory, and doing the joining once at authoring time in the advisory database rather than on every user's machine. That way `brew vulns` stays at a single batched API call however many matching strategies feed the database behind it.

Of the skipped formulae, 322 have a `stable.url` pointing at a language package registry (145 PyPI, 144 npm, plus Maven, Hackage, CPAN, crates and a few others), and a URL like `files.pythonhosted.org/.../foo-1.2.tar.gz` encodes enough to build a `pkg:pypi/foo@1.2` purl and query OSV's PyPI ecosystem directly. The same applies to the 12,283 `resource` blocks across homebrew-core, which are vendored dependencies inside formulae; about 91% of those are registry URLs (over 10,000 PyPI alone) and none are scanned today.

That leaves roughly 1,960 formulae hosted on SourceForge, GNU mirrors, gnome.org download servers, Debian archives and the like, with no direct package identifier at all. For those the join goes through [Repology](https://repology.org), which already indexes Homebrew alongside every major Linux distro: look up the formula name, get back the Debian, Alpine and openSUSE source-package names for the same upstream, query OSV's distro ecosystems for those, and collect the CVE aliases.

Repology's [bulk dumps](https://dumps.repology.org/) turned out to be 10GB+ PostgreSQL restores rather than anything directly loadable, so the [index builder](https://github.com/Homebrew/advisory-database/pull/11) paginates the API instead, about 43 pages at one request per second, and writes a distilled `formula -> {distro -> srcname}` map into the advisory repo. Name matching needs normalisation (`postgresql@16` maps to Repology's `postgresql`, `libmatio` maps to `matio`, and Repology sometimes lists a Debian binary package where the OSV feed keys on the source package), and corrections to Repology's project grouping go upstream to [repology-rules](https://github.com/repology/repology-rules) instead of into a local override layer.

OSV has no CPAN, CTAN or LuaRocks ecosystem, and 61 formulae vendor at least one CPAN module, so that path reads the [cpan-security-advisory](https://github.com/briandfoy/cpan-security-advisory) database directly. Between the registry URLs, the Repology mapping and the CPAN feed, roughly 96% of homebrew-core ends up with at least one automated join key, with a few hundred formulae left over that will need a hand-curated mapping file in the advisory repo.

### Next

The scan output can surface in more of Homebrew than a dedicated subcommand. Running the scanner once inside `brew generate-formula-api` and attaching a `vulnerabilities` field to each formula's JSON would put open findings on [formulae.brew.sh](https://formulae.brew.sh) formula pages and in `brew info` without a live query, since both already read that JSON. From there `brew install` could warn when the version being installed has an open advisory, and `brew upgrade` could list which CVEs each pending bump resolves alongside the version change, so the security case for an upgrade is visible when you're deciding whether to run it.

Every installed keg already carries an SPDX SBOM listing its dependencies with versions, and the built-in command reads that SBOM for the top-level source URL now, so the inventory for scanning a formula's whole dependency tree is already on disk and the remaining work is the same matching problem as everything above. Casks are harder because a large share of them package closed-source applications, with no upstream repo to query and no visible dependency list to walk. The [EU Cyber Resilience Act](/2026/07/01/the-cra-is-not-about-open-source) will change that from outside Homebrew: vendors selling into Europe will have to produce SBOMs for their products, and as those start to appear for the apps that casks install, scanning a cask's dependency tree becomes the same job as scanning a keg's.

On the maintainer side, a [homebrew-core](https://github.com/Homebrew/homebrew-core) PR that bumps a formula is the natural place to report which CVEs the bump closes, which are still open at the new version, and which affect any vendored `resource` blocks. Running the full matcher against the one changed formula is cheap enough to sit inside `brew test-bot` alongside the existing audit checks. Anything it finds that isn't already a `BREW-*` record is a candidate for the advisory database, giving it a second feed driven by formula changes as well as the nightly advisory sweep. Restoring `--sarif` and `--cyclonedx` output brings back the code-scanning and SBOM integrations for downstream projects that want to scan their own Brewfiles in CI.

Underneath all of that, [osv.dev ingestion](https://github.com/google/osv.dev/pull/5660) is still the gate for querying `{ecosystem: "Homebrew"}` directly rather than fetching the advisory JSON, and CVSS v4 scoring still needs its lookup table before v4-only records get a severity label.

Thanks to [Mike McQuaid](https://github.com/MikeMcQuaid) and Patrick for all the help getting this over the line so far, and to the rest of the Homebrew maintainers for reviews across an unreasonable number of pull requests. If you're on Homebrew 6.0.11 or later `brew vulns` is already there; the [advisory database](https://github.com/Homebrew/advisory-database) and the [discussion thread](https://github.com/orgs/Homebrew/discussions/6869) that most of this fell out of are good places to follow along.

---

By Andrew Nesbitt (https://nesbitt.io). Markdown version of https://nesbitt.io/2026/07/17/plumbing-homebrew-into-the-vulnerability-ecosystem.html
