Back in January I released brew-vulns, 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 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. 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 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 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 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:
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 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: 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 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 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 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 unfixed 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. Along the way, extending the scanner’s homepage fallback 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.
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 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 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 by William Woodruff and again by others afterwards, and it finally landed 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 [email protected], and @ is also purl’s version separator, so pkg:brew/openssl@3 is ambiguous and the @ in the name has to be percent-encoded as pkg:brew/openssl%403.
The 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 was straightforward, as was documenting the database URL and how Homebrew versions sort, which any tool evaluating an affected range needs to implement.
Getting osv.dev itself to ingest the feed is the piece still in progress. 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 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 and every reference to the old name across brew and the repo’s own workflows had to follow.
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 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, a hidden dev command, and the workflow switched over.
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 via FormulaVersions to find the first revision where the resolving patch appeared, and merges into existing records rather than overwriting them so a hand-corrected boundary or a curated record survives the next nightly run. The contributing guide 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 with a supporting vulns/ module directory, following the same pattern as brew livecheck, and the tracking issue 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, vers, cvss-suite, sbom and 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 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 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/[email protected] 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, 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 turned out to be 10GB+ PostgreSQL restores rather than anything directly loadable, so the index builder 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 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 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 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 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 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 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 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 and the discussion thread that most of this fell out of are good places to follow along.