The registry is not a vetting service
npm install, pip install and cargo add feel like reading from a library. They are closer to running a stranger's shell script with your developer's credentials, on the machine that holds your SSH keys, cloud tokens and source code. Public registries accept almost anything from almost anyone, and they remove malware after it is reported, not before it is published.
Vulnerable dependencies (a bug someone might exploit) are a patching problem, covered in our piece on SBOM-driven vulnerability tracking. This article is about the other category: packages that are malicious on purpose. They do not need a CVE, a vulnerable code path or an exposed service. They only need you to install them.
The five ways malicious code arrives
1. Typosquatting
The attacker publishes a package whose name is one slip away from a popular one: a transposed letter, a missing hyphen, a plural, a different separator (python-dateutil vs dateutil-python), or the right name in the wrong ecosystem. The package often works, because it re-exports the real library, and adds a payload on top.
Typosquats rely on volume. Most get a handful of downloads before removal, but a handful of downloads from CI runners with deploy keys is plenty.
2. Dependency confusion
Published by Alex Birsan in 2021, this one abuses resolver logic rather than humans. If your company uses an internal package called acme-billing-utils and your build tool queries both the private registry and the public one, an attacker who publishes acme-billing-utils publicly with a higher version number may win the resolution. Internal package names leak through package.json files in public repos, JavaScript source maps and error messages.
3. Compromised maintainer accounts
The package is legitimate and popular; the publisher's account is not. Phished npm credentials, leaked tokens in CI logs, or an expired email domain that the attacker re-registers to reset the password. Real examples are well documented:
- ua-parser-js (October 2021): hijacked releases shipped a cryptominer and a credential stealer.
- chalk, debug and related packages (September 2025): a maintainer was phished through a fake npm support email, and malicious versions carried code that rewrote cryptocurrency addresses in browser contexts.
- Shai-Hulud (September 2025): a self-propagating npm worm that harvested tokens from infected developer machines and used them to publish trojaned versions of the victims' own packages.
4. Long-con takeovers
Some attackers earn trust instead of stealing it. In event-stream (2018) a new contributor was handed maintenance of an abandoned but widely used package, then added a dependency that targeted a specific Bitcoin wallet. The xz-utils backdoor (CVE-2024-3094, March 2024) was the patient version: years of legitimate contributions, social pressure on the original maintainer, then a backdoor hidden in release tarballs that never appeared in the main source tree. It was caught by an engineer chasing a half-second SSH latency regression.
5. Protestware
The maintainer is who they claim to be and decides to break their own package. In January 2022 the author of colors and faker pushed versions that printed garbage in an infinite loop. In March 2022 node-ipc shipped code that overwrote files on machines geolocated to Russia and Belarus. Intent varies; the operational effect on you is identical to malware.
| Technique | Who is lying | What fails | Best control |
|---|---|---|---|
| Typosquat | Package name | Human typing, AI suggestions | Lockfile review, name checks |
| Dependency confusion | Version number | Resolver config | Scoped names, pinned registries |
| Hijacked maintainer | Publisher identity | Registry auth | Release cooldown, provenance |
| Long-con takeover | Contributor history | Social trust | Diff review on updates, cooldown |
| Protestware | Nobody (it is the author) | Implicit trust in "latest" | Pinning, cooldown |
AI coding assistants add a sixth path. Models sometimes suggest package names that do not exist. Attackers register those hallucinated names ("slopsquatting") and wait. Treat any dependency an assistant proposes like a pull request from a stranger.
Install scripts: where most payloads run
The payload does not need you to import the package. npm preinstall, install and postinstall hooks run arbitrary commands the moment the package lands in node_modules, including for transitive dependencies you never chose. Python source distributions can execute setup.py at install time, and Rust build.rs scripts run at compile time.
A typical malicious hook is short and boring on purpose:
{
"name": "left-padd",
"version": "1.0.3",
"scripts": {
"postinstall": "node lib/setup.js"
}
}
lib/setup.js then reads environment variables, ~/.npmrc, ~/.aws/credentials and .git/config, and posts them to something like hxxps://telemetry-cdn[.]example[.]com/collect. Obfuscated JavaScript is common; if you need to read one, paste it into the JS deobfuscator rather than running it.
Turn scripts off by default and allow them explicitly:
# npm: never run lifecycle scripts unless asked
npm config set ignore-scripts true
npm ci --ignore-scripts
# pnpm 10+: dependency scripts are blocked unless listed
# package.json
# "pnpm": { "onlyBuiltDependencies": ["esbuild", "sharp"] }
Some packages genuinely need a build step (native modules, binary downloaders). That list is short, it should be reviewed, and it should live in version control.
Vetting a dependency in ten minutes
You will not audit every line of every package. You can, however, refuse to add something new without answering a few questions. For each new direct dependency:
- Is the name exactly right? Compare against the project's official docs or repository link, not a search result or an assistant's suggestion. Watch for lookalike scopes (
@types-node/...). - Does the registry entry point to a real repository, and does the published tarball roughly match it? Packages whose published files differ from the repo deserve a closer look.
- Who publishes it, and did that change recently? A new maintainer on an old package, or a first release after years of silence, is the classic takeover signature.
- Does it have install scripts? If a date-formatting library needs
postinstall, stop. - What does it pull in? A small utility with forty transitive dependencies is forty more publishers you trust.
- How old is the version? Hours-old releases of popular packages are exactly what the attacks above produce.
- Is the blast radius proportional? Twenty lines you could write yourself are not worth a new trust relationship.
This is the kind of check that should be automated rather than remembered. postmortem is our open-source supply-chain scanner: it runs locally against a project's dependencies and flags things like install hooks and risky packages, without uploading your code. For first-party code, Code indexes a repository and runs static analysis on your own machines. Our engineering team also wrote up how we replay real supply-chain incidents to test detection, on the engineering notes page.
Why a release cooldown works so well
Look at the timeline of almost every hijacked-maintainer incident: a malicious version is published, someone in the community notices within hours to a few days, the registry pulls it. The damage concentrates on whoever installed in that window, usually through automated updates or unpinned ranges.
A release cooldown (also called minimum release age) means your tooling refuses versions younger than N days. You trade a few days of freshness for not being in the first wave.
Most mainstream tools support it now:
# Renovate (renovate.json, shown as YAML for readability)
minimumReleaseAge: "3 days"
# Dependabot (.github/dependabot.yml)
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
# pnpm (pnpm-workspace.yaml), value in minutes
minimumReleaseAge: 4320
For Python, uv can resolve as of a past date with --exclude-newer. Keep a documented bypass for genuine emergencies (a patched release for an exploited CVE) so the cooldown does not become the thing people disable.
A cooldown does not help with long cons. The xz-utils backdoor sat in releases for weeks. Cooldowns cut off the fast, noisy attacks; slow ones still need diff review on updates of critical dependencies and anomaly signals like new maintainers.
Lockfiles and resolver hygiene
Most of the dependency confusion and typosquat risk disappears with plain configuration:
- Commit lockfiles and install with them (
npm ci,pnpm install --frozen-lockfile,pip install --require-hashes). A build that resolves fresh versions every run is a build that can be poisoned between runs. - Scope internal packages (
@acme/billing-utils) and bind the scope to your private registry in.npmrc. Claim the scope on the public registry too, so nobody else can. - Do not mix public and private indexes with "highest version wins" semantics.
pip --extra-index-urlis the textbook trap. - Review lockfile diffs in pull requests. A one-line
package.jsonchange that adds three hundred lockfile lines is worth a look.
When you find one
If a scan or an advisory says you installed a malicious version, treat it as a credential compromise, not a dependency bug:
- Identify every machine that ran the install (developer laptops count, not only CI).
- Rotate everything those machines could read: registry tokens, cloud keys, SSH keys, CI secrets.
- Check whether your own packages were republished from stolen tokens (the Shai-Hulud pattern).
- Pull network indicators and hashes from the advisory and hunt in proxy and EDR logs. The IOC extractor helps turn an advisory into a list.
What to do Monday
- Set
ignore-scripts(npm) or move to an allowlist (pnpm 10) and write down the few packages that truly need build scripts. - Add a 3 to 7 day cooldown in Renovate, Dependabot or your package manager.
- Make lockfile-only installs mandatory in CI.
- Scope internal packages and claim the public scope.
- Adopt the seven-question vetting routine for every new direct dependency, and automate what you can.
- Write the "we installed a bad package" runbook now, with the list of secrets to rotate.