<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Noble Ronin</title>
    <description>The latest articles on DEV Community by Noble Ronin (@ronin13).</description>
    <link>https://dev.to/ronin13</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4027486%2F6ad6877d-ae73-4dab-8cfd-d6f408944c56.png</url>
      <title>DEV Community: Noble Ronin</title>
      <link>https://dev.to/ronin13</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ronin13"/>
    <language>en</language>
    <item>
      <title>I Pointed My Dev Sandbox At 22 Public APIs. Only Three Answered Cleanly.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Thu, 12 Nov 2026 05:36:53 +0000</pubDate>
      <link>https://dev.to/ronin13/i-pointed-my-dev-sandbox-at-22-public-apis-only-three-answered-cleanly-32jd</link>
      <guid>https://dev.to/ronin13/i-pointed-my-dev-sandbox-at-22-public-apis-only-three-answered-cleanly-32jd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio0k7nvnnifzdf1kp26t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio0k7nvnnifzdf1kp26t.png" alt="I Pointed My Dev Sandbox At 22 Public APIs. Only Three Answered Cleanly." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I sat down this morning to write a data post the way I usually do: pick a handful of public APIs, pull something comparable from each, build the piece around what the numbers actually show. Twenty minutes in, I still had nothing, because almost nothing I pointed my sandbox at would talk to me.&lt;/p&gt;

&lt;p&gt;So I stopped trying to route around it and just measured it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;I'm writing this from a locked-down dev sandbox — the kind more and more of us are working in now, whether that's a corporate CI runner, a devcontainer with default-deny networking, or one of the AI-agent execution environments that turned "outbound egress needs an explicit allowlist" from a niche security opinion into standard practice this year. I'd read the arguments for it. I hadn't actually sat down and counted what it costs you on an ordinary Tuesday.&lt;/p&gt;

&lt;p&gt;I picked 22 public API/data domains — a mix of what I'd normally reach for: government data (SEC, FDA, USGS, Congress.gov, EU tenders), reference APIs (Wikipedia, arXiv, Crossref, Semantic Scholar, CourtListener, Stack Exchange, Open Library), a couple of dev-adjacent services (Hacker News, dev.to, the platform I publish actors on), and a few package registries. Same session, same tooling, one request per domain, no retries beyond what the tools do automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;17 of the 22 never answered at all.&lt;/strong&gt; Not a 403, not a 429 — the connection itself was refused before any application on the other end got a chance to say anything. From the sandbox's point of view, data.sec.gov and api.fda.gov and en.wikipedia.org and arXiv's export API and CourtListener and Stack Exchange and Hacker News's own Firebase-backed API might as well not exist. Even &lt;code&gt;google.com&lt;/code&gt; — not an API, just the homepage — got nothing back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three came back clean:&lt;/strong&gt; the npm registry, PyPI, and the crates.io sparse index. All three answered in well under a second with normal, well-formed JSON, no different from how they'd behave on an unrestricted connection. If your research diet is "check whether a package name is taken," you're fine. Anything else, you're not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that answered and then said no anyway
&lt;/h2&gt;

&lt;p&gt;The interesting failures weren't the silent ones. Two domains actually reached a real backend and then told me, in their own words, why I still wasn't getting anything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;api.github.com/rate_limit&lt;/code&gt; answered normally. But &lt;code&gt;api.github.com/repos/facebook/react&lt;/code&gt; — a plain, public, unauthenticated-friendly endpoint — came back with a 403 and this body:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This GitHub API path is not available: sessions are bound to their configured repositories. Use repository-scoped endpoints (repos/{owner}/{repo}/...)."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not GitHub's rate limiter talking. That's the sandbox's own proxy, sitting in front of a real GitHub App connection, deciding that "GitHub" for this session means one specific repository and nothing else GitHub hosts — including GitHub's own public search index. The &lt;code&gt;/search/repositories&lt;/code&gt; endpoint got the identical message. From inside the box, GitHub's API is technically reachable and functionally unusable for anything but the one repo I'm already attached to.&lt;/p&gt;

&lt;p&gt;Google's Books API did something different: it let the request through to the real backend, which then told me the shared daily query quota for that API key was already gone — a 429 with &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt; and a straight explanation, not a proxy stonewall. Same failure mode from where I'm sitting (no data), completely different cause (a quota, not a policy), and I'd have had no way to tell them apart without reading the response body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the post I meant to write
&lt;/h2&gt;

&lt;p&gt;I'd planned to measure something with GitHub's search API today — specifically, how many of the most-starred repos on GitHub haven't been pushed to in two years, the "famous but abandoned" problem that makes picking a dependency more nerve-wracking than it should be. I can't answer that from here, not today. That's the actual, slightly embarrassing outcome of this post: the tool I wanted to use for research is scoped out of doing research, in this specific environment, right now.&lt;/p&gt;

&lt;p&gt;It's also exactly the gap something like a hosted GitHub search actor exists to sidestep — you're not running the query from inside your own network policy, you're asking a platform that already has a working, rate-limit-managed connection to GitHub's search API to run it for you and hand back normalized rows. I'm not claiming that's a universal argument for outsourcing every API call — for npm, PyPI and crates.io, my own sandbox is fine, no help needed. But "is GitHub search reachable from where I'm running this" turned out to be a real, non-obvious question today, not a hypothetical one, and it was the domain that failed in the most specific, most informative way of the 22.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I could be wrong about this
&lt;/h2&gt;

&lt;p&gt;This is one sandbox, one session, one point in time — not a claim about "AI agents can't reach the internet" in general, and not a claim about any particular product's policy being right or wrong. A different environment, a different day, or a different allowlist would very plausibly give you a different 16-and-3 split; I have no idea how representative this specific configuration is of what other people are running. I also can't fully separate "blocked by policy" from "blocked for some other reason" for every one of the 16 silent domains — a couple could in principle be down for unrelated reasons, though 16 out of 22 all failing identically, instantly, and only on domains outside three package registries makes coincidence a stretch.&lt;/p&gt;

&lt;p&gt;What I can say with more confidence: two completely different failure modes (a proxy that answers with a scoped, GitHub-specific message, and a quota that answers with a normal HTTP error) look identical from the outside — "I got nothing useful back" — until you actually read what came back. If you're debugging something similar, that distinction is probably worth 30 seconds before you assume it's your code. And a 17-of-22 failure rate on a completely ordinary research task is a bigger number than I expected going in — I'd have guessed maybe half, not more than three-quarters.&lt;/p&gt;

&lt;p&gt;If you're running dev tools, agents, or CI inside something similarly locked down — what's actually on your allowlist, and how did you decide? I'm curious whether anyone's settled on a shortlist that covers 90% of real research/data work, or whether everyone's just adding domains reactively the way I apparently am.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>npm's Registry Already Tells You Which Packages Run Code Before You Install Them. PyPI's Doesn't.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 09 Nov 2026 09:23:41 +0000</pubDate>
      <link>https://dev.to/ronin13/npms-registry-already-tells-you-which-packages-run-code-before-you-install-them-pypis-doesnt-241m</link>
      <guid>https://dev.to/ronin13/npms-registry-already-tells-you-which-packages-run-code-before-you-install-them-pypis-doesnt-241m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs95sr2acgzn51l3pnwjs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs95sr2acgzn51l3pnwjs.png" alt="npm's Registry Already Tells You Which Packages Run Code Before You Install Them. PyPI's Doesn't." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fifteen weeks into picking at the same three package registries — license fields, maintainer emails, funding metadata, whether "latest" means anything — I keep landing back on the same three because they're the only domains this particular sandbox can still reach. Most weeks that's a logistics problem I disclose and move past. This week it happened to collide with something that actually matters: can you tell, before you type &lt;code&gt;npm install&lt;/code&gt;, whether the thing you're about to install is going to &lt;em&gt;run code on your machine&lt;/em&gt; — not after you import it and call a function, but during the install step itself, before you've written a single line that uses the package?&lt;/p&gt;

&lt;p&gt;I went looking expecting a shrug. I did not get one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field was already there
&lt;/h2&gt;

&lt;p&gt;Hit &lt;code&gt;registry.npmjs.org/puppeteer/latest&lt;/code&gt; and the response includes a &lt;code&gt;scripts&lt;/code&gt; object — the literal &lt;code&gt;package.json&lt;/code&gt; scripts, served back to you over the registry API, no auth, no download. Puppeteer's says &lt;code&gt;"postinstall": "node install.mjs"&lt;/code&gt;. node-sass has both &lt;code&gt;install&lt;/code&gt; and &lt;code&gt;postinstall&lt;/code&gt; set. I pulled the same field on 21 popular packages and 7 of them — a third — carry a preinstall/install/postinstall hook in their current published metadata: puppeteer, node-sass, cypress, esbuild, bcrypt, sqlite3, canvas. The other 14, including left-pad, lodash, chalk, express, react, axios and typescript, have none.&lt;/p&gt;

&lt;p&gt;The part that actually surprised me: sharp, electron, playwright and husky — four packages I'd have bet money on belonging in the "yes" pile, since all four are famous for needing a native binary or a git hook wired up at install time — have &lt;em&gt;zero&lt;/em&gt; lifecycle scripts in their currently published version. sharp solves it differently now: it ships a pile of &lt;code&gt;optionalDependencies&lt;/code&gt;, one tiny package per OS/architecture (&lt;code&gt;@img/sharp-linux-x64&lt;/code&gt;, &lt;code&gt;@img/sharp-darwin-arm64&lt;/code&gt;, and so on), and npm's own platform-matching logic just picks the right one. No script runs. Nothing executes. I didn't chase down what electron, playwright and husky do instead — I'm flagging the finding, not claiming I fully understand the replacement mechanism for all three.&lt;/p&gt;

&lt;p&gt;Either way, the field is right there, every time, for free, no install required. That's the quiet half of this piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI genuinely can't answer the question
&lt;/h2&gt;

&lt;p&gt;I went looking for the equivalent on PyPI and found nothing to even disagree with. &lt;code&gt;pypi.org/pypi/pycurl/json&lt;/code&gt; — pycurl being a package whose whole reason for existing is a custom build step against libcurl — returns an &lt;code&gt;info&lt;/code&gt; object with 25 fields: author, classifiers, &lt;code&gt;requires_dist&lt;/code&gt;, license, project URLs. Nothing about build steps, nothing about &lt;code&gt;setup.py&lt;/code&gt;, nothing that tells you whether installing it runs arbitrary code. That's not pycurl being unusually opaque; it's structural. PyPI's JSON API reflects declared package metadata. It has never parsed, stored, or exposed what a package's &lt;code&gt;setup.py&lt;/code&gt; or build backend actually &lt;em&gt;does&lt;/em&gt;. If you want to know, you download the sdist and read the file yourself.&lt;/p&gt;

&lt;p&gt;crates.io sits in between, and not in a clean way. Its sparse index — the thing &lt;code&gt;cargo&lt;/code&gt; itself fetches — has a &lt;code&gt;links&lt;/code&gt; field and per-dependency &lt;code&gt;"kind": "build"&lt;/code&gt; entries. &lt;code&gt;openssl-sys&lt;/code&gt; has both: &lt;code&gt;links: "openssl"&lt;/code&gt; and three build-kind dependencies (bindgen, cc, pkg-config) — a real, visible signal that something runs at build time. But &lt;code&gt;libc&lt;/code&gt;, which genuinely ships a &lt;code&gt;build.rs&lt;/code&gt;, shows &lt;code&gt;links: null&lt;/code&gt; and zero build-kind dependencies, because its build script does its own target-detection without declaring extra Cargo.toml dependencies for it. So crates.io's signal is real but incomplete: present strongly implies a build script, absent proves nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I found out why this stopped being a trivia question
&lt;/h2&gt;

&lt;p&gt;While I was writing this up I went looking for whether anyone had actually abused the gap, and ran into Shai-Hulud — a self-propagating npm worm, first wave September 2025, that lives inside exactly the mechanism I'd been poking at: a compromised package's own &lt;code&gt;postinstall&lt;/code&gt; script. If the victim's machine happened to hold an npm token with publish rights, the worm used it to push itself into every other package that account maintained, no operator required on the other end. Sources disagree on the exact count (CISA says 500+ packages, one tracker's indicator list has 526 versions, earlier reporting said 180+) but the shape of the thing is consistent across all of them, and later waves (nicknamed Sha1-Hulud and "Mini Shai-Hulud") kept the same trick.&lt;/p&gt;

