Blog·
Eight things our slopsquatting scores got wrong, and what we changed
VDB keeps a public list of package names that language models recommend and that an attacker could register: slopsquatting candidates. Each one carries a risk score. Since we started publishing that list, and readers started taking it apart, we found eight places where the score said something the evidence did not support.
Most of them had the same shape. A rule that was meant to clear a name rested on something weaker than it looked: our own spelling, our own sampling, a fact an attacker can buy, or no fact at all. This post lists all eight, what we changed, and what is still open.
| We assumed | Why it was wrong | What we do now |
|---|---|---|
| A 404 is a target | kaTeX 404s; katex is real. 21 of our top 100 were spellings, not targets | Fold to the registry's form first; withdraw what exists |
| Any missing npm name is claimable | Only Auth0 can publish under @auth0 | A name in an owned scope scores medium |
| One spelling, one package | crates.io answers cdx_rs with cdx-rs | Ask the registry what it calls the package |
| A score holds once written | An official CNCF package sat on the list a month after it aged out | Every stored finding is re-scored daily, before the dataset publishes |
| A signed build statement clears a young package | Anyone can attest a build from their own repository | Provenance is shown, never scored |
| Registered before we saw the name means not a response to it | Our first sighting dates our sampling, not the suggestion | Age alone decides |
| No creation date, nothing to worry about | The Go proxy gives no dates, so every Go module was cleared | Undated scores medium; Go modules are dated from deps.dev |
| A low verdict needs no record | Nothing that cleared a name could be audited | Every low verdict is stored with the rule that produced it |
Part one: a 404 is not one fact
1. Spelling is not existence
npm answers 404 for kaTeX and 200 for katex. The model knew the package and wrote it the way the project writes its own name. Nobody can register the capitalised form, so it is not a target. When we looked at our hundred highest-risk entries before publishing them, 21 were spellings of real packages or not package names at all.
Now: every name is folded to the form its registry stores (lower case on npm, PEP 503 on PyPI) before it is looked up. A finding whose folded form exists is withdrawn. One whose folded form is also missing is filed under that form, because that is the name an attacker would register.
2. A scope changes who the attacker can be
@auth0/yup does not exist. But only Auth0 can publish under@auth0, so an install of it fails; it does not install something hostile.
Now: a missing name inside a scope that has an owner scores medium, and is counted separately.
3. The registry decides what a name is
crates.io answers a request for cdx_rs with the crate cdx-rs. We held one finding per spelling, withdrew one, and kept its twin.
Now: we ask the registry what it calls the package, and believe it.
Part two: a package that resolves needs a judgement
A 404 is one fact. A package that answers 200 is a question: is this the project the model meant, or someone who registered the name after a model started suggesting it? Our answer starts with age. A package registered in the last 14 days scores high, under 60 days medium, older than that low. Every mistake below was an exception to that ladder, a rule that let a young package off.
4. A signed build statement is not a reference
npm provenance and PyPI's PEP 740 attestations record that an artifact was built by a named workflow in a named repository. We treated that as a reason to clear a young package. It is not one. The attestation says which repository built the package, notwhose repository it is. An attacker can create a repository, configure a Trusted Publisher for it and publish a squatted name with a valid attestation in a few minutes, and that went straight to low.
Now: the classifier does not take provenance as an input at all, so no later change can quietly bring the rule back. The repository is still published with each finding, as built_from, for a reader to weigh.
5. Our first sighting is not the first suggestion
To stop flagging unrelated projects that happen to share a hallucinated name, we cleared any package registered before we first saw a model produce its name. The date we compared against was our own first sighting. That dates our sampling, not the world. Near the day our collection began, the rule read "registered before we started looking". A squatter who registered early and waited would have cleared through it.
The independent date would be the model's training cutoff, which the vendor fixes and our sampling cannot move. A name registered after every recommending model's cutoff was invented first and registered later. But every model we sample has a cutoff older than our 60-day window, so anything registered before one is already old enough to clear by age. The exception had nothing honest left to do.
Now: age alone decides. This costs us something: cdx-rs, a cd replacement published five days before a model offered the name for CycloneDX parsing, is flagged again. We cannot tell a coincidence from a squat by its registration date, and a list of attack targets should fail toward caution.
6. No date is not a safe date
When a registry confirmed a package existed but gave no creation date, we scored it low. That is a safe verdict on absence of evidence, and it applied to every Go module: the Go module proxy answers whether a module exists and nothing else. A module pushed yesterday cleared.
Now: an undated package scores medium. Go modules are dated from deps.dev, which lists every version the proxy has served with its time.
Part three: a score is a statement about a moment
7. A score has to be recomputed
opentelemetry-configuration, an official OpenTelemetry package, was 41 days old when we scored it medium. A month later it was well past our 60-day window, and still on the list, because nothing re-ran the classifier over what was already stored. If a scoring function reads a clock, something has to run it again. Ours existed as a cleanup job that ran only when someone remembered to run it.
Now: every stored finding is re-scored once a day, before the public dataset is regenerated, so the site, the API and the dataset carry the same day's verdicts. The score's boundaries are 14 and 60 days, so a day of lag is well inside them. The same run re-scored the findings withdrawn under rules 4 and 5, and two of them came back onto the list.
8. A verdict that clears a name needs a record
A finding is stored. A name that scored low was dropped. So when a reader suggested the obvious audit — compare the names each clearing rule let through against the date our collection began, and see whether they cluster at the boundary — we could not run it. Rule 5 had cleared names and left nothing behind.
Now: every low verdict is stored with the rule that produced it, the package's registration date and our first sighting. It is never published as a finding; it is there so the next rule we get wrong can be caught by looking.
What is still open
- Go dates are set by the author. The time deps.dev reports for a Go version is the commit's, and a commit's date is whatever its author wrote. npm and PyPI record when the registry received an upload, which the publisher cannot choose. A backdated Go module still scores as old. It is better than no date, and it is weaker than the other ecosystems.
- Age is a proxy. What we would like to know is whether anyone uses a package, not how long it has existed. We have not found a usage signal that is cheap to check, hard to fake and available across all four registries.
- The audit in rule 8 needs data. We will run it once a few weeks of low verdicts have accumulated, and publish what it shows.
The pattern
Every rule that flagged a name was easy to defend: a 404, a two-day-old package. Every rule we had to remove was one that cleared a name. They were added for good reasons, each to stop a specific false positive, and each rested on something nobody had asked the question of: can an attacker produce this, and does it actually describe the world or only us?
If you score anything for risk, that is the question worth asking of your exceptions. The rule that says "this one is fine" is the one an attacker reads.
Several of these were found by readers of our posts on dev.to; thank you. Each change is listed with its date in the changelog. To check a package before installing it, the API answers without a key.
Comments
No comments yet. Corrections are especially welcome.
Sign in to comment. No account?Sign up — it is free.