Skip to main content

Your Node Buildpack's --ignore-scripts Flag Didn't Stop Phantom Gyp

7 min readDora NodaDora Noda
Share
On this page

If your CI pipeline runs npm ci --ignore-scripts and you've treated that as the safe way to install untrusted dependencies, June 3, 2026 was the day that assumption broke in production. A worm called Miasma compromised 57 npm packages — including @vapi-ai/server-sdk, a voice-AI SDK with over 408,000 monthly downloads — across 286-plus malicious versions in under two hours, using a technique researchers named Phantom Gyp. It didn't touch a single postinstall script. It shipped a weaponized binding.gyp file instead, and --ignore-scripts does nothing to stop that, because binding.gyp was never a lifecycle script to begin with.

That's the part worth sitting with before anything else: the flag every hardening guide recommended wasn't disabled, patched around, or exploited through some clever edge case. It simply didn't apply to this attack surface. A dependency install that your team explicitly hardened would have still executed attacker code.

The 157-Byte File That Did the Damage

Here's the mechanism, and it's short enough to state plainly. When npm installs a package and finds a binding.gyp file sitting in its root, it automatically runs node-gyp rebuild for that package — no entry in package.json's scripts block required. That behavior exists for a legitimate reason: packages like sharp, bcrypt, or better-sqlite3 compile real C/C++ native addons at install time, and binding.gyp is the standard config format node-gyp reads to do it.

--ignore-scripts blocks preinstall, install, and postinstall entries in package.json. It does not intercept node-gyp, because node-gyp invocation runs down a separate code path entirely — npm sees the binding.gyp file and fires off the build tool directly, independent of whatever lifecycle-script policy you've set. This wasn't a zero-day. A 2017 npm GitHub issue flagged the exact ambiguity — binding.gyp plus ignore-scripts plus publish — nine years before Miasma turned it into a production worm. Nobody closed it, because nobody had weaponized it yet.

Phantom Gyp did. Attackers dropped a 157-byte binding.gyp into @vapi-ai/server-sdk and dozens of related packages at roughly 23:30 UTC on June 3. Within an hour, malicious updates had spread to over 50 additional packages, including ai-sdk-ollama (120,000-plus monthly downloads). The payload harvested credentials for npm, GitHub, AWS, GCP, Azure, HashiCorp Vault, and Kubernetes; exfiltrated them to attacker-controlled GitHub repos; injected persistence via GitHub Actions workflows; and used any reachable maintainer token to republish itself into more packages. Fifty-seven packages, 286-plus malicious versions, under two hours — a self-propagating supply chain worm that ran during dependency installation, before a single line of application code executed, and outside the detection surface of every scanner that only inspects package.json lifecycle hooks.

Why This Wasn't Even Miasma's First Trick

Phantom Gyp was the campaign's second wave, not its first. Two days earlier, the same Miasma operators had compromised 32 @redhat-cloud-services packages using a more conventional technique. The binding.gyp pivot on June 3 reads like a direct response to the ecosystem's own hardening: teams had gotten good at watching lifecycle scripts, so the worm moved to a file most tooling never looks at.

That timing matters for one more reason. Miasma also republished malicious versions under version numbers that already existed — which should have been caught by lockfile integrity. Committed package-lock.json files carry SHA-512 hashes per resolved version, and npm ci (not npm install) refuses to proceed when a hash doesn't match. Teams running npm ci against a committed lockfile had a real backstop here; teams running loose npm install against a floating semver range didn't. If your build step's install command is npm install rather than npm ci, that's a bigger gap than the binding.gyp issue by itself.

What Actually Closes the Gap

The honest complication in this story is that the fix already shipped — just five weeks after the attack, and not because of it. npm v12, released July 8, 2026, blocks the implicit node-gyp rebuild that a binding.gyp file triggers, treating it identically to an explicit lifecycle script. Under v12, a package with a binding.gyp and no approved entry in the project's allowScripts block simply doesn't get its native build run — the same allowlist mechanism that now gates postinstall also gates the code path Phantom Gyp used.