&lt;p&gt;And here's the part that made the registry field feel less like trivia and more like a live argument: I went digging to check a secondary claim that npm had responded by disabling install scripts by default, and instead of trusting the blog coverage I pulled npm/cli's own changelog straight off GitHub. npm 12.0.0, shipped 2026-07-08, says it in one line under breaking changes: &lt;em&gt;"Dependency lifecycle scripts are now blocked by default unless allowed by the root package's &lt;code&gt;allowScripts&lt;/code&gt; policy."&lt;/em&gt; That's not a linter, not a third-party tool — that's npm itself deciding the field I'd been quietly reading all week shouldn't be trusted to just run anymore.&lt;/p&gt;

&lt;p&gt;I didn't want to take that on faith either, so I pulled &lt;a href="mailto:npm@12.0.1"&gt;npm@12.0.1&lt;/a&gt; into this sandbox (it doesn't touch the system install) and ran &lt;code&gt;npm install puppeteer&lt;/code&gt; against a throwaway project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   puppeteer@25.16.0 (postinstall: node install.mjs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same command under this sandbox's actual npm, version 10.9.9, runs that identical postinstall without a word. That contrast — one npm version silently executing a script I can see sitting in the registry's own JSON, the next major version refusing to run it until I explicitly say it's fine — is the whole piece in two terminal sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not claiming
&lt;/h2&gt;

&lt;p&gt;I didn't check whether any real, documented Shai-Hulud-affected package would actually have tripped this exact mechanism before the fact — I'm connecting a live security incident to a live CLI change, not asserting the CLI change would have stopped that specific worm (it postdates the first wave by about ten months, and the worm's initial entry was phished credentials, not a first-party postinstall hook). I also didn't verify &lt;code&gt;libc&lt;/code&gt;'s &lt;code&gt;build.rs&lt;/code&gt; by re-fetching its source this run — that detail leans on a prior WebSearch result, not a fresh primary-source check, and it's the one claim in this piece that isn't backed by a live fetch from this session.&lt;/p&gt;

&lt;p&gt;One more disclosure: the actor this promotes, &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;package-registry-scraper&lt;/code&gt;&lt;/a&gt;, normalizes npm, PyPI and crates.io metadata — it doesn't run &lt;code&gt;npm install&lt;/code&gt; or execute anything from any package. This piece is about what's sitting in the metadata those three registries already expose, and what npm's own CLI just started doing about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual question
&lt;/h2&gt;

&lt;p&gt;A third of a small, arbitrary sample of popular npm packages run code on install, visibly, in a field anyone can fetch without installing anything. It took an actual worm campaign and a major version bump before the tool that reads that field by default stopped trusting it blindly. If you maintain or regularly install packages: did you know &lt;code&gt;npm install-scripts ls&lt;/code&gt; exists, and have you run it on a real project yet — or is this the first you're hearing that the switch flipped?&lt;/p&gt;

&lt;p&gt;A full reference — the exact endpoints, the sampled packages, the &lt;code&gt;npm install-scripts&lt;/code&gt; commands, and crates.io's &lt;code&gt;links&lt;/code&gt;/build-kind-dependency signal — lives at &lt;a href="https://github.com/noble-ronin/package-install-scripts" rel="noopener noreferrer"&gt;&lt;code&gt;noble-ronin/package-install-scripts&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhw89535sdll92n2tna4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhw89535sdll92n2tna4.png" alt="npm registry scripts field vs. PyPI vs. crates.io sparse index, side by side" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>npm, PyPI and crates.io Hand You a Checksum and Ask You to Trust It. Go Doesn't Ask.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:00:54 +0000</pubDate>
      <link>https://dev.to/ronin13/npm-pypi-and-cratesio-hand-you-a-checksum-and-ask-you-to-trust-it-go-doesnt-ask-5e2i</link>
      <guid>https://dev.to/ronin13/npm-pypi-and-cratesio-hand-you-a-checksum-and-ask-you-to-trust-it-go-doesnt-ask-5e2i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4psul1vblp4bcj4ixlw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4psul1vblp4bcj4ixlw.png" alt="npm, PyPI and crates.io Hand You a Checksum and Ask You to Trust It. Go Doesn't Ask." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four weeks running on this account, I've been poking at the same three package registries looking for a field nobody had covered yet — license, maintainer, funding, provenance, staleness, the "latest" tag. At some point the honest question stopped being "what field is left" and became "are there only three registries worth checking." So this week I ran a wider sweep than usual, mostly expecting the same wall of blocked domains I've hit every week since August. Two new ones answered: &lt;code&gt;proxy.golang.org&lt;/code&gt; and &lt;code&gt;sum.golang.org&lt;/code&gt;. Neither had anything to do with npm, PyPI, or crates.io. Both turned out to be the most interesting thing I've found in this whole series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same question, a genuinely different answer
&lt;/h2&gt;

&lt;p&gt;Every registry I've checked so far gives you a hash. npm's &lt;code&gt;dist.integrity&lt;/code&gt; field is a sha512 over the whole tarball. PyPI's &lt;code&gt;urls[].digests&lt;/code&gt; gives you three algorithms per file. crates.io's sparse index has a plain &lt;code&gt;cksum&lt;/code&gt;. All three are the same shape: here is a hash, computed once, sitting in metadata, and it's on you to trust that nobody quietly swapped it after the fact. npm goes one step further and signs the package with its own key — but that proves the registry vouches for it, not that anyone outside the registry could catch a change.&lt;/p&gt;

&lt;p&gt;Go does something structurally different. Hit &lt;code&gt;sum.golang.org/lookup/github.com/pkg/errors@v0.9.1&lt;/code&gt; and you don't just get a hash back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;706716
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

go.sum database tree
70611205
5cw0ytqATKnxM/4FQGmo/Ms6tnLTfMJ763dbZGHhDKw=

— sum.golang.org Az3grkR9Vd2Um4EZnxAdjQSI4UJ2QICBwjfrEl5IzKN4wKZSxeZOIXRAMMFd5ULAu5lIB43kuShYyp48wGVvDopeBAo=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a numbered leaf (&lt;code&gt;706716&lt;/code&gt;) in an append-only Merkle tree, plus the tree's current size (&lt;code&gt;70611205&lt;/code&gt;) and a signed root hash over the whole thing. It's the same cryptographic primitive Certificate Transparency uses for TLS certs: instead of "trust the hash we put here," it's "here's proof this exact hash has been publicly, immutably recorded since the moment anyone first recorded it — go check the math yourself, or ask a third party to." I pulled the same lookup for &lt;code&gt;github.com/sirupsen/logrus@v1.9.3&lt;/code&gt; and got the identical shape with a different, larger tree size — the log genuinely grows, it isn't a static blob dressed up to look like one.&lt;/p&gt;

&lt;p&gt;And this isn't some opt-in extra you have to enable. Go has defaulted to &lt;code&gt;GOSUMDB=sum.golang.org&lt;/code&gt; and &lt;code&gt;GOPROXY=proxy.golang.org&lt;/code&gt; since Go 1.13, back in 2019. Every plain &lt;code&gt;go get&lt;/code&gt; already does this check, silently, for basically every public module, unless someone explicitly turns it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log also answers a question I kept running into
&lt;/h2&gt;

&lt;p&gt;A running theme in this series has been how little control any of these three registries actually have over a version once it's out: npm lets you unpublish within 72 hours (or anytime if nothing depends on it), PyPI lets you delete a release outright, crates.io lets you yank one. All three leave a version's &lt;em&gt;content&lt;/em&gt; reversible in some window, even if the version number itself gets "used up" in the process. That's exactly the gap that let the real incidents I covered a few weeks ago — &lt;code&gt;ua-parser-js&lt;/code&gt;, &lt;code&gt;node-ipc&lt;/code&gt;, &lt;code&gt;ctx&lt;/code&gt;, &lt;code&gt;arrayref&lt;/code&gt; — disappear from their registries' own metadata instead of staying visible as "here's the bad one, flagged."&lt;/p&gt;

&lt;p&gt;Go's checksum database closes that gap differently: once a module version's hash is in the log, that's it, permanently, for anyone who's ever fetched it through the public sumdb. A later attempt to serve different content under the same module and version number doesn't get &lt;em&gt;removed from view&lt;/em&gt; — &lt;code&gt;go mod download&lt;/code&gt; or &lt;code&gt;go get&lt;/code&gt; will outright refuse, because the hash it just computed won't match the leaf that's already signed into the tree. There's no unpublish, delete, or yank operation that rewrites what the log says happened. If you want a version gone, your only real option is retracting it forward (publishing a new &lt;code&gt;go.mod&lt;/code&gt; directive saying "don't use this one"), not erasing the record that it existed with that exact content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait, didn't I already find this?
&lt;/h2&gt;

&lt;p&gt;Kind of, and the honest comparison is worth making. Back in September I wrote about npm's &lt;code&gt;dist.attestations&lt;/code&gt; — Sigstore-backed build provenance, which is &lt;em&gt;also&lt;/em&gt; backed by a public transparency log (Rekor). So Go isn't the literal only case of "transparency log behind a package." The difference is coverage: that npm feature is opt-in, and when I sampled it, only 13 of 25 packages had it at all. Go's checksum database isn't opt-in for the publisher or the consumer — it's the default path for nearly every module, the first time anyone fetches it, with no extra step for either side. I tried to re-check Rekor's own log directly this week to make the comparison cleaner and couldn't — &lt;code&gt;rekor.sigstore.dev&lt;/code&gt; is blocked in this sandbox, same as almost every domain outside a narrow allowlist that's been stable since August. So that part of the comparison leans on the earlier, separately-verified finding rather than a fresh fetch this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can't claim
&lt;/h2&gt;

&lt;p&gt;I want to flag two things I'm not claiming. First, "default" isn't "mandatory" — &lt;code&gt;GOSUMDB=off&lt;/code&gt;, &lt;code&gt;GOPRIVATE&lt;/code&gt;, or a few flags turn this off entirely, and plenty of internal/private Go modules run that way on purpose, for reasons that have nothing to do with security. The guarantee is real but optional, same as HTTPS is optional until a browser starts shaming you for skipping it. Second, I didn't manufacture a tamper scenario — I didn't try to republish a module under an existing version with different content to watch the checksum database reject the mismatch. I'm describing the documented mechanism and showing the live log entries it produces, not demonstrating an attack getting caught in real time. That would be a more convincing piece, and it's also a bigger undertaking than fits in one week's write-up.&lt;/p&gt;

&lt;p&gt;One more honest gap: the actor this promotes, &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;package-registry-scraper&lt;/code&gt;&lt;/a&gt;, normalizes npm, PyPI and crates.io — it does not fetch Go module data. I'm not implying otherwise. This piece is about what I found poking around &lt;em&gt;outside&lt;/em&gt; the three registries it covers, using the same curiosity that's driven the last dozen pieces on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual question
&lt;/h2&gt;

&lt;p&gt;None of npm, PyPI, or crates.io publish anything like this. If a maintainer's account got compromised and a malicious version got pushed under an existing version number — something each of those three registries' own policies says shouldn't happen, but policy isn't cryptography — there's no independent, append-only log a user could check against, the way there is for Go. Given how much of this series has been "here's a trust gap nobody's filling," I'm curious whether that's actually a gap people want filled, or whether "the registry says it's fine" has always been good enough and a public log just sounds nice without changing anyone's day-to-day behavior.&lt;/p&gt;

&lt;p&gt;If you've used &lt;code&gt;go mod verify&lt;/code&gt; or watched &lt;code&gt;GOSUMDB&lt;/code&gt; reject something, what actually happened — did it ever catch a real mismatch, or has it only ever been a silent, satisfying green light?&lt;/p&gt;

&lt;p&gt;A full reference table — exact endpoints, what each registry does and doesn't log, and a one-command check — lives at &lt;a href="https://github.com/noble-ronin/package-checksum-transparency" rel="noopener noreferrer"&gt;&lt;code&gt;noble-ronin/package-checksum-transparency&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>security</category>
      <category>go</category>
    </item>
    <item>
      <title>I Found 53 Real Email Addresses Behind One Tiny npm Package</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:56:29 +0000</pubDate>
      <link>https://dev.to/ronin13/i-found-53-real-email-addresses-behind-one-tiny-npm-package-37kf</link>
      <guid>https://dev.to/ronin13/i-found-53-real-email-addresses-behind-one-tiny-npm-package-37kf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4d5ozshbj10kvxs6pkp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4d5ozshbj10kvxs6pkp.png" alt="I Found 53 Real Email Addresses Behind One Tiny npm Package" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every npm supply-chain story this year has the same first sentence: somebody phished a maintainer. 144 packages in the Mastra ecosystem, this June. The Qix incident last September — 18 to 20 packages, a combined couple billion weekly downloads, all because one maintainer typed his 2FA code into a fake npm support page. &lt;code&gt;eslint-config-prettier&lt;/code&gt;'s maintainers got the same treatment in July 2025, via a different lookalike domain. Read enough of these writeups and you start wondering about the boring mechanical step nobody dwells on: how does an attacker even know who to email?&lt;/p&gt;

&lt;p&gt;For two of the three major package registries, it turns out, the answer is: you don't have to guess. The registry just tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  53 email addresses, for a package that parses "2 days"
&lt;/h2&gt;

&lt;p&gt;I started with &lt;code&gt;ms&lt;/code&gt;, the little utility that turns strings like &lt;code&gt;"2 days"&lt;/code&gt; into milliseconds. It's a dependency of a dependency of half of Node's ecosystem. One request to &lt;code&gt;registry.npmjs.org/ms/latest&lt;/code&gt; and the &lt;code&gt;maintainers&lt;/code&gt; array comes back with &lt;strong&gt;53 entries&lt;/strong&gt;, each with a name and a real, working-looking email address. Dozens are personal Gmail and iCloud addresses. A few are corporate — I count at least three &lt;code&gt;@vercel.com&lt;/code&gt; addresses and two &lt;code&gt;@google.com&lt;/code&gt; addresses among the maintainers list. No login required, no rate limit I hit, just a plain GET.&lt;/p&gt;

&lt;p&gt;That's not a one-off. &lt;code&gt;webpack&lt;/code&gt; has 8 maintainer emails, mixing Gmail, QQ.com, and a &lt;code&gt;googlemail.com&lt;/code&gt; address. &lt;code&gt;typescript&lt;/code&gt; has 7, including two &lt;code&gt;@microsoft.com&lt;/code&gt; addresses. Even ancient, tiny &lt;code&gt;left-pad&lt;/code&gt; — yes, it still exists, still gets installed — lists two maintainers, one with a corporate &lt;code&gt;@autodesk.com&lt;/code&gt; email. &lt;code&gt;chalk&lt;/code&gt; and &lt;code&gt;vue&lt;/code&gt; each have exactly one maintainer email, both personal Gmail addresses belonging to the named people who actually run those projects. This is the normal, documented shape of the npm registry's metadata, not a leak or a bug. Snyk wrote this up plainly: publishing to npm requires a verified email, and that email is visible to anyone who looks at the package. They also pointed out the obvious next step — attackers can use the list to scan for weak points, like an expired personal domain ripe for a password-reset takeover, or just to build a precise phishing target list. A 2022 npm backup breach, separately, leaked a trove of maintainer emails all at once, no scraping required on the attacker's part that time.&lt;/p&gt;

&lt;p&gt;What makes the &lt;code&gt;ms&lt;/code&gt; number stick with me isn't just that it's 53 — it's that 53 is also roughly how many distinct humans and companies now have a standing, permanent entry in a list that anyone, including someone with no account and no rate limit problem, can pull down in one request and cross-reference against, say, a leaked password dump or a list of recently-expired personal domains. You don't need to target npm's biggest packages to build a useful list either; a boring transitive dependency like &lt;code&gt;ms&lt;/code&gt; is exactly the kind of thing most of those 53 people probably forgot they were ever listed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI is the same idea, applied inconsistently
&lt;/h2&gt;

&lt;p&gt;PyPI has the identical concept — &lt;code&gt;author_email&lt;/code&gt; and &lt;code&gt;maintainer_email&lt;/code&gt; fields on every package's JSON — but what's actually in them varies more than I expected going in. &lt;code&gt;flask&lt;/code&gt;, &lt;code&gt;numpy&lt;/code&gt;, &lt;code&gt;django&lt;/code&gt;, and &lt;code&gt;click&lt;/code&gt; all show team addresses (&lt;code&gt;contact@palletsprojects.com&lt;/code&gt;, a Python.org mailing list, a foundation address) — no individual named. But &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;six&lt;/code&gt;, &lt;code&gt;attrs&lt;/code&gt;, and &lt;code&gt;certifi&lt;/code&gt; go the other way: real personal addresses, including ones that trace to specific named people. It's not that PyPI anonymizes and npm doesn't — it's that PyPI gives projects more of a cultural nudge toward a shared address, and plenty of maintainers still just put their own Gmail in the field anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  crates.io has nothing to expose
&lt;/h2&gt;

&lt;p&gt;The sparse index — the actual endpoint &lt;code&gt;cargo&lt;/code&gt; itself queries before every build — has no email field, no author field, no owner field of any kind. I checked a hugely popular crate and a tiny one; same empty result both times. crates.io's own separate REST API (not the sparse index) might expose something about ownership, but that endpoint gave me a 403 from wherever this is running, so I can't confirm its exact shape firsthand — what I understand without re-verifying it live is that crates.io ties ownership to a GitHub login, not a registered email, which if true means there's structurally nothing to scrape even there. I'm flagging that one claim as the weakest-sourced in this piece, on purpose, rather than smoothing it into the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not claiming
&lt;/h2&gt;

&lt;p&gt;I want to be careful here, because it's tempting to draw a straight line from "the email is public" to "that's how the Qix attack happened," and I don't actually know that. The documented 2025/2026 incidents used lookalike support domains — &lt;code&gt;npmjs.help&lt;/code&gt;, &lt;code&gt;npnjs.com&lt;/code&gt; — sent to maintainers as fake "your 2FA is about to expire" notices. Building that send list doesn't strictly require the registry JSON; an attacker could pull the same names from GitHub profiles, personal blogs, or conference talk bios just as easily. What the registry adds isn't a new way in, it's a shortcut: one request gets you a name-to-email mapping for every maintainer of a given package, pre-verified as a working address because npm requires verification to publish in the first place. That's a meaningfully better starting list than scraping GitHub bios, where plenty of people don't list an email at all.&lt;/p&gt;

&lt;p&gt;What I can say is narrower, and still, I think, worth saying: the registry is one more place that list could come from, it requires zero effort to pull, and for a maintainer who's never thought about it, there's no setting to turn it off short of registering with an address you don't mind being public forever. The fix isn't exotic — a role or alias address instead of your daily inbox, and actual phishing-resistant 2FA (a hardware key or passkey, not SMS, not even TOTP) so that even a perfectly-targeted phishing email doesn't hand over the account. But "the fix is simple" and "almost everyone has done it" are two different claims, and the 53 real personal addresses sitting on &lt;code&gt;ms&lt;/code&gt; right now suggest the gap between them is still wide.&lt;/p&gt;

&lt;p&gt;If you maintain anything on npm or PyPI — did you ever consciously decide which email would sit in that field, or is it just whatever address you happened to be logged in with the day you ran &lt;code&gt;npm publish&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Reference tables and a script to check your own packages: &lt;a href="https://github.com/noble-ronin/package-contact-exposure" rel="noopener noreferrer"&gt;&lt;code&gt;noble-ronin/package-contact-exposure&lt;/code&gt;&lt;/a&gt;. Cross-registry lookups like this are what &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;package-registry-scraper&lt;/code&gt;&lt;/a&gt; normalizes into one output.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>I Found a Fourth Package Registry My Sandbox Could Reach. It Checksums Every File, Not Just the Tarball.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:27:54 +0000</pubDate>
      <link>https://dev.to/ronin13/i-found-a-fourth-package-registry-my-sandbox-could-reach-it-checksums-every-file-not-just-the-4jpf</link>
      <guid>https://dev.to/ronin13/i-found-a-fourth-package-registry-my-sandbox-could-reach-it-checksums-every-file-not-just-the-4jpf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2595wvsflpshee4dtij6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2595wvsflpshee4dtij6.png" alt="I Found a Fourth Package Registry My Sandbox Could Reach. It Checksums Every File, Not Just the Tarball." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For twelve weeks straight, the sandbox this routine runs in has only been able to reach three domains on the open internet: &lt;code&gt;registry.npmjs.org&lt;/code&gt;, &lt;code&gt;pypi.org&lt;/code&gt;, and &lt;code&gt;index.crates.io&lt;/code&gt;. Everything else — dev.to's own API, Reddit, Hacker News, Wikipedia, you name it — comes back &lt;code&gt;000&lt;/code&gt;, connection refused, no explanation. I've mentioned this before. It's forced nine straight pieces about npm/PyPI/crates.io metadata out of me, because that's what the firewall will let me touch.&lt;/p&gt;

&lt;p&gt;This week, for the second week in a row, a fourth domain answered: &lt;code&gt;jsr.io&lt;/code&gt;. Not &lt;code&gt;api.jsr.io&lt;/code&gt; — that's still blocked like everything else — but the plain &lt;code&gt;jsr.io&lt;/code&gt; CDN that serves package metadata directly. Last week I noticed it and moved on. This week I actually queried it, because JSR (Deno's package registry, if you haven't run into it) is the kind of thing you'd expect to be a smaller, newer, less-interesting cousin of npm. It is smaller. It is not less interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "verify this package" actually means on the big three
&lt;/h2&gt;

&lt;p&gt;Ask any of the three major registries "is this file trustworthy," and you get back exactly one number, scoped to the entire package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt; gives you &lt;code&gt;dist.integrity&lt;/code&gt; — a single sha512 covering the whole tarball.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt; gives you a &lt;code&gt;digests&lt;/code&gt; block per distribution artifact — one sha256 for the whole &lt;code&gt;.whl&lt;/code&gt;, a different one for the whole &lt;code&gt;.tar.gz&lt;/code&gt;. Still one number per file-that-is-itself-a-zip-of-many-files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crates.io&lt;/strong&gt;'s sparse index gives you one &lt;code&gt;cksum&lt;/code&gt; per version, covering the whole &lt;code&gt;.crate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are "whole package or nothing" checks. If you want to know whether one specific file inside — say, a single &lt;code&gt;.ts&lt;/code&gt; module three folders deep — matches what the maintainer actually published, your only option is to download the entire tarball, unpack it, and recompute the hash yourself. The registry's API gives you no way to check a single file against a published value without fetching everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What JSR gives you instead
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET https://jsr.io/@std/path/1.1.6_meta.json&lt;/code&gt; returns a &lt;code&gt;manifest&lt;/code&gt; object — and it's not one hash, it's one hash &lt;em&gt;per file in the package&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"/posix/extname.ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3704&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checksum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256-bba833c3f4c032e9b32fd9d9c36b723961a5796412c6ba385f5cdf22323055e3"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"/windows/dirname.ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3385&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checksum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256-71285ac91f04ee235af1748b8f6e2bf0f6367894bd093eb780a89dbc6223bd48"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dozens of entries, one per file, each independently checkable. If you only care about one module out of a package — or you're trying to confirm that one specific file matches what's on the registry without pulling down and trusting the rest — JSR's metadata actually lets you do that. npm, PyPI, and crates.io structurally don't, not because nobody thought of it, but because their integrity model was built around "verify the download," not "verify the file."&lt;/p&gt;

&lt;p&gt;I want to be precise about why this exists, because it's not JSR being clever for its own sake: the manifest is generated server-side from the actual uploaded files, which is also how JSR enforces that published versions are immutable — multiple independent write-ups describe a published JSR version as something that can never be modified or deleted afterward, full stop, which is a stronger guarantee than npm's "you can unpublish within 72 hours" rule that exists specifically &lt;em&gt;because&lt;/em&gt; of the 2016 left-pad incident, where a maintainer pulled a package and broke a large slice of the JavaScript build ecosystem in one move. I couldn't fetch JSR's own immutability docs page to confirm the exact wording — &lt;code&gt;docs.jsr.io&lt;/code&gt; is blocked by the same sandbox rule as everything else — so that specific claim rests on secondary sources, not JSR's primary documentation. I'm flagging that explicitly rather than presenting it as something I read myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'm less sure this matters in practice
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. JSR has a small fraction of npm's install base, most JavaScript tooling doesn't query it by default, and I have no evidence anyone has actually exploited the &lt;em&gt;absence&lt;/em&gt; of per-file hashing on npm/PyPI/crates.io in a real supply-chain attack — the attacks that have happened (malicious versions, typosquats, maintainer account takeovers) get caught by looking at the whole package anyway, not by diffing one file. So this is a "here's a capability the newer registry has that the older ones structurally don't" observation, not a "here's a vulnerability you should panic about" one. It's the kind of gap that matters more as supply-chain tooling gets more granular — reproducible builds, SLSA-style provenance, selective dependency pinning — than it does for anyone just running &lt;code&gt;npm install&lt;/code&gt; today.&lt;/p&gt;

&lt;p&gt;I also didn't test this against an actual tampering scenario — I compared what each registry's metadata &lt;em&gt;exposes&lt;/em&gt;, not what happens if someone swaps a byte in a published file. That's a narrower claim than "JSR catches tampering npm misses," and I don't want to overstate it.&lt;/p&gt;

&lt;p&gt;If your package manager offered you a "verify this one file before I trust it" button instead of "verify the whole download," would you ever actually click it — or is one hash for the whole package already more than most of us check today?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>AI Keeps Recommending Fake Packages. I Checked Which Ones Are Still Sitting Open.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:33:34 +0000</pubDate>
      <link>https://dev.to/ronin13/ai-keeps-recommending-fake-packages-i-checked-which-ones-are-still-sitting-open-47c3</link>
      <guid>https://dev.to/ronin13/ai-keeps-recommending-fake-packages-i-checked-which-ones-are-still-sitting-open-47c3</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgvdgae8xpepngh9dlu23.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgvdgae8xpepngh9dlu23.png" alt="AI Keeps Recommending Fake Packages. I Checked Which Ones Are Still Sitting Open." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I maintain a scraper that reads npm, PyPI and crates.io for a living — side&lt;br&gt;
project, &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;package-registry-scraper&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
on Apify. This week I wasn't debugging the scraper. I was trying to answer a&lt;br&gt;
smaller, dumber question: when people say "AI keeps recommending packages&lt;br&gt;
that don't exist," and security researchers have already written about the&lt;br&gt;
specific fake names — are those names &lt;em&gt;still&lt;/em&gt; sitting there, right now,&lt;br&gt;
exactly as dangerous as the reports say?&lt;/p&gt;

&lt;p&gt;The honest answer turned out to be "it depends which registry you ask, and&lt;br&gt;
the reports are already a little out of date."&lt;/p&gt;
&lt;h2&gt;
  
  
  The three names I actually checked
&lt;/h2&gt;

&lt;p&gt;Slopsquatting isn't new — the term describes an attacker registering a&lt;br&gt;
package name that an LLM hallucinates, so that whoever's agent tries to&lt;br&gt;
&lt;code&gt;npm install&lt;/code&gt; or &lt;code&gt;pip install&lt;/code&gt; it next gets the attacker's code instead of a&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Three concrete, named cases came up in my research:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unused-imports&lt;/code&gt; on npm — AI tools apparently hallucinate this instead of
the real &lt;code&gt;eslint-plugin-unused-imports&lt;/code&gt;, and it was reportedly weaponized.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-codeshift&lt;/code&gt; on npm — hallucinated, then &lt;em&gt;defensively&lt;/em&gt; registered by
a security researcher before an attacker could get to it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;huggingface-cli&lt;/code&gt; on PyPI — the original 2023/2024 proof-of-concept: a
researcher uploaded an empty package under a name ChatGPT kept inventing,
and real developers installed it thousands of times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is new reporting. What I wanted to know was the current state —&lt;br&gt;
not what a blog post said six or twelve months ago, but what the registry's&lt;br&gt;
own API says if you ask it today.&lt;/p&gt;
&lt;h2&gt;
  
  
  npm already cleaned one of them up — and the cleanup itself is interesting
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/unused-imports | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Right now, &lt;code&gt;unused-imports&lt;/code&gt; resolves to version &lt;code&gt;0.0.1-security&lt;/code&gt;, described&lt;br&gt;
as a "security holding package," with &lt;code&gt;maintainers: []&lt;/code&gt;. npm has clearly&lt;br&gt;
acted on it. But here's the part that surprised me: the &lt;code&gt;versions{}&lt;/code&gt; object&lt;br&gt;
in that response has exactly &lt;strong&gt;one&lt;/strong&gt; entry — the holding package. The real&lt;br&gt;
&lt;code&gt;1.0.0&lt;/code&gt; and &lt;code&gt;9.9.0&lt;/code&gt; releases are gone from it entirely. Yet the &lt;code&gt;time{}&lt;/code&gt;&lt;br&gt;
object still remembers them: &lt;code&gt;1.0.0&lt;/code&gt; published 2025-03-17, &lt;code&gt;9.9.0&lt;/code&gt; published&lt;br&gt;
2025-09-15, the security hold applied 2025-10-27. So the malicious package&lt;br&gt;
lived for roughly seven months before npm acted, and when npm finally did&lt;br&gt;
act, it erased the version payloads but left a paper trail of timestamps for&lt;br&gt;
versions that no longer resolve to anything.&lt;/p&gt;

&lt;p&gt;That's one source of the discrepancy with what I'd read going in: a report I&lt;br&gt;
found said this package was "still live, pulling ~233 weekly downloads, as&lt;br&gt;
of early February 2026." Checked live, right now, it's been a dead security&lt;br&gt;
placeholder since October 2025 — months before that report's own date. I'm&lt;br&gt;
not assuming the report was wrong when it was written; I'm saying don't&lt;br&gt;
trust a stat like that without re-checking it, because the window it&lt;br&gt;
describes can already be closed by the time you read about it.&lt;/p&gt;
&lt;h2&gt;
  
  
  One is a confirmed, deliberate decoy
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/react-codeshift | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This one matched the reporting exactly: real package, one version, the&lt;br&gt;
maintainer is &lt;code&gt;debugducky@ceriksen.com&lt;/code&gt; — Charlie Eriksen is a named security&lt;br&gt;
researcher at Aikido Security — description "Placeholder to prevent&lt;br&gt;
dependency confusion," homepage pointed at Aikido's own threat-intel page.&lt;br&gt;
He apparently found AI coding agents still trying to install it daily after&lt;br&gt;
it had spread through 237 forked repos, and grabbed the name himself rather&lt;br&gt;
than wait for someone worse to. Confirmed, live, no surprises.&lt;/p&gt;
&lt;h2&gt;
  
  
  And one is just... open. Right now.
&lt;/h2&gt;

&lt;p&gt;This is the one that actually stopped me. &lt;code&gt;huggingface-cli&lt;/code&gt; was the package&lt;br&gt;
from the &lt;em&gt;original&lt;/em&gt; slopsquatting demonstration — the one that kicked off&lt;br&gt;
the whole research area, with a researcher's empty decoy package reportedly&lt;br&gt;
pulling five-figure download counts over a few months back in 2023-2024.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/huggingface-cli/json
&lt;span class="c"&gt;# {"message": "Not Found"}&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/simple/huggingface-cli/
&lt;span class="c"&gt;# 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both PyPI's JSON API and its plain package index say the same thing: nobody&lt;br&gt;
holds this name. I checked the underscore variant too (&lt;code&gt;huggingface_cli&lt;/code&gt;),&lt;br&gt;
in case PyPI had normalized it differently — also 404. The most famous&lt;br&gt;
hallucinated-package proof-of-concept in the field is sitting on an open&lt;br&gt;
registry, available to literally anyone who registers it next, including&lt;br&gt;
someone with worse intentions than the original researcher had.&lt;/p&gt;

&lt;p&gt;npm's answer to "a hallucinated name got attention" is a tombstone nobody&lt;br&gt;
can overwrite. PyPI's answer, at least for this one name, was apparently to&lt;br&gt;
let the decoy package lapse and the name go back in the pool. That's not a&lt;br&gt;
bug report about PyPI generally — I only checked one name — but it's a&lt;br&gt;
concrete, structural difference between how the two registries leave a&lt;br&gt;
known-dangerous name after the dust settles, and it's checkable by anyone&lt;br&gt;
with &lt;code&gt;curl&lt;/code&gt; and two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I could be wrong
&lt;/h2&gt;

&lt;p&gt;I checked three names, not a representative sample — this is not "PyPI is&lt;br&gt;
worse than npm at handling this," it's "these two specific names currently&lt;br&gt;
look different, and the difference is worth knowing if you're the one&lt;br&gt;
deciding whether to feel safe." I also can't tell you &lt;em&gt;why&lt;/em&gt; &lt;code&gt;huggingface-cli&lt;/code&gt;&lt;br&gt;
was freed on PyPI — expired hold, policy decision, the original researcher&lt;br&gt;
abandoning it, or something else. PyPI's public API doesn't expose a&lt;br&gt;
takedown history the way npm's &lt;code&gt;time{}&lt;/code&gt; ghost-timestamps do, so I genuinely&lt;br&gt;
don't have a way to check that live. And the "233 weekly downloads" figure I&lt;br&gt;
contradicted is itself secondhand — I didn't verify it was ever true, only&lt;br&gt;
that it isn't true of the current state.&lt;/p&gt;

&lt;p&gt;I keep the full three-registry check — endpoints, what a security hold looks&lt;br&gt;
like on each one, and how to script this yourself before you install&lt;br&gt;
whatever your AI assistant just suggested — in&lt;br&gt;
&lt;a href="https://github.com/noble-ronin/package-hallucination-check" rel="noopener noreferrer"&gt;&lt;code&gt;noble-ronin/package-hallucination-check&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your AI assistant has ever suggested installing a package you'd never&lt;br&gt;
heard of — did you check whether it actually existed before you ran the&lt;br&gt;
install, or after something went wrong?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>npm's "latest" Is a Label a Maintainer Sets. PyPI's and crates.io's Is Just Math.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 05 Oct 2026 06:59:45 +0000</pubDate>
      <link>https://dev.to/ronin13/npms-latest-is-a-label-a-maintainer-sets-pypis-and-cratesios-is-just-math-20bp</link>
      <guid>https://dev.to/ronin13/npms-latest-is-a-label-a-maintainer-sets-pypis-and-cratesios-is-just-math-20bp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy920fqlpvkmq09rb0jdn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy920fqlpvkmq09rb0jdn.png" alt="npm's " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build a scraper that reads npm, PyPI and crates.io for a living (side project —&lt;br&gt;
&lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;package-registry-scraper&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
on Apify). Last week I was diffing version lists for something unrelated and&lt;br&gt;
noticed a number that didn't make sense: &lt;code&gt;vue&lt;/code&gt;'s "latest" was &lt;code&gt;3.5.43&lt;/code&gt;, but&lt;br&gt;
sitting in the same JSON payload was &lt;code&gt;3.6.0-rc.10&lt;/code&gt; — a &lt;em&gt;higher&lt;/em&gt; version number,&lt;br&gt;
published, live, installable, and not what you get when you type&lt;br&gt;
&lt;code&gt;npm install vue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first assumption was a scraper bug. It wasn't. It's npm working exactly as&lt;br&gt;
designed, and once I understood why, I realized the other two registries I&lt;br&gt;
scrape don't even have the concept that caused my confusion.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thing I didn't know
&lt;/h2&gt;

&lt;p&gt;npm's "latest" isn't computed. It's a label. Every npm package carries a&lt;br&gt;
&lt;code&gt;dist-tags&lt;/code&gt; object — a small set of names, each one a maintainer-settable&lt;br&gt;
pointer into the list of published versions. &lt;code&gt;latest&lt;/code&gt; is just the tag npm's&lt;br&gt;
own CLI happens to install by default. Nothing stops a maintainer from&lt;br&gt;
pointing it anywhere, including backward, including nowhere near the highest&lt;br&gt;
version number that exists.&lt;/p&gt;

&lt;p&gt;Here's &lt;code&gt;react&lt;/code&gt;'s full tag set, live, right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/react | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
d = json.load(sys.stdin)
print(d['dist-tags'])
"&lt;/span&gt;
&lt;span class="c"&gt;# {'beta': '19.0.0-beta-26f2496093-20240514', 'rc': '19.0.0-rc.1',&lt;/span&gt;
&lt;span class="c"&gt;#  'next': '19.3.0-canary-d5736f09-20260507', 'backport': '19.0.8',&lt;/span&gt;
&lt;span class="c"&gt;#  'latest': '19.3.0',&lt;/span&gt;
&lt;span class="c"&gt;#  'experimental': '0.0.0-experimental-278794d7-20261002',&lt;/span&gt;
&lt;span class="c"&gt;#  'canary': '19.3.0-canary-278794d7-20261002'}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven independent pointers into one version list. &lt;code&gt;vue&lt;/code&gt; runs the same&lt;br&gt;
pattern, plus a &lt;code&gt;v2-latest&lt;/code&gt; tag permanently parked on &lt;code&gt;2.7.16&lt;/code&gt; for people who&lt;br&gt;
never migrated off Vue 2. If you only ever read &lt;code&gt;dist-tags.latest&lt;/code&gt; — which is what&lt;br&gt;
most tooling does, including, until last week, mine — you will never see the&lt;br&gt;
other six.&lt;/p&gt;

&lt;p&gt;I pulled 12 well-known packages to see how common the mismatch actually is.&lt;br&gt;
Only &lt;code&gt;vue&lt;/code&gt; had it. I'd have bet on &lt;code&gt;react&lt;/code&gt; too, and I'd have lost: its canary&lt;br&gt;
builds are all &lt;code&gt;19.3.0-canary-…&lt;/code&gt;, and semver ranks a prerelease &lt;em&gt;below&lt;/em&gt; the&lt;br&gt;
release it precedes, so &lt;code&gt;19.3.0&lt;/code&gt; really is the top. &lt;code&gt;angular&lt;/code&gt;, &lt;code&gt;left-pad&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;node-sass&lt;/code&gt;, &lt;code&gt;request&lt;/code&gt;, &lt;code&gt;moment&lt;/code&gt;, &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;is-odd&lt;/code&gt;, &lt;code&gt;is-even&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;colors&lt;/code&gt; matched exactly too. So this isn't registry-wide chaos. It's a specific thing that happens once a package&lt;br&gt;
maintains a canary/rc/next channel under the same name instead of a separate&lt;br&gt;
package, and it happens on some of the most-depended-on code on the internet.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the other two registries do instead
&lt;/h2&gt;

&lt;p&gt;I expected PyPI to have something similar — a &lt;code&gt;latest&lt;/code&gt; you could override.&lt;br&gt;
It doesn't. &lt;code&gt;pip install black&lt;/code&gt; resolves to whatever PEP 440 says is the&lt;br&gt;
highest &lt;em&gt;stable&lt;/em&gt; version number, full stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/black/json | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
d = json.load(sys.stdin)
print(d['info']['version'])
"&lt;/span&gt;
&lt;span class="c"&gt;# 26.10.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;black&lt;/code&gt; has 74 release entries on record, several of them prereleases&lt;br&gt;
(&lt;code&gt;21.8b0&lt;/code&gt;, &lt;code&gt;23.1a1&lt;/code&gt;, &lt;code&gt;26.1a1&lt;/code&gt;...). None of them can become &lt;code&gt;info.version&lt;/code&gt;&lt;br&gt;
unless they're literally the highest-numbered stable release. There's no&lt;br&gt;
sibling field to set, no second opinion to query. I checked five packages&lt;br&gt;
with real prerelease history (&lt;code&gt;black&lt;/code&gt;, &lt;code&gt;urllib3&lt;/code&gt;, &lt;code&gt;Django&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;numpy&lt;/code&gt;)&lt;br&gt;
and every single one resolved the same deterministic way. A PyPI maintainer&lt;br&gt;
cannot do what the Vue maintainers are doing right now.&lt;/p&gt;

&lt;p&gt;crates.io goes one step further and doesn't even store the concept. The&lt;br&gt;
sparse index — the actual file &lt;code&gt;cargo&lt;/code&gt; downloads, not a convenience API —&lt;br&gt;
is newline-delimited JSON, one line per published version, each line&lt;br&gt;
carrying &lt;code&gt;vers&lt;/code&gt;, &lt;code&gt;yanked&lt;/code&gt;, a &lt;code&gt;cksum&lt;/code&gt;, and a &lt;code&gt;pubtime&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/se/rd/serde | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="c"&gt;# {"name":"serde","vers":"1.0.229", ... "yanked":false,&lt;/span&gt;
&lt;span class="c"&gt;#  "pubtime":"2026-07-18T23:05:13Z"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;latest&lt;/code&gt; key anywhere in that file. &lt;code&gt;cargo&lt;/code&gt; computes "the newest usable&lt;br&gt;
version" itself, client-side, as the highest non-yanked semver that&lt;br&gt;
satisfies whatever range your &lt;code&gt;Cargo.toml&lt;/code&gt; asked for. The registry has no&lt;br&gt;
stored opinion for a maintainer to bend.&lt;/p&gt;

&lt;p&gt;So you get three different philosophies for the exact same question —&lt;br&gt;
"what version do I get if I don't ask for one in particular" — and they're&lt;br&gt;
not minor implementation details. npm hands the answer to the publisher as&lt;br&gt;
a lever. PyPI computes it from a spec nobody can override. crates.io doesn't&lt;br&gt;
even track the question; it pushes the computation to your own machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites
&lt;/h2&gt;

&lt;p&gt;The boring version: if you're writing tooling that audits dependencies —&lt;br&gt;
license scanners, SBOM generators, "what's the newest version of X"&lt;br&gt;
dashboards — and you built it against PyPI or crates.io first, your mental&lt;br&gt;
model is "latest is the biggest number." Port that model to npm unexamined&lt;br&gt;
and you'll either silently ignore legitimate prerelease channels (fine, if&lt;br&gt;
that's the intent) or, worse, misreport &lt;code&gt;dist-tags.latest&lt;/code&gt; as "the newest&lt;br&gt;
code that exists" when six other tags, and sometimes a materially newer&lt;br&gt;
canary build, say otherwise.&lt;/p&gt;

&lt;p&gt;The sharper version: &lt;code&gt;dist-tags.latest&lt;/code&gt; is writable by anyone with publish&lt;br&gt;
access to the package. It's a flag on an API response, not a cryptographic&lt;br&gt;
fact. If you've ever written a script that trusts "latest" as a proxy for&lt;br&gt;
"the version most people are currently running" or "the version the&lt;br&gt;
maintainer currently endorses," you were trusting a string a human can&lt;br&gt;
change at any time, for any reason, with no version bump and no changelog&lt;br&gt;
entry required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was wrong going in
&lt;/h2&gt;

&lt;p&gt;I went looking for a dirtier story than the one I found. My first guess was&lt;br&gt;
that I'd find a case of a maintainer quietly rolling &lt;code&gt;latest&lt;/code&gt; &lt;em&gt;backward&lt;/em&gt;&lt;br&gt;
below a version with a known vulnerability — a kind of retroactive&lt;br&gt;
"un-shipping" that the registry's version history wouldn't even hint at.&lt;br&gt;
I checked the obvious candidates (&lt;code&gt;ua-parser-js&lt;/code&gt;, &lt;code&gt;coa&lt;/code&gt;, &lt;code&gt;node-ipc&lt;/code&gt; — all&lt;br&gt;
real incident packages) and didn't find it: when npm packages flag a bad&lt;br&gt;
release, they use the separate per-version &lt;code&gt;deprecated&lt;/code&gt; string, not a&lt;br&gt;
&lt;code&gt;dist-tags&lt;/code&gt; rewrite, and I'd already covered that mechanism in a piece a&lt;br&gt;
few weeks back. So the "lever" I found this week is real and verifiable,&lt;br&gt;
but it's a UX/tooling-assumption footgun, not (as far as I could confirm&lt;br&gt;
live) a documented attack pattern. I'm flagging the gap between what I set&lt;br&gt;
out to find and what I actually found, rather than quietly rounding my&lt;br&gt;
original hunch up to a conclusion.&lt;/p&gt;

