The Bun CVE Gap: When Your Package Manager Can't Do Surgical Updates

Nicolas Charpentier
Nicolas Charpentier
May 21, 2026
10 min read

A while back, I wrote Minimizing Risk: Properly and Safely Resolving CVEs in Your Dependencies. The idea was simple: when a CVE lands in one of your dependencies, pick the smallest possible diff that resolves it. Try to update the vulnerable package within its existing range first, then update the parent that pulls it in, and only reach for resolutions/overrides as a last resort, scoping them as narrowly as possible.

Recently, I tried to apply that same workflow to a project using Bun, and I quickly found out that the tooling doesn't really support it. So I dug into it to understand what's going on, and I figured I would also check whether this was a Bun specificity or something the rest of the ecosystem shares.

The short version: it's a Bun specificity. Yarn Berry, pnpm, and npm all let you update a transitive within its parent's range and all support some form of scoped override syntax. Bun supports neither. As far as I can tell, it's currently the only mainstream JavaScript package manager that doesn't let you do this cleanly.

If you're running Bun on a project where CVE hygiene matters, this is the kind of thing you'll want to know before you discover it the hard way.

The scenario

Let's start with the most common case: a CVE lands on a transitive dependency. The parent that pulls it in already accepts a wide range (something like ^1.2.0), and a patched version exists inside that range.

In Yarn, this is the easy case. You run yarn up <pkg> --recursive, the transitive gets re-resolved in place, the parent stays put, and the diff is one line in the lockfile. Done.

In Bun, it's not that simple. In fact, the answer from a Bun collaborator on the open feature request is, verbatim:

Screenshot of a GitHub comment from a Bun collaborator suggesting to delete bun.lock to force an update

yeah i just delete my bun.lock to force update all lol

Note

To be fair, I'm pretty sure that comment was meant lightheartedly back in November 2025, and I don't want to read too much into it. But now more than ever, we need a reliable lockfile, and "just delete it" really shouldn't be the workflow we end up reaching for.

Please don't do that. The whole point of a lockfile is reproducibility: same versions, same machine, same CI, every time. Deleting it doesn't "force update" anything in a controlled way, it regenerates the entire tree from scratch. To be fair, I'm pretty sure that comment was meant lightheartedly back in November 2025, and I don't want to read too much into it. But now more than ever, we need a reliable lockfile, and "just delete it" really shouldn't be the workflow we end up reaching for.

