On November 24, 2025, researchers identified a wave of backdoored npm packages that had been downloading a second JavaScript runtime during install. Not a shell script, not a compiled binary from a CDN. A whole other interpreter. A file named setup_bun.js detected the host OS and architecture, downloaded or located the Bun runtime, and cached the binary in ~/.cache or the platform equivalent. Then it used Bun to run a roughly 10MB obfuscated bundle called bun_environment.js. Infected releases carried those two new files plus a modified package.json.
Datadog assessed that the worm installed Bun "likely to evade standard Node.js monitoring." That assessment is the interesting part of this incident, and it is also where the popular retelling goes wrong in two directions at once.
The vendors disagree on scope. Datadog counted 796 unique npm packages backdoored across 1,092 versions. Socket, in analysis updated through November 26, put it at over 500 packages across 700+ versions. Different collection windows, different counting rules, no reconciliation I can offer. Compromised releases appeared under the @zapier, @asyncapi, @postman, @posthog, and @ensdomains scopes. The last observed malicious publish was at 6 p.m. UTC on November 24, and Datadog saw exfiltration peak early that morning UTC, so the campaign was already running well before the day it surfaced. No source I read gives a start date. CISA had published an alert on an earlier wave on September 23, 2025, titled "Widespread Supply Chain Compromise Impacting npm Ecosystem." Whether the November actor is the same one, I did not verify.
What ran, and in what order
The injected entry point was a preinstall hook invoking setup_bun.js under node:
"preinstall": "node setup_bun.js"
Socket describes that injection rather than reproducing the diff, so read the snippet as the shape of the hook, not a byte-for-byte quote. The load-bearing detail holds either way. The entry point is node.
The dropper was built for a quiet install log. It spawned the payload as a detached background process with the environment flag POSTINSTALL_BG=1, suppressed all stdout and stderr, and returned immediately. Speed is a separate question, since setup_bun.js fetches a full runtime in the preinstall foreground before it hands off. Quiet, not fast.
The payload downloaded TruffleHog and used it to actively hunt for secrets on the host, while also reading cloud instance metadata services and cloud secrets managers on AWS, Azure, and Google Cloud. Stolen data went to repositories created in the victim's own GitHub account, with names matching the pattern [0-9a-z]{18} and a fixed description string, "Sha1-Hulud: The Second Coming." Datadog observed exfiltrated data from over 500 unique GitHub users and more than 150 organizations, and called that a lower bound. For propagation, the worm used stolen npm tokens to fetch up to 100 of the maintainer's packages, inject the malicious files, bump the patch version, and republish. When it found no usable credentials to steal or spread with, it tried to destroy the user's home directory, using cipher on Windows and shred on Linux and macOS.
Microsoft's guidance makes the timing point precisely: the payload executes during the preinstall phase, before tests or security checks. My extrapolation, not Microsoft's: a pipeline whose composition analysis runs after npm install never gets a turn.
The flag most teams do not run
ignore-scripts=true looks like the control that would have stopped this. The documented execution path is an ordinary preinstall hook, and my reading is that with scripts disabled, setup_bun.js and bun_environment.js land in node_modules as inert files with nothing to invoke them. What no source establishes is that the hook was the only execution path across all 1,092 affected versions, so that is inference about the reported behavior rather than a guarantee about the campaign. CISA's April 2026 axios alert recommends exactly that setting in .npmrc to prevent potentially malicious scripts from executing during npm install.
So the honest failure mode is not a defeated control. It is a control most teams do not run, for reasons that are not stupid. Packages that fetch or compile a native binary at install time stop working when scripts are off. Turning the flag on globally means auditing each of those dependencies and finding another way to get the binary onto the machine. I found no dataset on how many teams enable it, so I will not put a number on adoption. The pattern is familiar regardless: a control with a real compatibility cost gets deferred until an incident makes the cost look cheap.
The flag is also weaker than its reputation. Koi Security reported six zero-days in JavaScript package managers on November 26, 2025, including script-execution paths that bypass --ignore-scripts: a git configuration option in .npmrc that redirects the git binary in npm, prepare/prepublish/prepack scripts running during git dependency fetch in pnpm v10, and a Bun trust list that validated only the package name. pnpm patched, as CVE-2025-69263 and CVE-2025-69264, and Bun patched in version 1.3.5. npm closed the report on December 5, 2025 as "Informative" with no remediation, its stated position being that "npm users are responsible for vetting the content of packages that they choose to install." I did not pull the NVD entries, so treat those two identifiers as pointers rather than verified descriptions.
The axios compromise is where I expected to find a limit on install-script blocking, and it turned out not to be one. Threat actors compromised the package on March 31, 2026, affecting versions 1.14.1 and 0.30.4, by injecting a malicious dependency named plain-crypto-js version 4.2.1. Microsoft's April 1, 2026 writeup describes that dependency executing automatically through a postinstall hook, with no import and no user action, and fetching a platform-specific second-stage remote access trojan from the actor's infrastructure. ignore-scripts=true blocks that. CISA paired its recommendation with min-release-age=7, which admits only packages published at least seven days earlier, and against a roughly three-hour exposure window either setting would have held. The case install-script blocking genuinely does not cover is a dependency that runs only when your code imports it, which is not what happened to axios.
One reporting caveat: cisa.gov returned HTTP 403 on both of my fetch attempts, so the CISA claims here come from search-returned summaries rather than pages I read, and the verbatim wording is unverified.
Node-centric detection was not blind either
The runtime swap does remove real visibility. Node's standard instrumentation injection points, --require and NODE_OPTIONS, do not apply to Bun, so Node-path telemetry disappears the moment the executing process is bun. Endpoint tooling and npm audit hooks that watch for child node processes spawned during install miss a process named bun entirely, because the detection keys on the name. Static scanners lose their usual signals too: Bun provides shell execution via Bun.$, decompression via Bun.gunzipSync, and native fetch as built-in primitives, which removes the suspicious module imports those scanners flag. None of this would be practical if Bun were hard to install, and it isn't. It ships as a single statically linked binary that drops into userland without root or a system package manager.
But the entry point was still node setup_bun.js. A Node process did spawn during install, and it did spawn a child. Microsoft's detection queries key on precisely that lineage: node spawning a shell such as bash, sh, or dash, and a shell executing JavaScript via Bun, plus GitHub Actions runner registration under the name SHA1HULUD. Process-lineage EDR had a hook where Node-internals instrumentation did not.
Fetching a second language runtime in the middle of a dependency install is not a stealthy action. It is a loud one, if anything is watching. A detection that fires on an unfamiliar executable appearing on a build host, or on an unexpected parent-child process pair, does not need to know what Bun is. The runtime swap trades one detection surface for another. It defeats tooling built on the assumption that Node is the only interpreter in the build, and it lights up tooling that reasons about process behavior instead of process names.
The limits of that claim: no incident report I found documents a specific EDR or SCA product that demonstrably failed to alert on this payload. "Node-centric detection missed it" is vendor reasoning about how detection is designed, not a measured miss rate.
What structurally changed
The propagation loop depended on long-lived npm publish credentials, and that is what got cut. On December 9, 2025, GitHub permanently revoked all existing npm classic tokens, stating they can no longer authenticate, be recreated, or be recovered. npm login now issues a session that expires after two hours and requires reauthentication to keep publishing, and granular write tokens are capped at a 90-day lifetime. GitHub points to OIDC trusted publishing as the replacement that removes the need to generate and manage publishing tokens at all.
That is the right shape of fix, and it did not end the technique. Microsoft documented a May 2026 "Mini Shai-Hulud" expansion affecting more than 170 npm packages and 2 PyPI packages across 404 malicious versions. Endor Labs documented a further Bun-based campaign in April 2026 against four SAP-ecosystem npm packages, mbt, @cap-js/db-service, @cap-js/sqlite, and @cap-js/postgres, carrying an 11.6 MB obfuscated credential stealer.
Your build host is not a Node host anymore. It is whatever host a dependency decides to make it, one preinstall hook at a time, and a control you left off because it breaks a native build is a control you do not have.
Top comments (0)