---
title: "The Bun CVE Gap: When Your Package Manager Can't Do Surgical Updates"
publishedAt: '2026-05-21T12:00:00Z'
summary: "Yarn Berry, pnpm, and npm all support surgical CVE remediation. Bun, today, doesn't. Here's what I found when I tried to apply my own workflow to a Bun project."
tags: ['dependencies', 'security', 'tooling', 'bun']
---

A while back, I wrote [Minimizing Risk: Properly and Safely Resolving CVEs in Your Dependencies](https://charpeni.com/blog/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](https://github.com/oven-sh/bun/issues/24523#issuecomment-3508066349) on the open feature request is, verbatim:

<div className="img-center">
  <Image
    alt={`Screenshot of a GitHub comment from a Bun collaborator suggesting to delete bun.lock to force an update`}
    src={`https://charpeni.com/static/images/the-bun-cve-gap-when-your-package-manager-cant-do-surgical-updates/delete-lock-file.png`}
    width={1342}
    height={344}
  />
</div>

> 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](https://charpeni.com/blog/minimizing-risk-properly-and-safely-resolving-cves-in-your-dependencies#dont-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](https://charpeni.com/blog/protecting-against-compromised-packages-with-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":

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

```bash
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`:

```bash
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`:

```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:

```bash
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](https://github.com/oven-sh/bun/issues/27520), which was closed without a fix. The corresponding feature request, [`bun update` for transitive dependencies](https://github.com/oven-sh/bun/issues/24523), 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](https://github.com/oven-sh/bun/pull/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. 🙂
