DEV Community

Sam Rivera
Sam Rivera

Posted on

Build a Slopsquatting Canary Before You Trust AI-Suggested Packages

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

  1. AI session proposes a change. If it touches package.json or suggests an install, the package names go into a file, one per line.
  2. The canary fetches npm registry metadata for each name and scores it against fixed thresholds.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

Run it like this:

printf 'lodash\nexpress-rate-limit\nsome-ai-suggested-pkg\n' > proposed.txt
python3 pkg-canary.py proposed.txt
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 audit and 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)