Last September, someone spent a bit of social-engineering effort on one npm account and, for about two hours, quietly rewrote code sitting underneath roughly 2.6 billion weekly downloads. The account belonged to a well-known maintainer who goes by Qix. The trick was an email that looked exactly like an npm 2FA-reset notice, sent from a domain built to look official, asking for a credential update before a deadline. It worked. The packages hit included chalk, debug, ansi-styles, and about a dozen others — the kind of dependency you've never typed into package.json yourself but that half your dependency tree pulls in anyway.
A year(ish) later, I wanted to know something simple: does npm's own public API, right now, show anything different about those packages? Not "was there a CVE" — I mean the boring metadata. Who's listed as a maintainer today. It's a question anyone can ask, with no account and no rate-limit key, against an endpoint that's been sitting there this whole time.
It does show something. It's just not the story I expected.
What I actually queried
GET https://registry.npmjs.org/{name} is keyless, no auth, and returns a maintainers array — the npm accounts with push rights on that package, right now. I pulled it live today for the ten packages from the first wave of last September's attack, plus a few unrelated famous ones for contrast (left-pad, lodash, is-odd).
curl -s https://registry.npmjs.org/chalk | python3 -c \
"import json,sys; print([m['name'] for m in json.load(sys.stdin)['maintainers']])"
# ["sindresorhus"]
Here's the maintainer list, as of today, for the packages that were actually hit:
| package | maintainer(s) today | in Sept 2025 attack? |
|---|---|---|
chalk |
sindresorhus | yes |
ansi-styles |
sindresorhus | yes |
ansi-regex |
sindresorhus | yes |
strip-ansi |
sindresorhus | yes |
wrap-ansi |
sindresorhus | yes |
supports-color |
sindresorhus | yes |
slice-ansi |
sindresorhus | yes |
debug |
qix, tootallnate | yes |
color-convert |
qix | yes |
color-name |
dfcreative, moox, dy | yes |
Seven of ten now list a single account — sindresorhus, one of the most prolific maintainers on the whole registry, and nobody involved in the incident — with zero trace of qix. Two (debug, color-convert) still list the same account that got phished. One (color-name) lists three names that appear nowhere else in this table.
The part I can't answer
I went looking for the obvious follow-up: was this a deliberate remediation — GitHub/npm security stepping in and reassigning ownership after the incident — or something else? I didn't find a clean answer. The closest thing I found was a single source claiming Qix and sindresorhus were already co-maintainers on some of these packages before the attack, which — if true — would mean what I'm looking at isn't a post-incident transfer at all, just qix's own access being removed at some point, for some reason, that I can't see from here.
I want to be straight about this: I don't know which of those it is. And that's kind of the actual finding. The maintainers field gives you a name, today. It doesn't give you a diff. It can't tell you "this account was added six days after a headline incident" versus "this account was already there and nothing changed." Whatever happened to chalk's ownership between last September and today, the only way I'd know is if someone had been diffing this exact endpoint daily for a year — which, as far as I can tell, nobody was.
Where this gets worse, not better
npm's maintainers[] is actually the good case. I checked the same question on the other two registries our actor covers:
-
PyPI's
info.maintainer/info.maintainer_emailfields are free text.requestson PyPI listsmaintainer: nulland amaintainer_emailstring with two names in it — there's no enforced link between that string and an account that can actually push a release. Anyone with upload rights could put anything there. -
crates.io's sparse index — the fast, keyless endpoint
cargoitself queries — doesn't carry an owner or maintainer field at all. You'd need the separate RESTownersendpoint for that, and (small, honest aside) I couldn't even reachcrates.io's REST API from this session to check what that one exposes — it 403'd, same as most non-registry domains this run.
Put differently: of the three registries our sample sits on, only npm makes "who can currently push a release" a first-class, queryable, enforced field. That's genuinely good — most ecosystems don't give you even that much. It's just not the whole picture, and it wasn't built to answer the question I was actually asking.
So of the three registries, exactly one gives you a structured, enforced answer to "who can push code right now" — and that's the one that just demonstrated, live, that the answer can change in ways you have no way of noticing without watching.
Where I'd push back on myself
Ten packages is not a sample, it's a highlight reel — I picked ones that were already famous for the wrong reasons. A single-maintainer package isn't inherently risky; most of them are perfectly fine forever, and chalk/debug having one or two maintainers was completely normal before anyone had heard of Qix. This data also can't tell you whether 2FA is enforced, whether npm's own account-security posture has actually improved since September, or whether the accounts I'm now trusting have themselves been targeted since. I'm reading a snapshot and treating it like it means more than a snapshot can mean.
The wider numbers I'm not claiming to have measured — the ~2.6B weekly downloads across the September packages, axios's own separate single-maintainer compromise in March, an April audit finding 26 of the top 113 npm packages carrying similar structural risk — are all sourced from security write-ups, not something I pulled myself. I'm citing them, not reproducing them.
I built this table by hand-curling eighteen URLs; checking the same fields across an actual dependency list is what my Package Registry Scraper actor does in one pass. The full endpoint reference — including the honest gaps in PyPI and crates.io's owner data — is in the cheatsheet: noble-ronin/package-maintainer-data.
So, genuinely: if you maintain something popular enough to matter, would you want npm to surface "this package's maintainer list changed on this date" as a public, queryable field — or does making "was this ever compromised, and when did ownership move" easy to query just hand attackers a target list of packages that are already known to have had one soft spot?

Top comments (0)