JSTGTECH
← Back to blog

The chalk/debug npm Compromise: Anatomy of a Package Hijack

4 min read

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 latest in a Dockerfile defeats them.
  • npm audit only 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.

Related posts