Dana asks her AI assistant for JSON validation code. It answers with a neat snippet and a one-line install: pip install flask-jsonschema-utils-lab. That package doesn’t exist on our lab registry. Dana opens a pull request, CI runs pip install -r requirements.txt, and if someone has registered that name in the meantime, their code runs inside your pipeline, next to your secrets.
This is called slopsquatting. You will learn what package hallucination is and why invented names can be registered, then reproduce the attack in a local lab and switch on CI gates one at a time to see which stop it and which don’t. Use it only on your own machine for authorized practice.
Everything runs on 127.0.0.1. The "AI assistant" is a recorded answer (no API key), and the "malicious" package is harmless: its hook writes a marker file and a log line. Nothing is ever published to PyPI or npm.
What is slopsquatting
A package hallucination is a code-generating model recommending a package that does not exist. Spracklen et al. studied it in "We Have a Package for You!" (arXiv 2406.10279, v3 dated 2 March 2025). I checked these numbers against the paper itself, not press coverage:
- 16 models and two prompt datasets produced 576,000 code samples in Python and JavaScript.
- The average hallucination rate was at least 5.2% for commercial and 21.7% for open-source models, with 205,474 unique non-existent package names in total.
- They took 500 prompts that had produced a hallucination and re-ran each one 10 times: 43% of those hallucinated names appeared in all 10 runs, 39% never came back, and 58% appeared more than once.
The repeats make this an attack. A name that comes back is one an attacker can predict, register on the public index, and wait for; the paper’s threat model is exactly that. The term "slopsquatting" is credited to Seth Larson of the Python Software Foundation (April 2025, per Wikipedia; I could not find his original post). Unlike typosquatting, the victim mistypes nothing, and unlike dependency confusion, no internal name collides. The assistant made the name up.
A 2026 single-author preprint (arXiv 2605.17062; I read only the abstract) reports overall rates of 4.62% to 6.10% for five newer models. The rate fell; the threat did not disappear.
Why install means code execution
Registering the name only matters if installing runs code, and for Python it often does. For a source distribution (sdist), pip builds through the PEP 517 build backend named in pyproject.toml, and a project can host that backend in its own tree with backend-path. So pip runs a Python file from the package it is about to install, as the user running pip install. npm has lifecycle scripts, which --ignore-scripts turns off. A wheel runs nothing at install time, but it can run code when imported, and a package that drops a .pth file into site-packages can have import lines run at every interpreter startup (site docs; deprecated in newer Pythons). So "wheels only" removes build-time execution, not execution. We use the import case later.
What is actually confirmed in the wild
As of 8 November 2026, I found no confirmed, attributed slopsquatting campaign against real victims. What I could confirm:
- Bar Lanyado (Lasso Security, 28 March 2024) uploaded an empty package under a name models kept suggesting,
huggingface-cli. It got more than 30,000 downloads in three months. No malicious use was reported. - Charlie Eriksen (Aikido, 21 January 2026) found
react-codeshift, a name an LLM conflated from two real packages, referenced by 237+ repositories through AI-written agent skills. He claimed it defensively: "Nobody got pwned." - Aikido’s roundup (20 February 2026) calls an npm package named
unused-imports"possibly" an intentional slopsquatting attack, intent unconfirmed. I couldn’t verify that independently.
So the demonstrated part is solid: models repeat invented names, and people and agents install them. The malicious part is plausible and cheap, not a proven campaign.
Lab setup: a registry open to any name
The lab models a fictional service, acme/report-tool. Three services stay up and listen on 127.0.0.1; three more run on demand:
| Service | Role | Port |
|---|---|---|
registry |
PyPI-style index standing in for the public one; open to any unclaimed name; seeded with two legitimate wheels | 3141 |
proxy |
Allowlist proxy in front of the registry (the fix) | 3142 |
assistant |
Mock AI assistant: one recorded answer, works offline | 8090 |
attacker |
Publishes the harmless look-alike package to the local registry | on demand |
ci |
The pipeline (ci/ci.sh), with fake CI_SECRET=FAKE-ci-secret-1234; MODE= picks the gates |
on demand |
detect |
Runs the detection rules over the registry, proxy and CI logs | on demand |
The "invented" name exists only on the local registry. Don’t register it, or a real assistant’s invented name, on a public index. Download the lab (link at the end), then start it:
unzip slopsquatting-ci-gate-lab.zip
cd slopsquatting-ci-gate
bash run.sh
It ends with Lab is up (127.0.0.1 only): and the three URLs. The first run needs internet once to pull python:3.12-slim.
The vulnerable pipeline is deliberately plain. This is the whole install step in MODE=vulnerable:
pip install --index-url http://registry:3141/simple/ --trusted-host registry --no-input -r /work/requirements.txt
No review of new dependencies, no lockfile, no hashes, no age check, scripts enabled, and CI_SECRET sits in the job environment.
Exploiting it
Mission 1: spot the invented name. Ask the assistant for the JSON validation code and find the dependency Dana would add.
Show solution
Solution: Mission 1
curl -s "http://127.0.0.1:8090/v1/suggest?task=json-validation"
The recorded answer contains from flask_jsonschema_utils import validate_payload and pip install flask-jsonschema-utils-lab. Neither approved dependency (acme-validate, acme-formatting) provides it. A new third-party name is the signal to stop and check.

Mission 2: is the name claimed? Ask the registry whether flask-jsonschema-utils-lab exists.
Show solution
Solution: Mission 2
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3141/simple/flask-jsonschema-utils-lab/
404: nobody owns the name. Registries are first come, first served, so the first publisher decides what it means.
Mission 3: play Mallory, then run the vulnerable pipeline. Publish the lab package and run CI on Dana’s PR. Find the proof that code ran, and what the hook could see.
Show solution
Solution: Mission 3
docker compose run --rm attacker --kind sdist
MODE=vulnerable docker compose run --rm ci
The attacker is a source distribution whose pyproject.toml points at an in-tree backend:
[build-system]
requires = []
build-backend = "backend"
backend-path = ["."]
pip imports backend.py and calls build_wheel, which first calls the lab’s harmless hook. The relevant lines (abridged, [...] marks skipped pip lines; the screenshot below shows the whole run):
[...]
[pip] Successfully installed acme-formatting-0.4.1 acme-validate-1.2.0 flask-jsonschema-utils-lab-1.0.0
[ci] test step
[test] selftest: {'title': 'Quarterly Numbers', 'rows': [1, 2]}
[ci] deploy step (stub): CI_SECRET present=yes, length 19. Nothing is deployed.
[lab-audit] marker file found: /tmp/pwned.marker
[lab-audit] LAB MARKER: code from flask-jsonschema-utils-lab ran during 'build_wheel (pip install of an sdist)'
[lab-audit] time=2026-11-08T13:06:07.052413+00:00 uid=0
[lab-audit] secret_like_env_names_visible=['CI_SECRET']
[lab-audit] (values are never read or written)
[lab-audit] RESULT mode=vulnerable pr=ai blocked_by=none marker=yes hook_saw=['CI_SECRET']
The build was green, the tests passed, and the marker exists. The build subprocess inherits the job’s environment, so any hook sees every variable the install step sees. The lab hook records only the names; a real attacker would not be so polite, which is why the install step should hold no secrets.

Fixing it
Each fix is an independent preset (MODE=<preset>), so you can test one layer at a time.
New-dependency diff with human approval
CI lists dependencies this PR adds and fails unless a reviewer has approved them in approved-deps.txt. In a real pipeline the input is git diff origin/main...HEAD -- requirements.txt.
[gate:diff] new dependency in this PR: flask-jsonschema-utils-lab -> NOT APPROVED
[gate:diff] BLOCK: new dependencies need human approval (add to approved-deps.txt in a reviewed PR)
Allowlist proxy
Point CI at an internal mirror that serves only approved names. Unknown names get a 403 and a log line, even if someone has registered them upstream.
# proxy/allowlist.txt
acme-validate
acme-formatting
pip’s view of the same event: ERROR: No matching distribution found for flask-jsonschema-utils-lab.
Age and provenance check
Ask the registry when a package was first uploaded and by whom; refuse anything younger than 30 days or from an unknown uploader. On a real index, provenance means verified publishers or attestations, not a username.
[gate:age] ok acme-validate: 971 days old
[gate:age] BLOCK flask-jsonschema-utils-lab: first upload 0.0 days ago (< 30.0 days)
Hashed lockfile
Install from a lockfile with every requirement pinned (==) and hashed, as pip’s hash-checking mode requires. The pipeline also fails when requirements.txt has names the lockfile lacks:
pip install --require-hashes -r requirements.lock
[gate:lockfile] BLOCK: requirements not in the hashed lockfile: flask-jsonschema-utils-lab
For npm, npm ci errors instead of updating a lockfile that disagrees with package.json.
No install scripts: wheels only
pip has no --ignore-scripts; refusing source distributions with pip install --only-binary=:all: is the closest control, since no build backend runs. The npm counterpart is npm ci --ignore-scripts. Both are cheap, and both have a hole you’ll see below.
No secrets in the install step
Run install and test without CI_SECRET; give it only to a separate deploy job that runs after everything passed. The nosecrets-only preset runs env -u CI_SECRET pip install .... Code still runs, but there’s nothing to steal.
Re-testing the fixes
Mission 4: predict, then run. Missions 4 and 5 need the attacker’s package on the local registry, so Mission 3 must have run first. If you ran --reset or cleanup.sh, publish it again with docker compose run --rm attacker --kind sdist; otherwise MODE=vulnerable fails with pip-install-failed and the gates look like they blocked for the wrong reason. For each preset, guess whether the attack gets through, then check (several minutes, run sequentially):
for m in diff-only proxy-only age-only hashes-only binary-only nosecrets-only fixed; do
MODE=$m docker compose run --rm ci 2>&1 | grep RESULT
done
Show solution
Solution: Mission 4
Every preset except nosecrets-only stops the sdist attack; nosecrets-only lets the code run with nothing to see (hook_saw=[]). MODE=fixed PR=benign still builds a normal PR.
Mission 5: beat the gates. Make the attacker more patient and more careful. Which gates survive?
MODE=age-only MIN_AGE_DAYS=0 docker compose run --rm ci # attacker waited 30 days
MODE=provenance-only MIN_AGE_DAYS=0 docker compose run --rm ci # ...but the uploader is unknown
MODE=hashes-regenerated docker compose run --rm ci # Dana regenerates the lockfile
docker compose run --rm attacker --reset
docker compose run --rm attacker --kind wheel # import-time hook, no build step
MODE=binary-only docker compose run --rm ci
APPROVE_NEW=1 MODE=fixed docker compose run --rm ci # reviewer rubber-stamps the dependency
Show solution
Solution: Mission 5
- Age alone fails against patience. At 0 days the package passes; the uploader check still blocks it.
- Hashes pin, they don’t vet. A lockfile regenerated from the PR holds the attacker’s hash, so the install passes with
marker=yes. - Wheels only is not enough. The hook runs when the test step imports the package:
ran during 'import (wheel variant)'. - A rubber-stamp is caught only by a separate control. With
APPROVE_NEW=1the diff gate passes, and the proxy still returns 403 (the age gate reportsregistry/proxy answered 403). This works only because the lab’sproxy/allowlist.txtis maintained separately from the approval step. If approving a dependency also adds it to the allowlist, only the age and uploader checks are left, and a patient attacker with a plausible uploader would pass them. Keep approval and allowlist changes as two reviewed steps.

scripts/layer_matrix.sh (about five minutes) runs every gate against both variants. This is its real output from the unzipped lab:

| Preset | sdist attack | wheel attack |
|---|---|---|
vulnerable |
runs | runs |
diff-only |
blocked (diff gate) | — |
proxy-only |
blocked (403) | — |
age-only (30 d) |
blocked | — |
age-only, patient attacker |
runs | — |
| age + uploader, patient attacker | blocked | — |
hashes-only |
blocked (drift) | — |
| hashes, lock regenerated from PR | runs | — |
binary-only |
blocked | runs (on import) |
nosecrets-only |
runs, sees no secret | runs, sees no secret |
fixed |
blocked | blocked |
fixed, reviewer approves the dependency |
blocked (proxy 403, reported by the age gate) | — |
fixed, benign PR |
builds normally | — |
A dash means that combination wasn’t run. fixed stops at the first gate that fails. The rubber-stamp row shows one later layer (the separately maintained allowlist) holding when the diff gate doesn’t; the lab does not show every layer covering every other.
Vulnerable vs. fixed
| Vulnerable | Fixed | |
|---|---|---|
| New dependency in a PR | Installed automatically | Needs reviewer approval |
| Index | Open registry, any name | Allowlist proxy, unknown names get 403 |
| Lockfile | None | Hashed, drift fails the build |
| Secrets during install | CI_SECRET visible |
Not present; deploy is a separate job |
| Against the lab attack | Marker written, secret name visible | Build stops, no marker |
Detecting this class of bug
The lab writes JSON logs for the registry, proxy and CI, and detect runs four rules over them. Run it after the vulnerable pipeline:
docker compose run --rm detect --last-run
ALERT [R1] run=1794143164-vulnerable new_dependency=flask-jsonschema-utils-lab age_days=0.0 not_in_allowlist=true
ALERT [R2] registry: flask-jsonschema-utils-lab first published 0.0 days ago was downloaded by 172.18.0.5 (pip/25.0.1 ...
ALERT [R4] run=1794143164-vulnerable install-time hook activity: ... stage=build_wheel (pip install of an sdist) package=flask-jsonschema-utils-lab
It exits with code 2 when anything fires (verified: exit=2). After MODE=proxy-only the signal is ALERT [R3] allowlist proxy refused 'flask-jsonschema-utils-lab'.

The core rule is R1: alert if new_dependency AND package_age_days < 30 AND not in allowlist. In the lab it is a few lines over the CI and registry data (condensed from detect/detect.py):
age = age_days(name) # from the registry's first upload time
if name not in allow and age is not None and age < 30:
alert(f"[R1] new_dependency={name} age_days={age:.1f} not_in_allowlist=true")
Mission 6: find the first-seen package yourself. (Needs jq on the host.) List every package name first uploaded to the registry, with its publisher.
Show solution
Solution: Mission 6
docker compose run --rm -T --entrypoint cat detect /data/logs/registry.log \
| jq -c 'select(.event=="upload" and .first_release) | {ts,package,uploader}'
You get the two seeded packages from acme-release-bot, then flask-jsonschema-utils-lab from mallory. A first upload from an unknown publisher that a build fetches within minutes is the pattern.
In a real environment the same ideas map to:
- PR and registry or proxy logs: diff manifests per PR; alert on names not in the allowlist, first-seen names, and packages published within N days of being installed.
- CI runtime: the lab’s R4 only reads a line its own hook writes. A real attacker won’t log, so you need sandboxing or runtime monitoring of the build.
Self-check questions
- Why does the 43% repeat rate matter more than the raw hallucination rate?
- How does slopsquatting differ from typosquatting and dependency confusion?
- Which gates still help if the attacker’s package has a plausible age?
- Why separate install and deploy jobs?
- What does "no confirmed in-the-wild campaign" change about how you present the risk?
Show answers
Answers
- A random one-off name is hard to target. A name that returns in all 10 re-runs (43% of the sampled hallucinations) can be predicted and registered in advance.
- Typosquatting needs the victim to mistype a real name, and dependency confusion needs an internal name that collides with a public one. In slopsquatting the victim types the name exactly as a tool suggested it.
- The new-dependency diff and the allowlist, because they don’t depend on age. The uploader or publisher check also helps. Age alone and wheels-only do not hold on their own.
- Anything that runs during install or tests sees the job’s environment. If secrets exist only in a later deploy job, a compromised install has nothing to steal.
- You present it as demonstrated and cheap, not as an active campaign. You don’t claim attacks you can’t cite.
Key takeaways
- An invented package name is a claim anyone can make true by registering it, and models repeat their inventions.
- Installing a source distribution runs code from the package. A wheel doesn’t at install, but it can on import.
- The new-dependency diff and the allowlist proxy are the strongest gates, because they ignore how old or well-behaved the attacker’s package looks.
- Age checks lose to patient attackers, hashes pin whatever you gave them, and wheels-only misses import-time code. Use them as layers.
- Keep secrets out of install and test steps. Deploy in a separate job.
- Verify that an AI-suggested package exists and is the one you meant before adding it.
- The lab shows the pipeline flaw, not real model behavior.
Lab: download and run it
What’s inside:
slopsquatting-ci-gate/
├── README.md
├── docker-compose.yml # registry, proxy, assistant; attacker, ci, detect on demand
├── run.sh / cleanup.sh # build + start / tear down
├── registry/ # PyPI-style registry, open to any name, seeds 2 legit wheels
├── proxy/ # allowlist proxy + allowlist.txt
├── assistant/ # mock AI assistant (recorded answer)
├── attacker/ # harmless look-alike package (sdist + wheel) and publisher
├── ci/ # ci.sh pipeline, gate.py, the "main" and "PR" repos
├── detect/ # detection rules R1-R4
└── scripts/layer_matrix.sh # every gate vs. every attack variant
Prerequisites: Docker with Compose v2 on Linux, macOS or WSL2, and ports 3141, 3142 and 8090 free on 127.0.0.1. Internet once, to pull python:3.12-slim. The commands use bash; on PowerShell set variables with $env:MODE = "...".
unzip slopsquatting-ci-gate-lab.zip
cd slopsquatting-ci-gate
bash run.sh
curl -s "http://127.0.0.1:8090/v1/suggest?task=json-validation"
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3141/simple/flask-jsonschema-utils-lab/
docker compose run --rm attacker --kind sdist
MODE=vulnerable docker compose run --rm ci
docker compose run --rm detect
MODE=fixed docker compose run --rm ci
bash scripts/layer_matrix.sh
Run these in order: the later commands need the package published by the attacker command (re-run it after any --reset). You should see Lab is up, a 404, then marker=yes for the vulnerable run, three ALERT lines, and blocked_by=gate:new-dependency-diff for the fixed run. The README lists every mission command. To stop and clean up:
bash cleanup.sh
This removes the containers, network, data volume (registry files and logs) and the images built for the lab. It listens on 127.0.0.1 only, contains only fake secrets, and never contacts a real registry.
References
- Spracklen et al., "We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs" (arXiv 2406.10279v3, 2 March 2025; primary source, PDF text extracted and numbers checked 2026-11-08)
- Churilov, "The Range Shrinks, the Threat Remains: Re-evaluating LLM Package Hallucinations on the 2026 Frontier-Model Cohort" (arXiv 2605.17062, submitted 16 May 2026; abstract only, single-author preprint; accessed 2026-11-08)
- Bar Lanyado, Lasso Security: write-up on AI package hallucinations and the
huggingface-cliexperiment (2024-03-28; accessed 2026-11-08) - Charlie Eriksen, Aikido: post on agent skills spreading a hallucinated
npxcommand (react-codeshift) (2026-01-21, updated 2026-03-17; accessed 2026-11-08) - Dania Durnas, Aikido: roundup on slopsquatting and AI package hallucination attacks (2026-02-20; accessed 2026-11-08)
- Wikipedia, "Slopsquatting" (term attribution to Seth Larson, unconfirmed against his own post; accessed 2026-11-08)
- Python docs:
site(.pthfiles processed at startup; accessed 2026-11-08) - PEP 517: A build-system independent format for source trees (in-tree backends,
backend-path; accessed 2026-11-08) - pip documentation: secure installs (
--require-hashes,--only-binary; accessed 2026-11-08) - npm documentation:
npm ci(lockfile mismatch behavior,ignore-scripts; accessed 2026-11-08)