In short

In 2018 the tired maintainer of event-stream gave publishing rights to a volunteer, who added a new dependency, flatmap-stream, whose version 0.1.1 hid an encrypted payload that stole from the Copay Bitcoin wallet. In 2021 an attacker hijacked the npm account of ua-parser-js's maintainer and published three versions whose install scripts ran malware, live for about four hours. Lockfiles, install scripts turned off by default, release-age delays, maintainer 2FA with trusted publishing, and review of new dependencies would have stopped or contained both.

  • event-stream: a social takeover. The new maintainer added flatmap-stream to event-stream 3.3.6, and the malicious code sat only in flatmap-stream 0.1.1's minified file.
  • The payload decrypted itself with the description of the package that imported it, so it ran only inside the Copay wallet's build (Copay 5.0.2 to 5.1.0).
  • ua-parser-js: an account takeover. Versions 0.7.29, 0.8.0 and 1.0.0 ran malware from an install script on 2021-10-22, from about 12:15 to 16:27 UTC.
  • The maintainer noticed through a flood of spam to his email, published 0.7.30, 0.8.1 and 1.0.1, and npm removed the bad versions; CVE-2021-4229 was assigned in 2022.
  • Both spread through transitive dependencies and version ranges: most victims never chose these packages.
  • The 2021 controls gap remains common: install scripts on by default, no release-age delay, and publishing that a password and token alone can do.

Incident at a glance

Dates
to
Category
Supply-chain compromise
Organizations
  • event-stream
  • flatmap-stream
  • Copay
  • ua-parser-js
  • coa
  • rc
  • npm
Impact
event-stream 3.3.6 pulled in flatmap-stream 0.1.1, whose encrypted payload ran only in the Copay wallet build (5.0.2 to 5.1.0) to steal wallet keys. ua-parser-js 0.7.29, 0.8.0 and 1.0.0 ran malware from a preinstall script for about four hours.
How it was found
Ayrton Sparling reported the flatmap-stream code on event-stream issue #116. The ua-parser-js maintainer noticed his email being flooded with spam and found the versions published from his hijacked account.

Timeline

  1. A new maintainer publishes event-stream 3.3.6 with a new dependency, flatmap-stream.
  2. event-stream 4.0.0 drops flatmap-stream; 3.x ranges keep resolving 3.3.6.
  3. Ayrton Sparling opens event-stream issue #116.
  4. npm removes flatmap-stream and event-stream 3.3.6 and takes ownership of event-stream.
  5. ua-parser-js 0.7.29, 0.8.0 and 1.0.0 are published from the hijacked account at about 12:15 UTC.
  6. Clean 0.7.30, 0.8.1 and 1.0.1 are out by 16:26 UTC; GitHub and CISA publish advisories.
  7. Malicious versions of coa and rc are published the same way.
  8. CVE-2021-4229 is published for the ua-parser-js malware.

Verify it

  1. Run the lockfile check across every repository and the lockfiles of archived builds you still deploy.
  2. Confirm CI installs with npm ci (or the pnpm and Yarn equivalents) and fails when the lockfile is out of date.
  3. Check that dependency install scripts are off by default (ignore-scripts, npm 12's allowScripts, or pnpm's allowBuilds).
  4. Confirm a release-age delay is set for installs and update bots (min-release-age, minimumReleaseAge or Dependabot cooldown).
  5. For packages you maintain, confirm publishing uses trusted publishing from CI and that interactive publishing requires 2FA.
Applies to
  • event-stream / flatmap-stream (npm) event-stream 3.3.6 with flatmap-stream 0.1.1
  • ua-parser-js (npm) Malicious 0.7.29, 0.8.0, 1.0.0; fixed 0.7.30, 0.8.1, 1.0.1
  • coa and rc (npm) Malicious versions published 2021-11-04; see the advisories

Teams that install npm packages, directly or transitively, in development and CI, and maintainers who publish packages to npm.

Part of
Workflow: Respond to a vulnerability. Collection: case studies
Learning goals and prerequisitesAbout 28 minutes: 8 to read and 20 for the exercise

What you will learn

  • Contrast a maintainer handover with an account takeover, and the evidence each leaves.
  • Explain how ranges, transitive dependencies and install scripts delivered the malicious versions.
  • Scope exposure from lockfiles and install times.
  • Choose consumer and maintainer controls, mapped to SSDF and S2C2F, that stop both patterns.

Before you start

Developers and platform engineers who install npm packages in development and CI, and maintainers who publish them

  • Know how npm resolves version ranges and what a lockfile records.
  • Know that dependencies can run lifecycle scripts during install.
  • For the code: Python 3 and npm.

Refresh first: Investigate a suspected malicious package safely

Go to the guided practice

