The event-stream and node-ipc incidents: npm supply-chain attacks dissected
A senior engineer can usually tell you what their direct dependencies are. Ask about the transitive ones and the answer gets vaguer. Ask who controls the npm publishing rights to a package five levels deep in the tree, the one that auto-updates inside a caret range while everyone sleeps, and the honest answer is almost always nobody knows. That gap is where two of the most instructive npm supply-chain attacks of the last decade lived. Neither needed a zero-day. Neither broke a sandbox. Both exploited the social contract of open source instead of any code in it.
The two cases rhyme but do not repeat. In late 2018 a stranger asked an exhausted maintainer for the keys to a popular streaming utility, got them, and used the access to smuggle a Bitcoin-wallet stealer into a single targeted application. In March 2022 the author of a million-download IPC library reached into his own package and made it overwrite files on machines geolocated to Russia and Belarus. One was theft dressed as a contribution. The other was sabotage dressed as protest. What they have in common is the thing worth studying: the moment a package.json entry resolves to running code, you have already trusted every human who can publish to that name, and you almost never chose those humans on purpose.
This post walks through both incidents from the primary sources. First event-stream: the handoff, the two-stage flatmap-stream payload, the AES key hiding in a package description, and how an unrelated deprecation warning blew it open. Then node-ipc: the geolocated file wipe, the peacenotwar dependency, and the strange afterlife of a package whose maintainer torched his own reputation. The closing section is about what npm itself changed, and why a third node-ipc compromise in 2026 proves the underlying problem is still wide open.
The trust model you inherit by typing npm install
Before the two cases, it helps to be precise about what an npm install actually authorises, because both attacks lived inside the gap between what developers think it does and what it does. When you run npm install, the client resolves a dependency tree, downloads tarballs, and unpacks them into node_modules. That part is inert. The part that is not inert is lifecycle scripts. A package can declare preinstall, install and postinstall hooks in its package.json, and npm runs them automatically, in a shell, with the privileges of the user running the install. There is no sandbox, no prompt, and by default no log you will read. A package that wants to run code on your machine the moment you add it does not need an exploit. It asks politely in a JSON field, and npm obliges.
Layer onto that the resolution semantics. npm encourages caret ranges (^1.2.3), which match any release up to the next major. The intent is good, security patches flow in without manual bumps, but the side effect is that the version you audited and the version you install are frequently not the same version. A lockfile pins the resolved tree, yet a large share of projects either do not commit one or regenerate it carelessly. And the tree is deep. A typical front-end app resolves hundreds to low thousands of transitive packages, published by an unknown set of people, almost none of whom the developer chose, evaluated, or could name. The registry binds the right to publish a given name to whoever currently holds the credential for that name. Reputation, in this model, is a property of a string and a token, not of an accountable human.
That is the whole attack surface in one paragraph. Code runs on install. Versions float. Trees are unread. Trust attaches to names. Hold that in mind, because event-stream attacked the binding between a name and its owner, and node-ipc attacked the assumption that an owner’s intentions stay fixed.
2018: the event-stream handoff
event-stream was the kind of package nobody thinks about. A small utility for composing Node.js streams, written by Dominic Tarr, an author of more than four hundred npm modules. By 2018 it pulled around two million downloads a week and sat under more than fifteen hundred dependent packages. Tarr had not shipped a feature in roughly five years. In his own words later, maintaining it had stopped being fun, and “if it’s not fun anymore, you get literally nothing from maintaining a popular package.”
So when a GitHub account named right9ctrl offered to take over maintenance in the late summer of 2018, Tarr accepted. He added the account as a contributor and granted npm publishing rights. This was not reckless by the standards of the time. As Tarr put it afterward, “sharing commit access/publish rights, with other contributors was a widespread community practice.” The whole npm registry ran on that assumption. A volunteer hands a stranger the keys because the stranger seems to be the only person who still cares about the code.
The first malicious move was quiet and patient. On 9 September 2018, commit 2b8285 added a new dependency to event-stream: flatmap-stream, pinned at ^0.1.0. The stated purpose was a flatmap operator, a reasonable feature to want. At that point flatmap-stream was clean. The poison arrived later, on 5 October, when [email protected] was published carrying obfuscated code that the ^0.1.0 range happily accepted. Anyone whose lockfile or caret range pulled the newest event-stream got the newest flatmap-stream with it, and the newest flatmap-stream was a weapon.
The payload’s targeting logic
What makes event-stream worth dissecting is not that it stole money. Plenty of malware steals money. It is the care taken to make sure the malware ran in exactly one place and stayed silent everywhere else. The injected code arrived in three stages. The first stage, embedded in flatmap-stream’s minified source, did nothing observable on its own. It read an AES-256 encrypted blob from a file disguised as a test fixture and tried to decrypt it using a key it pulled from the environment at install time: process.env.npm_package_description, the description field of whatever top-level package was being built.
This is the elegant, nasty part. npm exposes a project’s own package.json fields as environment variables to lifecycle scripts. The attacker did not hardcode a target name into the payload where a researcher could read it. They used the target’s own self-description as the decryption key. On almost every machine that ran the code, the description was wrong, the AES decryption produced garbage, a try-catch swallowed the error, and the payload did nothing. The single description that worked was Copay’s, the BitPay-built open-source Bitcoin wallet, whose package.json description was the string “A Secure Bitcoin Wallet.” Feed that exact string in as the key and the blob decrypted into the next stage.
The second stage gated execution further. It activated only when specific build commands ran, essentially only when the iOS, Android, or desktop Copay applications were being built and bundled. The third stage was the actual theft. It injected code into another file already present in Copay’s tree, @zxing/library’s ReedSolomonDecoder.js, so the malicious logic rode inside a legitimate-looking dependency at runtime. The injected code harvested the victim’s Copay account data and private keys, but only for accounts holding more than 100 Bitcoin or 1000 Bitcoin Cash, and shipped the loot to a server in Malaysia. Small wallets were left untouched. The attacker wanted whales and wanted no noise.
Each design decision here points the same way: toward patience and away from detection. Hardcoding “copay” into the payload would have let any grep across the registry find it. Using the description as the AES key meant the malicious branch only existed, in decrypted form, on a machine that already was the target. Gating on the mobile and desktop build commands meant a developer running tests, or a CI job that only linted, would never trigger the theft stage. Injecting into an existing @zxing file rather than writing a new one meant the runtime payload had no suspicious provenance; it lived inside a barcode-decoding library that Copay legitimately shipped. Filtering on a 100 BTC floor, worth several million dollars at the time, meant the exfiltration traffic was rare enough to stay quiet. Every choice traded reach for stealth. This was not a smash-and-grab. It was a sniper that loaded itself only when it saw the one face it was waiting for.
The obfuscation itself was unremarkable, and that is the point worth sitting with. There was no novel packer, no anti-debugging, no VM. The payload was minified, with single-letter variable names and hex-encoded strings for the sensitive references, and the encrypted blobs sat as hex in a file labelled like a test fixture. A reviewer who opened it would have seen obvious obfuscation. The catch is that essentially nobody opens the source of a transitive dependency’s patch release. flatmap-stream was a package most affected developers had never heard of, pulled in indirectly, updated automatically. The obfuscation did not need to defeat scrutiny. It only needed to survive the complete absence of scrutiny, which is the normal condition of a node_modules tree. Compare this with the years of effort that went into hiding the XZ Utils backdoor in build artefacts, and event-stream looks almost lazy. It did not have to be careful, because no one was looking.
How it unravelled
The attack did not fall to a security scanner. It fell to a deprecation warning. The payload used Node’s older crypto.createDecipher, which had been deprecated, and developers running the affected tree started seeing the warning in their consoles with no obvious explanation. In late November 2018 a developer named Ayrton Sparling filed an issue pointing at flatmap-stream as suspicious. Once the community started pulling the thread, others enumerated candidate package descriptions to brute-force the AES key and recovered the plaintext, which exposed the Copay targeting in full. npm’s security team was notified on 26 November 2018 and removed flatmap-stream that day. By then the malicious code had been live for roughly seven weeks.
There is a detail in the discovery worth dwelling on, because it says something about how these things actually surface. The attacker did not slip up on operational security. The wallets were stolen, the server in Malaysia received its data, and the targeting was sound. What betrayed the operation was an entirely incidental engineering choice: the payload used Node’s crypto.createDecipher, an API Node had already deprecated in favour of createDecipheriv. Running the affected dependency printed a deprecation warning to the console, and a deprecation warning is exactly the kind of low-grade noise a curious developer eventually investigates. It was not a security alarm. It was a code smell. The attack survived seven weeks of active distribution and two million weekly downloads, and then died because someone wondered why a stream utility was using a deprecated crypto function. That is the texture of npm supply-chain detection in practice. The defences that work are rarely the ones aimed at the threat.
Dominic Tarr’s public statement afterward is more useful than most post-mortems because it refuses the easy moral. He pointed out that he had shared publish rights before without incident, that at the time right9ctrl “looked like someone who was actually trying to help me,” and that the structural problem was a package being treated as digital property with rights transferred but responsibility retained. His two proposed fixes were blunt: pay maintainers so they stay engaged, or actually help maintain the things you depend on. “Personally, I prefer the second,” he wrote, “but the first probably has it’s place.” Neither has happened at scale. The incident later became the subject of a formal academic analysis at EuroSec ‘22, which reconstructed the commit-by-commit timeline that the rushed 2018 write-ups only sketched.
If you want the cleaner cousin of this technique, where the malicious package never needed a handoff because it impersonated an internal name the build system trusted, the dependency confusion attack is worth reading next. event-stream needed a human to say yes. Dependency confusion skips that step.
2022: node-ipc turns its users into a target
The node-ipc story has no stranger and no theft. The maintainer was the attacker, and he signed his work. Brandon Nozaki Miller, who publishes as RIAEvangelist, wrote node-ipc, a Node.js library for local and remote inter-process communication downloaded over a million times a week. In March 2022, two weeks into Russia’s invasion of Ukraine, he turned it into a weapon against a geography.
It did not happen in a vacuum. Two months earlier, in January 2022, the maintainer of two other extremely popular npm packages, colors and faker, had deliberately broken them, pushing a version of colors that printed an infinite loop of garbage characters and zeroing out faker. That was a protest about unpaid maintainer labour rather than geopolitics, but it had already put the question on the table: what stops an author from sabotaging the millions of projects that depend on them? The answer, it turned out, was nothing. node-ipc was the second act, and it raised the stakes from broken builds to destroyed data.
On 7 March 2022 he published [email protected], followed by 10.1.2. Both carried a new file in the package’s dao folder named ssl-geospec.js, dressed up to look like SSL geolocation support. Inside, behind base64-encoded strings and light obfuscation, was destructive logic. On import and execution, the code made an HTTPS request to api.ipgeolocation.io to read the host’s external IP and country. If the country decoded to “russia” or “belarus”, the module walked the filesystem from base paths it had base64-encoded to hide them, the encodings Li8=, Li4v, Li4vLi4v and Lw== decoding to ./, ../, ../../ and /, and overwrote every file it could reach with a single heart emoji. Effectively a wipe.
The blast radius was wide because node-ipc sits deep in the trees of much larger tools. Vue’s CLI depended on it transitively, and projects that had not pinned their versions were exposed. Unity shipped it too. The destructive versions were tracked as CVE-2022-23812 and npm pulled 10.1.1 and 10.1.2 within roughly a day. But the maintainer did not stop. Within hours he published 10.1.3, which removed the wiper, and then 11.0.0, which swapped the destruction for a softer gesture: a new dependency he had created called peacenotwar. That module printed an anti-war message and dropped a WITH-LOVE-FROM-AMERICA.txt file on the user’s desktop. A week later, on 15 March, he backported the peacenotwar dependency into the stable branches as 9.2.2 and 11.1.0, which is how desktop files started appearing for people who had never opted into anything beyond a stable IPC library.
Why “it only writes a file” misses the point
Miller’s defence, given to Motherboard at the time, was that “There was no actual code to wipe computers. It only puts a file on the desktop,” and that the destructive commits were the work of an account compromise. The first claim describes the later peacenotwar versions, not the 10.1.1 and 10.1.2 releases that the CVE and multiple independent analyses documented overwriting files. The distinction he drew does not survive the version history. And the second claim sits awkwardly next to peacenotwar, which he authored and which he was openly proud of.
The technical severity was real, but the more lasting damage was to trust. A maintainer who will wipe a developer’s home directory because their IP geolocates to the wrong country has redefined what installing their package means. It no longer means “I get IPC functionality.” It means “I accept whatever political judgement the author makes about my machine’s location, enforced by code that runs with my privileges.” Every dependency carries that latent term. node-ipc just made it explicit. The community reaction was loud and mostly hostile, even among developers who supported Ukraine, because the payload did not distinguish between the Russian state and a Russian NGO, a Belarusian dissident, or a misgeolocated VPN exit in the wrong country.
Snyk assigned the peacenotwar behaviour its own advisory separate from the wiper CVE, and a maintenance fork sprang up almost immediately under a different maintainer so downstream projects could depend on node-ipc’s API without depending on its author’s politics. That fork is the only reason a lot of build pipelines kept working.
The defenders of the protest framing argued that the targets were Russian and Belarusian, the aggressor states, and that a developer has the moral right to decide who benefits from their unpaid work. The argument falls apart on contact with how the targeting actually worked. The check was a single GeoIP lookup against the host’s external IP. GeoIP is approximate at the best of times and routinely wrong: a Russian human-rights NGO routing through a misattributed block, a Ukrainian developer connected through a Russian-registered transit IP, a researcher on a VPN whose exit happened to sit in Minsk. None of them get a vote. The payload does not read intentions; it reads a country code that a database guessed from an address. Indiscriminate data destruction keyed on an unreliable geolocation signal is not a precision protest. It is a landmine with a coin-flip fuse, and the people most likely to step on it are precisely the people inside those countries who oppose the war and lack the infrastructure to route around it.
There is also a second-order cost that outlived the incident. Every security team that had quietly trusted “it’s just an open-source dependency from a known maintainer” had to revise the threat model to include the maintainer themselves as a potential adversary, acting on motives that have nothing to do with the software. That is a harder thing to defend against than a stranger with a stolen token, because there is no anomalous handoff to flag, no new publisher to distrust. The signed commit comes from the same person who has signed every commit for years. The lesson security teams took from node-ipc was not “watch for account takeovers.” It was “a dependency is a standing grant of code execution to a person whose future state of mind you cannot audit.”
What the two cases share, and where they differ
Strip away the motives and the mechanics line up. Both attacks ran arbitrary code at install or build time, which is npm’s default and the root of most of its risk. Both used trivial obfuscation, base64 and minification, not because it would fool a determined reviewer but because almost nobody reviews a transitive dependency’s source at all. Both exploited version ranges: event-stream poisoned a dependency after it had been accepted so the caret range delivered the payload, and node-ipc relied on downstream projects floating their versions instead of pinning. The defensive lesson in both is the same boring one. A lockfile that pins exact versions, plus --ignore-scripts where you can tolerate it, would have blunted both.
The differences matter for defence because they break different assumptions. event-stream broke the assumption that a package’s reputation transfers cleanly with its name. The package was the same package; the human behind the publish token was not. node-ipc broke the assumption that a known, trusted maintainer’s intentions are stable over time. Yesterday’s reliable author is today’s political actor, and your CI pipeline cannot tell the difference because both publish through the same npm publish. Reputation, in the npm trust model, attaches to a name and a token, not to a verified, accountable identity with anything at stake.
For a defender, the practical takeaways are unglamorous and they are the same across both. Commit a lockfile and treat it as a security boundary, not a convenience; a pinned, reviewed package-lock.json would have stopped both the flatmap-stream auto-update and the unpinned node-ipc pull. Run installs with --ignore-scripts in environments that do not need lifecycle hooks, which removes the install-time execution that most stealthy payloads rely on, and re-enable scripts only for the specific packages that genuinely require them. Pin or vendor critical dependencies rather than floating caret ranges across a trust boundary. Watch for sudden maintainer changes and brand-new transitive dependencies appearing in a diff, because the event-stream attack was visible in exactly that signal months before it was understood. And separate build environments from anything holding secrets or funds, since both payloads did their real damage at build time inside the developer’s own privileged context. None of this is novel and none of it is hard. It is just rarely done, because the registry’s defaults push the other way.
There is a reason supply-chain attacks keep landing on package registries specifically. The same dynamics show up in the browser, where a single third-party script tag pulls in code you did not write and cannot pin, which is exactly the problem Subresource Integrity tries to solve with a hash. And they show up at the CDN layer, where a dependency you trusted for years can be sold or hijacked, as in the Polyfill.io supply-chain attack. npm’s version is just the one with the most reach, because the median web application’s dependency tree is enormous and almost entirely unread.
What changed, and what did not
npm did respond, slowly, over the years that followed. Two-factor authentication became available and then required for high-impact packages, which raises the cost of the right9ctrl-style token grab. npm added the --ignore-scripts flag and, later, manifest-confusion and provenance work; the npm provenance feature now lets a package prove it was built and published from a specific source repository and CI workflow, signed through Sigstore. Audit tooling improved. Lockfiles became the norm rather than the exception. None of this is nothing.
But the core defect is structural and remains. Installing a package still runs that package’s code, often at install time, with the developer’s full privileges, and the trust decision is delegated to whoever happens to hold the publish token for a name buried in a tree nobody reads end to end. The clearest proof that the lesson did not take is node-ipc itself. In May 2026, three fresh malicious versions of node-ipc, 9.1.6, 9.2.3 and 12.0.1, appeared on npm. This time it was a credential stealer reaching for AWS, Google Cloud, Azure, SSH keys, Kubernetes tokens and dozens of other secret categories, and it had nothing to do with protest. The dormant original maintainer’s account appears to have been taken over after the email domain tied to it, atlantis-software.net, expired in January 2025 and was re-registered by someone else in May 2026, a week before the malicious publishes. A name with a reputation, an account nobody was watching, an expired domain. Same package, third compromise, an entirely new mechanism.
That is the durable observation from both incidents and the 2026 sequel. The attack surface is not a bug in any one package. It is the registry’s decision to bind trust to a mutable name and a transferable token, and to execute whatever that name resolves to. event-stream showed that the binding can be socially engineered away from its owner. node-ipc showed it can be abused by the owner. The 2026 stealer showed that an abandoned name with a lapsed domain is a loaded gun left on the table. Until installing a dependency stops meaning “run this stranger’s code with my permissions,” every fix is a narrower net over the same hole.
Sources & further reading
- npm (2018), Details about the event-stream incident — npm’s official post-mortem with version numbers, the AES-key-by-description trick, and the Copay balance thresholds.
- Dominic Tarr (2018), Statement on event-stream compromise — the original maintainer’s account of why he handed over publish rights and his views on dependency responsibility.
- Arvanitis et al. (2022), A Systematic Analysis of the Event-Stream Incident — EuroSec ‘22 paper reconstructing the commit-level timeline and three-stage payload.
- Snyk (2018), A post-mortem of the malicious event-stream backdoor — technical breakdown of stages A, B and C and the @zxing/library runtime injection.
- Thomas Hunter II (2018), Compromised npm package: event-stream — early independent analysis of the obfuscated payload and its targeting.
- BleepingComputer (2022), BIG sabotage: Famous npm package deletes files to protest Ukraine war — reporting on the node-ipc versions, ssl-geospec.js, and the peacenotwar dependency.
- NVD (2022), CVE-2022-23812 Detail — the official record for the node-ipc file-overwrite vulnerability in 10.1.1 and 10.1.2.
- Snyk (2022), node-ipc / peacenotwar advisory — analysis of the base64-encoded geolocation check and the targeted path strings.
- Wikipedia (2022), Peacenotwar (malware) — overview of the protestware module, affected projects, and community response.
- The Hacker News (2026), Stealer Backdoor Found in 3 Node-IPC Versions Targeting Developer Secrets — the 2026 account-takeover compromise via an expired maintainer domain.
Further reading
Dependency confusion: how internal package names became an attack vector
How Alex Birsan's 2021 research turned the name of a private package into remote code execution at 35 companies, why installers prefer the higher public version, and the scoping defenses that close the gap.
·18 min readSubresource Integrity and the supply-chain risk of third-party scripts
Traces how the integrity attribute verifies a third-party script against a cryptographic hash, what a compromised CDN it stops, the dynamic-resource gap it cannot close, and why adoption stayed in single digits.
·20 min readThe Polyfill.io supply-chain attack: how a CDN dependency went rogue
A single-incident deep dive into the June 2024 Polyfill.io attack: the February domain sale, the conditional payload injected into hundreds of thousands of sites, the evasion logic that hid it, and the takedown that followed.
·21 min read