GuardVibe
Blog
· 12 min read

Slopsquatting: When Your AI Invents a Package Name

AI agents invent npm package names, and attackers register them. Why the same fake names repeat, and how to catch phantom imports before npm install runs.

You ask your agent for an invoice PDF endpoint. It comes back with a clean route handler, a Zod schema, a Clerk auth check — and this line at the top:

import { renderInvoicePdf } from "next-pdf-renderer";

The code reads well. The function name is exactly what you would expect. Then the build fails with Module not found, and the agent, trying to be helpful, runs npm install next-pdf-renderer to fix it.

At the time of writing, next-pdf-renderer does not exist on npm; the registry returns a 404 for it. The model made it up. If the name were still unregistered, the install would fail and you would lose five minutes. If someone had registered it last week, the install would succeed, and whatever that package's postinstall script does would run on your machine, with your credentials, before you had read a single line of it.

That second scenario has a name: slopsquatting. This guide covers why models invent package names, why those names are predictable enough to attack, and how to catch phantom imports in a codebase you already have, before anything gets installed.

What slopsquatting is

Typosquatting relies on a human mistyping lodash as lodahs. Slopsquatting relies on a model confidently writing a package name that never existed. The attacker registers that name and waits for the next developer (or the next agent) to install it.

The term was coined by Seth Larson, the Python Software Foundation's Security Developer-in-Residence, as a mix of "AI slop" and "typosquatting". The attack itself had already been shown in practice. In 2024, Bar Lanyado of Lasso Security noticed that models kept recommending a PyPI package called huggingface-cli, which is not how Hugging Face distributes its CLI. He registered the name with an empty package. According to The Register's report, it received more than 15,000 downloads in three months, and install instructions for it turned up in the README of a public Alibaba repository. The package was harmless. A malicious one would have looked exactly the same from the outside.

Why models invent packages, and why the same ones keep coming back

The most detailed data comes from We Have a Package for You! (Spracklen et al., USENIX Security 2025). The researchers generated 576,000 code samples in Python and JavaScript across 16 models. Of 2.23 million package references in that code, 440,445 (19.7%) pointed to packages that did not exist. Commercial models hallucinated less, with an average of at least 5.2% compared with 21.7% for open-source models. JavaScript came out worse than Python: 21.3% against 15.8%.

Three findings from the paper matter more for attackers than the headline rate:

Hallucinations repeat. When the researchers ran the same prompt ten times, 43% of hallucinated packages showed up in all ten runs, and 58% showed up more than once. A name that appears every time is one an attacker can find and register. If hallucinations were random noise, this attack would not work. They are not random.

Most invented names are not typos. Only 13.4% of the hallucinated names were within an edit distance of 1–2 from a real package. Almost half (48.6%) were six or more edits away. That is why typosquat detection alone misses this: next-pdf-renderer does not look like a misspelling of anything. It looks like a package that ought to exist.

Names leak across ecosystems. 8.7% of hallucinated Python packages turned out to be valid JavaScript packages. A model that mixes up ecosystems can send a pip install to a name that means something completely different elsewhere, or the other way around.

The mechanism is easy to follow once you think about what the model is optimizing for. It produces the most plausible continuation of your code. next- + pdf + -renderer is a very plausible package name: it follows the naming patterns of thousands of real packages. Nothing in generation checks the name against the registry. Your prompt said "generate a PDF", not "use only dependencies already in package.json". So the model fills the gap the same way it fills every other gap, with whatever looks most typical. (We cover this pattern more broadly in why AI coding agents keep writing the same vulnerabilities.)

Agents make this worse in one specific way: they close the loop themselves. A human who sees an unfamiliar import might look it up. An agent that sees Module not found has an obvious fix available, which is to install the package. The hallucination and the install can happen in the same turn with no human in between.

What the attacker gets

On npm, code runs at two points, and the attacker gets both.

Install time. Lifecycle scripts (preinstall, install, postinstall) run during npm install unless scripts are disabled. At that point the script has the permissions of the user running the install: your shell environment, ~/.npmrc tokens, cloud CLI credentials, SSH keys, and .env files in the project. In CI it has whatever secrets the job was given.

{
  "name": "next-pdf-renderer",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "node setup.js"
  }
}

