Back to glossary

Bun (package manager)

The Bun package manager (bun install) is an npm-registry client built into Bun that skips dependency install scripts unless the package is trusted.

Also known as bun install, bun pm, bunx, bun.lock, trustedDependencies

The Bun package manager (the bun install and bun pm commands) is built into the Bun JavaScript runtime: a native npm-registry client that resolves package.json, links packages from a global cache into node_modules, and writes a text bun.lock. Unlike npm's historical default, it skips dependency lifecycle scripts unless the package is trusted.

That last clause is the whole security model, and Bun keys it on package names. As of Bun 1.4.4 (October 2026), a built-in list of 367 npm package names may run install scripts with no configuration. Any name you add to trustedDependencies stays trusted for every future version of that package. We think default-off install scripts are the right design, and Bun shipped them before npm or pnpm did. We also think name-keyed trust is the weakest part of it. Two names on Bun's default list, nx and mbt, have each shipped a malicious install script after attackers gained publishing access, and a default bun install would have run both.

What is the Bun package manager?

#

The Bun package manager is the dependency installer inside the Bun runtime, and it speaks the same protocol as npm. It reads package.json, fetches metadata and tarballs from registry.npmjs.org (or whatever .npmrc and bunfig.toml point at), and lays packages out in node_modules so Node.js and Bun can both resolve them. Bun has no registry of its own. Every package on npm, and every malicious package on npm, is one bun add away.

