supply chain security

Detecting Typosquatting Packages

Flag misspelled, brandjacked, and typosquatted package names across npm, PyPI, and crates.io before installation using edit-distance, keyboard-proximity, and known-target corpus matching with typomania, OSSGadget, and pypi-scan.

dependency-screeningnpmossgadgetpackage-registrypypisupply-chain-securitytypomaniatyposquatting
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Authorized Use Only: This skill is for defensive software-supply-chain security, package screening, and authorized research. Use the corpus-matching and registry-query techniques here only against registries you are permitted to query at scale and packages you intend to evaluate for your own organization. Mass automated registry scraping may violate registry terms of service.

Overview

Typosquatting is a software-supply-chain attack (MITRE ATT&CK T1195.002 — Supply Chain Compromise: Compromise Software Supply Chain) in which an adversary publishes a malicious package whose name is a near-miss of a popular legitimate package — reqeusts for requests, python-sqlite for sqlite3, crossenv for cross-env. A developer who fat-fingers the name, copies a name from a poisoned tutorial, or trusts an AI-generated dependency list (the "slopsquatting" variant, where models hallucinate package names attackers then register) installs the squat instead. Because most ecosystems execute install-time scripts (postinstall in npm, setup.py/build hooks in PyPI), the payload runs immediately with the developer's privileges.

This skill covers proactive, pre-installation detection: screening a candidate package name against a corpus of popular/known-good names using the same name-mutation primitives attackers use, then triaging high-risk matches. The canonical open-source detector is typomania (Rust Foundation), a Rust port of the academic typogard tool ("Defending Against Package Typosquatting", University of Kansas); typomania powers crates.io's live typosquatting checks. Cross-ecosystem coverage comes from Microsoft OSSGadget's oss-find-squats and, for PyPI, IQTLabs pypi-scan. The ecosyste-ms typosquatting-dataset provides a curated ground-truth corpus of known squats mapped to their legitimate targets.

Sources: Rust Foundation typomania (https://github.com/rustfoundation/typomania), Microsoft OSSGadget, ecosyste-ms typosquatting-dataset, and OWASP CI/CD / SLSA supply-chain guidance.

When to Use

  • Before adding a new dependency to package.json, requirements.txt, pyproject.toml, or Cargo.toml
  • As a CI/CD gate that screens every newly introduced dependency name in a pull request
  • When triaging an AI-generated or tutorial-sourced dependency list ("slopsquatting" review)
  • During security review of a lockfile diff to catch a swapped or newly-pinned squat
  • When building a registry-side or proxy-side guardrail that blocks installs of suspected squats

Prerequisites

  • Rust toolchain (cargo) to build/use typomania, or a prebuilt OSSGadget release
  • Python 3.8+ for pypi-scan and the helper script in this skill
  • Network access to the target registry's public API (npmjs.org, pypi.org, crates.io)
  • A corpus of "popular" package names for the ecosystem (download counts or a top-N list)

Install the tooling:

# typomania (library + example harness) — Rust Foundation
git clone https://github.com/rustfoundation/typomania
cd typomania
cargo build --release
cargo run --example registry   # demonstrates the Harness against a fake registry
 
# OSSGadget (Microsoft) — cross-ecosystem squat finder
# Download a release binary, then:
oss-find-squats pkg:npm/requests          # purl syntax
oss-find-squats pkg:pypi/reqeusts
 
# pypi-scan (IQTLabs) — PyPI typosquat enumerator
git clone https://github.com/IQTLabs/pypi-scan
cd pypi-scan
pip install -r requirements.txt
 
# ecosyste-ms ground-truth dataset of known squats
git clone https://github.com/ecosyste-ms/typosquatting-dataset

Objectives

  • Generate the candidate squat set for a given legitimate name using the standard mutation primitives
  • Screen a candidate package name against a popular-name corpus and flag near-misses
  • Enrich each suspected squat with registry metadata (age, downloads, maintainer, install scripts)
  • Score and triage findings to suppress false positives (legitimate forks, scoped packages)
  • Wire the check into CI/CD as a blocking gate on new dependencies

MITRE ATT&CK Mapping

Technique ID Name Tactic
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Initial Access

A typosquatted dependency is the delivery vehicle for T1195.002: the attacker compromises the victim's supply chain not by breaching a real package but by getting a malicious look-alike installed in its place. Related downstream behavior frequently includes T1059 (Command and Scripting Interpreter) via install hooks and T1041/T1567 (Exfiltration) of tokens and environment variables.

Workflow

Step 1: Build the popular-name corpus

The detector needs a reference set of legitimate names to compare against. Pull top packages by download count for the ecosystem.

# PyPI: top packages dataset (Hugo van Kemenade's top-pypi-packages)
curl -s https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json \
  -o top-pypi-packages.json
 
# npm: query the registry's most-depended-upon search
curl -s 'https://registry.npmjs.org/-/v1/search?text=not:unstable&popularity=1.0&size=250' \
  -o npm-top.json
 
# crates.io: top crates by downloads
curl -s 'https://crates.io/api/v1/crates?sort=downloads&per_page=100' \
  -H 'User-Agent: typosquat-screen (security@example.com)' -o crates-top.json

Step 2: Generate candidate squats with the standard mutation primitives

typomania/typogard apply a fixed set of name transformations that mirror real attacker behavior. Reproduce them to understand what a screen must catch:

1. Repeated characters     requests  -> reqquests
2. Omitted characters      requests  -> requsts
3. Swapped/transposed      requests  -> reqeusts
4. Swapped words           python-dateutil -> dateutil-python
5. Common typos (1-edit)   requests  -> rewuests   (keyboard adjacency)
6. Homophones / vowel swap requests  -> requeasts
7. Version / suffix tricks lodash    -> lodashs, lodash-js
8. Delimiter swaps         cross-env -> crossenv, cross_env
9. Scope confusion (npm)   @types/node -> types-node

Run typomania's harness, which implements these as reusable primitives behind the Corpus and Harness traits:

# In the typomania checkout: feed your popular corpus, then check a name.
# The Harness::check method (parallelized via rayon) compares the candidate
# against every corpus entry using the squatting primitives.
cargo run --example registry -- --corpus top-pypi-packages.json --name reqeusts

Step 3: Screen with OSSGadget oss-find-squats

OSSGadget queries the live registry, generates mutations of the supplied package, and reports which mutated names actually exist as published packages.

# Find names that squat on a legitimate package, checking which exist in the registry
oss-find-squats pkg:npm/lodash
oss-find-squats pkg:pypi/requests
 
# Reverse direction: given a SUSPECT name, find the legitimate package it mimics
oss-find-squats --quiet pkg:npm/loadsh

Step 4: Enumerate PyPI squats with pypi-scan

cd pypi-scan
# Find candidate typosquats of a specific package
python pypi_scan.py -p requests
 
# Scan the top-N most-downloaded PyPI packages for existing squats
python pypi_scan.py -n 50

Step 5: Enrich suspected squats with registry metadata

A near-miss name is only suspicious if it is also young, low-download, or ships install scripts. Pull metadata to triage:

# npm package metadata: creation time, maintainers, scripts
curl -s https://registry.npmjs.org/loadsh | \
  python -c 'import sys,json;d=json.load(sys.stdin);v=d["dist-tags"]["latest"];print("created:",d["time"]["created"]);print("scripts:",d["versions"][v].get("scripts",{}))'
 
# npm download counts (last week)
curl -s https://api.npmjs.org/downloads/point/last-week/loadsh
 
# PyPI JSON API: release history and author
curl -s https://pypi.org/pypi/reqeusts/json | \
  python -c 'import sys,json;d=json.load(sys.stdin);i=d["info"];print(i["name"],i["author"],i["home_page"]);print("releases:",list(d["releases"].keys()))'

Step 6: Score, triage, and confirm

Combine signals into a risk score. High risk = small edit distance to a popular name AND (package age < 90 days OR downloads < 1000 OR presence of postinstall/preinstall/setup-time network calls). Cross-check against the ecosyste-ms known-squats dataset:

# Is this name a documented squat?
grep -i 'loadsh' typosquatting-dataset/data/*.csv

Confirm malicious intent by inspecting (in a sandbox/VM only) the install scripts and source tarball — never npm install or pip install a suspect on your workstation.

Step 7: Enforce in CI/CD

Add a blocking gate that screens every new dependency name introduced by a PR:

# .github/workflows/typosquat-gate.yml
name: typosquat-gate
on: [pull_request]
jobs:
  screen:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Screen new dependencies
        run: |
          git diff origin/${{ github.base_ref }}...HEAD -- package.json requirements.txt \
            | grep '^+' | python scripts/agent.py screen --ecosystem npm --corpus top.json --stdin

Tools and Resources

Tool Purpose Source
typomania Rust typosquat-detection library (powers crates.io) https://github.com/rustfoundation/typomania
OSSGadget oss-find-squats Cross-ecosystem squat finder (purl) https://github.com/microsoft/OSSGadget
pypi-scan PyPI typosquat enumerator https://github.com/IQTLabs/pypi-scan
ecosyste-ms typosquatting-dataset Curated known-squat ground truth https://github.com/ecosyste-ms/typosquatting-dataset
top-pypi-packages PyPI popular-name corpus https://hugovk.github.io/top-pypi-packages/
OWASP CI/CD Security Top 10 Supply-chain control guidance https://owasp.org/www-project-top-10-ci-cd-security-risks/

Mutation Primitives Reference

Primitive Legit Squat Why it works
Transposition requests reqeusts Common typing slip
Omission requests requsts Dropped character
Repetition requests reqquests Stuck key
Delimiter swap cross-env crossenv Hyphen vs none ambiguity
Word order python-dateutil dateutil-python Reordered compound name
Homoglyph/vowel requests requeasts Visual/phonetic similarity
Suffix/scope lodash lodash-js, loadsh Plausible "official" variant

Validation Criteria

  • Popular-name corpus downloaded for each in-scope ecosystem
  • Mutation primitives reproduced and a known squat (e.g., loadsh) is correctly flagged
  • typomania / OSSGadget / pypi-scan run against at least one real package
  • Suspected squats enriched with age, download, and install-script metadata
  • Findings cross-checked against the ecosyste-ms known-squats dataset
  • Risk scoring suppresses obvious false positives (legit scoped/forked packages)
  • CI/CD gate screens new dependency names on every pull request
  • No suspect package installed outside a disposable sandbox
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 2

api-reference.md2.1 KB

API and Command Reference - Typosquatting Detection

typomania (Rust library)

Item Description
Harness Primary struct; Harness::check(name) compares a candidate against the corpus
Corpus trait Implement to provide the popular-name reference set for any registry
Package trait Implement to expose a package's name/metadata to the harness
rayon feature Enabled by default; parallelizes Harness::check across many packages
cargo run --example registry Runs the bundled example against a fake registry

OSSGadget oss-find-squats

Command Purpose
oss-find-squats pkg:npm/<name> Generate squat candidates of an npm package and report which exist
oss-find-squats pkg:pypi/<name> Same for PyPI
oss-find-squats pkg:cargo/<name> Same for crates.io
oss-find-squats --quiet <purl> Suppress non-finding output

Package URL (purl) format: pkg:<type>/<namespace>/<name>@<version>

pypi-scan (IQTLabs)

Command Purpose
python pypi_scan.py -p <package> Find candidate typosquats of one package
python pypi_scan.py -n <N> Scan the top-N most-downloaded PyPI packages

Registry metadata endpoints

Endpoint Returns
https://registry.npmjs.org/<pkg> Full npm package doc (time, maintainers, versions, scripts)
https://api.npmjs.org/downloads/point/last-week/<pkg> npm weekly download count
https://pypi.org/pypi/<pkg>/json PyPI info, releases, author, urls
https://crates.io/api/v1/crates/<pkg> crates.io crate metadata (requires User-Agent)
https://registry.npmjs.org/-/v1/search?text=...&popularity=1.0 npm popularity search

Risk-scoring signals

Signal High-risk value
Edit distance to popular name 1–2
Package age < 90 days
Weekly downloads < 1000
Install scripts preinstall / postinstall / network in setup.py
Maintainer overlap Different maintainer than legit package
Repository URL Missing, or points to legit project (impersonation)
standards.md1.5 KB

Standards and References - Detecting Typosquatting Packages

MITRE ATT&CK

Technique ID Name Tactic Rationale
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Initial Access A typosquatted package is the malicious artifact the attacker gets installed in place of the legitimate dependency, compromising the victim's software supply chain at install time.

NIST Cybersecurity Framework 2.0

ID Name Rationale
ID.RA-09 The authenticity and integrity of hardware and software is assessed prior to acquisition and use Pre-installation typosquat screening is exactly the activity of assessing a package's authenticity before it enters the environment.

Official Resources

Key Research

  • Rust Foundation: typomania powers crates.io typosquatting detection
  • JFrog / Sonatype: annual reports on registry typosquatting and "slopsquatting" trends

Scripts 1

agent.py7.3 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""
Typosquatting screening helper.

Generates the standard typogard/typomania mutation set for a candidate package
name, screens names against a popular-name corpus, and enriches suspected
squats with live registry metadata (npm / PyPI) to support triage.

No third-party dependencies required (stdlib only). Network calls use urllib.

Examples:
    # Screen a single name against a PyPI corpus
    python agent.py screen --ecosystem pypi --corpus top-pypi-packages.json --name reqeusts

    # Screen names from stdin (e.g., a diff of requirements.txt)
    git diff | python agent.py screen --ecosystem npm --corpus npm-top.json --stdin

    # Show the generated mutation set for a legit name
    python agent.py mutate --name requests

    # Pull registry metadata for triage
    python agent.py enrich --ecosystem pypi --name reqeusts
"""
import argparse
import json
import sys
import urllib.request
import urllib.error

QWERTY_ADJ = {
    "q": "wa", "w": "qeas", "e": "wrsd", "r": "etdf", "t": "ryfg",
    "y": "tugh", "u": "yihj", "i": "uojk", "o": "ipkl", "p": "ol",
    "a": "qwsz", "s": "awedxz", "d": "serfcx", "f": "drtgvc",
    "g": "ftyhbv", "h": "gyujnb", "j": "huikmn", "k": "jiolm",
    "l": "kop", "z": "asx", "x": "zsdc", "c": "xdfv", "v": "cfgb",
    "b": "vghn", "n": "bhjm", "m": "njk",
    "0": "9", "1": "2", "2": "13", "3": "24", "4": "35",
}
DELIMS = ["-", "_", ".", ""]


def mutations(name):
    """Reproduce the typogard/typomania squat primitives for `name`."""
    out = set()
    n = name.lower()
    # 1. omission
    for i in range(len(n)):
        out.add(n[:i] + n[i + 1:])
    # 2. repetition
    for i in range(len(n)):
        out.add(n[:i] + n[i] + n[i:])
    # 3. transposition / swap of adjacent chars
    for i in range(len(n) - 1):
        out.add(n[:i] + n[i + 1] + n[i] + n[i + 2:])
    # 4. keyboard-adjacency 1-edit substitution
    for i, ch in enumerate(n):
        for repl in QWERTY_ADJ.get(ch, ""):
            out.add(n[:i] + repl + n[i + 1:])
    # 5. delimiter swaps
    for d in DELIMS:
        for d2 in DELIMS:
            if d and d in n:
                out.add(n.replace(d, d2))
    # 6. word-order swap for compound names
    for d in ("-", "_", "."):
        if d in n:
            parts = n.split(d)
            if len(parts) == 2:
                out.add(d.join(reversed(parts)))
    # 7. common suffixes
    for suf in ("js", "py", "lib", "cli", "2", "-ng"):
        out.add(n + suf)
        out.add(n + "-" + suf)
    out.discard(n)
    return {m for m in out if m}


def levenshtein(a, b):
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def load_corpus(path):
    with open(path, "r", encoding="utf-8") as fh:
        data = json.load(fh)
    names = set()
    # Accept several common corpus shapes
    if isinstance(data, dict) and "rows" in data:          # top-pypi-packages
        names = {r["project"] for r in data["rows"]}
    elif isinstance(data, dict) and "objects" in data:     # npm search
        names = {o["package"]["name"] for o in data["objects"]}
    elif isinstance(data, list):
        names = {(x if isinstance(x, str) else x.get("name", "")) for x in data}
    elif isinstance(data, dict):
        names = set(data.keys())
    return {n.lower() for n in names if n}


def http_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": "typosquat-screen/1.0"})
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode("utf-8"))


def enrich(ecosystem, name):
    """Pull triage metadata from the live registry."""
    try:
        if ecosystem == "npm":
            d = http_json(f"https://registry.npmjs.org/{name}")
            latest = d.get("dist-tags", {}).get("latest")
            ver = d.get("versions", {}).get(latest, {})
            return {
                "name": name,
                "created": d.get("time", {}).get("created"),
                "maintainers": [m.get("name") for m in d.get("maintainers", [])],
                "scripts": ver.get("scripts", {}),
                "repository": ver.get("repository"),
            }
        elif ecosystem == "pypi":
            d = http_json(f"https://pypi.org/pypi/{name}/json")
            info = d.get("info", {})
            return {
                "name": name,
                "author": info.get("author"),
                "home_page": info.get("home_page"),
                "releases": list(d.get("releases", {}).keys()),
                "project_urls": info.get("project_urls"),
            }
    except urllib.error.HTTPError as exc:
        return {"name": name, "error": f"HTTP {exc.code} (name may not exist)"}
    except Exception as exc:  # noqa: BLE001
        return {"name": name, "error": str(exc)}
    return {"name": name, "error": "unsupported ecosystem"}


def screen_name(name, corpus, threshold=2):
    """Return list of (legit_name, distance) the candidate squats on."""
    name = name.lower().strip()
    hits = []
    if name in corpus:
        return []  # exact legit match is not a squat
    for legit in corpus:
        d = levenshtein(name, legit)
        if 0 < d <= threshold and abs(len(name) - len(legit)) <= threshold:
            hits.append((legit, d))
    hits.sort(key=lambda x: x[1])
    return hits


def cmd_mutate(args):
    for m in sorted(mutations(args.name)):
        print(m)


def cmd_screen(args):
    corpus = load_corpus(args.corpus)
    candidates = []
    if args.stdin:
        for line in sys.stdin:
            for tok in line.replace('"', " ").replace(",", " ").split():
                tok = tok.strip("+-=:'`@^~>< \t")
                if tok and tok[0].isalpha():
                    candidates.append(tok)
    if args.name:
        candidates.append(args.name)
    flagged = 0
    for cand in dict.fromkeys(candidates):
        hits = screen_name(cand, corpus, args.threshold)
        if hits:
            flagged += 1
            top = ", ".join(f"{l}(d={d})" for l, d in hits[:3])
            print(f"[FLAG] {cand} -> resembles {top}")
    print(f"\nScreened {len(set(candidates))} name(s), flagged {flagged}.", file=sys.stderr)
    sys.exit(1 if flagged else 0)


def cmd_enrich(args):
    print(json.dumps(enrich(args.ecosystem, args.name), indent=2))


def main():
    p = argparse.ArgumentParser(description="Typosquatting screening helper")
    sub = p.add_subparsers(dest="cmd", required=True)

    m = sub.add_parser("mutate", help="Print squat mutations of a name")
    m.add_argument("--name", required=True)
    m.set_defaults(func=cmd_mutate)

    s = sub.add_parser("screen", help="Screen names against a popular-name corpus")
    s.add_argument("--corpus", required=True)
    s.add_argument("--ecosystem", choices=["npm", "pypi", "cargo"], default="pypi")
    s.add_argument("--name")
    s.add_argument("--stdin", action="store_true")
    s.add_argument("--threshold", type=int, default=2)
    s.set_defaults(func=cmd_screen)

    e = sub.add_parser("enrich", help="Pull registry metadata for triage")
    e.add_argument("--ecosystem", choices=["npm", "pypi"], required=True)
    e.add_argument("--name", required=True)
    e.set_defaults(func=cmd_enrich)

    args = p.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
Keep exploring