The setup.js that ships with a package like this does not need to be clever. Reading process.env and making one HTTPS request is enough.

Import time. Even with scripts disabled, the package's module code runs as soon as your app imports it, whether that happens in the dev server, in tests, or in production. --ignore-scripts closes the first door and does nothing about the second. A convincing slopsquat package also exports a working renderInvoicePdf, so nothing looks broken and nobody goes back to check.

That is the specific danger here: the package is supposed to be imported. It gets past review because the code that uses it is correct.

How to detect phantom imports in an existing codebase

A hallucinated package leaves a trace before it is installed. It is imported in source but not declared in any manifest. Human developers almost never produce this state on purpose, because they add the dependency first and import it second. AI-written code produces it all the time.

Step 1: list imports that no manifest declares

This runs from a project root and needs no network access:

# 1. Every bare package specifier imported in source
grep -rhoE "(from|import|require\()[[:space:]]*['\"][^./'\"][^'\"]*['\"]" \
  --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.mjs' \
  --exclude-dir=node_modules --exclude-dir=.next . \
  | sed -E "s/.*['\"]([^'\"]+)['\"]/\1/" \
  | grep -vE '^(node:|@/|~/)' \
  | awk -F/ '{ print ($1 ~ /^@/) ? $1"/"$2 : $1 }' \
  | sort -u > /tmp/imported.txt

# 2. Every package declared in package.json
node -e 'const p=require("./package.json");
  console.log(Object.keys({...p.dependencies,...p.devDependencies,...p.peerDependencies,...p.optionalDependencies}).join("\n"))' \
  | sort -u > /tmp/declared.txt

# 3. Imported but never declared
comm -23 /tmp/imported.txt /tmp/declared.txt

The awk step reduces subpath imports to the package name, so @clerk/nextjs/server becomes @clerk/nextjs and next/server becomes next. The grep -v removes the node: builtins and the common @/ and ~/ path aliases. If you use other aliases from tsconfig.json, add them to that pattern.

On a test fixture with one real hallucination, one typo, and one commented-out import, the output is:

commented-out-pkg
lodahs
next-pdf-renderer
zod-sanitize-html

