Last week I asked an AI coding session to add retry logic to a small webhook forwarder. It did, and it also suggested I npm install a package I'd never heard of to "handle backoff cleanly." The package existed. It had a README. It had been published 11 days ago by an account with one package and no other public history.
I didn't install it. But I noticed how close I came, because the suggestion arrived inline with otherwise-correct code, and my fingers were already on the keyboard.
This is the slopsquatting pattern: models occasionally hallucinate plausible package names, and squatters register those names hoping someone auto-installs the suggestion. The defense isn't willpower. It's a 60-line check that runs before the install command, every time, without me thinking about it.
Here's the canary I built, plus the workflow around it. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I run the AI side of this workflow on MonkeyCode because its free model access covers low-stakes tasks like this one and its free server option gives me somewhere to run the canary without touching my laptop — but the script below is registry-agnostic and works with any coding assistant, or with no assistant at all.
The workflow
- AI session proposes a change. If it touches
package.jsonor suggests an install, the package names go into a file, one per line. - The canary fetches npm registry metadata for each name and scores it against fixed thresholds.
- Anything that fails gets quarantined into a report. I review manually. Nothing auto-installs.
The rule that makes this work: the AI is never allowed to run its own install commands. Suggestions flow through the canary. That's it.
The canary (pkg-canary.py)
#!/usr/bin/env python3
"""Check npm package names against slopsquatting heuristics before install."""
import json, sys, urllib.request
from datetime import datetime, timezone
REGISTRY = "https://registry.npmjs.org"
MIN_AGE_DAYS = 180 # younger packages get flagged
MIN_VERSIONS = 3 # single-release packages get flagged
FIELDS = {} # filled per package
def fetch(name):
url = f"{REGISTRY}/{name}"
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.load(r)
except urllib.error.HTTPError as e:
return {"_error": e.code}
def age_days(iso):
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - dt).days
def check(name):
data = fetch(name)
flags = []
if data.get("_error") == 404:
return {"name": name, "verdict": "MISSING",
"flags": ["not in registry — likely hallucinated"]}
if "_error" in data:
return {"name": name, "verdict": "ERROR", "flags": [f"http {data['_error']}"]}
times = data.get("time", {})
versions = [v for v in data.get("versions", {})]
created = times.get("created")
if created and age_days(created) < MIN_AGE_DAYS:
flags.append(f"published {age_days(created)}d ago (< {MIN_AGE_DAYS}d)")
if len(versions) < MIN_VERSIONS:
flags.append(f"only {len(versions)} version(s)")
if not data.get("repository"):
flags.append("no repository field")
maintainers = data.get("maintainers", [])
if len(maintainers) <= 1:
flags.append("single maintainer")
verdict = "FLAGGED" if flags else "PASS"
return {"name": name, "verdict": verdict, "flags": flags,
"versions": len(versions),
"age_days": age_days(created) if created else None}
if __name__ == "__main__":
names = [l.strip() for l in open(sys.argv[1]) if l.strip() and not l.startswith("#")]
results = [check(n) for n in names]
print(json.dumps(results, indent=2))
if any(r["verdict"] in ("FLAGGED", "MISSING", "ERROR") for r in results):
sys.exit(1)
Run it like this:
printf 'lodash\nexpress-rate-limit\nsome-ai-suggested-pkg\n' > proposed.txt
python3 pkg-canary.py proposed.txt
Exit code 1 means "do not install until a human looks." I wire that into the session so a failing canary literally blocks the install step.
What a real flag looks like
Sanitized output from the webhook-forwarder incident:
{
"name": "<suggested-package>",
"verdict": "FLAGGED",
"flags": [
"published 11d ago (< 180d)",
"only 1 version(s)",
"no repository field",
"single maintainer"
],
"versions": 1,
"age_days": 11
}
Four flags on one package. I wrote the ten lines of backoff logic by hand instead. It took six minutes.
The decision table I actually use
The canary's output is a starting point, not a verdict. When something is flagged:
| Situation | My call |
|---|---|
| MISSING from registry | Hallucination. Ask the AI for the stdlib approach or do it by hand. |
| FLAGGED, but it's a famous package I mistyped | Fix the typo, re-run. |
| FLAGGED, unfamiliar, and the task is trivial | Write the code inline. Most "helper" packages save less time than a review costs. |
| FLAGGED, unfamiliar, and the task is genuinely hard | Manual audit: read the tarball, check the maintainer's history, search the name + "malicious". If still unsure, abandon the dependency. |
| PASS | Install — but PASS only means "didn't trip crude heuristics." |
Cost and time boundary
The whole loop — AI session proposing the change, canary run, my review — costs me nothing in metered spend because the generation step runs on free model access and the canary executes on the free server option. A canary run itself takes 2–4 seconds per package (registry latency dominates). My manual review of a flagged package is capped at 10 minutes; if I can't gain confidence in 10 minutes, the dependency dies. That cap matters — without it, "review" becomes an open-ended research project and I'll just install the thing to make the feeling stop.
If you're on a paid assistant, the marginal cost of the extra "give me the stdlib version instead" round-trip is a few cents at most. Either way, this is the cheapest security control I run.
Limitations, honestly
- Registry metadata is not safety. An aged, multi-version package can still be compromised or sold to a bad maintainer. The canary catches the fresh hallucination squat, which is one attack shape, not all of them.
- False positives are real. Brand-new legitimate packages from solo devs look exactly like squats to this script. That's why the verdict is "FLAGGED," not "BLOCKED" — the human decision table is the actual product here.
-
npm only. Porting to PyPI means changing the registry URL and field names (
info.requires_dist, upload times live elsewhere). Thirty minutes of work, but I haven't needed it yet. -
It does nothing for packages you already installed. This is a gate, not an audit. Run
npm auditand lockfile reviews separately.
Who should skip this: if you're on a team with a real dependency-firewall setup (an internal registry proxy, Sigstore/policy checks, Socket, etc.), use that. This canary is for solo builders whose current control is "vibes and package-lock.json." It's strictly better than vibes, and strictly worse than a proxy.
Where I'd take it next
The obvious upgrade is a download-count check from the npm downloads API — a package with 40 weekly downloads and one version is a different risk than one with 400k. I left it out because download counts are gameable and I didn't want the script implying confidence it hasn't earned.
If you've built a similar gate: what's the one signal you found that actually separated squats from honest new packages? Age and version count got me 80% there, but I suspect the last 20% is maintainer history, and I haven't found a clean way to score that yet.
Top comments (0)