Safeguard
Incident Analysis•

PyPI ctx and npm ua-parser-js: timeline and lessons for defenders

Two package takeovers, two ecosystems: ctx on PyPI in 2022 and ua-parser-js on npm in 2021. A sourced timeline and what defenders should change.

Safeguard Security Team
5 min read

The ctx incident hit PyPI in May 2022 after an expired email domain let someone take over the maintainer account. The ua-parser-js incident hit npm in October 2021 when three versions shipped with malicious code. Both teach the same lesson: a trusted package name is not a trusted release.

A note on names. The title pairs "PyPI ctx" with "ua-parser". The widely documented ua-parser-js incident was on npm, not PyPI, so this post covers them as two separate events.

What happened with ctx on PyPI?

The ctx package had sat unchanged for about eight years. According to reporting on PyPI's own advisory, the email domain tied to the maintainer's account had expired. Someone re-registered it and used it to take over the account. PyPI described the result as a project taken over through user account compromise.

Timeline from the sources:

  • Around 19:18 UTC on 14 May 2022: the first malicious versions appeared.
  • 23 May 2022: a Reddit post raised the alarm.
  • 24 May 2022: PyPI published its security advisory and removed ctx.

The malicious code read the process environment variables when a Ctx object was created, base64-encoded them and sent them to a Heroku app. PyPI estimated about 27,000 malicious copies were downloaded and advised checking any install from the previous ten days for stolen secrets such as cloud keys.

The same Heroku domain appeared in a change to the phpass PHP project. A person claimed responsibility, said the intent was a demonstration, and said collected data was deleted. Those statements are the person's own and could not be verified from the sources. The code did what the code did regardless of intent.

PyPI explained that its existing defense (removing "verified" email status when mail bounces) did not trigger because no email had been sent to the expired domain between expiry and takeover. It said it could monitor expiring domains but that doing so would add support load. It recommended multi-factor authentication, version pinning and pip hash-checking mode.

What happened with ua-parser-js on npm?

The GitHub advisory for ua-parser-js says the package had three versions published with malicious code: 0.7.29, 0.8.0 and 1.0.0. The advisory was published on 22 October 2021. Patched versions are 0.7.30, 0.8.1 and 1.0.1.

CISA published an alert the same day telling users to update to the patched versions. The GitHub advisory goes further: treat any machine that installed or ran the affected package as fully compromised, rotate all secrets and keys from a different computer, and note that removing the package may not remove all resulting malware.

The sources we fetched do not describe the payload in detail, so we do not either.

What do the two incidents have in common?

  • The package name was trusted, the release was not.
  • Credentials were the prize. ctx went after environment variables, and the ua-parser-js advisory tells you to rotate secrets.
  • A window of days passed between the first bad release and removal.
  • Both ended with the same cleanup problem: you must work out which machines installed the bad version, and what secrets they held.

Where do the two differ?

ctx was a dormant, low-profile package revived through an account takeover, so almost nobody was watching it. ua-parser-js was a widely used package, so the exposure was broad. One lesson is about abandoned dependencies, the other is about popular ones.

What should defenders change?

Do not read this as a list of failures by maintainers. A single expired domain was enough in 2022 to unlock an account, which is a registry design question as much as a maintainer one. Defenders can only control their own side.

What to do

  1. Pin versions and use lockfiles. Where your tooling supports it, use hash checking so a replaced file fails to install.
  2. Keep an inventory of every installed package version per build, so "who installed 0.7.29?" is a query, not a week of work.
  3. Review dormant dependencies. A package with no release in years deserves a second look at its maintainers and account recovery details.
  4. Keep secrets out of build and developer environment variables where you can, and use short-lived credentials.
  5. Turn on multi-factor authentication for your own registry accounts and check the recovery email domains you own.
  6. Write the response runbook now: identify affected builds, rotate secrets from a clean machine, rebuild, and check logs.
  7. Delay adoption of brand new releases by a short, fixed period, so removal can happen before you install.

Quick check

Was ua-parser-js a PyPI incident?

No. The documented ua-parser-js incident was on npm. The PyPI incident from the same family of events is ctx.

Why did ctx get taken over?

Reporting on PyPI's advisory says the maintainer's email domain expired and was re-registered, which allowed an account takeover.

What is the first response step after a bad package install?

Find every machine and build that installed the bad version, then rotate the secrets those environments held, working from a clean computer.

Sources

Last reviewed: 18 October 2026.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.