Before relying on this, know its limits:

  • It is regex, not a parser. It matches commented-out imports (commented-out-pkg above) and imports that appear inside strings and template literals.
  • Builtins without the node: prefix show up. import fs from "fs" will be listed. Remove those by hand or add them to the exclusion.
  • Monorepos need every manifest. In a workspace, a package may be declared in packages/*/package.json and not at the root. Step 2 only reads the root manifest.
  • Dynamic imports with computed names (import(`./plugins/${name}`)) are invisible to it, and they cannot be resolved statically anyway.

Step 2: check each name against the registry

A name that is imported but not declared is only a candidate. The registry tells you which of three states it is in:

while read -r pkg; do
  created=$(npm view "$pkg" time.created 2>/dev/null)
  if [ -z "$created" ]; then echo "MISSING   $pkg"; continue; fi
  weekly=$(curl -s "https://api.npmjs.org/downloads/point/last-week/$pkg" \
    | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).downloads ?? 0))')
  echo "OK        $pkg  created=$created  weekly=$weekly"
done < /tmp/imported.txt

Here is how to read the results:

  • MISSING: the name is not on the registry (or npm view failed for some other reason, so check your network first). Right now, this is a hallucination nobody has claimed yet. It is also a name anyone can register. Remove the import. Do not "fix" it by publishing a placeholder of your own inside your team, and do not let the agent install it.
  • Recently created, low downloads: this is the pattern to look at most closely. A package created weeks ago with a few hundred weekly downloads, whose name matches something your agent invented, should be treated as hostile until you have read its source.
  • Old, high downloads: probably real. The agent may just have forgotten to add it to package.json.

One real result is worth mentioning. In our fixture, lodahs came back as OK with a 2019 creation date. It is a security holding package: someone registered the typo on purpose so an attacker could not. The registry state alone does not decide whether something is safe. You have to read what is there.

What a scanner can and cannot catch

GuardVibe's slopscan command runs the same two checks with a real parser in place of the regex. It ignores comments and template-literal bodies, and it adds typosquat matching against popular packages:

npx guardvibe slopscan . --offline   # phantom imports + typosquats, no network
npx guardvibe slopscan .             # adds npm registry lookups

On the same fixture, the offline run reports next-pdf-renderer and zod-sanitize-html as phantom imports and lodahs as a typosquat of lodash, and it skips the commented-out import. The online run marks the first two as nonexistent on the registry. It exits non-zero when it finds anything, so you can use it as a gate.

Here is what no scanner of this kind can catch, GuardVibe included:

  • A phantom import that was already "fixed". If the agent added the hallucinated name to package.json and installed it, it is no longer a phantom import. It is a declared dependency. From then on, the only signals left are registry metadata (age, downloads, maintainers) and human review of the package source.
  • The install that already happened. If postinstall ran on a developer laptop yesterday, a scan today finds the import but cannot undo what the script did. If you find a hallucinated name that resolves to a package someone else registered, and it was installed, treat it as a credential exposure and rotate.
  • Non-npm ecosystems. slopscan checks npm. A hallucinated pip install in a README or a Dockerfile needs its own check.

How to prevent it

In order of leverage:

1. Scan before the install, not after

The install is where the damage happens, so any check that runs after npm install is already too late for install-time payloads. Run the phantom-import check on the diff before dependencies are installed: in a pre-commit hook, as the first CI step (before npm ci), or as part of the agent's own workflow. If your agent can run shell commands, require explicit approval for install commands in its permission settings, so that "Module not found → npm install" is never a silent, automatic step.

2. Make new dependencies a reviewed event

Treat a pull request that adds a dependency as a different kind of change from one that only edits code. A few rules that work in practice:

  • The lockfile diff is reviewed, not collapsed.
  • Every new top-level dependency gets a one-line reason in the PR description, including a link to its repository.
  • A new dependency created less than 30 days ago needs a second reviewer.

3. Turn on release-age cooldowns

Both major package managers can now refuse versions that were published too recently. That also covers a package registered last week to match a popular hallucination.

npm 11.10.0 and later, in .npmrc:

min-release-age=7

pnpm 10.16.0 and later, in pnpm-workspace.yaml (the value is in minutes):

minimumReleaseAge: 10080
minimumReleaseAgeExclude:
  - "@myorg/*"

See the npm config reference and the pnpm dependency-resolution settings for exact behavior. A cooldown will not stop a patient attacker who registered a name months ago. It does remove the cheapest version of the attack: noticing a new hallucination and registering it the same day.

4. Stop running dependency scripts by default

Set ignore-scripts=true in .npmrc, and allowlist the few packages that really need a build step. With npm that means running their scripts explicitly. Recent pnpm versions gate dependency build scripts behind an explicit allowBuilds list instead of running them silently. This removes install-time execution. As covered above, it does nothing about import-time execution, so it complements the other steps and does not replace them.

5. Constrain the prompt

The research found that models are fairly good at spotting their own hallucinated packages when asked. Three of the four models tested caught them with over 75% accuracy. Use that. Put the rule where your agent reads it (CLAUDE.md, AGENTS.md, .cursor/rules):

## Dependencies
- Only import packages already listed in package.json.
- If a task needs a new package, stop and propose it: name, npm URL,
  weekly downloads, and why an existing dependency cannot do the job.
- Never run npm/pnpm/yarn install for a package you introduced in this session
  without explicit approval.

This is the cheapest control on the list. It is also the least reliable, because a rule in a context file is a request, not an enforcement mechanism. Keep the scan in step 1 even when the prompt rule is in place.

The takeaway

Slopsquatting works because of a gap. Dependency scanners check packages that are known and published against vulnerability databases, and a hallucinated name is neither of those until someone makes it one. The signal that closes the gap is simple: an import that no manifest declares. That signal appears before anything is installed, so the check belongs before the install too.

Run the phantom-import check against your current repo today. If it finds nothing, you have a baseline. If it finds something, look up every name on the list before anyone runs npm install. That includes your agent.

For more on the kinds of bugs that show up in AI-written code week to week, see our write-up on this week's fail-open authorization bugs.

Get the next one in your feed reader

Follow GuardVibe in your feed reader. No account, no email.