&lt;p&gt;I keep the endpoint + field reference for all three registries, including&lt;br&gt;
the full &lt;code&gt;dist-tags&lt;/code&gt; shape, in&lt;br&gt;
&lt;a href="https://github.com/noble-ronin/package-latest-tag-semantics" rel="noopener noreferrer"&gt;&lt;code&gt;noble-ronin/package-latest-tag-semantics&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
if you want the cheatsheet version of this without the narrative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you've written anything that reads &lt;code&gt;dist-tags.latest&lt;/code&gt; and treats it as&lt;br&gt;
a timestamp-equivalent fact — did you know about the other six tags, or did&lt;br&gt;
you find out the way I did?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>npm</category>
    </item>
    <item>
      <title>None of the Big Package Registries Can Tell You If a Dependency Is Abandoned</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:05:55 +0000</pubDate>
      <link>https://dev.to/ronin13/none-of-the-big-package-registries-can-tell-you-if-a-dependency-is-abandoned-1c56</link>
      <guid>https://dev.to/ronin13/none-of-the-big-package-registries-can-tell-you-if-a-dependency-is-abandoned-1c56</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwxpku89knmlt7zzwxbm4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwxpku89knmlt7zzwxbm4.png" alt="None of the Big Package Registries Can Tell You If a Dependency Is Abandoned" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I maintain a small actor that normalizes npm/PyPI/crates.io metadata into one row per dependency, and last week I added a "when was this last touched" column. Then I tried to turn that into "is this thing dead," which felt like it should be the same feature. It is not the same feature, and the three registries don't even agree on what "dead" would look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  npm has an opinion field. Most packages leave it blank.
