
This is the fourth installment of what is now officially a series about CVE hygiene, and it's the one I was hoping I'd get to write. First, the workflow: resolve CVEs with the smallest possible diff, and only reach for resolutions/overrides as a last resort. Then, the Bun CVE gap: Bun was the only mainstream JavaScript package manager that couldn't do any of it. Most recently, pnpm's audit --fix: pnpm automating the entire workflow better than anyone.
Bun 1.4 shipped yesterday, and it closes the gap: in-place transitive updates are now the default behavior of bun update, there's a proper bun audit fix, and overrides finally support nested and version-scoped forms. I re-ran the reproductions from the original post against 1.4.0 to see if the claims hold up.
Everything below was tested on Linux in August 2026 with Bun 1.4.0. The original post was based on Bun 1.3.14.
At the end of the original post, I mentioned I had opened a pull request adding a --transitive flag to bun update so transitives could be re-resolved in place: oven-sh/bun#31143.
That PR is now closed, unmerged. And I couldn't be happier about it:

It was superseded by oven-sh/bun#38333, which makes transitive updates the default behavior of bun update instead of an opt-in flag, and closes the feature request that had been open since November 2025. I proposed a flag mostly to be conservative about changing existing behavior; the Bun team went further and made it the default, which is exactly where I hoped this would eventually land. Getting superseded by something strictly better, with credit in the description, is the good ending for a drive-by contribution.
The best part is the title of the PR that shipped all of this: "install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes". I closed the pnpm post by calling pnpm the package manager that's quietly becoming the best at this. Apparently the Bun team agrees: pnpm is now the yardstick.
Also, yes: the comment closing my PR was written by Claude, on behalf of the Bun team. Welcome to 2026.
Before digging into the details, here's what CVE remediation looks like on Bun 1.4. The workflow from the original post (smallest possible diff first, overrides last) now maps directly onto Bun commands:
bun audit to see which advisories apply to your tree.bun audit fix --dry-run to review the plan: what moves, what's blocked, and why.bun audit fix to apply it, adding --latest only when a fix genuinely requires crossing a major version.bun update <name> re-resolves it in place, within every parent's range.Then re-run bun audit to confirm, test your project, done. If that list sounds unremarkable, that's the point: it's the same workflow you'd use with Yarn Berry, pnpm, or npm.
Everything from the original series' checklist (update within range, then the parent, and only then a scoped override) now has a first-class Bun command behind it, and every reproduction that failed in 1.3 passes in 1.4.
Three months ago, Bun couldn't update a transitive dependency in place at all. Today, transitive updates are the default behavior of bun update, bun audit fix respects your pins, your ranges, and your release-age quarantine, and overrides can finally be scoped. One release, and Bun is suddenly near the front of the pack.
The Bun CVE gap is closed. And nobody has an excuse to delete bun.lock anymore. 🙂
The rest of this post is the receipts: the reproductions from the original post, re-run against 1.4.0. Let's dig in.
Same minimal setup as last time. body-parser@1.20.0 transitively pins qs to 6.10.3:
{
"name": "bun-transitive-test",
"dependencies": {
"body-parser": "1.20.0"
}
}
bun install
bun why qs
# qs@6.10.3
# └─ body-parser@1.20.0 (requires 6.10.3)
# └─ bun-transitive-test (requires 1.20.0)
In 1.3.14, bun update qs silently added qs as a direct dependency and left the vulnerable transitive in place. In 1.4.0:
bun update qs
# bun update v1.4.0
# Checked 41 installs across 42 packages (no changes)
No phantom direct dependency, package.json untouched. And "no changes" is the honest answer here: body-parser@1.20.0 pins qs to exactly 6.10.3, so there is nothing to re-resolve within range.
Now the scenario the whole series is built around: a transitive that is genuinely stale within its parent's range. Same reproduction as the original post: chokidar@3.5.0 depends on braces ~3.0.2, and I hand-edited bun.lock to downgrade braces from 3.0.3 to 3.0.2. The lockfile says 3.0.2, the parent's range would happily accept 3.0.3.
In 1.3.14, no command re-resolved this within range (except a global override). In 1.4.0:
bun why braces
# braces@3.0.2
# └─ chokidar@3.5.0 (requires ~3.0.2)
bun update braces
# ^ braces 3.0.2 -> 3.0.3
# 1 package installed
The transitive gets re-resolved in place, the parent stays put, package.json is untouched, and the diff is one line in the lockfile. This is yarn up braces --recursive, except it's just bun update braces now. The primitive I spent an entire post complaining about now exists.
A bare bun update does the same for every package in bun.lock, like pnpm update and npm update, and patterns work too:
bun update
bun update braces
bun update '@types/*'
One shape doesn't map one-to-one: bun update chokidar (the parent) still leaves chokidar's own dependencies locked. If you want the transitive moved, name it directly or run a bare bun update.
bun audit fixConveniently, my reproduction aged into a real-world example while I wasn't looking: body-parser@1.20.0 and its pinned qs@6.10.3 both picked up advisories since May.
bun audit
# body-parser@1.20.0
# (direct dependency)
# low: body-parser vulnerable to denial of service when invalid limit value silently disables size enforcement (<1.20.6)
# high: body-parser vulnerable to denial of service when url encoding is enabled (<1.20.3)
#
# qs@6.10.3
# body-parser > qs
# low: qs's arrayLimit bypass in comma parsing allows denial of service (>=6.7.0 <=6.14.1)
# moderate: qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion (<6.14.1)
#
# 4 vulnerabilities (1 high, 1 moderate, 2 low)
Bun has had a read-only bun audit for a while. What's new in 1.4 is bun audit fix, and it starts with my favorite flag:
bun audit fix --dry-run
# fixing:
# ^ body-parser 1.20.0 -> 1.20.6
# package.json: 1.20.0 -> 1.20.6
#
# blocked by a dependent's range:
# ^ qs 6.10.3 -> 6.14.2
# body-parser@1.20.0 depends on qs@6.10.3
#
# Would fix 2 vulnerabilities in 1 package (checked 41)
# 2 vulnerabilities remaining
Three details in there are worth slowing down for:
body-parser to exactly 1.20.0, and the fix requires leaving that pin. So Bun rewrites package.json and says so, right in the plan: package.json: 1.20.0 -> 1.20.6. After applying, the pin is still a pin ("1.20.6", not "^1.20.6"). Per the help text: "package.json is only changed when an exact pin has to be bumped". Ranged dependencies get fixed in the lockfile without touching your manifest at all.qs fix is 6.14.2, the minimum version that resolves both advisories, not 6.15.3, the newest release. That's the same principle I praised in pnpm's minimumReleaseAgeExclude design: pre-approve the version that fixes the problem, not whatever happens to be newest.qs can't move because body-parser@1.20.0 pins it exactly, and the output names the blocker instead of skipping it or force-overriding it.Now, apply it:
bun audit fix
# Fixed 4 vulnerabilities in 1 package (checked 41)
bun why qs
# qs@6.15.3
# └─ body-parser@1.20.6 (requires ~6.15.1)
bun audit
# No vulnerabilities found (checked 41 packages)
Wait, the dry run said it would fix 2 and leave 2 remaining, but the real run fixed all 4? That's the cascade: bumping body-parser to 1.20.6 changes its qs requirement to ~6.15.1, which resolves to 6.15.3, past both qs advisories. The dry run under-promises because it plans against the current tree; the real run re-audits the tree it actually installed and reports from that. I'll take a tool that surprises me in this direction.
When a fix needs a new major version, bun audit fix says so and refuses; --latest opts into rewriting your declared ranges to take it. There's also --ignore GHSA-… and --audit-level for CI exit-code control.
The one thing I miss from pnpm's implementation is --interactive, to review and apply fixes one by one. The closest Bun equivalent is bun audit fix --dry-run, review the plan, then apply.
minimumReleaseAge interactionIn the pnpm post, I called the interaction between audit --fix and minimumReleaseAge something I hadn't seen anywhere else. Well, now I've seen it somewhere else.
Bun 1.3 shipped install.minimumReleaseAge in bunfig.toml (in seconds), and in 1.4, security fixes bypass it, with an annotation. Here's the same reproduction with a quarantine window large enough to block the body-parser fix, which was 42 days old at the time of testing:
[install]
minimumReleaseAge = 4320000 # 50 days, exaggerated for the demo
bun audit fix
# fixing:
# ^ body-parser 1.20.0 -> 1.20.6 (newer than --minimum-release-age)
# package.json: 1.20.0 -> 1.20.6
The fix installs immediately, and the bypass is called out inline instead of happening silently.
The design is different from pnpm's, though. pnpm persists the exception: the patched version lands in minimumReleaseAgeExclude in your config, so the "why was the quarantine bypassed" context survives into the pull request. Bun's bypass is a one-time decision annotated in the command output: nothing is persisted, and there's nothing to clean up afterwards. The version change itself still shows up in your package.json and bun.lock diff either way. I still have a soft spot for pnpm's explicit, reviewable entry, but Bun gets the important part right: a known CVE fix shouldn't have to sit out a quarantine window that exists to protect you from unknown ones.
The bypass is scoped to the vulnerable package itself. If the patched release pulls in brand-new dependencies of its own that are also inside your window, those are still blocked and the fix fails with a resolution error. I could only trigger this with an absurdly large window (two years); with a realistic window of one to three days, you're unlikely to ever hit it.
The other half of the original post was about Bun's overrides being keyed by package name only: every copy of a name gets redirected, and all the scoped syntaxes from other package managers either warned or silently did nothing.
In 1.4, they all just work. npm's nested form:
{
"name": "bun-nested-overrides-test",
"dependencies": {
"body-parser": "1.20.0",
"qs": "6.9.0"
},
"overrides": {
"body-parser": { "qs": "6.11.0" }
}
}
bun why qs
# qs@6.9.0
# └─ bun-nested-overrides-test (requires 6.9.0)
#
# qs@6.11.0
# └─ body-parser@1.20.0 (requires 6.10.3)
Only body-parser's copy of qs is redirected; the direct qs@6.9.0 is left alone. That's the surgical override the original post was trying to express. Yarn's "body-parser/qs" and pnpm's "body-parser>qs" forms are accepted too, and produce the same result.
Version-scoped overrides also work now:
{
"overrides": {
"qs@<6.11.0": "6.11.0"
}
}
This is the version-keyed resolutions pattern from my original CVE post, the exact syntax that used to silently match nothing in Bun. Overrides are still the last-resort tool, and everything I've written about scoping and removing them still applies. The difference is that Bun now rarely forces you to reach for them, and when you do, they're finally expressive enough to be surgical.
A few more bun install additions from 1.4 that fit the theme:
bun dedupe collapses duplicate versions using only versions already in the lockfile, never touches package.json, and --check fails CI when duplicates exist.bun prune removes everything from node_modules that the lockfile doesn't put there.bun pm diff shows what changed between two versions of a package, including new install scripts and new imports of child_process, fs, net, or vm. Handy for reviewing the updates you're about to take.