Two takeovers: a handover and a hijack

Popular packages are maintained by people, and an attacker who controls what those people publish controls what millions of installs receive. These two incidents show the two common routes. In event-stream the attacker was given control: the original maintainer, who no longer used the package, handed publishing rights to a volunteer who asked for them. In ua-parser-js the attacker took control: the maintainer's npm account was hijacked and used to publish malicious versions.

Neither package was something most victims had chosen. event-stream was a dependency of other tools, and ua-parser-js sat deep in the dependency trees of front-end and Node.js projects. Version ranges and transitive dependencies delivered the malicious versions automatically.

Timeline

Timeline
WhenEventSource
2018 (before September)A volunteer, GitHub user right9ctrl, emails event-stream's maintainer asking to maintain it and receives publishing rights.event-stream issue #116 (maintainer's comment)
2018-09-09event-stream 3.3.6 is published with a new dependency, flatmap-stream.npm incident report; npm registry
2018-09-16event-stream 4.0.0 drops flatmap-stream; users of 3.x ranges keep getting 3.3.6.npm registry; issue #116
Autumn 2018flatmap-stream 0.1.1 carries the payload, only in its minified file.npm incident report; issue #116
2018-11-20Ayrton Sparling (FallingSnow) opens issue #116, 'I don't know what to say.'event-stream issue #116
2018-11-26npm is notified, removes flatmap-stream and event-stream 3.3.6, and takes ownership of event-stream; Copay confirms versions 5.0.2 to 5.1.0 shipped the code.npm incident report
2021-10-22 ~12:15 UTCua-parser-js 0.7.29, 0.8.0 and 1.0.0 are published from the maintainer's hijacked npm account.npm registry; issue #536
2021-10-22 ~15:38 UTCThe maintainer confirms the hijack on issue #536 and reports it to npm.ua-parser-js issue #536
2021-10-22 ~16:16-16:26 UTCClean 0.7.30, 0.8.1 and 1.0.1 are published; GitHub publishes GHSA-pjwm-rvh2-c87w and CISA an alert.npm registry; GitHub; CISA
2021-11-04The coa and rc packages are hijacked the same way.GitHub advisories
2022-05-24CVE-2021-4229 is published for the ua-parser-js malware.CVE record

event-stream: how a targeted payload hid in a new dependency

The maintainer explained on the issue that he no longer used the module and got nothing from maintaining it, so when someone emailed to offer help he gave it to them. The new maintainer published event-stream 3.3.6 with flatmap-stream as a new dependency, then released 4.0.0 without it, which made the repository look clean while every project on a 3.x range kept installing 3.3.6.

The malicious code was in flatmap-stream 0.1.1, and only in its minified file, which few people read; the readable source on GitHub did not contain it. It carried AES-encrypted data disguised as a test fixture and used the description of the package that imported it as the key. The data decrypted only inside the Copay Bitcoin wallet's build, where it tried to harvest account data and private keys from wallets holding more than 100 Bitcoin or 1,000 Bitcoin Cash. Everywhere else it did nothing visible, which is why it ran unnoticed for weeks.

ua-parser-js: a hijacked account and a four-hour window

On 2021-10-22 the maintainer of ua-parser-js, a widely used user-agent parser, noticed his email being flooded with spam from hundreds of websites, which he took as an attempt to hide notifications. Someone had hijacked his npm account and published 0.7.29, 0.8.0 and 1.0.0. Analysis posted on the issue showed the payload ran from a preinstall script and downloaded binaries on Windows and Linux; commenters reported attempts to steal saved passwords and cookies, and GitHub's advisory told anyone who installed it to treat the computer as fully compromised.

He reported it to npm, deprecated the versions and, after advice on the issue, published 0.7.30, 0.8.1 and 1.0.1 so that ranges would resolve past the bad releases. The malicious versions were the latest for about four hours. Two weeks later the coa and rc packages were hijacked in the same way. All three were account takeovers, not code changes in the projects' repositories, which is why repository review alone could not catch them.

Why did they reach so many installs?

  • Transitive dependencies: victims installed tools that depended on these packages, often several levels deep.
  • Version ranges: ^3.3.0 resolved to event-stream 3.3.6, and ^0.7.0 to ua-parser-js 0.7.29, in any install that was not locked.
  • Install scripts: npm ran dependencies' preinstall scripts by default, so ua-parser-js's payload ran during npm install, before any code imported it.
  • No delay: a new version was installable the moment it was published, so the four-hour window was enough.
  • Weak publishing controls: a stolen password or token was enough to publish, and a handover needed only the old maintainer's agreement.
  • Unreadable artifacts: the event-stream payload sat in a minified file that differed from the published source.

Check lockfiles for the affected versions

Lockfiles record exactly what was installed, so they answer the exposure question for any build that used them. The script below walks npm lockfiles in formats 1, 2 and 3 and reports every copy of the malicious versions with its install path. A hit in a lockfile used in CI or on a developer machine during the window means the code ran there, for ua-parser-js at install time.

Testfind_bad_versions.pyPython
"""Report malicious package versions from the event-stream, ua-parser-js, coa and rc incidents
in npm lockfiles (lockfileVersion 1, 2 or 3). Usage: python3 find_bad_versions.py */package-lock.json
"""
import json
import sys

BAD = {
    "event-stream": {"3.3.6"},
    "flatmap-stream": {"0.1.1"},
    "ua-parser-js": {"0.7.29", "0.8.0", "1.0.0"},
    "coa": {"2.0.3", "2.0.4", "2.1.1", "2.1.3", "3.0.1", "3.1.3"},
    "rc": {"1.2.9", "1.3.9", "2.3.9"},
}


def entries(lock):
    """Yield (install path, name, version) for every package in the lockfile."""
    for path, meta in lock.get("packages", {}).items():  # lockfileVersion 2 and 3
        if path:
            yield path, meta.get("name") or path.rsplit("node_modules/", 1)[-1], meta.get("version")
    stack = [("", lock.get("dependencies", {}))]          # lockfileVersion 1
    while stack:
        prefix, deps = stack.pop()
        for name, meta in deps.items():
            yield f"{prefix}{name}", name, meta.get("version")
            stack.append((f"{prefix}{name} > ", meta.get("dependencies", {})))


found = False
for file in sys.argv[1:]:
    with open(file, encoding="utf-8") as f:
        lock = json.load(f)
    for path, name, version in sorted(set(entries(lock))):
        if version in BAD.get(name, ()):
            found = True
            print(f"{file}: {name}@{version} at {path}")
sys.exit(1 if found else 0)

Harden installs and publishing

Three consumer settings would have blocked or contained both incidents: install from a lockfile, turn dependency install scripts off by default, and refuse versions only hours old. npm 12 blocks dependency install scripts unless allowScripts approves them, and pnpm has not run them by default since version 10. For maintainers, trusted publishing from CI with provenance and 2FA for interactive publishes means a stolen password or token cannot publish on its own; /domains/supply-chain/releases/trusted-publishing-and-updates/ covers the setup.

Configurationnpm-hardening.shShell
# ~/.npmrc or the project's .npmrc (npm 11.10+)
ignore-scripts=true    # no preinstall/postinstall from dependencies (npm 12 blocks them by default)
min-release-age=3      # skip versions published in the last 3 days

# CI: install exactly the lockfile, never re-resolve ranges
npm ci

# What is installed, and through which parent?
npm ls event-stream flatmap-stream ua-parser-js --all

# Maintainers: publish from CI with trusted publishing, and require 2FA
# for any interactive publish, so a stolen password or token is not enough
npm access set mfa=publish <package-name>

Which controls would have helped?

The OpenSSF Secure Supply Chain Consumption Framework (S2C2F) uses both incidents as examples: it maps event-stream to malware scanning (SCA-4) and a curated feed (ENF-2), and ua-parser-js to provenance verification (AUD-1), ENF-2 and SCA-4, all at maturity level 3.

Which controls would have helped?
ControlSSDF or S2C2FEffectGuide
Lockfiles and npm ci everywhereSSDF PW.4; S2C2F ENF-1Stops silent upgrades to a new malicious version; you upgrade on purpose./domains/supply-chain/dependencies/package-attack-response/
Dependency install scripts off unless allowedSSDF PO.5ua-parser-js's preinstall payload would not have run./domains/supply-chain/ci-cd/developer-workstation-security/
Release-age delay on installs and update botsS2C2F ENF-2 (curated feed)A four-hour window would have closed before anyone installed it./domains/supply-chain/dependencies/evidence-based-triage/
Malware scanning and deny-lists in a package proxyS2C2F SCA-4, ING-3Blocks known-bad versions across the organization within minutes of an advisory./domains/supply-chain/dependencies/package-attack-response/
Review new dependencies and maintainer changesSSDF PW.4; S2C2F AUD-1A new, unused package added by a new maintainer is a signal worth a look./domains/supply-chain/dependencies/project-health-and-intake/
Trusted publishing, provenance and 2FA for maintainersSSDF PS.1, PS.2A hijacked password or token cannot publish; provenance links a version to its source and workflow./domains/supply-chain/releases/trusted-publishing-and-updates/

Lessons that still apply

  • Maintainer burnout is a security issue: a package with one tired maintainer can change hands with an email, as xz Utils showed again in 2024 (/domains/supply-chain/dependencies/case-study-xz-utils/).
  • Install-time code is the most dangerous kind, because it runs on developer machines and CI runners before tests or review.
  • Malicious versions are usually caught within hours to weeks; a short release-age delay is cheap protection.
  • Compare what you install with what you reviewed: the published package, including minified files, not the GitHub repository.
  • The same pattern scaled up in the self-spreading npm worms of 2025 and 2026: /domains/supply-chain/dependencies/case-study-shai-hulud/.

What the public evidence does not establish

The identity of event-stream's new maintainer and of whoever hijacked the ua-parser-js, coa and rc accounts is not public, and no first-party source connects the incidents to each other. The exact publication date of flatmap-stream 0.1.1 is not in the npm report, and the registry no longer lists the removed versions. How the ua-parser-js account was taken over is not documented beyond the maintainer's account of the spam flood, and no public source counts the machines that actually ran either payload.

Guided practice

It is 2021-10-23. Five projects are in front of you. Decide which installed a malicious version, where the code ran, and what to do. Treat the facts as true.

Supplied scenario material
Plain text
A: Web app; package-lock.json committed 2021-09-30 pins ua-parser-js 0.7.28; CI runs npm ci.
B: Internal tool; no lockfile; package.json has "ua-parser-js": "^0.7.21"; CI ran npm install at 2021-10-22 13:40 UTC on a runner with AWS deploy keys in its environment.
C: Developer laptop; ran npm install in a project depending on ua-parser-js ^1.0.0 at 2021-10-22 18:05 UTC.
D: Old build image built 2018-10-20 for an Electron app; its lockfile has nodemon > ps-tree > event-stream 3.3.6 > flatmap-stream 0.1.1; the app is not a cryptocurrency wallet.
E: A package you maintain; publishing uses an npm token stored in ~/.npmrc on one laptop; no 2FA.
  1. 1. Clear A

    Was project A exposed?

    Expected result and reasoning

    No: the lockfile pins 0.7.28 and npm ci installs exactly that, so the malicious 0.7.29 was never resolved.

  2. 2. Scope B

    What happened in B's CI run, and what do you do?

    Expected result and reasoning

    Without a lockfile, ^0.7.21 resolved to 0.7.29, the latest during the window, and its preinstall script ran on the runner. Treat the runner as compromised, rotate the AWS deploy keys from another machine and audit their use since 12:15 UTC, rebuild the runner, and add a lockfile and npm ci.

  3. 3. Decide C

    Was the laptop exposed?

    Expected result and reasoning

    Probably not: the fixed 1.0.1 was published around 16:26 UTC, before 18:05, and npm had flagged the bad versions. Confirm with the installed version (npm ls ua-parser-js) and the npm logs before closing it.

  4. 4. Judge D

    Does D's flatmap-stream copy matter?

    Expected result and reasoning

    The malicious package is present, but the payload only decrypted inside the Copay wallet's build, keyed on the importing package's description, so D is not a known victim. Rebuild the image without event-stream 3.3.6 anyway, and record why it was judged not exploited.

  5. 5. Protect E

    What should the maintainer of E change?

    Expected result and reasoning

    Remove the long-lived token from the laptop, publish from CI with trusted publishing and provenance, and require 2FA for any interactive publish. That way a hijacked account or a stolen laptop file is not enough to publish, which is what happened to ua-parser-js.

Your deliverable: A table for A to E (installed a malicious version, where it ran, action) and the publishing changes for E.

The projects are invented; the versions, dates and times are from the advisories and the npm registry. Real scoping needs install logs and lockfiles from the window, and the absence of a lockfile means you can only estimate what resolved.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. npm: Details about the event-stream incident (2018-11-27)
  2. dominictarr/event-stream issue #116: I don't know what to say
  3. GitHub Advisory GHSA-mh6f-8j2x-4483: event-stream and flatmap-stream. 26 November 2018.
  4. faisalman/ua-parser-js issue #536: compromised npm packages
  5. GitHub Advisory GHSA-pjwm-rvh2-c87w: Embedded malware in ua-parser-js. 22 October 2021.
  6. CISA: Malware discovered in popular npm package ua-parser-js (2021-10-22)
  7. GitHub Advisory GHSA-73qr-pfmq-6rp8: Embedded malware in coa. 4 November 2021.
  8. GitHub Advisory GHSA-g2q5-5433-rhrf: Embedded malware in rc. 4 November 2021.
  9. OpenSSF S2C2F: Secure Supply Chain Consumption Framework
  10. npm Docs: config (ignore-scripts, min-release-age)
  11. GitHub Changelog: npm install-time security and GAT bypass2fa deprecation. 8 July 2026.

Reference information

CAPEC attack patterns

Download this guide as Markdown