On November 26, 2018, npm pulled flatmap-stream after it had been downloaded roughly 8 million times as a dependency of event-stream, a utility package with about 2 million weekly downloads of its own. A new maintainer, using the handle "right9ctrl," had earned publish rights by volunteering to help with an unmaintained project, then quietly added flatmap-stream as a dependency carrying an AES-256-encrypted payload. The payload only decrypted and ran when it detected the package.json description field of the Copay bitcoin wallet app — a targeting mechanism precise enough to evade most sandboxed analysis. It was caught almost by accident, flagged by a deprecated-API warning a developer noticed and reported. Three years later, security researcher Alex Birsan showed an entirely different weakness was just as exploitable: build systems that default to the highest-versioned package across public and private registries, letting him get code execution inside more than 30 companies including Apple, Microsoft, PayPal, Shopify, and Uber, for over $130,000 in bug bounties combined. This post breaks down how these attack patterns actually work in npm today, and what controls stop each one.
What actually happened in the event-stream incident?
The event-stream incident worked because npm's trust model treats "has publish access" as equivalent to "is trustworthy," with no distinction between the original author and a later volunteer. The original maintainer, dominictarr, had stopped actively developing event-stream and handed publish rights to right9ctrl, who had offered to take over maintenance — a common, previously unremarkable pattern in open source. Right9ctrl then published a version adding flatmap-stream as a new dependency. That package's index.js was intentionally obfuscated and contained the encrypted payload, which decrypted only when require'd inside an application whose package.json description matched Copay's, specifically targeting Bitcoin and Bitcoin Cash wallet-copy theft. Because the trigger condition was so narrow, most static scanning and manual review at the time found nothing suspicious. npm's postmortem confirmed the package was removed on November 26, 2018, and that the maintainer transfer itself — not a compromised credential — was the initial access vector. (Source: npm's incident writeup, Snyk's postmortem.)
How does dependency confusion actually trick a build system?
Dependency confusion tricks a build system by exploiting the default rule that most package managers resolve to the highest available version number, regardless of which registry it came from. In 2021, Alex Birsan found internal package names — things like <company>-node-sdk — referenced in leaked internal package.json files, config files, and even error messages, then published his own public npm packages under those exact names with a deliberately high version number, such as 9.9.9. When an internal build ran npm install, it queried both the internal registry and the public npm registry, and because most configurations don't pin a package to a specific registry per-name, it pulled Birsan's version instead of the private one — executing his code, including a postinstall script that phoned home, inside the target's own build infrastructure. No phishing, no credential theft, and no CVE were involved: it was a pure misconfiguration in registry precedence. Apple, Microsoft, PayPal, and Shopify each paid roughly $30,000–$40,000 in bounties once confirmed. (Source: Birsan's original writeup.)
What makes postinstall scripts such a persistent attack surface?
Postinstall scripts are a persistent attack surface because npm executes them automatically by default the moment a package is installed, before any human reviews the code that shipped inside it. A package.json scripts.postinstall (or preinstall) entry runs with the same OS-level privileges as the user running npm install — no sandbox, no permission prompt — which means it can read SSH keys, cloud credentials in ~/.aws, browser cookie stores, or environment variables containing CI secrets, and exfiltrate them over plain HTTP before the install even finishes printing its progress bar. This is precisely the mechanism the flatmap-stream and countless smaller typosquat campaigns since have relied on: the malicious logic doesn't need to be in code a developer will ever read or call, it just needs to be reachable at install time. Because transitive dependencies also carry their own scripts, a single top-level npm install can trigger dozens of postinstall hooks a team never directly chose to trust.
How prevalent is typosquatting as an attack vector on npm today?
Typosquatting remains one of the most common attack vectors because it requires no infrastructure compromise at all — just publishing a package with a name one keystroke away from a popular one (lodahs for lodash, crossenv for cross-env) and waiting for a mistyped install command or an automated dependency bump to pull it in. Security researchers, including teams at Sonatype and Phylum who continuously monitor public registries, have repeatedly documented waves of newly published npm packages using edit-distance-based name collisions against top-1000 packages, frequently paired with obfuscated postinstall scripts rather than any code a developer would ever import or call. Because npm's registry has no pre-publish content review, and package names are allocated first-come-first-served, this pattern costs an attacker almost nothing to attempt repeatedly under rotating names even after individual packages are taken down.
What controls actually stop these three patterns?
No single control stops all three patterns, because they exploit different trust assumptions — publish-time identity, registry-resolution order, and install-time execution. Against maintainer takeover and malicious publishes like flatmap-stream, npm now requires two-factor authentication for maintainers of high-download packages, closing the exact handoff-of-trust gap that let right9ctrl publish unreviewed. Against dependency confusion, the concrete fix is registry configuration: scope internal packages under a reserved @org/ namespace, and pin your .npmrc so internal-scoped names always resolve to your private registry rather than falling through to the public one — the misconfiguration Birsan exploited is fixable in a single config line. Against postinstall payloads, running installs with npm install --ignore-scripts in CI, combined with committing and enforcing a lockfile via npm ci so a build never silently resolves to a newer, unreviewed version, removes the most common execution path outright. Layering in an install-time proxy that inspects packages before they land — checking typosquat edit-distance, namespace-confusion risk, and postinstall behavior in one pass — catches what registry configuration and script-blocking alone can miss, since not every team can run --ignore-scripts universally without breaking native-module builds.
How Safeguard Helps
Safeguard's Package Firewall runs as an install-time proxy in front of npm and pip, so every fetch — including transitive dependencies pulled in by your direct ones — is evaluated before it reaches disk, with typosquat edit-distance detection, dependency/namespace-confusion checks, and static behavioral analysis of the package archive that never executes the code it's inspecting. Findings route through allow, warn, block, or quarantine modes, so a team can baseline in audit mode before enforcing. Underneath it, Eagle — Safeguard's malware classification model — scores every npm publish across seven indicator classes including install-script behavior and credential-harvesting patterns, and retroactively re-scores the full historical corpus whenever it's updated, so a package that looked benign at install time but is later reclassified as malicious surfaces as a finding within seconds rather than waiting for the next scheduled scan.