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.
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.
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.
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.
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
Một lệnh HTTP gọi tới registry.npmjs.org trả về một tài liệu JSON có trường tên là time. Trường đó ánh xạ mọi phiên bản mà một package từng phát hành sang đúng cái giây mà phiên bản ấy tới nơi. Tôi lấy nó cho keyv, package nằm giữa vụ worm ngày 4/8, và thấy dòng này vẫn còn nằm trong đó:
keyv@6.0.0 → 2026-08-04T09:35:00.763Z
Cái tarball 6.0.0 thì không còn. Bạn không cài được, không đọc được, trang phiên bản chỉ còn cái vỏ. Nhưng cái giây nó tới nơi thì vẫn nằm đó, và sẽ còn nằm đó, bởi trường ấy do registry ghi chứ không phải người publish ghi.
Thứ mà một lệnh GET không cần đăng nhập vẫn trả về. Bản phát hành lương thiện cuối cùng và con worm, cách nhau 14,2 giờ, ghi lại tới từng phần nghìn giây.
Bài trước tôi kết lại rằng nên đọc dòng provenance như một cảm biến chứ không phải như tờ biên lai, và rằng tôi chưa thấy ai xây thứ đó. Câu ấy rộng quá, và tôi đã sửa lại cho hẹp: Socket, Phylum và package-analysis của OpenSSF đã theo dõi hành vi publish từ nhiều năm nay. Nhưng họ nhìn vào artifact, tức là tải tarball về rồi mở ra xem. Câu hỏi hẹp hơn mới là thứ tôi thật sự muốn biết, và theo chỗ tôi tra thì nó vẫn còn bỏ ngỏ: liệu có nhìn thấy con worm chỉ bằng metadata thôi không, trước khi bất kỳ ai tải về thứ gì?
Thế là tôi dựng phiên bản nhỏ nhất của ý đó rồi chĩa vào dữ liệu thật. Bốn mươi bảy package chia ba nhóm: mười lăm package độc lập có nhịp phát hành tự nhiên như chalk, express, semver; mười tám package thuộc các họ monorepo phát hành hàng loạt cùng lúc, lấy từ @babel, @typescript-eslint và @angular; và mười bốn package trong vùng keyv với cacheable, đúng chỗ con worm đã chạy qua. Kết quả là 29.824 sự kiện publish trải từ tháng 12/2010 tới tháng 9/2026, tất cả lấy từ packument công khai, không đăng nhập, không tải tarball, mỗi package tốn khoảng chín mươi kilobyte.
Bộ phát hiện hiển nhiên nhất là đo tốc độ. Trong vụ đó, con worm tái phát hành với nhịp khoảng một package mỗi giây cho mỗi namespace, nhét 2.234 phiên bản nhiễm độc vào 444 tên package chỉ trong 34 phút, lẽ ra phải là một dị thường hét vào mặt người ta so với cách phần mềm vẫn được phát hành. Dữ liệu của tôi đồng ý rằng lẽ ra phải thế. Trong 29.777 khoảng cách giữa hai lần publish liên tiếp, chỉ 0,13 phần trăm là dưới một giây và 4,1 phần trăm là dưới một phút. Publish nhanh vốn đã hiếm sẵn.
Tỉ lệ nền của hành vi lành tính. Phần lớn package nằm im hàng ngày trời giữa hai bản phát hành, và đó chính là điều kiện để một bộ phát hiện sự kiện hiếm trở nên rẻ.
Rồi tôi cho bộ phát hiện theo tốc độ chạy trên toàn bộ dòng sự kiện, và nó hỏng, hỏng theo kiểu lẽ ra tôi phải đoán được từ đầu.
Cái phút bận rộn nhất trong mười sáu năm dữ liệu của tôi có 29 lần publish, và nó thuộc về express, tháng 8/2013. Mười một phút bận tiếp theo đều thuộc về @typescript-eslint, nơi mười tám package ra lò trong mười ba giây ở một đợt phát hành hoàn toàn bình thường, đơn giản vì công cụ của họ làm việc như vậy. Đặt ngưỡng thấp hơn con worm thì bạn gắn cờ mọi monorepo tử tế trong hệ sinh thái. Đặt cao hơn thì bạn chẳng bắt được gì, vì một con worm muốn né chỉ cần publish chậm lại mà vẫn xong việc từ lâu trước khi có người thức dậy. Tốc độ publish là cái núm vặn nằm trong tay kẻ tấn công.
Thứ tôi không ngờ là điểm chung của những đợt bùng nổ ấy. Tôi lấy mọi phút trong bộ dữ liệu có từ mười lần publish trở lên, được 38 phút, rồi với mỗi phút đếm xem nó chạm vào bao nhiêu nhóm maintainer khác nhau.
Ba mươi tám trên ba mươi tám chạm đúng một nhóm.
Mọi đợt bùng nổ lành tính tôi đo được, dù nhanh tới đâu, đều nằm gọn trong một quyền publish duy nhất. Con worm ngày 4/8 đi xuyên qua mười hai tổ chức. Trục ngang là đặc trưng thất bại; trục dọc là đặc trưng có tác dụng.
Không một đợt lành tính nào đi xuyên qua ranh giới quyền publish. Tôi không nghĩ mẫu lớn hơn sẽ xoá được điều đó, vì lý do nằm ở cấu trúc chứ không phải ở thống kê. Một đợt phát hành monorepo nhanh vì một lý do rất cụ thể: một công cụ phát hành, chạy một lần, cầm một token, lặp qua những package cùng chung một chủ. Tốc độ chỉ là hệ quả phụ của việc token là duy nhất. Con worm nhanh vì lý do ngược lại. Toàn bộ mục đích của nó là cầm cái token nó vừa ăn trộm và dùng ở chỗ mà token trước đó không với tới được. Đi xuyên qua các quyền publish không phải hệ quả phụ của hành vi worm. Nó chính là định nghĩa của hành vi ấy.
Vậy đại lượng đáng đo không phải số lần publish mỗi phút. Nó là số quyền publish bị đi xuyên qua mỗi phút, vốn là một tính chất của đồ thị chứ không phải một tốc độ, và hai thứ này tách nhau ra đúng ở chỗ quan trọng: một đợt phát hành hợp lệ có thể đẩy con số thứ nhất lên cao tuỳ thích trong khi vẫn giữ con số thứ hai ở mức một.
Có một phiên bản tổng quát hơn của chuyện này mà tôi không sao đặt xuống được. Hãy nhìn những gì người publish kiểm soát: tên package, số phiên bản, nội dung tarball, chữ ký, tuyên bố provenance, changelog, và sau đó là cả việc phiên bản ấy còn tồn tại hay không. Từng thứ một đều do người cầm token viết ra, và con worm hồi tháng 8 đã chứng minh rằng cầm được token thì tất cả những thứ đó đều thành thật. Giờ hãy nhìn những gì registry viết: cái giây bản upload tới nơi, token nào đã trình ra, thứ tự các thứ tới, và những gì tới cùng lúc bên cạnh. Người publish không chạm được vào các trường đó, và việc gỡ phiên bản cũng không xoá được chúng.
Kẻ tấn công tối ưu hoá mọi trường chúng kiểm soát, mà trong một chuỗi cung ứng đã ký số và chứng thực thì điều đó giờ bao gồm cả các bằng chứng. Những trường bên phải chưa bao giờ thuộc quyền tối ưu hoá của chúng.
Người phòng thủ từng ở vào tình thế này rồi. Khi TLS nuốt mất phần payload, phòng thủ mạng không chấm dứt; nó dịch sang các bản ghi luồng, và hình dạng của một kết nối hoá ra mang theo lượng thông tin đáng ngạc nhiên mà payload từng mang. Phòng thủ chuỗi cung ứng thì vẫn đang đọc payload. Còn bản ghi luồng thì nằm sẵn trong một API công khai suốt bấy lâu, không cần đăng nhập, không bị giới hạn tần suất, mà tôi chưa tìm thấy ai coi nó là telemetry.
Chuyện này không xa những gì giới nghiên cứu đã nói, và đó là một phần lý do tôi tin nó. Zimmermann cùng đồng nghiệp đã đo phiên bản cấu trúc của đúng sự thật này từ năm 2019: một số ít maintainer của npm có thể với tới một phần lớn hệ sinh thái, nên thứ quyết định bán kính sát thương là tầm với của token chứ không phải chất lượng mã (USENIX Security 2019). Thứ tôi đo được là cái bóng của kết quả đó trong chiều thời gian. Lập luận cũ của Axelsson về phát hiện xâm nhập cũng đúng ở đây một cách đặc biệt mạnh: khi tỉ lệ nền của thứ bạn đi săn là rất nhỏ, việc chọn đặc trưng quan trọng hơn việc chọn thuật toán phân loại, bởi một đặc trưng có tỉ lệ nền lành tính gần bằng không sẽ làm được việc mà tinh chỉnh mô hình bao nhiêu cũng không mua nổi. Số quyền publish bị đi xuyên qua trông giống một đặc trưng như thế. Tốc độ publish thì rõ ràng là không.
Giờ tới những chỗ sẽ bị đánh trượt nếu đem đi phản biện, mà tôi thà tự viết ra còn hơn để người khác viết hộ.
Bốn mươi bảy package là mẫu do tôi chọn, và tôi chọn khi đã biết trước mình mong nhìn thấy gì, gần đúng với định nghĩa của một mẫu thiên lệch. Nó nghiêng về những dự án lớn, được chăm sóc tốt, mà đó lại chính là những nơi có công cụ phát hành kỷ luật với một token duy nhất. Một nghiên cứu trên toàn bộ registry hoàn toàn có thể lôi ra những đợt lành tính đi xuyên nhiều quyền publish mà mẫu của tôi không thể chứa. Một bản vá bảo mật được phối hợp giữa vài tổ chức trong cùng một buổi chiều, nhìn từ bên ngoài, sẽ rất giống một con worm.
Trường time cũng không phải bản ghi sạch về ý định của con người. Nó ghi cả những lần tái phát hành và những lần di trú dữ liệu của registry, chứ không chỉ các bản phát hành có chủ đích. Tôi khá chắc đợt express năm 2013 thuộc loại đó chứ không phải một người publish 29 lần trong một phút, nhưng đứng ngoài registry thì tôi không chứng minh được khác biệt ấy. Thứ gì xây trên nền này đều phải mang theo lời lưu ý đó suốt.
Và 38 trên 38 là một gợi ý về tỉ lệ nền, không phải một phép đo tỉ lệ nền. Câu nói trung thực là: trong 29.824 sự kiện, tôi không tìm được một phản ví dụ lành tính nào, một khẳng định yếu hơn và hữu ích hơn so với việc nói rằng không tồn tại phản ví dụ nào.
Cả hai script đều nằm trong repo (github.com/daihongphucecq/npm-publish-shape). collect.py lấy metadata cho bất kỳ danh sách package nào bạn đưa vào, còn analyse.py in ra mọi con số trong bài này; cả hai không dùng thư viện ngoài. Thí nghiệm tôi chưa chạy được mới là thí nghiệm giải quyết dứt điểm chuyện này: lấy một năm sự kiện từ một bản sao đầy đủ của registry hoặc từ luồng changes của CouchDB, tính số quyền publish bị đi xuyên qua mỗi phút trên toàn hệ sinh thái, rồi xem tỉ lệ lành tính có thật sự bằng không hay chỉ là nhỏ. Nếu chỉ là nhỏ, ý tưởng vẫn sống nhưng cần một ngưỡng và một ngân sách báo động giả. Nếu bằng không, bộ phát hiện chỉ là một dòng số học, và ngày 4/8 nó đã bật vào khoảng 09:36, sớm hơn cảnh báo công khai đầu tiên chừng bốn mươi phút.
Tôi bắt tay vào để xác nhận một điều mình đã viết ra rồi, và bản đầu tiên của nó sai. Cái bộ phát hiện theo tốc độ mà tôi từng rất tự tin lại chính là thứ một monorepo giẫm phải vào một ngày thứ Ba bình thường. Bản sống sót thì nhỏ hơn, lạ hơn và rẻ hơn để chạy, và tôi đã không tìm ra nó nếu không dựng cái bản thất bại kia trước.
Sources / Nguồn: - Phương pháp và mã nguồn: github.com/daihongphucecq/npm-publish-shape — 29.824 sự kiện publish, 47 package, thu thập ngày 8/9/2026 từ registry npm công khai. - Định dạng packument của npm registry (trường time mà cả bài dựa vào): 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. (Bài nằm sau tường phí của ACM Digital Library; trích ở đây vì lập luận, không phải vì đường dẫn.) - SafeDep — các con số 2.234 phiên bản / 444 package / 12 tổ chức: safedep.io - Snyk — dòng thời gian sự cố ngày 4/8/2026: snyk.io - Datadog Security Labs — cơ chế của con worm và cách nó xử lý token: securitylabs.datadoghq.com - Bài viết trước của tôi, chính là bài mà bài này đem ra kiểm chứng và sửa lại một phần: dhf.io.vn