← Notes & write-ups

Speed is not the signal: 29,824 npm publish timestamps and the field an attacker cannot forge

15 September 2026· 8 min read

One HTTP request to registry.npmjs.org returns a JSON document with a field called time. It maps every version a package has ever published to the second that version arrived. I fetched it for keyv, the package at the centre of the 4 August worm, and found this line still sitting inside it:

keyv@6.0.0 → 2026-08-04T09:35:00.763Z

The 6.0.0 tarball is gone. You cannot install it, you cannot read it, and the version page is a stub. The second it arrived is still there, and it will stay there, because the registry wrote that field rather than the publisher.

Timeline showing keyv 6.0.0-rc.1 published 3 August 2026 at 19:21:08 UTC and keyv 6.0.0 published 4 August at 09:35:00.763 UTC, 14.2 hours apart
What one unauthenticated GET still returns. The last honest release and the worm, 14.2 hours apart, recorded to the millisecond.

I ended my last write-up by saying the provenance feed should be read as a sensor rather than as a receipt, and that I had not seen anyone build it. That sentence was too wide and I have since narrowed it: Socket, Phylum and OpenSSF's package-analysis have watched publishing behaviour for years. But they watch the artifact. They fetch the tarball and look inside it. The narrower question is the one I actually wanted answered, and as far as I can tell it is still open: is a worm visible in the metadata alone, before anybody downloads anything?

So I built the smallest possible version of that and pointed it at real data. Forty-seven packages across three groups: fifteen independent ones with an organic release cadence such as chalk, express and semver; eighteen from monorepo families that release many packages at once, from @babel, @typescript-eslint and @angular; and fourteen from the keyv and cacheable neighbourhood the worm actually ran through. That gave 29,824 publish events between December 2010 and September 2026, all of it from the public packument, no authentication, no tarballs, roughly ninety kilobytes per package.

The obvious detector is a rate. During the incident the worm republished at about one package per second per namespace and put 2,234 poisoned versions into 444 package names inside 34 minutes, which ought to be a screaming anomaly against the way software normally ships. My data agrees that it should be. Of the 29,777 gaps between one publish and the next, 0.13 per cent are under a second and 4.1 per cent are under a minute. Publishing quickly is already rare.

Cumulative distribution of gaps between consecutive publishes on a log scale, showing 0.13 per cent under one second and 4.1 per cent under one minute
The benign base rate. Most packages sit still for days between releases, which is exactly the condition under which a rare-event detector can be cheap.

Then I ran the rate detector across the whole stream, and it failed in a way I should have seen coming.

The busiest single minute in sixteen years of my sample is 29 publishes, and it belongs to express in August 2013. The eleven next busiest all belong to @typescript-eslint, which ships eighteen packages in thirteen seconds on a routine release because that is simply what its tooling does. Put the threshold anywhere below the worm and you flag every well-run monorepo in the ecosystem. Put it above and you catch nothing, because a worm that wants to evade you can publish more slowly and still finish long before a human wakes up. Publish rate is a knob the attacker owns.

What I did not expect was what those bursts had in common. I took every minute in the dataset carrying ten or more publishes, which gave 38 of them, and for each one counted how many distinct maintainer sets it touched.

Thirty-eight out of thirty-eight touched exactly one.

Scatter plot of publishes per minute against publishing authorities crossed. Every benign burst sits on the line at one authority. The keyv worm sits far above it at twelve organisations.
Every benign burst I measured, however fast, stayed inside a single publishing authority. The 4 August worm crossed twelve organisations. The horizontal axis is the feature that fails; the vertical axis is the one that works.

Not one benign burst crossed a publishing authority. I do not think a larger sample would erase that, because the reason is structural rather than statistical. A monorepo release is fast for a specific reason: one release tool, running once, holding one credential, iterating over packages that all answer to the same owner. The speed is a side effect of the credential being singular. A worm is fast for the opposite reason. Its whole purpose is to take a credential it has just stolen and use it somewhere the previous credential could not reach. Crossing authorities is not a side effect of worm behaviour. It is the definition of it.

So the quantity worth measuring is not publishes per minute. It is authorities crossed per minute, which is a property of a graph rather than a rate, and the two come apart precisely where it matters: a legitimate release drives the first number as high as it likes while holding the second at one.