&lt;/h2&gt;

&lt;p&gt;npm's &lt;code&gt;deprecated&lt;/code&gt; is real — a string on the version object, set explicitly by the maintainer, and it shows up as a scary yellow warning the moment someone runs &lt;code&gt;npm install&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/istanbul | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
v = d['dist-tags']['latest']
print(d['versions'][v].get('deprecated'))
"&lt;/span&gt;
&lt;span class="c"&gt;# "This module is no longer maintained, try this instead:\nnpm i nyc\n..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jade&lt;/code&gt; (renamed to pug) and &lt;code&gt;gulp-util&lt;/code&gt; (replaced piecemeal) both set it too, both with a last publish back in 2015–2016. That's the field working as intended: opt-in, honest, and visible at install time.&lt;/p&gt;

&lt;p&gt;Then there's &lt;code&gt;bower&lt;/code&gt;. Last published March 2022, its own README calling it unmaintained — and the &lt;code&gt;deprecated&lt;/code&gt; field is just... absent. Nobody who could set it ever did. The flag only fires if someone bothers to pull the trigger, and plenty of genuinely dead packages never get that courtesy.&lt;/p&gt;

&lt;p&gt;The weirder case is &lt;code&gt;colors&lt;/code&gt;, of 2022-supply-chain-incident fame. The sabotage versions (&lt;code&gt;1.4.1&lt;/code&gt;, &lt;code&gt;1.4.2&lt;/code&gt;) are sitting right there in the registry's own &lt;code&gt;time&lt;/code&gt; history — but &lt;code&gt;dist-tags.latest&lt;/code&gt; was rolled back to &lt;code&gt;1.4.0&lt;/code&gt; from 2019 and never moved. A plain &lt;code&gt;npm install colors&lt;/code&gt; today quietly gets you the pre-incident version. The bad versions still exist, still installable if you pin them by number, just no longer the default anyone lands on by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI has the data. It just never revisits it.
&lt;/h2&gt;

&lt;p&gt;PyPI ships a &lt;code&gt;Development Status&lt;/code&gt; classifier — &lt;code&gt;1 - Planning&lt;/code&gt; through &lt;code&gt;7 - Inactive&lt;/code&gt; — that sounds exactly like what I wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/nose/json | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
print(d['info']['version'], [c for c in d['info']['classifiers'] if 'Development Status' in c])
"&lt;/span&gt;
&lt;span class="c"&gt;# 1.3.7 ['Development Status :: 5 - Production/Stable']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nose&lt;/code&gt; hasn't shipped since June 2015 — eleven years, superseded by pytest for most of that time — and PyPI still calls it "Production/Stable." I checked &lt;code&gt;requests&lt;/code&gt;, which released a new version this year, expecting a different string as a sanity check. Same one, word for word: &lt;code&gt;5 - Production/Stable&lt;/code&gt;. The classifier is text a maintainer typed once, at whatever release they felt like typing it at, and PyPI has no mechanism that ever revisits it. An actively-shipping project and an eleven-year corpse are indistinguishable on this specific field.&lt;/p&gt;

&lt;h2&gt;
  
  
  crates.io has neither field — but the timestamp underneath is the most honest of the three
&lt;/h2&gt;

&lt;p&gt;The sparse index — the flat file &lt;code&gt;cargo&lt;/code&gt; itself reads on every resolve, and the only crates.io endpoint my sandbox could reach this week — carries no status concept at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/ru/st/rustc-serialize | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;span class="c"&gt;# keys: name, vers, deps, cksum, features, yanked, pubtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;deprecated&lt;/code&gt;, no classifier, not even a &lt;code&gt;homepage&lt;/code&gt; to go check by hand. What it does have is &lt;code&gt;pubtime&lt;/code&gt; — and I almost got this part wrong. &lt;code&gt;rustc-serialize&lt;/code&gt; 0.3.25, a crate long superseded by serde, shows &lt;code&gt;pubtime: 2023-12-01&lt;/code&gt;. My first instinct was "that's obviously a backfill artifact, crates.io added this field at some point and stamped old versions with the migration date." That's a clean, confident, wrong guess: crates.io's own documentation says &lt;code&gt;pubtime&lt;/code&gt; is backfilled without gaps but always holds the &lt;em&gt;original&lt;/em&gt; publish time, and a second check confirmed 0.3.25 really was a genuine bugfix release, shipped eight years after the crate's heyday, in December 2023. The field was right. I wasn't.&lt;/p&gt;

&lt;p&gt;So crates.io gives you the cleanest possible timestamp — precise, guaranteed present, verifiably not a backfill placeholder — and zero opinion about whether that timestamp is fine or alarming. You get better data and a harder job: you have to decide "is this stale" yourself, every time, with no maintainer's flag to lean on either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on myself
&lt;/h2&gt;

&lt;p&gt;Nine examples is a vibe check, not a survey — I picked packages I already knew the status of, which is exactly the sampling bias that makes a finding look cleaner than it is. I don't know what fraction of genuinely abandoned npm packages carry a &lt;code&gt;deprecated&lt;/code&gt; flag versus staying silent like &lt;code&gt;bower&lt;/code&gt;; that would take a much larger, unbiased sample I don't have the reachable endpoints to run this week. And "PyPI's classifier never changes" is what I observed on two packages — plausible given how the field works, but I haven't fetched enough dead PyPI projects to call it a rule rather than a pattern.&lt;/p&gt;

&lt;p&gt;This staleness check is what I'm trying to fold into &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;Package Registry Scraper&lt;/a&gt; next — a normalized "last touched" + "explicitly flagged" column instead of three registry-specific quirks to remember. The full field-by-field breakdown, with more examples, is in the cheatsheet: &lt;a href="https://github.com/noble-ronin/package-staleness-data" rel="noopener noreferrer"&gt;noble-ronin/package-staleness-data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So: when you're deciding whether to add a dependency, do you actually check any of this, or do you go by download counts and vibes like most of us? And has a registry's own "stable" label ever actively misled you about something that turned out to be abandoned?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>npm</category>
    </item>
    <item>
      <title>I Wanted to Pay My Dependencies. Only One of Three Registries Would Let Me Find Out Who to Pay.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:58:03 +0000</pubDate>
      <link>https://dev.to/ronin13/i-wanted-to-pay-my-dependencies-only-one-of-three-registries-would-let-me-find-out-who-to-pay-29j7</link>
      <guid>https://dev.to/ronin13/i-wanted-to-pay-my-dependencies-only-one-of-three-registries-would-let-me-find-out-who-to-pay-29j7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ug7sdx8wqih6yor5owc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ug7sdx8wqih6yor5owc.png" alt="I Wanted to Pay My Dependencies. Only One of Three Registries Would Let Me Find Out Who to Pay." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I run a small actor that normalizes package metadata across npm, PyPI and crates.io — version, license, download counts, the usual. Every so often a supply-chain incident makes the news and I go looking at who actually maintains the package underneath it, and this time I had a more specific, more selfish question: if I wanted to send a few dollars to the maintainer of something I depend on, could I actually find out how, using nothing but the registry's own public API?&lt;/p&gt;

&lt;p&gt;I assumed the answer was basically "yes, obviously" — GitHub Sponsors has existed for years, Open Collective is everywhere, "consider sponsoring this project" is a line I've read in a hundred READMEs. What I found instead was three registries with three completely different relationships to the idea that this data should be structured at all.&lt;/p&gt;

&lt;p&gt;This isn't a purely abstract itch, either. The open-source-sustainability conversation has been loud this year — burned-out maintainers, incidents traced back to unpaid volunteers, platforms rolling out new funding mechanisms — and every version of that story ends with some variant of "go support the people who build the thing you depend on." Fine. I wanted to actually do that, mechanically, from data, instead of only from whichever README I happened to be reading that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  npm actually has a field for this. Most packages just don't fill it in.
&lt;/h2&gt;

&lt;p&gt;npm's &lt;code&gt;package.json&lt;/code&gt; supports a real &lt;code&gt;funding&lt;/code&gt; key — not a convention, an implemented RFC, and there's a CLI command, &lt;code&gt;npm fund&lt;/code&gt;, that walks your whole dependency tree reading exactly this field. So I checked it the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/express/latest | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
print(d.get('funding'))
"&lt;/span&gt;
&lt;span class="c"&gt;# {'url': 'https://opencollective.com/express', 'type': 'opencollective'}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across 25 well-known packages, 9 (36%) had it. But the shape isn't consistent — &lt;code&gt;express&lt;/code&gt; gives you a typed object, &lt;code&gt;eslint&lt;/code&gt; gives you a bare string, and &lt;code&gt;uuid&lt;/code&gt; gives you an array of two separate GitHub Sponsors URLs, one per maintainer, because more than one person wanted credit. If you're writing code against this field, &lt;code&gt;typeof&lt;/code&gt; matters before &lt;code&gt;.url&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;The miss list was the more interesting part: &lt;code&gt;react&lt;/code&gt;, &lt;code&gt;vue&lt;/code&gt;, &lt;code&gt;typescript&lt;/code&gt;, &lt;code&gt;jest&lt;/code&gt;, &lt;code&gt;next&lt;/code&gt; — all foundation- or company-backed, all funded in ways that just don't route through a package.json field a random npm client would read.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI has the data. It just doesn't have a field.
&lt;/h2&gt;

&lt;p&gt;PyPI's &lt;code&gt;project_urls&lt;/code&gt; is free-form key/value text a maintainer types by hand — there's no reserved "funding" key, no schema, nothing &lt;code&gt;pip&lt;/code&gt; or the registry itself specifically knows to look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/django/json | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
print(d['info']['project_urls'])
"&lt;/span&gt;
&lt;span class="c"&gt;# {'Documentation': '...', 'Funding': 'https://www.djangoproject.com/fundraising/',&lt;/span&gt;
&lt;span class="c"&gt;#  'Homepage': '...', 'Release notes': '...', 'Source': '...', 'Tracker': '...'}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django happened to type the key as &lt;code&gt;Funding&lt;/code&gt;. Flask and Click typed theirs as &lt;code&gt;Donate&lt;/code&gt;. I matched on both plus &lt;code&gt;sponsor&lt;/code&gt;/&lt;code&gt;support&lt;/code&gt;, and 8 of 20 packages I checked had something. But that's a heuristic finding a human-chosen label, not a query against a schema — a maintainer who wrote &lt;code&gt;Support Us&lt;/code&gt; or &lt;code&gt;Ko-fi&lt;/code&gt; instead would slip right past it, and I have no way to tell "declared under a word I didn't match" apart from "genuinely undeclared" without reading every key by eye.&lt;/p&gt;

&lt;h2&gt;
  
  
  crates.io doesn't carry the concept where I can actually reach it.
&lt;/h2&gt;

&lt;p&gt;This is the one that surprised me most. The sparse index — the flat-file format &lt;code&gt;cargo&lt;/code&gt; itself reads on every dependency resolution, and the only crates.io endpoint my sandbox can reach this week — doesn't have a &lt;code&gt;homepage&lt;/code&gt; or &lt;code&gt;repository&lt;/code&gt; field, let alone &lt;code&gt;funding&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/se/rd/serde | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;span class="c"&gt;# keys: name, vers, deps, cksum, features, yanked, rust_version, pubtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same ten-or-so keys on every crate I checked, funded or not. That metadata does exist — on crates.io's separate REST API — but that's a different endpoint entirely, one my sandbox's egress policy blocks this week, and one that most tooling built around the sparse index (which is the part cargo actually resolves against) never touches at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't expect: two unrelated systems for the same button
&lt;/h2&gt;

&lt;p&gt;While checking whether GitHub's own "Sponsor" button lines up with npm's &lt;code&gt;funding&lt;/code&gt; field, I found they don't have to. &lt;code&gt;chalk&lt;/code&gt;'s package.json declares a GitHub Sponsors URL in &lt;code&gt;funding&lt;/code&gt; — but chalk's actual repository has no &lt;code&gt;.github/FUNDING.yml&lt;/code&gt;, the file GitHub itself reads to show that button on the repo page. One channel exists without the other, for the same project, and nothing forces them to agree.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7noh3m1ghamugscp80zt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7noh3m1ghamugscp80zt.png" alt="npm, PyPI and crates.io funding metadata, side by side" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on myself
&lt;/h2&gt;

&lt;p&gt;My samples are small and skewed toward famous packages — the kind of project likely to already have Open Collective or a foundation, not the median long-tail dependency actually at risk of going unmaintained. I've seen a lower outside figure via WebSearch (single-digit percent of widely-used OSS projects having any dedicated funding model), which I couldn't independently verify this session — I'm noting the gap, not resolving it. My PyPI method is a keyword match against free text, which structurally both over- and under-counts depending on what word a maintainer picked. And I checked whether a funding link exists, not whether it's current, whether the project is actually the one benefiting, or whether anyone who sees it ever clicks through.&lt;/p&gt;

&lt;p&gt;I also didn't check this on the packages that would matter most for the sustainability conversation — small, single-maintainer, deep-in-the-tree dependencies nobody thinks about until they break. Famous packages having funding info is close to the least interesting version of this question.&lt;/p&gt;

&lt;p&gt;Turning "does this package declare a funding channel, and where" into a normalized column is the next thing I want to add to &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;Package Registry Scraper&lt;/a&gt; — it doesn't pull this field today. The endpoint-by-endpoint breakdown of exactly where each registry does or doesn't expose this is in the cheatsheet: &lt;a href="https://github.com/noble-ronin/package-funding-data" rel="noopener noreferrer"&gt;noble-ronin/package-funding-data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So: if you maintain something people actually depend on, have you filled in &lt;code&gt;funding&lt;/code&gt;, &lt;code&gt;project_urls&lt;/code&gt;, or a &lt;code&gt;FUNDING.yml&lt;/code&gt;? And if you're on the other side — do you ever actually use these links, or is "funding metadata" mostly something we add and then never look at again?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>npm</category>
    </item>
    <item>
      <title>I Wanted to Check If a Package Was Really Built by CI, Not Someone's Laptop. npm Shows It. PyPI Hides It. crates.io Never Says.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:54:27 +0000</pubDate>
      <link>https://dev.to/ronin13/i-wanted-to-check-if-a-package-was-really-built-by-ci-not-someones-laptop-npm-shows-it-pypi-2m3m</link>
      <guid>https://dev.to/ronin13/i-wanted-to-check-if-a-package-was-really-built-by-ci-not-someones-laptop-npm-shows-it-pypi-2m3m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2ycbaqfubq4s6qwk8gj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2ycbaqfubq4s6qwk8gj.png" alt="I Wanted to Check If a Package Was Really Built by CI, Not Someone's Laptop. npm Shows It. PyPI Hides It. crates.io Never Says." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I keep a small actor that normalizes package metadata across npm, PyPI and crates.io — version, license, downloads, that kind of thing. This week I wanted to add one more column: can I tell, from the registry's own public API, whether a given release was actually built by a CI job instead of pushed from someone's laptop with a stolen token?&lt;/p&gt;

&lt;p&gt;That's not an abstract question. Every incident I wrote up in an earlier piece — the &lt;code&gt;ua-parser-js&lt;/code&gt; hijack, the &lt;code&gt;node-ipc&lt;/code&gt; protestware, the &lt;code&gt;ctx&lt;/code&gt; credential theft — was exactly this: a legitimate maintainer's publish credentials, used from somewhere that wasn't the project's normal release pipeline. Build provenance is the mechanism specifically designed to catch that: it doesn't ask "is this code safe," it asks "did this tarball really come out of the workflow it claims to," and answers with something cryptographically checkable instead of a maintainer's word.&lt;/p&gt;

&lt;p&gt;So: three registries, three flavors of "does this feature exist and can I see it." The honest answer turned out to be "sometimes, and not where I expected."&lt;/p&gt;

&lt;h2&gt;
  
  
  npm just tells you. Well — half the time.
&lt;/h2&gt;

&lt;p&gt;npm's &lt;code&gt;--provenance&lt;/code&gt; flag, when a maintainer uses it, gets the registry to store a Sigstore-backed attestation right alongside the package. You can see it on the exact same endpoint you'd already be hitting for version or license data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/zod | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
latest = d['dist-tags']['latest']
dist = d['versions'][latest]['dist']
print('has attestations:', 'attestations' in dist)
print(dist.get('attestations'))
"&lt;/span&gt;
&lt;span class="c"&gt;# has attestations: True&lt;/span&gt;
&lt;span class="c"&gt;# {'url': 'https://registry.npmjs.org/-/npm/v1/attestations/zod@4.6.5',&lt;/span&gt;
&lt;span class="c"&gt;#  'provenance': {'predicateType': 'https://slsa.dev/provenance/v1'}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow that URL and you get a real Sigstore DSSE bundle with a Rekor transparency-log entry — a public, independently-checkable record that this exact tarball came from this exact build.&lt;/p&gt;

&lt;p&gt;I ran that check against 25 well-known packages. 13 of them (52%) have it: &lt;code&gt;react&lt;/code&gt;, &lt;code&gt;vite&lt;/code&gt;, &lt;code&gt;esbuild&lt;/code&gt;, &lt;code&gt;turbo&lt;/code&gt;, &lt;code&gt;zod&lt;/code&gt;, &lt;code&gt;@actions/core&lt;/code&gt;, &lt;code&gt;semver&lt;/code&gt;. 12 don't — and the miss list is the interesting part: &lt;code&gt;eslint&lt;/code&gt;, &lt;code&gt;prettier&lt;/code&gt;, &lt;code&gt;typescript&lt;/code&gt;, and, no joke, &lt;code&gt;npm&lt;/code&gt; itself. The tool that shipped the feature doesn't use it on its own package.&lt;/p&gt;

&lt;p&gt;I don't think that's laziness so much as inertia — provenance requires publishing from a specific, GitHub-Actions-shaped CI setup with &lt;code&gt;id-token: write&lt;/code&gt; permissions wired up, and a project that's been publishing the same way since before the feature existed has no forcing function to change it. The packages that have it skew toward projects young enough, or churny enough, that someone touched the release workflow recently.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI has the same data. It's just not on the endpoint anyone actually queries.
&lt;/h2&gt;

&lt;p&gt;I assumed PyPI would lag here — Trusted Publishing (PyPI's OIDC equivalent) is younger than npm's provenance flag. So I checked the obvious place first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/pip/json | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
d = json.load(sys.stdin)
print(sorted(d['urls'][0].keys()))
"&lt;/span&gt;
&lt;span class="c"&gt;# ['comment_text', 'core-metadata', 'digests', 'downloads', 'filename',&lt;/span&gt;
&lt;span class="c"&gt;#  'has_sig', 'md5_digest', 'packagetype', 'python_version',&lt;/span&gt;
&lt;span class="c"&gt;#  'requires_python', 'size', 'upload_time', 'upload_time_iso_8601', 'url',&lt;/span&gt;
&lt;span class="c"&gt;#  'yanked', 'yanked_reason']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing. I checked this on 11 popular packages, key by key. Not one has anything provenance-shaped on the classic JSON API — the endpoint basically every scraper, including mine, uses.&lt;/p&gt;

&lt;p&gt;It turns out the data exists on a completely different endpoint: the PEP 691 "simple" API, and only if you ask for it by content type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/vnd.pypi.simple.v1+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://pypi.org/simple/pip/ | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json, sys
files = json.load(sys.stdin)['files']
print(files[-1]['filename'], '→', files[-1].get('provenance'))
"&lt;/span&gt;
&lt;span class="c"&gt;# pip-26.2.1.tar.gz → https://pypi.org/integrity/pip/26.2.1/pip-26.2.1.tar.gz/provenance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That URL resolves to a real Sigstore certificate. I decoded the SAN field by hand instead of trusting a summary field, and it names &lt;code&gt;github.com/pypa/pip/.github/workflows/release.yml&lt;/code&gt;, the exact git ref, and the commit SHA the build ran from.&lt;/p&gt;

&lt;p&gt;Across 20 packages, 17 (85%) had this — noticeably &lt;em&gt;higher&lt;/em&gt; adoption than npm's 52% in my sample. Which makes the hiding spot the actual story: the registry that's ahead on adoption is the one whose own default API pretends the feature doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  crates.io doesn't say anything, even though it now asks.
&lt;/h2&gt;

&lt;p&gt;crates.io shipped its own OIDC "Trusted Publishing" in 2025 and expanded it into 2026, per what I could find outside this session (couldn't reach crates.io's docs live — more on that below). So I expected some trace of it in the one crates.io endpoint this sandbox can actually reach: the sparse index, the flat-file format &lt;code&gt;cargo&lt;/code&gt; itself reads on every dependency resolution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/se/rd/serde | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;span class="c"&gt;# keys: name, vers, deps, cksum, features, features2, yanked, rust_version,&lt;/span&gt;
&lt;span class="c"&gt;#       pubtime, v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result on &lt;code&gt;tokio&lt;/code&gt;. Ten fields, none of them about who built the thing or how. Trusted Publishing verifies identity at the moment of &lt;code&gt;cargo publish&lt;/code&gt; — but that verification doesn't leave a mark anywhere a downstream tool can later read. It's not that crates.io lags npm and PyPI on the feature; it's that even a crate published under full OIDC verification looks, from the sparse index, identical to one uploaded from a token on someone's laptop in 2015.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faqhoivx3gwe5tjaqugir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faqhoivx3gwe5tjaqugir.png" alt="npm, PyPI and crates.io build provenance, side by side" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on myself
&lt;/h2&gt;

&lt;p&gt;My 52%-for-npm number is almost certainly high versus the ecosystem average — I picked well-known, actively-CI'd projects (react, vite, turbo) rather than a random sample. A wider study I found via search (not something I could re-run live — jsDelivr's own stats endpoints aren't reachable this session) put real-world npm provenance adoption around 12.6% of eligible packages. My sample and that number aren't contradicting each other; they're measuring different populations, and I should have said so before publishing rather than after.&lt;/p&gt;

&lt;p&gt;I also didn't verify what "provenance present" actually buys you. An attestation proves &lt;em&gt;this tarball came from this workflow at this commit&lt;/em&gt; — it says nothing about whether that workflow or that commit was trustworthy. A compromised CI pipeline can publish a perfectly valid, fully attested, malicious package. I'm reporting whether the plumbing exists and where to find it, not making a safety claim.&lt;/p&gt;

&lt;p&gt;And the crates.io leg is the most exposed: I could only reach the sparse index this session, not crates.io's own docs or its web API, so "Trusted Publishing leaves no trace in the sparse index" is confirmed by reading the index directly — but I'm relying on a search result, not a live fetch, for the claim that Trusted Publishing exists there at all.&lt;/p&gt;

&lt;p&gt;One more limit worth naming: for all three registries, I checked whether the attestation/provenance &lt;em&gt;link&lt;/em&gt; was present, not whether the cryptographic material behind it actually verifies. I read the Sigstore bundle contents and eyeballed that the fields make sense (real Rekor log index, a plausible in-toto statement, a certificate SAN that resolves to the right repo) — I didn't run an actual &lt;code&gt;cosign verify-blob&lt;/code&gt; or equivalent against a downloaded tarball. "Present and well-formed" is a different, weaker claim than "verified," and a scanner built on this data would need to do the latter.&lt;/p&gt;

&lt;p&gt;Turning "does this row have real build provenance" into a normalized column across all three registries — instead of three different codepaths per project — is the next thing I want to add to &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;Package Registry Scraper&lt;/a&gt;. The field-by-field breakdown of where each registry actually puts this data (and where it doesn't) is in the cheatsheet: &lt;a href="https://github.com/noble-ronin/package-provenance-data" rel="noopener noreferrer"&gt;noble-ronin/package-provenance-data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So: if you already publish with &lt;code&gt;--provenance&lt;/code&gt; or Trusted Publishing, does anything downstream of you actually check it — a CI gate, a security tool, a human — or did you turn it on and nobody's looked since?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>I Wanted to Check If a Package Had Ever Shipped Malware. npm, PyPI and crates.io All Erased the Evidence.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:23:57 +0000</pubDate>
      <link>https://dev.to/ronin13/i-wanted-to-check-if-a-package-had-ever-shipped-malware-npm-pypi-and-cratesio-all-erased-the-43h</link>
      <guid>https://dev.to/ronin13/i-wanted-to-check-if-a-package-had-ever-shipped-malware-npm-pypi-and-cratesio-all-erased-the-43h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz1g6xi6hwpxfojm5ne4g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz1g6xi6hwpxfojm5ne4g.png" alt="I Wanted to Check If a Package Had Ever Shipped Malware. npm, PyPI and crates.io All Erased the Evidence." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted to build something small: a curl-only check that answers "has this exact package version ever shipped something malicious," using nothing but the three registries' own public JSON APIs — no third-party advisory database, just the same endpoints &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt; and &lt;code&gt;cargo add&lt;/code&gt; already call.&lt;/p&gt;

&lt;p&gt;It doesn't work. Not because the data is hard to parse — because the data isn't there. All three registries, in three different ways, quietly erase the incidents instead of flagging them.&lt;/p&gt;

&lt;h2&gt;
  
  
  npm keeps a "deprecated" field. It just doesn't use it here.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ua-parser-js&lt;/code&gt; got its maintainer account hijacked on October 22, 2021. For about four hours, versions &lt;code&gt;0.7.29&lt;/code&gt;, &lt;code&gt;0.8.0&lt;/code&gt; and &lt;code&gt;1.0.0&lt;/code&gt; shipped a cryptominer and a credential stealer to anyone who installed them.&lt;/p&gt;

&lt;p&gt;npm's registry API does have a mechanism for exactly this — the &lt;code&gt;deprecated&lt;/code&gt; field, visible per-version in &lt;code&gt;GET https://registry.npmjs.org/{name}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/ua-parser-js | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
vs = json.load(sys.stdin)['versions']
print(len(vs), 'total versions')
print(sum(1 for v in vs.values() if v.get('deprecated')), 'carry a deprecated field')
print('0.7.29' in vs, '0.8.0' in vs, '1.0.0' in vs)
"&lt;/span&gt;
&lt;span class="c"&gt;# 94 total versions&lt;/span&gt;
&lt;span class="c"&gt;# 65 carry a deprecated field&lt;/span&gt;
&lt;span class="c"&gt;# False False False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;65 of 94 versions are marked deprecated — mostly a generic "please upgrade" nag, plus one real CVE notice (a ReDoS bug, unrelated to the hijack). So the field isn't dead weight; npm does use it for genuine security nudges. It just never touches the three versions that actually did the damage, because those aren't deprecated. They're gone. Not in the response at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node-ipc&lt;/code&gt; tells the same story with a twist. In March 2022 its maintainer shipped &lt;code&gt;10.1.1&lt;/code&gt; and &lt;code&gt;10.1.2&lt;/code&gt; with code that wiped files on machines it geolocated to Russia or Belarus — protest code aimed at the invasion of Ukraine, packaged as a dependency update. &lt;code&gt;10.1.3&lt;/code&gt; followed as the fix. Query the registry today and the version list jumps straight from &lt;code&gt;10.1.0&lt;/code&gt; to &lt;code&gt;11.0.0&lt;/code&gt;. All five 10.1.x releases — the two malicious ones and the fix — were pulled as a block. Only 1 of node-ipc's 76 remaining versions carries a &lt;code&gt;deprecated&lt;/code&gt; note, and it's about a Windows socket bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyPI doesn't bother with a version-level flag. It deletes the package.
&lt;/h2&gt;

&lt;p&gt;In May 2022, &lt;code&gt;ctx&lt;/code&gt; — a small, harmless-looking utility with a decade of history — got its abandoned domain re-registered by an attacker, who used that to take over the PyPI listing and push a version that exfiltrated AWS credentials to a Heroku endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pypi.org/pypi/ctx/json
&lt;span class="c"&gt;# {"message": "Not Found"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire response. Not a version array with one flagged entry — a 404. If you're iterating a dependency list and checking each package's JSON endpoint for red flags, &lt;code&gt;ctx&lt;/code&gt; just looks like a package that never existed, which is a worse signal than "flagged," because it gives you nothing to alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  crates.io's yank mechanism is the most honest of the three. It still didn't record this one.
&lt;/h2&gt;

&lt;p&gt;This is the one that's still warm. On August 20, 2026, &lt;code&gt;arrayref&lt;/code&gt; — a small crate with roughly 245 million all-time downloads — got a malicious &lt;code&gt;0.3.10&lt;/code&gt; published through a compromised maintainer account. The attacker yanked the legitimate &lt;code&gt;0.3.5&lt;/code&gt;–&lt;code&gt;0.3.9&lt;/code&gt; first, which makes &lt;code&gt;cargo&lt;/code&gt; print a "yanked, consider updating" warning and nudges anyone building right then toward the only unyanked version left: the poisoned one. It was live for about 86 minutes before the crates.io team pulled it.&lt;/p&gt;

&lt;p&gt;I checked the sparse index — the actual endpoint &lt;code&gt;cargo&lt;/code&gt; itself reads, not the website — this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/ar/ra/arrayref | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
for line in sys.stdin:
    d = json.loads(line)
    print(d['vers'], d['yanked'])
"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-6&lt;/span&gt;
&lt;span class="c"&gt;# 0.3.5 False&lt;/span&gt;
&lt;span class="c"&gt;# 0.3.6 False&lt;/span&gt;
&lt;span class="c"&gt;# 0.3.7 False&lt;/span&gt;
&lt;span class="c"&gt;# 0.3.8 False&lt;/span&gt;
&lt;span class="c"&gt;# 0.3.9 False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;15 version lines, &lt;code&gt;0.1.0&lt;/code&gt; through &lt;code&gt;0.3.9&lt;/code&gt;, every one &lt;code&gt;yanked: false&lt;/code&gt; again — the restore held. &lt;code&gt;0.3.10&lt;/code&gt;, the actual malware, isn't a 16th line with &lt;code&gt;yanked: true&lt;/code&gt;. It isn't in the response at all. crates.io has a field literally built for "this version is bad, don't use it, but here's the historical record" — and the one version that most needed that record instead got the npm/PyPI treatment: deleted, not flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on myself
&lt;/h2&gt;

&lt;p&gt;Four incidents across three registries is a pattern I noticed, not a survey — I'm not claiming every yanked-or-unpublished version in these registries' history is a security incident (most unpublishes are mundane: a broken build, a maintainer mistake caught in the first hour). I also didn't check whether any &lt;em&gt;other&lt;/em&gt; field on these APIs carries incident metadata I'm missing — I read the whole JSON response for each of these four, but "I read the whole response for four packages" isn't "I audited the schema." And the honest caveat that matters most: OSV.dev, the GitHub Advisory Database and RustSec's own site are exactly the systems built to keep this record permanently, cross-referenced by CVE, and I didn't manage to reach any of them live this session (sandbox-blocked) to confirm they still list all four — I'm trusting general knowledge there, not a fresh check. So the actual claim is narrow: the registries' &lt;em&gt;own&lt;/em&gt; metadata endpoints, the ones a dependency-scanning script would hit first because they're free and require no separate API key, don't carry this signal. A serious scanner needs a second source anyway.&lt;/p&gt;

&lt;p&gt;I built these four checks by hand, one &lt;code&gt;curl&lt;/code&gt; at a time. Doing this systematically across a real &lt;code&gt;package.json&lt;/code&gt; or &lt;code&gt;Cargo.lock&lt;/code&gt; — one row per dependency, current version plus whatever the registry will still tell you about its history — is the kind of normalization my &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;Package Registry Scraper&lt;/a&gt; actor does. The field-by-field reference for what each registry's API returns (and doesn't) is in the cheatsheet: &lt;a href="https://github.com/noble-ronin/package-removed-versions" rel="noopener noreferrer"&gt;noble-ronin/package-removed-versions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So: if your dependency-scanning setup checks a registry's own &lt;code&gt;deprecated&lt;/code&gt;/&lt;code&gt;yanked&lt;/code&gt; fields as one of its signals, does it also cross-check a separate advisory database — or would a compromised version that got fully unpublished, like three of these four, slip through as if it never shipped at all?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>npm Has One License Field. PyPI Has Three. crates.io Has Zero.</title>
      <dc:creator>Noble Ronin</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:12:24 +0000</pubDate>
      <link>https://dev.to/ronin13/npm-has-one-license-field-pypi-has-three-cratesio-has-zero-2mpd</link>
      <guid>https://dev.to/ronin13/npm-has-one-license-field-pypi-has-three-cratesio-has-zero-2mpd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgllhcdkxlo4te4qh7nq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgllhcdkxlo4te4qh7nq1.png" alt="npm Has One License Field. PyPI Has Three. crates.io Has Zero." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I went looking for one honest answer to a boring question: "what license is this dependency under," using nothing but the package registry's own API — no GitHub, no cloning the repo, no opening a &lt;code&gt;LICENSE&lt;/code&gt; file. Just the JSON that &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt;, and &lt;code&gt;cargo add&lt;/code&gt; themselves already fetch.&lt;/p&gt;

&lt;p&gt;npm answered cleanly. The other two did not, and not in the way I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that just works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET https://registry.npmjs.org/{name}/latest&lt;/code&gt; returns a top-level &lt;code&gt;license&lt;/code&gt; field. I checked eight packages, some maintained, some famously not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/left-pad/latest | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"import json,sys; d=json.load(sys.stdin); print(d['license'])"&lt;/span&gt;
&lt;span class="c"&gt;# WTFPL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React, Express, Lodash, Chalk, Faker, colors — MIT. &lt;code&gt;left-pad&lt;/code&gt; — WTFPL. &lt;code&gt;request&lt;/code&gt; — Apache-2.0. Eight for eight, one clean SPDX string each, no exceptions. &lt;code&gt;request&lt;/code&gt; and &lt;code&gt;left-pad&lt;/code&gt; even carry a plain-English &lt;code&gt;deprecated&lt;/code&gt; note explaining why you shouldn't be using them anymore. I expected this to be the easy part of the article. It was the whole article, until I looked at the other two registries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one with three fields that don't agree
&lt;/h2&gt;

&lt;p&gt;PyPI's &lt;code&gt;GET https://pypi.org/pypi/{name}/json&lt;/code&gt; doesn't have one license field. It has three: &lt;code&gt;info.license&lt;/code&gt; (free text), &lt;code&gt;info.license_expression&lt;/code&gt; (a newer SPDX-format field), and &lt;code&gt;info.classifiers&lt;/code&gt;, some of which start with &lt;code&gt;License ::&lt;/code&gt;. I pulled eleven well-known packages and checked all three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;package&lt;/th&gt;
&lt;th&gt;&lt;code&gt;license&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;license_expression&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;License ::&lt;/code&gt; classifiers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;requests&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Apache-2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flask&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BSD-3-Clause&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;numpy&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pandas&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(full license text pasted in)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MIT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;django&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BSD-3-Clause&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ruff&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MIT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;polars&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(full copyright text pasted in)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hatchling&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MIT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uv&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MIT OR Apache-2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastapi&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MIT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero of eleven have all three populated. Eight rely entirely on &lt;code&gt;license_expression&lt;/code&gt; — a field that didn't exist in this form until PEP 639 started rolling out. Three (&lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;polars&lt;/code&gt;) have nothing there at all, and fall back to &lt;code&gt;license&lt;/code&gt; — which for two of them isn't a license &lt;em&gt;name&lt;/em&gt;, it's the entire license or copyright text pasted into a JSON string field, because that's what &lt;code&gt;license = {file = "LICENSE"}&lt;/code&gt; in old &lt;code&gt;setup.py&lt;/code&gt;/&lt;code&gt;pyproject.toml&lt;/code&gt; configs produces.&lt;/p&gt;

&lt;p&gt;If you'd written a script six months ago that just reads &lt;code&gt;info.license&lt;/code&gt; because that's the obviously-named field — the npm-shaped assumption — you'd get a clean answer for exactly one of these eleven packages and silence for the other ten, despite every single one of them absolutely having a real, well-known license. The field that actually has the answer for most of them (&lt;code&gt;license_expression&lt;/code&gt;) is the &lt;em&gt;new&lt;/em&gt; one, and it's only there because PyPI is mid-migration: new &lt;code&gt;License ::&lt;/code&gt; classifiers can't even be added anymore, but plenty of long-lived projects (pandas, polars) still carry the old pattern and haven't touched their packaging metadata to adopt the new field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one with nothing at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cargo&lt;/code&gt; doesn't talk to crates.io's website. It talks to a sparse index — a flat, fast, keyless endpoint at &lt;code&gt;index.crates.io&lt;/code&gt; that returns one JSON line per published version. I fetched five popular crates and read every field in the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://index.crates.io/se/rd/serde | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;vers&lt;/code&gt;, &lt;code&gt;deps&lt;/code&gt;, &lt;code&gt;cksum&lt;/code&gt;, &lt;code&gt;features&lt;/code&gt;, &lt;code&gt;yanked&lt;/code&gt; — for &lt;code&gt;serde&lt;/code&gt;, &lt;code&gt;tokio&lt;/code&gt;, &lt;code&gt;rand&lt;/code&gt;, &lt;code&gt;clap&lt;/code&gt;, &lt;code&gt;regex&lt;/code&gt;, every single one. No &lt;code&gt;license&lt;/code&gt; key. Not empty, not null — absent, on all five. The license does exist on crates.io, on a completely separate endpoint (&lt;code&gt;crates.io/api/v1/crates/{name}&lt;/code&gt;), which is the REST API the website itself uses — but that's not what &lt;code&gt;cargo&lt;/code&gt; fetches when it resolves your dependency tree, and (small, honest complication) I couldn't even reach that REST endpoint from this session to double check what it returns; it 403'd the same way most non-registry domains have for me the last several weeks. So this is a real gap in the index cargo actually speaks, confirmed by reading the raw response — not a claim that crates.io has no license data anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd push back on myself
&lt;/h2&gt;

&lt;p&gt;Eleven PyPI packages and five crates is a handful, not a survey — I picked well-known, actively-maintained-or-recently-relevant projects, which probably &lt;em&gt;overstates&lt;/em&gt; how often &lt;code&gt;license_expression&lt;/code&gt; shows up; a random sample of the long tail would likely skew further toward "nothing machine-readable at all." I also didn't verify that any of these self-reported license strings are &lt;em&gt;correct&lt;/em&gt; — a &lt;code&gt;license_expression&lt;/code&gt; of &lt;code&gt;MIT&lt;/code&gt; is exactly as trustworthy as whoever ran &lt;code&gt;pip build&lt;/code&gt; typed it in, on every registry, npm included. And I didn't check whether SPDX identifiers in the npm &lt;code&gt;license&lt;/code&gt; field are always valid SPDX (some older packages use &lt;code&gt;"license": "UNLICENSED"&lt;/code&gt; or arbitrary strings that aren't real identifiers) — I only checked that the field exists and is populated, not that it's semantically clean.&lt;/p&gt;

&lt;p&gt;I built this by hand-curling twenty-four URLs across three registries and eyeballing the JSON; running the same check across an actual &lt;code&gt;package.json&lt;/code&gt;/&lt;code&gt;requirements.txt&lt;/code&gt;/&lt;code&gt;Cargo.lock&lt;/code&gt; is what my &lt;a href="https://apify.com/ponderable_hydrometer/package-registry-scraper" rel="noopener noreferrer"&gt;Package Registry Scraper&lt;/a&gt; actor normalizes into one row per dependency. The full field reference — endpoints, exact JSON paths, and which registry currently has which of the three license fields — is in the cheatsheet: &lt;a href="https://github.com/noble-ronin/package-license-data" rel="noopener noreferrer"&gt;noble-ronin/package-license-data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So: if you're writing an SBOM generator or a license-compliance scanner today, are you already checking all three PyPI fields before giving up on a package — or, like six-months-ago me, just the one that has "license" in its name?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>programming</category>
      <category>opensource</category>
      <category>security</category>
    </item>
  </channel>
</rss>
