How an npm supply chain attack hits AI-built apps (and how to stop it)
An npm supply chain attack happens when a package you install turns hostile, so your own code never has to be wrong. In September 2025 the Shai-Hulud worm (malware that copies itself from one victim to the next) showed how: a postinstall script, a command npm runs automatically after installing a package, stole tokens and secrets, then republished other packages. You can't vet every dependency, but you can lock versions, install with npm ci, turn off install scripts and keep fewer secrets on your machine.
QUICK CHECKLIST
- Commit your lockfile and review its diffs
- Install with npm ci in CI and deploys
- Run installs with --ignore-scripts, then test the build
- Replace latest, * and ^ ranges with exact versions
- Keep long-lived tokens off dev machines and CI
- Rotate every secret if you installed a bad version
What an npm supply chain attack looks like
The attacker targets the code you depend on, not the code you wrote. They get a malicious version of a package onto the registry. You install it, and its install script runs on your laptop or build server with your permissions.
The Shai-Hulud npm worm is the clearest recent example. According to Wiz, a cloud security company, malicious versions of multiple popular packages were published to npm (the public package registry) on September 15, 2025. They contained a postinstall script that harvested sensitive data and sent it to public GitHub repos named Shai-Hulud. CISA, the US government's cybersecurity agency, says the worm compromised over 500 packages.
The payload ran TruffleHog (an open-source scanner that searches files for credentials) and also read environment variables and cloud keys. CISA lists GitHub personal access tokens and cloud API keys for AWS, GCP and Azure as targets.
It spread through npm tokens. When a compromised package finds more npm tokens in its environment, it publishes malicious versions of every package that token can reach. CISA describes this as authenticating to the registry as the compromised developer and injecting code into other packages.
Why AI-built projects are an easy target
AI coding tools make this attack easier to land, mostly through habits you never see. Cursor, Claude Code, Lovable and Bolt will add a dependency and run the install in one go. You review a diff full of app code and the package.json change slides by.
The usual mistakes:
- The AI picks a package you've never heard of and you don't check it. If the name was invented, the risk is slopsquatting.
- Versions are
latest,*or a^range. A^range accepts newer minor and patch releases, so a fresh install can pull something published an hour ago. - There's no lockfile (
package-lock.json, the file that records the exact version of every package), or the tool regenerated it, so every install picks versions again. - CI (the automated build service, such as GitHub Actions) and deploys run
npm installinstead ofnpm ci. - Install scripts run by default. In npm the
ignore-scriptssetting defaults to false. - Real keys sit in
.envon the machine where installs run. How .env files leak covers the other half of that problem.
CISA's advice to check package-lock.json or yarn.lock for affected packages, and to pin versions to releases from before September 16, 2025, only works if those files exist. A project with loose ranges and no lockfile can't even say whether it installed a bad version.
Before and after: loose installs vs locked installs
Two changes remove most of the surprise. Pin what you ask for, and install exactly what the lockfile says.
{
"dependencies": {
"example-ui-lib": "latest",
"example-utils": "*",
"example-sdk": "^2.1.0"
}
}{
"dependencies": {
"example-ui-lib": "3.4.1",
"example-utils": "1.9.0",
"example-sdk": "2.1.4"
}
}Exact versions are optional if you commit the lockfile and use npm ci. They still make intent obvious and stop an AI tool from widening a range later. npm install --save-exact example-lib writes an exact version.
- run: npm install - run: npm run build
- run: npm ci --ignore-scripts - run: npm run build
Per the npm docs, npm ci needs a package-lock.json or npm-shrinkwrap.json, exits with an error if the lock doesn't match package.json, and never writes to either file. That error is useful. If the AI changed a dependency and forgot the lockfile, your build stops instead of installing something nobody reviewed.
How to protect your app from an npm supply chain attack, step by step
Do these in order. The first three close the install-script path Shai-Hulud used and stop unreviewed versions from slipping in. Apply --ignore-scripts on your laptop too, not just in CI.
Commit your lockfile and install from it
Keep
package-lock.json(orpnpm-lock.yaml,yarn.lock) in git and tell your AI tool never to delete or regenerate it. Usenpm ciin CI, Docker builds and deploy settings. With pnpm, usepnpm install --frozen-lockfile. Read lockfile changes in every pull request, because a package you didn't ask for shows up there first.Turn off install scripts by default
Run
npm ci --ignore-scripts, or putignore-scripts=truein your project's.npmrc(npm only, because pnpm reads only auth and registry settings from.npmrc). The npm docs say npm then does not run scripts specified in package.json files. Your ownnpm runcommands still work, but their pre- and post-scripts, such asprebuild, are skipped. pnpm has its own build-script settings, so check the current docs for how to approve the few packages that need a build step.Slow down on brand-new releases
The pnpm settings page lists
minimumReleaseAge, which sets a minimum age a release must reach before pnpm will resolve it. It also listsminimumReleaseAgeExcludeandminimumReleaseAgeStrict. Put them inpnpm-workspace.yamland check the settings page for units and exceptions.Shrink what a stolen token can do
CISA recommends phishing-resistant MFA on developer accounts, especially GitHub and npm, removing GitHub Apps and OAuth apps you don't use, and enabling branch protection, Secret Scanning alerts and Dependabot security updates. Use test keys on your dev machine and keep production keys out of your local
.envwhere you can. If you publish packages, read the current npm docs on trusted publishing so no long-lived publish token sits in CI.Vet every new package
Look for signs of malicious npm packages: a misspelled name, an unfamiliar publisher or a package that is only days old. Ask the AI why it needs the dependency and whether an existing package already does the job.
If you think you installed a bad version, rotate first. Revoke and regenerate GitHub tokens, npm tokens, SSH keys and any API keys that were in your environment (CISA says to do this immediately). Then follow Wiz's cleanup: rm -rf node_modules && npm cache clean --force, upgrade to a clean release, and check GitHub for Shai-Hulud repos, shai-hulud branches and unexpected workflow files. Rotating API keys after a breach walks through the order.
What these defenses don't cover
None of these settings makes an install safe. Each one narrows one path.
- The npm ci lockfile check only guarantees you get what's locked. If a bad version was already locked,
npm ciinstalls it faithfully. --ignore-scriptsstops install scripts. It doesn't stop code that runs when your app imports or builds a package.npm auditsends a description of your dependencies to the registry and asks for known vulnerabilities, per the npm docs. A malicious version nobody has reported yet may not appear, so a clean audit is not a clean bill of health.npm audit signaturesverifies registry signatures and provenance attestations (signed records of where and how a package was built). It won't flag a malicious version published with a stolen token.
npm audit fix --force can make things worse. The docs say it can install modules outside your stated range, including SemVer-major changes, and strongly recommend against it. Don't let an AI tool run it for you. For the wider picture, see the vibe coding security checklist.
Test it yourself
Run these in your own project only. Steps 1 to 3 only read files. Step 4 deletes and reinstalls node_modules and runs your build, which executes dependency code, so use CI or a throwaway environment if you suspect a bad version is locked.
# 1. Is a lockfile committed?
git ls-files | grep -E 'package-lock.json|pnpm-lock.yaml|yarn.lock'
# 2. Any loose ranges?
grep -nE '"(\^|~)[0-9]|"\*"|"latest"' package.json
# 3. Which locked packages declare install scripts?
node -e "const l=require('./package-lock.json');if(!l.packages){console.log('Old lockfile format');process.exit(1)}for(const [p,v] of Object.entries(l.packages))if(v.hasInstallScript)console.log(p)"
# 4. Does a clean install without scripts still build?
npm ci --ignore-scripts && npm run buildHow to read the results:
- Step 1 prints nothing: no lockfile is committed. That's the bad result. Fix it first.
- Step 2 prints lines: each one is a range, not an exact version. Pin them. An empty result doesn't prove everything is exact, so also look for
>=,xranges,nextand git URLs. - Step 3 lists packages that run code at install time. A short list is normal. Look hard at names you don't recognize.
- Step 4 fails: a package needs its script. A passing build is the good result.
Then check what the registry knows and look for Shai-Hulud traces:
npm audit npm audit signatures npm ls @ctrl/tinycolor --all
Compare any match against the exact versions in the appendix of Wiz's post. A match on name alone is not proof of infection. On GitHub, check your account and orgs for a public repo named Shai-Hulud, a repo ending in -migration with the description Shai-Hulud Migration, or a branch named shai-hulud. Any of those means you should treat your tokens as stolen and rotate them.
Fix prompt you can paste
Give this to Cursor or Claude Code. It asks for a report and diffs first, so nothing changes behind your back.
Audit this repo for npm supply chain risks. Do not add, remove or upgrade any package without telling me first. 1. Find every package.json and lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock). If a lockfile is missing or in .gitignore, say so and tell me how to generate and commit it. 2. List every dependency version that is "latest", "*", "next" or starts with ^ or ~. Propose exact versions taken from the lockfile. Give file and line for each. 3. Find install steps in CI workflows, Dockerfiles, Vercel/Netlify settings and the README. Replace npm install with npm ci (pnpm: install --frozen-lockfile), add --ignore-scripts, and tell me which packages break because they need a build script. 4. List every preinstall, install, postinstall or prepare script in package.json files, and lockfile packages with hasInstallScript. Give file and line. 5. Look in .env files, CI workflows and scripts for long-lived npm or GitHub tokens. Give file and line. Never print secret values. 6. For packages added in recent commits, run npm view <name> and flag any with a misspelled name, an unfamiliar publisher or a first publish only days ago. For every finding, give the file and line, one sentence on the risk, and the exact fix as a diff. Show me the diff before you apply it.
If you want a second look after the cleanup, you can run the project through Leakcade.
Further reading
- CISA: Widespread supply chain compromise impacting the npm ecosystem
- Wiz: Shai-Hulud npm supply chain attack
- npm docs: npm ci
- npm docs: npm audit
- pnpm docs: Settings (pnpm-workspace.yaml)
FAQ
What is the Shai-Hulud npm worm?
It's a self-replicating worm that CISA says compromised over 500 npm packages in September 2025. A postinstall script harvested credentials and uploaded them to public GitHub repos named Shai-Hulud. It then used stolen npm tokens to publish more compromised package versions.
Does npm ci protect me from malicious packages?
Only partly. It installs exactly what your lockfile says and fails if the lockfile and package.json don't match, so new versions can't slip in during a build. It doesn't check whether a locked version is safe, so pair it with --ignore-scripts and a review of new packages.
Does --ignore-scripts stop an npm supply chain attack?
It stops package scripts such as postinstall, which is what Shai-Hulud used. It doesn't stop code that runs when your app imports or builds the package. Some packages also need their scripts, so run a full build after turning it on.
Does npm audit catch malicious packages?
It reports known vulnerabilities by sending your dependency list to the registry and reading back advisories. A malicious version that hasn't been reported yet may not show up, so treat audit as one layer of defense.
What is pnpm minimumReleaseAge?
It sets a minimum age a release must reach before pnpm will resolve it. The delay gives time for a malicious version to be reported and removed. Put it in pnpm-workspace.yaml, not .npmrc, and check the pnpm settings page for the unit.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms