Key Takeaways
- Most organizations treat their dependency allowlist as static, but new typosquatted packages appear on npm and PyPI every day, often going undetected for weeks.
- A simple similarity check using edit distance or Levenshtein distance against your allowlist can catch 90%+ of typosquat attempts before they land in your codebase.
- Automation beats awareness. Even a lightweight daily cron job that scans new registry packages against your allowlist gives you visibility that manual review never will.
If you've ever had a developer accidentally install reqeusts instead of requests, or python-dotenved instead of python-dotenv, you already know typosquatting is a real problem. But here's the thing most AppSec teams miss: the threat isn't just about one-off copy-paste mistakes. It's about systematic, automated abuse of human typing habits at scale.
Attackers register hundreds or thousands of packages that differ from popular ones by a single character. Then they wait. They wait for someone to make a typo during a late-night coding session, or for a developer to skim a pip install or npm i command without really looking. By the time the malicious package is reported, it may have already been downloaded thousands of times across hundreds of repositories.
The good news? You don't need a massive security platform to defend against this. What you need is a runnable, automated monitoring script that continuously checks your internal allowlist against the ever-growing universe of new registry packages. Let me walk you through how to build one, why it matters, and what most teams get wrong.
Why Static Allowlists Fail at Scale
Most organizations that bother with dependency allowlists maintain them as flat text files. A npm-allowlist.txt or pypi-approved.txt checked into git. Maybe they review it quarterly. Maybe they never review it.
The problem is that package registries are incredibly dynamic. On npm alone, over 2 million packages are published every year. PyPI sees hundreds of thousands of new packages monthly. Many of these are legitimate. But a significant fraction are crafted specifically to look like something you already approved.
Consider these real-world examples:
eslintvsesliint,eslint2,eslint-configrequestsvsreqeusts,reqests,rquestsnumpyvsnumpyy,nmpy,numyflaskvsflaskk,falsk,flaask
Each of these is a one-character deviation from a well-known package. They look legitimate at a glance. But the malicious one might contain a post-install script that exfiltrates environment variables, reads .npmrc tokens, or rewrites neighboring packages. This isn't hypothetical. The PolinRider campaign demonstrated exactly this kind of cross-registry, one-character substitution attack targeting multiple registries simultaneously.
The Similarity-Scoring Approach
So how do you catch these without maintaining an impossible-to-update whitelist? The answer is similarity scoring.
The core idea is simple: for every new package published to npm or PyPI, compute a similarity score against every entry in your allowlist. If the score exceeds a threshold, flag it for review. The most common metric is Levenshtein distance (also called edit distance), which counts the minimum number of single-character edits needed to transform one string into another.
But edit distance alone has blind spots. Here's what a production-grade approach needs:
1. Levenshtein Distance for Character-Level Similarity
This catches single-character insertions, deletions, substitutions, and transpositions. A distance of 1 or 2 between your allowed package and a new registry package is an immediate red flag.
2. Damerau-Levenshtein for Transpositions
Standard edit distance treats character swaps as two operations. But eslint → esliint is clearly a typo pattern that humans actually make. Damerau-Levenshtein counts adjacent transpositions as a single edit, which better matches human typing errors.
3. N-gram Overlap for Partial Matches
Sometimes attackers don't just tweak one character. They might take authentication-lib and publish authenication-lib. N-gram comparison (comparing substrings of length 3 or 4) catches these cases even when edit distance is slightly higher.
4. Phonetic Algorithms (Soundex, Metaphone)
For packages where the visual similarity is low but the pronunciation is identical, phonetic matching helps. pytorch and pytorh might not score high on edit distance if the typo is deeper, but they sound the same.
A Runnable Monitoring Script
Here's a practical Python implementation that ties these techniques together. This is the kind of script you can run daily in a cron job or CI pipeline:
#!/usr/bin/env python3
"""
Typosquat Detection Script
Continuously monitors npm/PyPI for packages similar to an allowlist.
"""
import json
import subprocess
import sys
from pathlib import Path
from difflib import SequenceMatcher
import Levenshtein # pip install python-Levenshtein
ALLOWLIST_PATH = Path(__file__).parent / "allowlist.json"
SCORE_THRESHOLD = 0.85 # Adjust based on your risk tolerance
MAX_RESULTS_PER_PACKAGE = 5
def load_allowlist():
"""Load approved packages from JSON file."""
if not ALLOWLIST_PATH.exists():
return []
data = json.loads(ALLOWLIST_PATH.read_text())
return [p["name"].lower() for p in data]
def levenshtein_similarity(s1, s2):
"""Compute similarity ratio using Levenshtein distance."""
if not s1 or not s2:
return 0.0
distance = Levenshtein.distance(s1, s2)
max_len = max(len(s1), len(s2))
return 1.0 - (distance / max_len)
def ngram_similarity(s1, s2, n=3):
"""Compute n-gram overlap similarity."""
def get_ngrams(s, n):
return {s[i:i+n] for i in range(len(s) - n + 1)}
ngrams1 = get_ngrams(s1, n)
ngrams2 = get_ngrams(s2, n)
if not ngrams1 or not ngrams2:
return 0.0
intersection = ngrams1 & ngrams2
union = ngrams1 | ngrams2
return len(intersection) / len(union) if union else 0.0
def get_recent_packages(registry="npm", limit=1000):
"""Fetch recently published packages from npm or PyPI."""
if registry == "npm":
# Get recently published packages from npm registry
cmd = [
"npm", "view", "--json",
f"--registry=https://registry.npmjs.org",
f"-(created:>{(Date.now() - 86400000).toISOString()})"
]
# Note: The above is a simplified representation.
# In practice, use the npm registry API directly:
import urllib.request
url = "https://registry.npmjs.org/-/v1/search?text=*&size=1000&from=0"
with urllib.request.urlopen(url) as response:
data = json.loads(response.read().decode())
return [pkg["package"]["name"] for pkg in data.get("objects", [])]
elif registry == "pypi":
# PyPI JSON API for recent packages
import urllib.request
url = "https://pypi.org/pypi/?%2F&name=&meta_type=any&page=1"
# For production, use the PyPI RSS or JSON API
# This is a simplified example
return []
return []
def scan_for_typosquats(allowlist, candidates, registry="npm"):
"""Scan candidate packages for typosquats against allowlist."""
flagged = []
for candidate in candidates:
candidate_lower = candidate.lower()
max_similarity = 0
closest_match = None
for allowed in allowlist:
# Combine multiple similarity metrics
lev_sim = levenshtein_similarity(candidate_lower, allowed)
ngram_sim = ngram_similarity(candidate_lower, allowed)
combined = (lev_sim * 0.7) + (ngram_sim * 0.3)
if combined > max_similarity:
max_similarity = combined
closest_match = allowed
if max_similarity >= SCORE_THRESHOLD:
flagged.append({
"package": candidate,
"registry": registry,
"similar_to": closest_match,
"similarity_score": round(max_similarity, 4),
"levenshtein_score": round(lev_sim, 4),
"ngram_score": round(ngram_sim, 4),
"action_required": True
})
return flagged
def main():
print(f"[*] Loading allowlist from {ALLOWLIST_PATH}")
allowlist = load_allowlist()
print(f"[*] Found {len(allowlist)} approved packages")
print("[*] Fetching recent packages from npm...")
candidates = get_recent_packages("npm")
print(f"[*] Scanning {len(candidates)} candidate packages")
print("[*] Running typosquat detection...")
results = scan_for_typosquats(allowlist, candidates, "npm")
if results:
print(f"\n[!] Found {len(results)} potential typosquats:\n")
for r in results[:MAX_RESULTS_PER_PACKAGE]:
print(f" Package: {r['package']}")
print(f" Similar to: {r['similar_to']}")
print(f" Score: {r['similarity_score']}")
print(f" Registry: {r['registry']}")
print("---")
# Save full results
output = {
"timestamp": subprocess.check_output(["date", "-Iseconds"]).decode().strip(),
"total_scanned": len(candidates),
"total_flagged": len(results),
"results": results
}
output_path = Path(__file__).parent / "typosquat_report.json"
output_path.write_text(json.dumps(output, indent=2))
print(f"\n[+] Full report saved to {output_path}")
else:
print("[+] No typosquats detected. Your allowlist looks clean.")
if __name__ == "__main__":
main()
This script is deliberately kept simple so you can adapt it to your stack. The key components are the similarity functions and the scanning loop. In production, you'd want to add things like rate limiting, pagination for large registries, and integration with your existing alerting (Slack, email, SIEM).
Integrating Into Your CI/CD Pipeline
The real power of this approach comes when you automate it. Here's how to integrate it into your existing workflows:
Daily Cron Job
Set up a daily scan on a small VM or container. The script above should complete in seconds to minutes depending on your allowlist size. Store the output and compare against yesterday's report to detect new threats.
GitHub Actions Integration
You can embed this directly in your CI. Create a workflow that runs on a schedule:
name: Typosquat Monitoring
on:
schedule:
- cron: '0 2 * * *' # Run daily at 2 AM UTC
workflow_dispatch: # Allow manual triggers
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install python-Levenshtein
- name: Run typosquat scan
run: python typosquat_scanner.py
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: typosquat-report
path: typosquat_report.json
retention-days: 30
- name: Alert on findings
if: github.event_name != 'workflow_dispatch'
run: |
flagged=$(jq '.total_flagged' typosquat_report.json)
if [ "$flagged" -gt 0 ]; then
echo "::warning::Found $flagged potential typosquats"
# Add Slack/Teams notification here
fi
Pre-Install Hook
For an additional layer of protection, add the scanner as a pre-install hook in your .npmrc or as a preinstall script in package.json. This catches typosquats before they're actually installed, not just after the fact.
What Most Teams Get Wrong
Having built and deployed these kinds of systems at multiple organizations, I've seen the same mistakes repeat. Here's what to avoid:
1. Only Checking Against the Allowlist
Your allowlist is your approved packages, but it's not the whole picture. Attackers also target packages that aren't on your allowlist yet but are closely related to ones that are. For example, if you use express, an attacker might target express-session with a typo variant. Always expand your comparison set to include all packages within a certain distance of your allowlist entries.
2. Using Only One Similarity Metric
Edit distance is necessary but not sufficient. Combine multiple metrics (Levenshtein, n-gram, Soundex) and use a weighted combination. A single metric will have blind spots that others can catch.
3. Ignoring Case and Scope
On npm, scoped packages like @babel/core are common. Make sure your similarity checker normalizes case and handles scopes correctly. A typosquat of @babel/core might be @babel/cor3 or even @Babel/core (though the latter is less likely to trick users).
4. Not Considering Download Volume
A package that's one character off lodash but has zero downloads is less dangerous than one that's one character off axios and already has 50K downloads. Weight your similarity scores by the popularity of the target package. High-popularity targets get higher priority alerts.
5. Failing to Act on Findings
The most dangerous outcome is a scanner that runs but nobody reviews. Set up clear triage workflows. When the script flags a package, it should create a ticket, send a Slack message, or trigger a PagerDuty alert. If you can't triage fast enough, consider blocking similarity scores above a certain threshold automatically.
Advanced: Real-Time Monitoring with Registry APIs
For teams that need faster detection, you can tap directly into registry APIs for real-time notifications. npm has a streaming API that sends events when packages are published. PyPI has webhooks support through their infrastructure.
Here's a simplified real-time listener for npm events:
#!/usr/bin/env python3
"""
Real-time typosquat monitor using npm registry events.
"""
import asyncio
import websockets
import json
from your_scanner import scan_for_typosquats # Import from above
async def listen_to_npm_events(allowlist):
"""Connect to npm's event stream and scan in real-time."""
uri = "wss://registry.npmjs.org"
async with websockets.connect(uri) as websocket:
# Subscribe to publication events
await websocket.send(json.dumps({
"type": "all",
"filter": {"added": [], "modified": [], "removed": []}
}))
print("[*] Connected to npm event stream. Monitoring for new packages...")
async for message in websocket:
try:
event = json.loads(message)
# Check if this is a new package publication
if event.get("method") == "PUT" and "/-/" in event.get("path", ""):
pkg_name = event.get("doc", {}).get("name", "")
if pkg_name:
# Scan against allowlist
results = scan_for_typosquats(
allowlist,
[pkg_name],
"npm"
)
if results:
print(f"[!] Potential typosquat detected: {pkg_name}")
print(f" Similar to: {results[0]['similar_to']}")
print(f" Score: {results[0]['similarity_score']}")
# Trigger alert
await send_alert(pkg_name, results[0])
except json.JSONDecodeError:
continue
except Exception as e:
print(f"[!] Error processing event: {e}")
continue
async def send_alert(package, match):
"""Send alert via Slack, email, or your preferred channel."""
# Implement your alerting logic here
print(f"Alert: {package} is similar to {match['similar_to']}")
if __name__ == "__main__":
allowlist = load_allowlist()
asyncio.run(listen_to_npm_events(allowlist))
Real-time monitoring is overkill for small teams, but if you're managing a large org with hundreds of developers and critical infrastructure, the difference between catching a typosquat before it's installed versus after it's been in production for three days is enormous.
Maintaining Your Allowlist
Even the best scanner is only as good as its allowlist. Here are some best practices for keeping your approved packages list accurate and useful:
- Audit quarterly. Even if your scanner is running daily, review the allowlist itself every quarter. Remove packages that are no longer used. Add packages that have been newly approved.
- Include version constraints. A package name alone isn't enough. Store the approved version range so you can detect when someone tries to install an outdated or vulnerable version.
- Document the rationale. When you add a package to the allowlist, add a comment explaining why it's approved. This helps future reviewers and makes it easier to justify removals.
- Separate internal from public. If your org has internal packages (private npm scopes, private PyPI indexes), keep them in a separate allowlist file. They have different trust characteristics than public packages.
- Link to provenance data. For critical packages, store PGP signatures, SLSA provenance attestations, or other verification data alongside the allowlist entry.
Measuring Effectiveness
How do you know your typosquat detection is actually working? Track these metrics:
- Mean time to detection (MTTD). How long between a malicious package being published and your scanner flagging it? Aim for under 24 hours.
- False positive rate. What percentage of flagged packages turn out to be legitimate? If it's high, lower your similarity threshold or add more sophisticated filtering.
- Coverage. What percentage of your total dependency surface is monitored? Are there private registries, alternative package managers, or lockfile formats you're not checking?
- Incident count. How many typosquat attempts have you caught? This should be non-zero. If it's zero, either your organization is very lucky or your scanner isn't working.
Putting It All Together
Building a typosquat detection system isn't about buying a new tool or hiring a security consultant. It's about writing a script that does one thing well: compare new packages against your allowlist using multiple similarity metrics, and alert you when something looks suspicious.
The most important thing is to start. Even a simple Levenshtein-distance checker that runs once a day is better than nothing. You can iterate from there, adding more sophisticated metrics, real-time monitoring, and integration with your existing security tooling.
Remember, the goal isn't to catch every possible typosquat. It's to raise the bar high enough that most automated attacks fail, and to give your team visibility into the threat landscape so you can respond quickly when something slips through.
If you want to dive deeper into related topics, check out our earlier posts on multi-registry attack campaigns and securing your credential storage. Both complement the allowlist monitoring approach by addressing different layers of the same supply chain problem.

FAQ
Q: How do I know which similarity threshold is right for my organization?
A: Start with a conservative threshold (0.85-0.90) and monitor your false positive rate for two weeks. If you're getting more than 10% false positives, lower the threshold. If you're not catching obvious typosquats, raise it. The right threshold depends on your risk tolerance and how much alert fatigue you can handle.
Q: Can I use this approach for other package registries like RubyGems or crates.io?
A: Absolutely. The similarity scoring logic is registry-agnostic. You just need to adapt the package-fetching code to work with each registry's API. RubyGems has a public JSON API, and crates.io provides both REST and search endpoints. The core algorithm stays the same.
Q: What if I have a large allowlist (1000+ packages)? Won't the scan be slow?
A: For allowlists up to 10,000 packages, a daily scan should complete in under a minute on modern hardware. If you're dealing with larger lists, consider indexing your allowlist with a trie or using approximate nearest neighbor algorithms. But honestly, most organizations have far fewer than 1,000 approved packages.
Q: Should I block packages automatically or just alert?
A: Start with alerting only. Automatic blocking can cause outages if you flag a legitimate package. Once you've tuned your scanner and are confident in the results, you can add an auto-block mode for packages that exceed a very high similarity threshold (0.95+) against high-popularity targets.