There is a more general version of this that I have not been able to put down. Look at what a publisher controls: the package name, the version number, the contents of the tarball, the signature, the provenance claim, the changelog, and afterwards whether the version continues to exist at all. Every one of those is written by whoever holds the credential, and August's worm demonstrated that holding the credential makes all of them honest. Now look at what the registry writes: the second the upload arrived, which credential presented it, the order things arrived in, and what else arrived alongside them. The publisher never touches those fields, and unpublishing does not remove them.

Two columns comparing fields written by the publisher, such as name, version, tarball, signature and provenance, against fields written by the registry, such as arrival time, credential identity and arrival order
An attacker optimises every field they control, and in a signed-and-attested supply chain that now includes the proofs. The fields on the right were never theirs to optimise.

Defenders have been in this position before. When TLS swallowed the payload, network defence did not end; it moved to flow records, and the shape of a connection turned out to carry a surprising amount of what the payload used to carry. Supply-chain defence is still reading payloads. The flow records have been sitting in a public API the whole time, unauthenticated and unrate-limited, and I have not found anyone treating them as telemetry.

None of this is far from what the literature already says, which is part of why I trust it. Zimmermann and colleagues measured the structural version of the same fact in 2019: a small number of npm maintainers can reach a large share of the ecosystem, so credential reach rather than code quality is what sets the blast radius (USENIX Security 2019). What I measured is that result's shadow in time. Axelsson's old argument about intrusion detection also applies with unusual force: when the base rate of the thing you are hunting is tiny, the choice of feature matters more than the choice of classifier, because a feature whose benign base rate is near zero does work that no amount of model tuning can buy. Authority-crossing looks like such a feature. Publish rate plainly is not.

Now the parts that would fail review, which I would rather write myself than have someone else write for me.

Forty-seven packages is a sample I chose, and I chose it already knowing roughly what I hoped to see, which is close to the definition of a biased sample. It leans toward large, well-maintained projects, and those are exactly the ones with disciplined single-credential release tooling. A full-registry study could easily surface benign cross-authority bursts that my sample cannot contain. A security fix coordinated across several organisations on the same afternoon would look, from the outside, very much like a worm.

The time map is also not a clean record of human intent. It records republishes and registry migrations as well as deliberate releases. I am fairly confident that the 2013 express burst is one of those rather than a person publishing 29 times in a minute, and from outside the registry I cannot prove the difference. Anything built on this has to carry that caveat all the way through.

And 38 out of 38 is a hint about a base rate, not a measurement of one. The honest statement is that across 29,824 events I could not find a benign counterexample, which is a weaker and more useful claim than saying none exists.

Both scripts are in the repository (github.com/daihongphucecq/npm-publish-shape). collect.py fetches the metadata for any package list you hand it and analyse.py prints every number in this post; neither has a dependency beyond the standard library. The experiment I could not run is the one that would settle it: take a year of events from a full registry mirror or the CouchDB changes feed, compute authorities-crossed per minute across the entire ecosystem, and find out whether the benign rate is genuinely zero or merely small. If it is merely small, the idea survives but needs a threshold and a false-positive budget. If it is zero, the detector is one line of arithmetic, and on 4 August it would have fired at roughly 09:36, about forty minutes before the first public warning.

I set out to confirm something I had already published, and the first version of it was wrong. The rate detector I was confident about is the one a monorepo trips on an ordinary Tuesday. The version that survived is smaller, stranger and cheaper to run, and I would not have found it without building the one that failed.

Sources / Nguồn:
- Method and code: github.com/daihongphucecq/npm-publish-shape — 29,824 publish events, 47 packages, collected 8 September 2026 from the public npm registry.
- npm registry packument format (the time map this rests on): github.com/npm/registry
- Zimmermann, Staicu, Tenny, Pradel — “Small World with High Risks: A Study of Security Threats in the npm Ecosystem”, USENIX Security 2019: usenix.org
- Ohm, Plate, Sykosch, Meier — “Backstabber's Knife Collection: A Review of Open Source Software Supply Chain Attacks”, DIMVA 2020: arxiv.org/abs/2005.09535
- S. Axelsson — “The Base-Rate Fallacy and the Difficulty of Intrusion Detection”, ACM Transactions on Information and System Security 3(3), 2000. (Paywalled at the ACM Digital Library; cited here for the argument, not the link.)
- SafeDep — the 2,234 versions / 444 packages / 12 organisations figures: safedep.io
- Snyk — incident timeline for 4 August 2026: snyk.io
- Datadog Security Labs — worm mechanics and token handling: securitylabs.datadoghq.com
- My previous write-up, whose closing claim this post tests and partly corrects: dhf.io.vn