Like any package manager, it does three jobs: resolve a version for every dependency, fetch the bytes, and place them on disk. Bun's speed comes from doing the second and third jobs as little as possible:

  • Registry responses are cached in a binary format under ~/.bun/install/cache/*.npm, which parses faster than JSON. Bun treats cached metadata as fresh for about 5 minutes.
  • Extracted packages live once in a global cache at ~/.bun/install/cache/<name>@<version>.
  • Files are placed with hardlink on Linux and Windows and clonefile on macOS, not copied. With a warm cache and an unchanged lockfile, an install is mostly link system calls.

We measured the effect on a project with Next.js 15.5.4, React 19, Vite, Jest, ESLint and TypeScript. The lockfile resolves 565 packages, of which 444 install on linux-x64. Each run deleted node_modules and reinstalled offline from a warm cache and an existing lockfile, on a 2-vCPU Linux container.

Package managerCommandMedian of 5 runs
npm 12.2.1npm ci --offline12.69 s
pnpm 12.15.0pnpm install --frozen-lockfile --offline0.55 s
Bun 1.4.4bun install --frozen-lockfile --offline0.11 s

Bun placed 16,023 files in 0.11 seconds because it hardlinked them rather than writing them. The pnpm runs set strict-dep-builds=false, for the reason given in the comparison section below. Cold installs are dominated by network fetches, and our container's proxied network made those numbers meaningless, so we left them out. Treat any single benchmark, including this one, as one machine and one project. Cross-tool rankings are easy to get wrong: pnpm 12 pulled Bun and Yarn from its public benchmark page in August 2026 after fixing cache-state bugs in the comparison.

Hoisted and isolated layouts

#

Bun supports two node_modules layouts. The hoisted linker flattens packages into one directory, as npm does. The isolated linker, added in Bun 1.2.19, keeps a store in node_modules/.bun/ and symlinks each package's declared dependencies only. Isolation prevents phantom dependencies: code that imports a package it never declared and happens to find hoisted. New workspaces default to isolated; single-package projects and lockfiles created before Bun 1.3.2 default to hoisted.

What bun.lock records

#

Bun's lockfile has been a text file, bun.lock, by default since Bun 1.2 (January 22, 2025); earlier versions wrote a binary bun.lockb. Bun 1.4 moved new lockfiles to lockfileVersion: 2 and added SHA-512 integrity for GitHub and tarball dependencies. Each package entry is one line: resolved identifier, metadata, and integrity hash. Two fields matter for security review:

JSON
{
  "lockfileVersion": 2,
  "configVersion": 1,
  "trustedDependencies": [
    "evil-postinstall",
  ],
  "packages": {
    "evil-postinstall": ["evil-postinstall@../evil/evil-postinstall-1.0.1.tgz", { "hasInstallScript": true }, "sha512-..."],
    "sharp": ["sharp@0.34.5", "", { "dependencies": { ... }, "hasInstallScript": true }, "sha512-..."],
  }
}

hasInstallScript marks every package that declares a lifecycle script, and trustedDependencies records which ones you allowed. Running grep hasInstallScript bun.lock is a quick audit of install-time execution in a Bun project. In our Next.js test project, 4 of 565 lockfile entries carry the flag: esbuild, sharp, fsevents (macOS only), and unrs-resolver.

Bun reached 1.0 on September 8, 2023. Anthropic acquired Bun in December 2025 and said it "will remain open source and MIT-licensed." Bun 1.4 shipped on August 20, 2026.

Does bun install run postinstall scripts?

#

The Bun package manager does not run a dependency's preinstall, install, postinstall, or prepare script unless that package is trusted. Your own project's scripts still run. We packed a local package whose postinstall script writes a marker file, then installed it next to esbuild:

Bash
$ cat evil/package.json
{"name":"evil-postinstall","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync('/tmp/bundemo/PWNED','ran')\""}}

$ bun install
bun install v1.4.4 (38afee6c8)
+ esbuild@0.25.0
+ evil-postinstall@../evil/evil-postinstall-1.0.0.tgz

3 packages installed [337.00ms]

Blocked 1 postinstall. Run `bun pm untrusted` for details.

$ ls /tmp/bundemo/PWNED
ls: cannot access '/tmp/bundemo/PWNED': No such file or directory

$ bun pm untrusted
./node_modules/evil-postinstall @../evil/evil-postinstall-1.0.0.tgz
 » [postinstall]: node -e "require('fs').writeFileSync('/tmp/bundemo/PWNED','ran')"

The install succeeds, the script does not run, and Bun prints one line about it. Bun applies the same rule to the implicit build step npm runs for any package that ships a binding.gyp file. Attackers in the Phantom Gyp waves hid payloads in binding.gyp command expansions so that no script appeared in package.json. We packed a package with only such a file. Bun listed it as [install]: node-gyp rebuild, blocked it, and the payload never ran.

Which packages count as trusted follows a short set of rules, checked in the order shown in Figure 1:

Figure 1 as text. First, --ignore-scripts (or install.ignoreScripts = true in bunfig.toml, or NPM_CONFIG_IGNORE_SCRIPTS=true) blocks every script, including trusted ones. Second, if package.json has a trustedDependencies array, a dependency's scripts run only if its name is in that array, and the built-in list is ignored entirely. An empty array blocks everything. Third, with no trustedDependencies field, Bun checks the dependency against its built-in list of 367 names, which you can print with bun pm default-trusted. That list applies only to packages resolved from the npm registry. A git:, github:, file:, or link: dependency is never trusted by default, even if its name is on the list. Anything else is blocked and reported by bun pm untrusted.

The built-in list exists because some popular packages break without their scripts: native modules such as sharp, bcrypt, better-sqlite3, and canvas, and binary downloaders such as electron, puppeteer, and cypress. For popular native packages such as esbuild and sharp, Bun also decides which scripts actually need to run and can link the prebuilt platform binary instead.

Bun turned scripts off before npm and pnpm did. Explaining why pnpm 10 would follow in January 2025, maintainer Zoltan Kochan said: "I really don't think we have a choice here as Bun already blocks the scripts." npm v12 made install scripts opt-in in July 2026.

Where name-based trust breaks

#

A name is not a version. Bun's trust decision never looks at what a script does, or at whether it changed since you approved it. We trusted our test package, then shipped a 1.0.1 whose postinstall writes a different file:

Bash
$ bun pm trust evil-postinstall
 ✓ [postinstall]: node -e "require('fs').writeFileSync('/tmp/bundemo/PWNED','ran')"

# bump to 1.0.1 with a new postinstall payload, then:
$ bun install
+ evil-postinstall@../evil/evil-postinstall-1.0.1.tgz

1 package installed [30.00ms]

$ ls /tmp/bundemo/PWNED_V2
/tmp/bundemo/PWNED_V2

The new script ran with no prompt, no warning, and no line in the output. That is the exact shape of a maintainer account takeover: a package you trusted for good reasons publishes a version you never reviewed.

The built-in list makes the same bet on your behalf, for 367 packages you never reviewed. That bet has lost at least twice.

The Nx compromise came first. Bun 1.2.21 was published to npm on August 25, 2025, and its default list held 366 names, including nx. On August 26, 2025, attackers used a stolen npm publish token to push malicious nx versions, starting with 21.5.0 and 20.9.0. The official Nx advisory says the package "contained a postinstall script that scanned user's file system for text files." The versions were pulled that night.

The SAP CAP compromise followed on April 29, 2026, as the first wave of Mini Shai-Hulud. mbt, SAP's Cloud MTA Build Tool, ships a legitimate install script that downloads its binary, which is why Bun trusts it. The malicious mbt@1.2.48, published at 09:55 UTC, added a preinstall script that downloads Bun and runs an 11.7 MB credential stealer. Bun 1.3.13, the current release that day, had mbt on its default list. Socket's analysis found that the loader skips the download step when Bun is already on PATH, which on a Bun user's machine it is.

In both cases a default bun install that resolved the bad version would have run the payload, because trust applies to every lifecycle script of a trusted name. We confirmed both names on the 1.2.21, 1.3.13, and 1.4.4 lists by installing those Bun versions and running bun pm default-trusted. We could not replay the installs, because the registry no longer serves the malicious versions.

npm took a different path. npm approve-scripts in npm v12 writes version-pinned entries such as pkg@1.2.3 by default, which keep an approval "narrowed to the specific version you reviewed." Bun already has the detection half. bun pm diff, added in Bun 1.4, compares two versions of a package and flags script changes:

Bash
$ bun pm diff evil-postinstall-1.0.0.tgz evil-postinstall-1.0.1.tgz
  ! postinstall script changed: node -e "require('fs').writeFileSync('/tmp/bundemo/PWNED_V2','ran')"

bun install does not consult it. We think Bun should pin trust to a version or to a hash of the script, and re-prompt when either changes. Until it does, treat every update to a trusted package as a code review.

Which supply chain attacks would Bun's defaults have stopped?

#

Bun's defaults would have blocked the install-time payloads of Shai-Hulud 2.0, the TanStack compromise, and the Phantom Gyp waves, but not those of the Nx and mbt compromises. The default script policy blocks install-time payloads in packages that are not on the trusted list, and nothing else. We checked recent campaigns against Bun 1.4.4's rules. None of the package names in Socket's Shai-Hulud 2.0 indicator list is on the 367-name default list. Mini Shai-Hulud has compromised 507 packages, and we could not check all of them; mbt is the one we found on the list.

IncidentHow the payload ranBun defaultWhy
Nx (Aug 2025)postinstall in nxRunsnx is on the default list
Shai-Hulud 2.0 (Nov 2025)preinstall runs setup_bun.jsBlockedNo listed package name is on the default list
SAP CAP, Mini Shai-Hulud (Apr 2026)preinstall in mbt and three @cap-js packagesRuns for mbt; blocked for @cap-js/*Only mbt is on the default list
TanStack, Mini Shai-Hulud (May 2026)Injected git optional dependency with a prepare scriptBlockedGit dependencies are never trusted by default
Phantom Gyp, Miasma (June 2026)binding.gyp command expansionBlockedBun treats binding.gyp as an install script
Mini Shai-Hulud import-time variantsCode runs when the package is importedRunsNot an install script
Any package in your trustedDependenciesAny lifecycle scriptRunsTrust is keyed on the name

Our verdict: the default stops the cheap, high-volume attack, where a stolen token pushes a script into an arbitrary package. It does not stop a compromise of a package on the list, a compromise of a package you trusted, or a payload that waits until your code imports it. Those need a check on what the package contains before it lands, not on what its manifest says it will run.

Why do Shai-Hulud payloads download Bun?

#

Shai-Hulud payloads download Bun because they use it as a second runtime for their credential stealer, not because their victims use Bun. In Shai-Hulud 2.0, a compromised package's npm preinstall hook ran setup_bun.js. That loader detected the OS and architecture, downloaded or located a Bun binary, cached it, and spawned a detached Bun process running a 10 MB obfuscated bun_environment.js with all output suppressed. The install finished normally while the stealer ran in the background. The same file pair turned up in a Maven Central artifact, org.mvnpm:posthog-node:4.18.1, which mirrored the compromised npm release.

Mini Shai-Hulud kept the design. As of October 2026, Socket's campaign tracker counts 1,069 compromised package artifacts across 507 packages on npm and PyPI since April 29, 2026. The tracker describes a shared payload architecture: a preinstall or import-time hook downloads a platform-specific Bun runtime from GitHub Releases and runs an 11.7 MB obfuscated JavaScript stealer. In the TanStack compromise, the injected git dependency ran bun run tanstack_runner.js from its prepare script.

Socket's write-ups do not say why the attackers chose Bun. Our read is practical. Bun is one self-contained binary with no install step, so a loader can fetch it with a single HTTP request. It runs a bundled JavaScript or TypeScript file directly. And the resulting process is bun, not node, so any monitoring keyed on Node child processes of a package install does not match it. The defensive signal is the download, not the runtime: an install hook that fetches an executable from GitHub Releases, or a bun process on a CI runner that never installed Bun.

Bun's other supply chain controls

#

Script blocking is one control. Bun ships three more, and none of them is on by default.

minimumReleaseAge. Added in Bun 1.3 (October 10, 2025), this setting implements a dependency cooldown. Bun refuses versions published more recently than the threshold, for direct and transitive dependencies, during new resolution. As of Bun 1.4.4 it is off unless you set it. We installed typescript@next with and without a three-day gate:

TOML, also INI
[install]
minimumReleaseAge = "3d"
minimumReleaseAgeExcludes = ["@types/node"]
Bash
# no gate (October 25, 2026)
$ bun add typescript@next
installed typescript@7.1.0-dev.20261024.1 with binaries:

# with minimumReleaseAge = "3d"
$ bun add typescript@next
installed typescript@7.1.0-dev.20261021.1 with binaries:

The gate skipped three days of nightlies and resolved the October 21 build. It only affects new resolution; versions already pinned in bun.lock are untouched. pnpm 11 turned on a 24-hour minimum release age by default. Bun should do the same.

The Security Scanner API. Since Bun 1.3, bun install can call a scanner package before it links anything into node_modules. Per the Security Scanner API docs, Bun calls the scanner's scan() with every package it is about to install, including transitive ones. A fatal advisory stops the install. A warn advisory prompts in a terminal and fails in CI. Socket publishes one such scanner, which checks each package against Socket's malware and typosquat detection:

TOML, also INI
# bunfig.toml, after: bun add -d @socketsecurity/bun-security-scanner
[install.security]
scanner = "@socketsecurity/bun-security-scanner"

Without a token it runs against Socket's public API; with SOCKET_API_TOKEN set it applies your organization's policy. The scanner fails closed. When our sandbox blocked the scanner's network call, Bun printed error: security scanner failed: ScannerFailed and installed nothing. The API's limit is scope. On a project with an existing lockfile, bun add <pkg> scans only the named package, its dependencies, and anything whose version changes, so run a full bun install or bun pm scan to check everything.

Frozen installs. bun ci is bun install --frozen-lockfile: it installs exactly what bun.lock pins and exits with an error if package.json disagrees. Bun does not enable this automatically in CI, so a plain bun install in a pipeline can still resolve new versions.

A lockfile is not an exposure record. Packages run through bunx, Bun's equivalent of npx, and packages installed with bun install -g never appear in a project's bun.lock. A clean bun.lock after an incident tells you what the project resolved, not what ran on the machine.

Bun, npm v12, and pnpm defaults compared

#

All three major JavaScript package managers now block dependency install scripts by default. They differ in what the allowlist is keyed on and what else is on by default.

Default behaviorBun 1.4.4npm v12pnpm 11 and 12
Dependency install scriptsBlocked; install succeeds with a noticeSkipped with a warning; strict-allow-scripts makes it an errorBlocked; allowlist in allowBuilds
Built-in allowlist367 package namesNoneNone
Approval keyed onPackage nameVersion-pinned entry by default (pkg@1.2.3)Package name patterns
Git and tarball dependenciesResolved; their scripts never trusted by defaultNot resolved unless --allow-git or --allow-remoteExotic subdependencies blocked
Minimum release ageAvailable, offAvailable, off24 hours

In our pnpm 12.15.0 run, unapproved builds for esbuild, sharp, and unrs-resolver failed the install with ERR_PNPM_IGNORED_BUILDS, where Bun and npm continue with a notice.

Verdict: npm v12 has the strictest trust model, because an approval does not carry over to an unreviewed version. pnpm has the best defaults for fresh-release attacks. Bun installed fastest in our benchmark and has a pre-install scanner API, but its built-in allowlist and name-keyed trust put it behind both on defaults. If you use Bun, replace the default list with an explicit trustedDependencies array, set minimumReleaseAge, and run bun ci in pipelines. An explicit array still trusts names, so a compromise of a package you listed will still run. Without those changes, a compromise of any of the 367 default names runs code on your machine the moment Bun installs it, as mbt would have in April 2026.