Supply chain risk in the JavaScript ecosystem usually gets discussed with anecdotes:
the left-pad story, the event-stream story, whichever compromise happened most recently. Those
are useful as warnings and useless as measurements. So I counted.
The counting
Working from the published npm mirror, I took every package row that carried both a
weeklyDownloads figure and a maintainerCount, deduplicated to the latest observation per
package, and got 10,663 distinct packages. Of those, 2,740 pull at least one million downloads a
week. And of those 2,740, exactly 1,312 have a maintainerCount of 1.
That is 47.9%. Just under half of the genuinely load-bearing packages in this sample have a
single npm account able to publish them.
A caveat that matters: maintainerCount counts npm publishing accounts, not humans and not
GitHub collaborators. A package can have twenty active contributors reviewing pull requests and
still have one account with the token that pushes to the registry. If anything that makes the
number more interesting rather than less, because the publishing account is precisely the thing
an attacker wants.
Who is carrying the weight
Sorted by weekly downloads, the top single-maintainer packages in the set are remarkably
concentrated:
| package | weekly downloads | dependents | publisher |
|---|---|---|---|
| ansi-styles | 662,289,688 | 3,828 | sindresorhus |
| strip-ansi | 501,823,446 | 10,850 | sindresorhus |
| supports-color | 484,476,087 | 4,464 | sindresorhus |
| string-width | 475,378,749 | 7,446 | sindresorhus |
| ansi-regex | 465,206,808 | 3,374 | sindresorhus |
| chalk | 455,240,749 | 158,754 | sindresorhus |
| wrap-ansi | 417,073,082 | 6,843 | sindresorhus |
| @types/node | 402,551,073 | 40,587 | types |
Seven of the top eight are published by the same account. These are not obscure utilities; they
are the terminal colour and string width primitives that sit underneath basically every CLI tool
in the ecosystem, and they move roughly half a billion downloads a week each.
Downloads are the wrong risk metric anyway
The column that actually predicts blast radius is dependentCount, and it disagrees with
downloads constantly. Look at chalk against ansi-styles. Ansi-styles has more weekly downloads,
662 million against chalk's 455 million, because it gets pulled in transitively by everything
that pulls in chalk. But chalk has 158,754 direct dependents and ansi-styles has 3,828.
Those two numbers answer different questions. Downloads tell you how often the package is
installed. Dependents tell you how many package authors made a deliberate decision to depend on
it. If you are modelling what breaks when a package goes bad, the second number is the one that
describes the surface, and it is forty times larger for chalk.
You can see this on the individual pages. Chalk's is at
https://reapx.dev/data/npm-package-scraper/chalk/, built from 13 runs, and it reports a
dependencyCount of 0 against that dependentCount of 158,754. A package with no dependencies
of its own and six figures of dependents is the ideal shape for a utility library and the worst
possible shape for a compromise. Ansi-styles is at
https://reapx.dev/data/npm-package-scraper/ansi-styles/ and strip-ansi, with 10,850 dependents,
at https://reapx.dev/data/npm-package-scraper/strip-ansi/.
Two smaller findings
TypeScript types have essentially won. Of the 10,663 packages, 7,213 ship type definitions and
3,450 do not, a little over two thirds. Chalk is in the minority that does not, which its page
shows as hasTypeScriptTypes: False alongside a fileCount of 12 and a first publish date of
2013-08-03.
Licensing is less varied than people assume. MIT accounts for 7,995 packages, Apache-2.0 for
1,299, ISC for 329, BSD-3-Clause for 142, and 203 rows carry no license field at all. If you are
writing a compliance check, that long tail of 203 is where your time will go.
Reproducing this
The per-package pages are indexed at https://reapx.dev/data/npm-package-scraper/, one page per
package name, each citing the runs that produced its rows. For the aggregate work above I used
the full mirror at https://huggingface.co/datasets/reapxdev/npm-package-scraper rather than
crawling the pages, which is the right way round: pages for looking one thing up, the dataset
file for counting across everything.
If you want to run this yourself against a different slice of the registry, every figure here
came from three columns, weeklyDownloads, maintainerCount and dependentCount, and a
groupby.
Top comments (0)