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:

  • eslint vs esliint, eslint2, eslint-config
  • requests vs reqeusts, reqests, rquests
  • numpy vs numpyy, nmpy, numy
  • flask vs flaskk, 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 eslintesliint 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:

  1. Mean time to detection (MTTD). How long between a malicious package being published and your scanner flagging it? Aim for under 24 hours.
  2. 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.
  3. Coverage. What percentage of your total dependency surface is monitored? Are there private registries, alternative package managers, or lockfile formats you're not checking?
  4. 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.

Typosquat detection showing similar package names

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.

About the Author

Dzul Qurnain

Suka nonton Anime, ngoding dan bagi-bagi tips kalau tahu.. Oh iya, suka baca ( tapi yang menarik menurutku aja)... Praktisi WordPress, web development, SEO, dan server administration yang membagikan tutorial teknis dan catatan implementasi nyata.

View All Articles