This is one of the very first anti-patterns flagged in the original post (Don't delete and regenerate your lock file), and with the amount of supply-chain attacks we've been seeing lately (I even wrote about minimum release age because of how bad it has gotten), it's an even worse idea than it used to be. You'd be throwing away every guarantee your lockfile was supposed to provide, across every dependency, just to fix one of them.

So, let's see what actually happens when you try to update a transitive in Bun.

bun update <pkg> doesn't update transitives in place

Let's set up a minimal reproduction. body-parser@1.20.0 transitively pins qs to 6.10.3, which makes for a convenient "transitive I want to bump":

package.json
{
  "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)

So far so good. Now let's try to update qs (a transitive) the way you would with yarn up qs --recursive:

bun update qs
# bun update v1.3.14
# installed qs@6.15.2
# 2 packages installed

That looks like it worked. It didn't. Look at package.json:

package.json
{
  "name": "bun-transitive-test",
  "dependencies": {
    "body-parser": "1.20.0",
    "qs": "^6.15.2"
  }
}

Bun silently added qs as a direct dependency. And the original transitive copy is still there:

bun why qs
# qs@6.15.2
#   └─ bun-transitive-test (requires ^6.15.2)
# qs@6.10.3
#   └─ body-parser@1.20.0 (requires 6.10.3)

So now we have two copies of qs: the new one we accidentally added to our manifest, and the original vulnerable transitive that we were actually trying to fix. If qs had a CVE, the project is still vulnerable, but package.json now claims we depend on qs directly. That's the opposite of what we want.

Note

This isn't a quirk of pinned versions. The same happens with regular semver ranges. As long as the parent's range would also accept the newer version, Bun will still refuse to update the transitive in place and will instead add the package as a direct dependency.

It's not a UX bug, it's the design

This is intentional. bun update <pkg> is essentially bun add <pkg>@latest with a guard that prevents exceeding the current range when one already exists. In other words, the command operates on your manifest's direct dependencies. If the package is already declared as a direct dependency, it updates it. If not, it adds it.

Transitive dependencies are simply not in scope for bun update. There is no command in Bun that says "find this package wherever it appears in the tree and re-resolve it within range". That primitive doesn't exist.

What about the other update flags?

You might reasonably hope that one of the other update variants would re-resolve transitives. To test this thoroughly, I set up a different reproduction where a transitive is genuinely stale within range:

  1. Installed chokidar@3.5.0, which depends on braces ~3.0.2. Bun resolves this to braces@3.0.3 (the latest in that range).
  2. Manually edited bun.lock to downgrade braces from 3.0.3 to 3.0.2. Now we have a stale transitive: the lockfile says 3.0.2, but the parent's range (~3.0.2) would happily accept 3.0.3.
  3. Ran every variant of update I could think of, and recorded what happened to braces:
  • bun install: stays at 3.0.2.
  • bun install --force: stays at 3.0.2.
  • bun update (no args): stays at 3.0.2.
  • bun update --force: stays at 3.0.2.
  • bun update braces: adds braces as a direct dependency; original transitive stays at 3.0.2.
  • bun update chokidar: stays at 3.0.2 (parent doesn't move).
  • bun update chokidar --latest: re-resolves, but only because chokidar@5 drops the braces dependency entirely.
  • "overrides": { "braces": "3.0.3" } + bun install: the transitive moves to 3.0.3 in place.

The only variant that re-resolved a transitive within range was the global override. Every other path either left it stale or polluted package.json.

Note

bun update <parent> --latest does sometimes refresh transitives, but only as a side effect of crossing a major version on the parent, which is exactly the kind of large diff we're trying to avoid. It's not a substitute for a surgical transitive update.

The underlying reason is that when Bun rebuilds the lockfile, it carries forward the old transitive resolutions verbatim instead of re-checking whether a newer in-range version is available. Old resolutions are sticky.

The root cause has been independently traced in oven-sh/bun#27520, which was closed without a fix. The corresponding feature request, bun update for transitive dependencies, has been open since November 2025 with no assignee and no linked PR.

What about pnpm and npm?

So far I've been comparing Bun to Yarn Berry, which is what my original post is built around. But I wanted to make sure I wasn't unfairly singling Bun out, so I ran the exact same scenario through pnpm and npm. Both of them updated the transitive in place. Neither of them touched package.json. This is the behavior I expected from Bun and didn't get. As far as I can tell, Bun is the only mainstream JavaScript package manager that doesn't handle this correctly today.

Note

Tested on macOS in May 2026 with Bun 1.3.14, pnpm 10.33.0, and npm 11.11.0.

The overrides escape hatch is also weaker

OK, so the in-place transitive update is out, the parent bump only works when you happen to be willing to take a major version jump, and we're pushed toward overrides (the last-resort tool) much more often than we would be with Yarn or pnpm.

Here's where it gets worse: Bun's overrides are strictly less expressive than Yarn's resolutions or npm's nested overrides.

Internally, Bun's override table is keyed by package name only. There's no concept of a path, a parent, or a source range. An override entry essentially says "every copy of this name becomes this version", and that's it. The maintainers acknowledge this in a TODO comment in the code, along with a sketch of how a more expressive design would look, but the work hasn't happened.

Concretely, the override syntaxes you may know from other ecosystems either emit a warning or silently fail to match in Bun:

  • Yarn-style nested resolutions ("foo/bar": "1.0") trigger a warning that nested resolutions aren't supported.
  • npm-style nested overrides ("foo": { "bar": "1.0" }) trigger a similar warning that nested overrides aren't supported.
  • Version-keyed entries ("bar@2.0.0": { "foo": "1.0" }) silently don't match anything.
  • patch: values trigger a warning that they aren't supported.
  • pnpm-style selectors like "foo>bar" (which can sneak in via lockfile migration) get hashed as a single package name and silently never match.

The version-keyed pattern from my original post ("lodash@~4.16.1": "~4.17.21") falls into that third category: Bun parses it as a key, but the version-range part has no effect, and the override either fails to apply or applies globally to every lodash. Either way, it's not the surgical override I was trying to express.

So in Bun, your only real options are:

  • Don't override (and stay vulnerable).
  • Override globally by package name ("lodash": "~4.17.21"), which redirects every consumer of lodash in your tree to that version, regardless of the range they asked for.
Note

"Global by name" is fine when every consumer of a package is happy with the same forced version. It is not fine when you have one consumer pinned to ~4.16.x for a reason, and another pinned to ~4.17.x, and you only want to redirect one of them. That distinction is the whole point of using a scoped override in the original post.

Wrapping up

I'm not saying Bun is bad. Their package manager is fast, the install UX is pleasant, and there's a lot to like. But "fast install" and "safe CVE remediation" are not the same problem, and on the CVE side, Yarn Berry, pnpm, and npm all give you what you need today, with different ergonomics. Bun, as far as I can tell, is the only one that doesn't.

Rather than just complain about it, I went ahead and opened a pull request adding a --transitive flag to bun update so transitives can be re-resolved in place: oven-sh/bun#31143. It's open at the time of writing; we'll see where it goes.

If you're running Bun on a project where CVE hygiene matters, this is something to keep in mind. And if you really need to update a transitive in place: please don't rm bun.lock and call it a day. 🙂