Open Source Security

event-stream: The Copay Attack That Rewrote npm

The 2018 event-stream incident was npm's first high-profile maintainer-handoff attack. The details still shape how we evaluate package trust.

Nayan Dey
Senior Security Engineer
6 min read

On November 20, 2018, a GitHub issue titled "I don't know what to say." was opened on the dominictarr/event-stream repository. Inside was a short exchange: a user named FallingSnow had noticed that flatmap-stream, a new dependency added to event-stream, contained an obfuscated payload targeting Bitcoin wallets. The conversation that followed, between Dominic Tarr (the original maintainer), the user right9ctrl (who had taken over the package), and a growing crowd of security researchers, is the clearest maintainer-handoff attack story npm has produced to date.

event-stream had roughly 1.5 million weekly downloads at the time of compromise.

The handoff

Dominic Tarr had maintained event-stream since 2011. By 2018 he was not using it. It had been essentially feature-complete for years. In late 2017, a GitHub user right9ctrl opened a few small issues and PRs, and eventually asked via email if he could take over the package.

Tarr, reasonably, said yes. He later wrote:

If it's not fun anymore, you get literally nothing from maintaining a popular package... I gave it away to someone who wanted to maintain it.

This is the pattern. Most widely used npm packages have exactly one maintainer. That maintainer, after a few years, stops being interested. If a stranger offers help, the cost of saying yes is zero. The cost of saying no is being nagged by GitHub notifications forever.

Right9ctrl shipped event-stream@3.3.6 on September 9, 2018, adding a new dependency on flatmap-stream@0.1.1, a package right9ctrl had also published a few days earlier. The next release, 4.0.0, removed the dependency, but by then flatmap-stream was already pulled in by any project still on the 3.3.x line, which included Copay.

The payload, decoded

flatmap-stream@0.1.1 had a tiny index.js and an index.min.js that no sane reader would audit visually. The malicious code was in the minified file. It was a three-stage loader:

Stage one: check process.env.npm_package_description. If it did not contain a specific string, exit silently. That string was the description of the copay-dash Bitcoin wallet package.

Stage two: AES-decrypt an embedded blob using the package description as the key. This is why nobody caught it via generic sandboxing. Running the code in isolation produced no effect. Only when installed as a transitive dependency of Copay did the decryption succeed.

Stage three: the decrypted payload was a second JavaScript blob that hooked bitcore-wallet-client's transaction-signing methods. On any Copay wallet with a balance above 100 Bitcoin or 1,000 Bitcoin Cash, it exfiltrated the private key to a server in Kuala Lumpur.

// Conceptual sketch, not the actual obfuscated code
if (env.npm_package_description.includes(TARGET)) {
  const payload = aesDecrypt(BLOB, TARGET_KEY);
  eval(payload);
}

The attack was surgical. It did nothing on any machine that was not Copay. It left no trace on CI systems that happened to install it. It was specifically designed to evade the kind of review that would happen if it started mining crypto or calling home from random developer laptops.

How it was found

FallingSnow noticed that flatmap-stream used Buffer.from in a context that would fail on older Node versions, and dug into why. Once the obfuscation was partially unwound by Adam Baldwin (then at npm, Inc.) and Ayrton Sparling (FallingSnow), the targeting logic gave up the game. npm pulled the package on November 26, 2018. Copay released a patched wallet on November 27.

No Bitcoin theft from Copay users was ever confirmed. Which is interesting. Either the attacker exfiltrated keys that happened to be in wallets that had already moved funds, or the timing window was closed before any high-value wallet updated. Or the data was exfiltrated but the attacker chose not to move the coins to avoid attribution.

What the community learned

Maintainer handoffs are supply chain events. Before event-stream, the accepted wisdom was that package takeover by new maintainers was mostly a quality concern. Afterwards, it became a trust concern. npm itself added audit trails for maintainer changes. GitHub added organization-level transfer notifications. Some organizations started pinning not just versions but maintainer identity via tools like npm audit signatures (which came later) and socket.dev heuristics.

Minified files in source repositories are a code smell. flatmap-stream shipped an index.min.js that was materially different from index.js. There is almost never a legitimate reason for a library to ship pre-minified code in its published tarball. Post-event-stream, security-aware teams started flagging npm packages whose .min.js contents did not match a reproducible minification of .js.

Transitive dependency depth is attack surface. Copay did not depend on flatmap-stream. Copay depended on event-stream, which depended on flatmap-stream. A typical JavaScript project in 2018 pulled in roughly 700 transitive packages from 50 direct ones. Most engineers had never heard of the authors of 95 percent of them.

Targeted payloads defeat sandboxes. Any dynamic analysis that runs a package in a clean VM and watches for network traffic was useless here, because the payload did nothing without the Copay environment. Defense had to shift upstream, to static review of new versions and provenance of new maintainers.

The npm policy aftermath

npm, Inc. introduced a series of changes over the next year:

  • npm audit became default in npm 6, released in May 2018 and continuously updated through the incident.
  • --ignore-scripts was documented and promoted more aggressively, because event-stream did not even require a postinstall script to do its damage, but many later attacks did.
  • Two-factor authentication for publishing became available and then strongly recommended. Maintainer account takeovers had been a problem for years (the eslint-scope incident from July 2018 was the warning shot); event-stream was what finally got the industry to move.
  • The npm package command started surfacing signature metadata, laying groundwork for the SLSA-aligned provenance attestations that would ship in 2023.

Other registries watched closely. PyPI's typosquatting defenses, RubyGems' MFA push in 2020, and the later Go module proxy immutability model all had event-stream in the background.

What the incident did not solve

event-stream did not make npm packages safer by default. It made security-aware teams more careful, and made security-unaware teams slightly more aware. But the structural problem, that one person can hand off a package used by a million projects, is still there in late 2018. bootstrap-sass was compromised earlier the same year through a similar but simpler path. electron-native-notify would be compromised in a targeted crypto-theft attack in 2019. Each time the specifics differ. The pattern is the same.

The real ask from event-stream is an honest one: if you depend on a package, you owe it some attention. That can be automated. It cannot be skipped.

How Safeguard Helps

Safeguard treats every npm dependency change as a supply chain event, not just a version bump. Our ingestion pipeline flags new maintainers, new sub-dependencies, and behavioral drift between published versions, exactly the signals that flatmap-stream would have lit up on. Reachability analysis tells you whether the suspicious package is actually reached from your application entrypoints, so you can prioritize the one real risk over the 400 theoretical ones. Griffin AI reviews package provenance and writes the fix PR that removes or pins the compromised line, SBOM generation captures your exact transitive graph for audit, and policy gates block releases that depend on packages with anomalous maintainer changes or unverified provenance.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.