So if you've fully migrated to npm v12 and committed an allowScripts block covering your legitimate native-module dependencies, Phantom Gyp's specific mechanism no longer works against you. But v12 is five weeks old as of this writing, and migrating isn't a version bump — it's an audit. Every native-module dependency (sharp, bcrypt, node-canvas), every binary fetcher (esbuild, puppeteer, playwright), and every monorepo bootstrap script needs its scripts explicitly re-approved and committed, or the build breaks instead of staying vulnerable. That's a real migration project, not a package.json edit, and most teams running Node in CI today are still on pre-v12 npm with --ignore-scripts as their only stated defense — which, as of June 3, was not a defense against this specific vector at all.

For anyone in that gap — pre-v12, relying on --ignore-scripts — the concrete mitigations that actually would have caught Phantom Gyp are narrower than "upgrade npm":

  • npm ci, never npm install, in any automated build. Lockfile hash verification is what actually would have blocked Miasma's republished-under-existing-version-number trick, independent of the binding.gyp issue entirely.
  • Scope CI credentials to least privilege. Miasma's entire payoff was harvesting npm, GitHub, and cloud tokens reachable from the build host. A build step with no standing AWS or Vault credentials has nothing for a binding.gyp payload to steal, regardless of whether it executes.
  • Filter network egress from the build sandbox. Miasma's payload needed to phone home to exfiltrate credentials and to pull a second-stage Bun binary into /tmp. A build environment that can't make arbitrary outbound connections turns a successful code-execution bug into a much less useful one.
  • Flag binding.gyp in packages with no legitimate native-module reason to ship one. This is the actual signal to scan for — not script hooks, which Phantom Gyp didn't use, but the presence of a build-triggering file in a dependency tree where nothing should need to compile C++.

How to Tell If a Build Was Hit

If you were running Node CI between June 3 and when your team patched, the concrete indicators are worth checking now rather than assuming you dodged it:

  • An unexpected node-gyp rebuild line in build logs for a package that has no legitimate reason to compile native code — a pure-JS SDK client, for instance, should never trigger one.
  • A /tmp/b-* directory holding a Bun binary. Miasma's payload pulled Bun as a second-stage runtime for its exfiltration logic, and egress-filtered CI would have logged the blocked connection attempting to fetch it.
  • Anomalously large index.js files — several megabytes where the legitimate package ships kilobytes — in node_modules for any of the affected packages or their dependents.
  • Unfamiliar GitHub Actions workflow files committed to a repo you didn't add them to; persistence was one of the payload's stated goals.

If any of those show up, the response isn't "delete the file" — treat the CI environment and every credential reachable from it as compromised, and rotate them. A worm built to harvest npm, GitHub, and cloud tokens doesn't leave a clean way to verify what it already exfiltrated before you noticed.

What This Means for a Buildpack

Every buildpack that auto-detects a Node app and runs its install step on a tenant's behalf has been treating --ignore-scripts as the standard hardening advice for years. That advice was never wrong about the threat it was written for; it was incomplete about the threat that showed up on June 3. A buildpack's npm install step needs the same layered defense outlined above, not a single flag: npm ci against a committed lockfile as the default install command (not npm install), a build sandbox with no standing cloud credentials and locked-down egress, and — once a project's tenant explicitly opts into npm v12 — a real path to commit and enforce an allowScripts block the same way it commits a lockfile today.

The uncomfortable lesson here isn't that npm's security model failed. It's that "the standard mitigation" is a moving target, and a build pipeline that hasn't revisited its own assumptions since the last incident is quietly betting that nobody finds the next binding.gyp-shaped gap. --ignore-scripts was good advice in 2025. On its own, in isolation, it stopped being sufficient advice the moment Phantom Gyp shipped.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a build pipeline that doesn't bet its security on a single npm flag. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex