DEV Community

jj1423
jj1423

Posted on Originally published at vdb.ai.kr

A 404 is the easy half. Scoring a package that actually exists is where we got it wrong.

Someone left this on my last post:

I'd also treat the "404 means stop" rule as only the first gate. A package resolving successfully doesn't establish that it is trustworthy. For newly registered packages that closely match common model-generated names, provenance and publication history become useful signals too.

The deeper lesson is that agents need pre-action verification, not just error recovery. Once the package is installed, the security decision has already been made.

Both correct, and the first one sent me to look at our own data. We publish a list of package names that LLMs invent. 1,885 of the 1,911 entries simply do not exist — those are easy. 26 of them answer 200.

I checked those 26 by hand. Almost all of them were wrong.

The one that stung

name        : opentelemetry-configuration
author_email: OpenTelemetry Authors <cncf-opentelemetry-contributors@lists.cncf.io>
repository  : github.com/open-telemetry/opentelemetry-python
summary     : OpenTelemetry Python Declarative Configuration (experimental)
Enter fullscreen mode Exit fullscreen mode

We had the official OpenTelemetry Python package on a public list of slopsquatting candidates. Not because anything about it is suspicious — because it was 41 days old when we looked, and our rule said 14–60 days is medium.

The same rule caught cdx-rs, which is a cd replacement for the terminal:

crate  : cdx-rs
desc   : A fast and interactive cd command alternative for directory navigation
repo   : github.com/Cinnamon-jp/cdx
created: 2026-08-30
Enter fullscreen mode Exit fullscreen mode

A model suggested cdx-rs as a Rust crate for parsing CycloneDX SBOMs. The name was already taken by something unrelated, published five days earlier. We scored it high.

Age was a proxy for a question I wasn't asking

The reasoning behind an age window is sound: an attacker who watches what models hallucinate registers the name afterwards, so a fresh registration under a hallucinated name is suspicious.

Read that sentence again and the actual signal is right there. Afterwards. Not "recently" — after the hallucination.

I had been measuring the wrong interval. created_at against today tells you how new a package is, which is a fact about the calendar. created_at against the first time any model produced that name tells you whether the registration could have been a response to it, which is the thing I actually wanted to know.

And we already stored it. Every finding carries hallucinated_by, with a first_seen per model, because the dataset is meant to be evidence rather than a count:

{
  "model": "gemini-2.5-flash",
  "task": "Rust crate for parsing CycloneDX SBOM",
  "first_seen": "2026-09-04T..."
}
Enter fullscreen mode Exit fullscreen mode

cdx-rs was published 2026-08-30. First sighting 2026-09-04. It predates the hallucination by five days — nobody registered it in response to anything. The new rule is four lines:

if first_hallucinated and info.created_at < first_hallucinated:
    return "low", "registered before the name was ever suggested"
age_days = (now - info.created_at).days
if age_days < 14:
    return "high", "registered after the name was suggested"
if age_days < 60:
    return "medium", "young"
return "low", "established"
Enter fullscreen mode Exit fullscreen mode

Both false positives drop to low and come off the list. A name registered after the first sighting keeps its score, and now the status string says why, which it never did before.

This is not airtight. Two people can invent the same obvious name independently, and "registered after" is correlation. But it is a far better question than "is this new", and it costs nothing — the data was already in the row.

npm's 200 means more than one thing

Three entries were labelled registered but never published: native-websocket, node-ics, sql-injection-detector. That status is meant for a name someone claimed and left empty — dangerous precisely because an existence check passes while the holder can publish anything under it later.

Except:

curl -s https://registry.npmjs.org/native-websocket | jq '.time'
Enter fullscreen mode Exit fullscreen mode
{
  "created": "2020-11-29T...",
  "modified": "2020-11-29T...",
  "unpublished": { ... }
}
Enter fullscreen mode Exit fullscreen mode

These were published and then taken down — in 2016, 2020 and 2022. npm leaves a tombstone: 200, no versions, and time.unpublished set. My check was versions == 0, which cannot tell an empty squat from a grave.

It matters because the two have different mechanics. An empty registered name is held by someone who can publish to it today. A tombstone is a name that was real — which is plausibly why a model learned it in the first place — and whether anyone can claim it depends on the registry's unpublish policy, not on the holder's intent. They are medium now, with a status that says what they are.

If you are doing this yourself: don't read versions alone. time.unpublished is the field.

A score is a statement about a moment

The last problem had no clever cause. opentelemetry-configuration was 41 days old when we scored it. It is 77 days old now. Nothing re-ran the classifier, so it sat on a public list for a month under a judgement that had expired.

Every finding that resolves is now reclassified on each cleanup run, and anything that drops to low is withdrawn — kept at its URL with a notice, because other people link to these IDs, but out of the list and out of the API.

The finding here is boring and general: if your scoring function reads a clock, something has to re-run it. A pipeline that only ever classifies on insert will accumulate verdicts that were true once.

What I still don't have

The comment's other word was provenance, and that is the honest gap. We score the name and the registry's timeline. We do not check who published it or from where.

Both big registries now expose this:

  • npm publishes provenance attestations — a signed statement that a package was built by a specific GitHub Actions workflow from a specific repository.
  • PyPI supports PEP 740 attestations on the same idea.

"Registered after the hallucination, by an anonymous account, with no attestation" is a very different package from "registered after the hallucination, built from a public repo with 400 stars by a workflow you can inspect." Right now we score those identically, which is the most useful thing anyone has told me about this project in a while.

That is the next signal. Until it lands, the list says what it can support and not more.


The data is here — CSV and JSON, daily, CC BY 4.0. The gate is one call and needs no key:

curl -X POST -H "Content-Type: application/json" \
  -d '{"packages": ["pkg:npm/fast-jsonwebtoken"]}' \
  https://vdb.ai.kr/v1/ai/check-packages
Enter fullscreen mode Exit fullscreen mode

Which is the commenter's last point, and the reason any of this exists: the verification has to happen before the install, not after the error.

Top comments (6)

Collapse
 
bhavin-allinonetools profile image
Bhavin Sheth •

The shift from “is this package new?” to “was it registered after the hallucination?” is a really strong improvement. I also like the distinction between an empty package name and an npm tombstone—small registry details like that can completely change the security assessment.

Collapse
 
jj1423 profile image
jj1423 •

Thanks! And your second sentence aged well — I hit another one the next day.

crates.io treats - and _ as the same name, so a lookup for cdx_rs comes
back as the crate cdx-rs — the one I'd just withdrawn. I was storing a
finding per spelling, so the twin sat there while I congratulated myself for
removing it. Now I just ask the registry what it calls the thing.

The provenance check landed too (npm attestations, PyPI PEP 740). It matches
zero rows right now — everything left that resolves is an empty name, a
tombstone, or crates.io. Which tells you something about the bucket.

Collapse
 
anp2network profile image
ANP2 Network •

The exculpating branch is gated on your own coverage rather than on anything about the world. first_seen is the earliest moment your sampling happened to catch a model emitting that name. It cannot date the first suggestion. So "registered before the name was ever suggested" reads, near the collection boundary, as "registered before we started looking", and every name registered before collection began clears through that branch as soon as first_hallucinated is populated at all. Someone willing to wait sits in exactly that window.

The and is the sharper part. A missing first_hallucinated drops through to the age ladder and can come out high; an early one returns low immediately. Two different kinds of not-knowing, two different verdicts, and no branch anywhere that says "no coverage". The safe output fires on absence of evidence.

Something adjacent in a public signed-event ledger I scan. The only mutual time constraint there is a parent reference that is a content hash of the parent event. A full-history scan resolved 6,988 parent/child pairs, 6,935 crossing different signing keys, and not one child carried a timestamp earlier than its parent. The constraint holds everywhere it applies. It barely applies: across 2,000 events of the two kinds that carry the most claims about the world, 6 had a parent reference. A timestamp backdated inside an unreferenced window contradicts nothing in the record. A comparison of two times only carries weight where both sides are pinned by something outside whoever is making the claim, and here one side is your sampling.

Cheap check, and it is a different question from re-scoring. Histogram first_seen against your collection start, then split the 26 resolving rows by which branch produced each low. Clustering of before-hallucination lows at the boundary means the rule is reading its own start date.

On provenance: an attestation is still declaration-shaped. It binds a build workflow to a repo and says nothing about anything ever using the result. In that same log, 23 of 31 signing keys had zero accepted work and zero deliveries while emitting 1,402 of 1,535 capability declarations, or 91.3%. Declarations pool where they are cheap, so the usage join is worth designing before attestation becomes a trust input.

Can the before-hallucination lows be separated from your collection boundary using anything independent of when the sampling started?

Collapse
 
jj1423 profile image
jj1423 •

You're right on both, and the provenance one was worse than a future concern: an attestation costs an attacker one repo and a trusted-publisher config, and it short-circuited to low. Both branches are gone; age decides, and the classifier no longer takes either input. Findings withdrawn under either rule were re-scored, and two came back onto the list.

To your question: the only independent pin I can find is the model's training cutoff, which the vendor fixes and our sampling can't move. Registered after every emitting model's cutoff means the name was invented first and registered later, whenever we happened to look. But every model we sample has a cutoff older than our 60-day window, so anything that predates one is already old enough to clear by age. The branch had nothing honest left to do.

Your check is the right one, and I couldn't run it: the detector dropped lows instead of storing them, so whatever cleared through that branch left no trace. Lows are recorded now, with the branch that cleared each one, so the histogram you describe is something I can draw in a few weeks.

Collapse
 
hannune profile image
Tae Kim •

The "score is a statement about a moment" section is the one that's staying with me. I had essentially the same gap with entity merge scores: the classifier ran on insert and I never set up periodic re-scoring, so half a year of flags in the trusted bucket were stale. We only caught it because a downstream mismatch looked wrong enough to trace back. I'm now curious what the right re-run cadence looks like for something like your false-positive cases, where the score improved as a package aged but nothing automatically promoted or demoted it.

Collapse
 
jj1423 profile image
jj1423 •

Same gap here, honestly — the re-scoring existed but only ran when someone ran it. As of today it's a daily job, ahead of the dataset publish, so the site and the published dataset carry that day's verdicts.

How I picked "daily": find the narrowest window your score depends on and re-score well inside it. Ours flips at 14 and 60 days, so a day of lag is fine. Inputs that change outside your clock (for us, a scope gaining an owner or a build attestation appearing) get picked up by the same sweep.

What I haven't done is give each verdict its own expiry date. At our size a full sweep is cheap, so it wasn't worth it yet.