Plumbing Homebrew into the Vulnerability Ecosystem

chmaynard1 pts0 comments

Plumbing Homebrew into the vulnerability ecosystem | Andrew Nesbitt

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<br>Checking 142 packages for vulnerabilities...<br>(18 packages skipped - no supported source URL)

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

Found 1 vulnerability in 1 package

10 resolved by formula patches (not counted; pass --no-ignore-patches to include):<br>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<br>url "https://deb.debian.org/.../libquicktime_1.2.4-12.debian.tar.xz"<br>sha256 "..."<br>type :backport<br>resolves "CVE-2016-2399", "CVE-2017-9122"<br>apply "patches/CVE-2016-2399.patch", "patches/CVE-2017-9122_et_al.patch"<br>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...

homebrew patches patch formula formulae brew

Related Articles