The chalk/debug npm Compromise: Anatomy of a Package Hijack
Typosquatting gets the headlines, but the more dangerous package attack is the
boring one: a real package, a real name, a real maintainer account, and a
malicious version. In September 2025, that is what happened to chalk, debug,
ansi-styles, and about fifteen other packages that sit underneath a huge
share of the JavaScript ecosystem. It is the cleanest case study for what a
registry compromise looks like from the inside, and for why your CI pipeline,
not the registry, is the place to defend.
What happened
A maintainer received a phishing email impersonating npm support, claiming their account needed a two-factor update. The link led to a look-alike domain that captured the credentials and TOTP code. With a valid session, the attacker published new patch versions of 18 packages (Aikido’s original write-up, Socket’s analysis).
The payload was a browser-side crypto stealer. It hooked fetch,
XMLHttpRequest, and wallet APIs and rewrote cryptocurrency destination
addresses to attacker-controlled lookalikes. It did nothing on a Node server,
which limited the damage: it mostly affected front-end bundles built during
the window. The malicious versions were live for roughly two hours before they
were pulled. Reported losses were small. The exposure was not: these packages
have billions of weekly downloads in aggregate.
Root cause
Nothing here is an npm vulnerability. Three weaknesses lined up:
- Phishable second factor. TOTP codes can be relayed in real time by an adversary-in-the-middle page. A WebAuthn/passkey credential is bound to the origin and would not have worked on the fake domain.
- Long-lived publish rights on a single human account. One compromised login could ship to every package that person maintained.
- Automatic consumption of fresh versions. Caret ranges (
^5.3.0) plus installs that ignore the lockfile meant new patch releases flowed into builds within minutes of publication.
The third one is yours, not the maintainer’s, and it is the easiest to fix.
Blast radius for a cloud team
Ask what actually ran the code. For this payload, it was whatever bundled the
package into front-end assets: a CI job running npm install rather than
npm ci, a Docker build with an unpinned RUN npm install, or a developer
laptop. The next campaign will not be polite enough to restrict itself to
browsers. The Shai-Hulud worm I covered
earlier ran in
postinstall and went straight for credentials in the environment. A build
runner holding an AWS role, a deploy token, and npm publish rights is the
real prize.
Remediation: shrink the window
Malicious versions are usually detected and yanked within hours. Your goal is to not be installing during those hours, and to lose little if you are.
1. Install from the lockfile only. Use npm ci, `pnpm install
–frozen-lockfile, or yarn install –immutable` in every pipeline and
Dockerfile. A resolver that is never invoked cannot pick up a brand-new
version.
2. Add a release-age cooldown. Several tools can refuse versions younger
than N days: pnpm’s minimumReleaseAge, Renovate’s minimumReleaseAge, and
Dependabot’s cooldown setting. Even three days would have skipped this
entire incident. The trade-off is slower security patches; most tools let you
exempt advisories or specific packages.
3. Disable lifecycle scripts by default. npm config set ignore-scripts
true (or pnpm’s default-deny for dependency build scripts) removes the
postinstall execution path. Then allowlist the few packages that need it,
such as native modules.
4. Starve the install step of secrets. Install dependencies in a step with no cloud credentials. With GitHub Actions, authenticate to AWS via OIDC in a later, separate job scoped to a role that can only deploy. A short-lived token beats a static key, but never being exposed to the install step beats both.
5. Harden your own publishing. If you maintain packages, require passkeys or hardware keys and move to npm trusted publishing (OIDC from CI), so no long-lived publish token exists to steal. Enable provenance so consumers can verify where a build came from.
6. Detect. Run a scanner that flags new versions with install scripts,
obfuscated code, or new network calls (Socket, Aikido, or npm audit
signatures for provenance). Review lockfile diffs in PRs, since a transitive
bump to a widely used package deserves a look.
Gotchas
- Cooldowns only help if the lockfile is committed and updates go through a
reviewed PR; a floating
latestin a Dockerfile defeats them. npm auditonly knows about advisories after the fact. It would have been silent during the two-hour window.- Pinning exact versions without a cooldown just moves the risk to the day you bump.
The takeaway
You cannot make every maintainer in your dependency tree use a passkey, but you can decide how soon your pipeline trusts a new release and what a compromised install step can reach. Frozen lockfiles, a few days of cooldown, no install scripts, and no credentials in the install job turn a supply-chain headline into a non-event.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Storm-0558: When a Stolen Signing Key Forges Any Token
A single stolen token-signing key let attackers mint valid cloud identities and read email in about 25 organizations. Root cause, blast radius, and what to do about your own keys.
CircleCI Breach: Stolen Session Cookies and Your CI Secrets
One infected engineer laptop, one stolen SSO session, and every customer secret in CircleCI was in play. What failed, the blast radius, and how to make CI secrets cheap to rotate.
Snowflake Customer Breaches: Why Passwords Without MFA Still Lose
The 2024 Snowflake customer data thefts needed no exploit, just stolen credentials and no MFA. Root cause, blast radius, and a phishing-resistant MFA rollout plan.