Build a Python Supply Chain Scanner

Detect typosquatting and supply chain attacks in Python dependencies.

Introduction

30 Second Summary

Every time you run pip install, you're trusting that the package name you typed is the real one. Attackers exploit this trust by registering packages with names just one character off from popular libraries, waiting for developers to make a typo.

In this project, you will build a Python command-line tool that scans your dependencies for social engineering attack patterns, flags suspicious packages, and generates a security threat report.

What You'll Build

A CLI tool called "Supply Chain Scanner" that analyzes a requirements.txt file using three detection layers: typosquatting analysis, package metadata inspection via the PyPI JSON API, and known vulnerability scanning.

By the end of this project, you'll have:

  • A working typosquatting detector that uses Levenshtein distance to flag packages with names suspiciously similar to popular libraries.
  • A metadata trust analyzer that queries the PyPI JSON API to identify red flags like new packages, missing authors, and suspicious email domains.
  • A vulnerability scanner integration that runs pip-audit to check for known security issues in your dependencies.
  • Secret Mission: Add dependency confusion detection that flags packages mimicking internal corporate namespaces.

Are there any prerequisites?

You should be comfortable with basic Python (functions, file I/O, and working with APIs). Familiarity with pip and requirements.txt files is also helpful.

Before We Start

Supply chain attacks are one of the fastest-growing threats in software security. Attackers register fake packages with names nearly identical to popular ones, hoping developers install the wrong one by mistake.

Before building your scanner, you need a project environment with the right tools installed. In this step, you'll create your project folder, set up an isolated Python environment, and install the three libraries your scanner depends on.

In this step, get ready to:
  • Create a project directory and virtual environment.
  • Install pip-audit, rapidfuzz, and requests.
  • Create the scanner and sample requirements files.
Create your project directory and virtual environment

A virtual environment keeps your scanner's dependencies isolated from the rest of your system. This prevents version conflicts with other Python projects on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir supply-chain-scanner
cd supply-chain-scanner

If no error appears, you are now inside your supply-chain-scanner folder on the Desktop.

  • Create a virtual environment by running this command:
python -m venv venv

What does this command do?

python -m venv venv tells Python to create a new virtual environment in a folder called venv.

This gives your project its own isolated copy of Python and pip, so anything you install stays contained within this project.

  • Activate the virtual environment so your terminal uses the isolated Python.

macOS

  • Activate by running:
source venv/bin/activate

Windows

  • Activate by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms you are now working inside the virtual environment.

Don't see (venv) in your prompt?

  • On Windows, if you get a "running scripts is disabled" error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again.
  • Make sure you are inside the supply-chain-scanner folder. Run pwd to check your current location.
  • help me activate my Python virtual environment
Install the dependencies

Your scanner needs three libraries: pip-audit for vulnerability scanning, rapidfuzz for string similarity calculations, and requests for calling the PyPI API.

  • Install all three libraries by running this command:
pip install pip-audit==2.10.1 rapidfuzz==3.14.5 requests==2.34.2

What are these libraries?

  • pip-audit scans your dependencies against a database of known security vulnerabilities.
  • rapidfuzz calculates how similar two strings are using Levenshtein distance. This powers the typosquatting detection.
  • requests makes HTTP calls to the PyPI JSON API to fetch package metadata.

The install may take a minute. Once it finishes, verify each library installed correctly.

  • Verify pip-audit by running:
pip-audit --version

You should see a version string containing 2.10.1 in the output.

  • Verify rapidfuzz by running:
python -c "from rapidfuzz import fuzz; print(fuzz.ratio('test','tset'))"

You should see a similarity score of around 75.0 printed. This confirms rapidfuzz can compare strings.

  • Verify requests by running:
python -c "import requests; print(requests.__version__)"

You should see 2.34.2 printed.

Seeing a ModuleNotFoundError?

  • Make sure (venv) appears in your terminal prompt. If it does not, reactivate your virtual environment.
  • Try running pip list to see which packages are installed in the current environment.
  • help me fix a ModuleNotFoundError after pip install
Create the project files

Your scanner will live in two files: the main script and a sample requirements file for testing.

macOS

  • Create both project files by running:
touch supply_chain_scanner.py sample_requirements.txt

Windows

  • Create both project files by running:
New-Item supply_chain_scanner.py, sample_requirements.txt
  • Confirm the files exist by running:
ls

You should see supply_chain_scanner.py and sample_requirements.txt listed alongside the venv folder.

What are these files for?

  • supply_chain_scanner.py is where all your scanner code will live. It is a single-file CLI application.
  • sample_requirements.txt will hold a mix of real and suspicious package names to test your scanner against.

Don't see both files listed?

  • Check you are inside the supply-chain-scanner folder by running pwd.
  • If a file is missing, re-run the create command for that specific file.
  • help me create files in my project directory

Your security lab is ready. Next up, you'll build the first real component of your scanner: a function that queries PyPI to fetch metadata about any Python package.

Build the Package Metadata Fetcher

Your scanner needs to know what it's scanning. Before you can detect social engineering attacks, you need a way to read a project's dependency list and pull detailed information about each package from the internet.

In this step, you'll build the foundation of your scanner: a file parser that reads requirements.txt, a function that queries the PyPI JSON API for package metadata, and a trust analyzer that flags six social engineering red flags.

In this step, get ready to:
  • Write a requirements file parser that extracts package names.
  • Build a function that queries the PyPI JSON API for package metadata.
  • Create a metadata analyzer that checks for six social engineering red flags.
Write the requirements loader and metadata fetcher

Your scanner needs two foundational capabilities: parsing a requirements.txt file to extract package names, and calling the PyPI JSON API to retrieve metadata about each package.

First, you'll set up the file with its imports and the reference list of popular packages that attackers commonly target with typosquatting.

  • Open supply_chain_scanner.py in your code editor and add the module docstring, imports, and reference data by pasting the following code:
"""Supply Chain Security Analyzer

Detects social engineering attack patterns in Python dependencies.
Analyzes typosquatting, metadata trust signals, and known vulnerabilities.
"""

import json
import subprocess
import sys
from datetime import datetime, timezone

import requests
from rapidfuzz import fuzz
from rapidfuzz.distance import Levenshtein


# Top 50 most popular PyPI packages (common targets for typosquatting)
POPULAR_PACKAGES = [
    "requests", "boto3", "urllib3", "setuptools", "typing-extensions",
    "botocore", "certifi", "charset-normalizer", "idna", "numpy",
    "python-dateutil", "packaging", "aiobotocore", "s3transfer", "pip",
    "pyyaml", "six", "cryptography", "jmespath", "colorama",
    "pydantic", "platformdirs", "awscli", "jinja2", "pandas",
    "pyasn1", "pluggy", "virtualenv", "filelock", "markupsafe",
    "tomli", "pygments", "pytz", "wheel", "click",
    "aiohttp", "scipy", "pillow", "wrapt", "flask",
    "sqlalchemy", "grpcio", "django", "fastapi", "matplotlib",
    "scikit-learn", "celery", "redis", "psycopg2", "httpx",
]

# Similarity threshold for typosquatting detection (percentage)
TYPOSQUAT_THRESHOLD = 85
# Packages younger than this many days are suspicious
SUSPICIOUS_AGE_DAYS = 30

What does this code do?

  • The module docstring at the top describes what this tool does.
  • The imports bring in requests for HTTP calls, rapidfuzz for string similarity, and standard library modules for JSON parsing, subprocesses, and date handling.
  • POPULAR_PACKAGES is the list of top 50 most-downloaded PyPI packages. Attackers register packages with names very close to these (like requets instead of requests) to trick developers.
  • TYPOSQUAT_THRESHOLD and SUSPICIOUS_AGE_DAYS are tuning constants for the detection logic you'll build next.

Now add the load_requirements() function directly below the constants. This function parses a requirements file and extracts package names, stripping version specifiers like ==2.34.2 or >=1.0.

  • Add this function below the SUSPICIOUS_AGE_DAYS line:

def load_requirements(filepath):
    """Parse a requirements.txt file and extract package names."""
    packages = []
    try:
        with open(filepath, "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("-"):
                    continue
                for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]:
                    if sep in line:
                        line = line.split(sep)[0]
                        break
                packages.append(line.strip())
    except FileNotFoundError:
        print(f"Error: File '{filepath}' not found.")
        sys.exit(1)
    return packages

What does this code do?

  • It opens the file and reads each line, skipping blank lines, comments (starting with #), and flags (starting with -).
  • For each remaining line, it strips version specifiers by splitting on operators like ==, >=, <=, etc. This turns requests==2.34.2 into just requests.
  • If the file doesn't exist, it prints an error and exits cleanly instead of crashing with a traceback.

Next, add the fetch_package_metadata() function. This is the function that talks to the PyPI JSON API to retrieve information about any Python package.

  • Add this function directly below load_requirements():

def fetch_package_metadata(package_name):
    """Fetch package metadata from PyPI JSON API."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    try:
        response = requests.get(url, timeout=10)
        if response.status_code == 404:
            return {"error": "Package not found on PyPI"}
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e)}

What does this code do?

  • It calls https://pypi.org/pypi/<package_name>/json which returns metadata about a package including its info (author, description, URLs) and releases (version history with upload timestamps).
  • A 404 response means the package doesn't exist on PyPI. This is useful information for your scanner because attackers sometimes reference packages that have been removed.
  • Network errors are caught and returned as a dictionary with an error key, so the rest of your code can handle failures gracefully.

What is the PyPI JSON API?

PyPI (the Python Package Index) provides a free public API that returns metadata about any hosted package. The endpoint GET /pypi/<project>/json returns a JSON object with top-level keys including info (project metadata like author, summary, homepage) and releases (a dictionary of version strings mapped to their release files).

Your scanner uses this data to spot suspicious patterns. A package with no author, no homepage, and only one release that was uploaded yesterday has a very different trust profile than a well-established library.

  • Save supply_chain_scanner.py.

Seeing import errors?

  • Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
  • If you closed your terminal since Step 1, reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

help me fix import errors in my supply chain scanner

Write the metadata trust analyzer

Now you'll build the function that looks at a package's metadata and flags social engineering red flags. This is where your scanner goes beyond simple name-checking and inspects whether a package looks trustworthy based on its history and maintainer details.

  • Add the analyze_metadata() function below fetch_package_metadata():

def analyze_metadata(package_name, metadata):
    """Analyze package metadata for social engineering red flags."""
    findings = []

    if "error" in metadata:
        findings.append({
            "check": "package_existence",
            "risk": "HIGH",
            "detail": metadata["error"],
        })
        return findings

    info = metadata.get("info", {})
    releases = metadata.get("releases", {})

    # Check 1: Package age
    if releases:
        first_release = None
        for version, files in releases.items():
            if files:
                upload_time = files[0].get("upload_time")
                if upload_time:
                    try:
                        release_date = datetime.fromisoformat(
                            upload_time.replace("Z", "+00:00")
                        )
                        if first_release is None or release_date < first_release:
                            first_release = release_date
                    except ValueError:
                        continue

        if first_release:
            age_days = (datetime.now(timezone.utc) - first_release).days
            if age_days < SUSPICIOUS_AGE_DAYS:
                findings.append({
                    "check": "package_age",
                    "risk": "MEDIUM",
                    "detail": f"Package is only {age_days} days old",
                })

    # Check 2: Very few releases
    if len(releases) <= 1:
        findings.append({
            "check": "release_count",
            "risk": "LOW",
            "detail": f"Only {len(releases)} release(s) published",
        })

    # Check 3: Missing project URLs
    project_urls = info.get("project_urls") or {}
    home_page = info.get("home_page", "")
    if not project_urls and not home_page:
        findings.append({
            "check": "missing_urls",
            "risk": "MEDIUM",
            "detail": "No project URLs or homepage provided",
        })

    # Check 4: Missing author information
    author = info.get("author", "")
    maintainer = info.get("maintainer", "")
    if not author and not maintainer:
        findings.append({
            "check": "missing_author",
            "risk": "LOW",
            "detail": "No author or maintainer information provided",
        })

    # Check 5: Suspicious email domains
    suspicious_domains = [
        "proton.me", "protonmail.com", "tutanota.com", "guerrillamail.com"
    ]
    author_email = info.get("author_email", "") or ""
    maintainer_email = info.get("maintainer_email", "") or ""
    for email in [author_email, maintainer_email]:
        if email:
            for domain in suspicious_domains:
                if domain in email.lower():
                    findings.append({
                        "check": "suspicious_email",
                        "risk": "MEDIUM",
                        "detail": f"Maintainer uses privacy-focused email: {domain}",
                    })

    # Check 6: Description references popular packages (impersonation)
    summary = (info.get("summary") or "").lower()
    for popular in POPULAR_PACKAGES[:20]:
        if popular in summary and popular != package_name.lower():
            findings.append({
                "check": "description_impersonation",
                "risk": "LOW",
                "detail": f"Description references popular package '{popular}'",
            })
            break

    return findings

What are the six red flags?

  • Check 1: Package age less than 30 days. Malicious packages are often published recently to exploit current events or tool names.
  • Check 2: Only 1 release published. Legitimate packages typically iterate through multiple versions.
  • Check 3: No project URLs or homepage. Real projects almost always link to a repository or documentation site.
  • Check 4: No author or maintainer info. Attackers often skip this metadata.
  • Check 5: Privacy-focused email domains (proton.me, tutanota.com). In the 2026 Axios npm compromise, the attacker changed the maintainer email to a Proton address before publishing the malicious release.
  • Check 6: Description references a popular package. Attackers sometimes include names like "requests" in their package description to appear in search results and confuse developers.

Why check for privacy-focused emails?

This is not about privacy being bad. Legitimate developers use these services too. The check flags it as a MEDIUM signal that should be combined with other red flags. A well-established package with a Proton email is fine. A brand-new package with a Proton email, no homepage, and one release is suspicious.

  • Save supply_chain_scanner.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete supply_chain_scanner.py so far:

"""Supply Chain Security Analyzer

Detects social engineering attack patterns in Python dependencies.
Analyzes typosquatting, metadata trust signals, and known vulnerabilities.
"""

import json
import subprocess
import sys
from datetime import datetime, timezone

import requests
from rapidfuzz import fuzz
from rapidfuzz.distance import Levenshtein


# Top 50 most popular PyPI packages (common targets for typosquatting)
POPULAR_PACKAGES = [
    "requests", "boto3", "urllib3", "setuptools", "typing-extensions",
    "botocore", "certifi", "charset-normalizer", "idna", "numpy",
    "python-dateutil", "packaging", "aiobotocore", "s3transfer", "pip",
    "pyyaml", "six", "cryptography", "jmespath", "colorama",
    "pydantic", "platformdirs", "awscli", "jinja2", "pandas",
    "pyasn1", "pluggy", "virtualenv", "filelock", "markupsafe",
    "tomli", "pygments", "pytz", "wheel", "click",
    "aiohttp", "scipy", "pillow", "wrapt", "flask",
    "sqlalchemy", "grpcio", "django", "fastapi", "matplotlib",
    "scikit-learn", "celery", "redis", "psycopg2", "httpx",
]

TYPOSQUAT_THRESHOLD = 85
SUSPICIOUS_AGE_DAYS = 30


def load_requirements(filepath):
    """Parse a requirements.txt file and extract package names."""
    packages = []
    try:
        with open(filepath, "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("-"):
                    continue
                for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]:
                    if sep in line:
                        line = line.split(sep)[0]
                        break
                packages.append(line.strip())
    except FileNotFoundError:
        print(f"Error: File '{filepath}' not found.")
        sys.exit(1)
    return packages


def fetch_package_metadata(package_name):
    """Fetch package metadata from PyPI JSON API."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    try:
        response = requests.get(url, timeout=10)
        if response.status_code == 404:
            return {"error": "Package not found on PyPI"}
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e)}


def analyze_metadata(package_name, metadata):
    """Analyze package metadata for social engineering red flags."""
    findings = []

    if "error" in metadata:
        findings.append({
            "check": "package_existence",
            "risk": "HIGH",
            "detail": metadata["error"],
        })
        return findings

    info = metadata.get("info", {})
    releases = metadata.get("releases", {})

    # Check 1: Package age
    if releases:
        first_release = None
        for version, files in releases.items():
            if files:
                upload_time = files[0].get("upload_time")
                if upload_time:
                    try:
                        release_date = datetime.fromisoformat(
                            upload_time.replace("Z", "+00:00")
                        )
                        if first_release is None or release_date < first_release:
                            first_release = release_date
                    except ValueError:
                        continue

        if first_release:
            age_days = (datetime.now(timezone.utc) - first_release).days
            if age_days < SUSPICIOUS_AGE_DAYS:
                findings.append({
                    "check": "package_age",
                    "risk": "MEDIUM",
                    "detail": f"Package is only {age_days} days old",
                })

    # Check 2: Very few releases
    if len(releases) <= 1:
        findings.append({
            "check": "release_count",
            "risk": "LOW",
            "detail": f"Only {len(releases)} release(s) published",
        })

    # Check 3: Missing project URLs
    project_urls = info.get("project_urls") or {}
    home_page = info.get("home_page", "")
    if not project_urls and not home_page:
        findings.append({
            "check": "missing_urls",
            "risk": "MEDIUM",
            "detail": "No project URLs or homepage provided",
        })

    # Check 4: Missing author information
    author = info.get("author", "")
    maintainer = info.get("maintainer", "")
    if not author and not maintainer:
        findings.append({
            "check": "missing_author",
            "risk": "LOW",
            "detail": "No author or maintainer information provided",
        })

    # Check 5: Suspicious email domains
    suspicious_domains = [
        "proton.me", "protonmail.com", "tutanota.com", "guerrillamail.com"
    ]
    author_email = info.get("author_email", "") or ""
    maintainer_email = info.get("maintainer_email", "") or ""
    for email in [author_email, maintainer_email]:
        if email:
            for domain in suspicious_domains:
                if domain in email.lower():
                    findings.append({
                        "check": "suspicious_email",
                        "risk": "MEDIUM",
                        "detail": f"Maintainer uses privacy-focused email: {domain}",
                    })

    # Check 6: Description references popular packages (impersonation)
    summary = (info.get("summary") or "").lower()
    for popular in POPULAR_PACKAGES[:20]:
        if popular in summary and popular != package_name.lower():
            findings.append({
                "check": "description_impersonation",
                "risk": "LOW",
                "detail": f"Description references popular package '{popular}'",
            })
            break

    return findings
Test the metadata fetcher

Time to verify that your functions work. You'll test by fetching metadata for a well-known package (should return clean data) and a non-existent package (should return your error message).

  • Test your fetcher against the requests package by running this command:
python -c "from supply_chain_scanner import fetch_package_metadata; data = fetch_package_metadata('requests'); print(list(data.keys()))"
  • You should see the top-level keys from the PyPI JSON API printed:
['info', 'last_serial', 'releases', 'urls', 'vulnerabilities']
  • Now test with a non-existent package name to verify your error handling:
python -c "from supply_chain_scanner import fetch_package_metadata; print(fetch_package_metadata('this-package-definitely-does-not-exist-xyz'))"
  • You should see your error dictionary printed:
{'error': 'Package not found on PyPI'}
  • Finally, test your metadata analyzer against the requests package to confirm a legitimate package passes cleanly:
python -c "from supply_chain_scanner import fetch_package_metadata, analyze_metadata; data = fetch_package_metadata('requests'); print(analyze_metadata('requests', data))"
  • You should see an empty list [] because requests is a well-established package with no red flags.

Getting a connection error?

  • Verify you have internet connectivity. The PyPI API call requires network access.
  • If you're behind a corporate proxy, you may need to configure proxy settings for Python's requests library.
  • If you see ModuleNotFoundError, make sure your virtual environment is active and you're running python from the supply-chain-scanner directory.

help me debug connection or import errors in my supply chain scanner

Your scanner's foundation is solid. You can now parse any requirements.txt file, pull metadata from PyPI, and flag suspicious trust signals. Next up, you'll build the typosquatting detector that uses Levenshtein distance to catch packages with deceptively similar names.

Build the Typosquatting Detector

Your scanner can already fetch metadata from PyPI and check for trust red flags. Now it needs to catch the most common social engineering trick in package registries: typosquatting.

Typosquatting is when an attacker registers a package with a name almost identical to a popular one (like requets instead of requests). In this step, you will build a detector that uses two complementary metrics to catch these fakes automatically.

In this step, get ready to:
  • Write the typosquatting detection function using Levenshtein distance and fuzzy ratio.
  • Create test data with legitimate and suspicious package names.
  • Run the detector against your test file and verify the results.
Write the typosquatting detection function

The detector uses two measurements from rapidfuzz to decide if a package name is suspicious.

Why two metrics instead of one?

fuzz.ratio() gives a percentage similarity score. It catches transpositions and rearrangements well, but can flag names that are simply short.

Levenshtein.distance() gives the exact number of single-character edits (insertions, deletions, substitutions) to transform one string into another. Combining both (similarity >= 85% AND distance <= 2) keeps false positives low while still catching real typosquats.

  • In supply_chain_scanner.py, find the end of the load_requirements() function.
  • Add the following check_typosquatting() function below it, before the fetch_package_metadata() function:
def check_typosquatting(package_name):
    """Check if a package name is suspiciously similar to a popular package."""
    findings = []
    package_lower = package_name.lower()

    for popular in POPULAR_PACKAGES:
        if package_lower == popular:
            continue
        similarity = fuzz.ratio(package_lower, popular)
        distance = Levenshtein.distance(package_lower, popular)

        if similarity >= TYPOSQUAT_THRESHOLD and distance <= 2:
            findings.append({
                "popular_package": popular,
                "similarity_score": similarity,
                "edit_distance": distance,
                "risk": "HIGH" if distance == 1 else "MEDIUM",
            })

    return findings

What does this code do?

  • package_lower = package_name.lower() normalizes the package name so comparisons are case-insensitive.
  • The loop compares the scanned package against every entry in POPULAR_PACKAGES (your list of 50 commonly impersonated packages).
  • fuzz.ratio() returns a score from 0 to 100 showing how similar the two strings are.
  • Levenshtein.distance() returns the number of single-character edits needed to turn one name into the other.
  • A package is flagged only when BOTH thresholds are met: similarity >= 85% and distance <= 2. An edit distance of 1 is rated HIGH risk because a single-character typo is the most common attack vector.
  • Save supply_chain_scanner.py.

Seeing an indentation error?

Make sure the new function starts at the same indentation level as load_requirements() above it (no extra indentation). Python is strict about whitespace consistency.

help me fix an indentation error in my check_typosquatting function

Create test data with suspicious package names

You need a test file with both legitimate packages and intentionally misspelled names. The misspelled names simulate what an attacker would register on PyPI to trick developers who make typos during installation.

  • Open sample_requirements.txt and paste in the following content:
# Legitimate packages (should pass clean)
requests==2.34.2
flask==3.1.1
numpy==2.3.0

# Suspicious names for testing typosquatting detection
# (These simulate what an attacker might register)
requets
numpyy
httpx-client

What does each entry test?

  • requests==2.34.2, flask==3.1.1, numpy==2.3.0 are real packages. Your detector should pass these cleanly because they match POPULAR_PACKAGES exactly (the if package_lower == popular: continue line skips exact matches).
  • requets is 2 edits away from requests. This simulates a developer mistyping the package name.
  • numpyy is 1 edit away from numpy (one extra character). This would be flagged as HIGH risk.
  • httpx-client tests a name that looks like it could be related to httpx but is different enough to potentially pass or fail depending on the thresholds.
  • Save sample_requirements.txt.
Test the detector against your sample file

Now wire up the typosquatting detector to process each package from the requirements file and print findings as it goes.

  • Run the following test command in your terminal to exercise the detector against your sample file:
python -c "
from supply_chain_scanner import load_requirements, check_typosquatting

packages = load_requirements('sample_requirements.txt')
for pkg in packages:
    findings = check_typosquatting(pkg)
    if findings:
        print(f'Analyzing: {pkg}... {len(findings)} finding(s)')
        for f in findings:
            print(f'  -> Similar to: {f[\"popular_package\"]} (similarity: {f[\"similarity_score\"]}%, edit distance: {f[\"edit_distance\"]})')
    else:
        print(f'Analyzing: {pkg}... OK')
"

What should I see?

Your terminal output should show that requests, flask, and numpy pass cleanly (they are exact matches to popular packages, so they are skipped).

requets should be flagged as similar to requests with a similarity score above 85%. numpyy should be flagged as similar to numpy with an edit distance of 1.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is still active (you should see (venv) in your terminal prompt). Also confirm you are running the command from inside the supply-chain-scanner directory where your Python file lives.

help me fix a ModuleNotFoundError when testing my typosquatting detector

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is what supply_chain_scanner.py should look like at this point:

"""Supply Chain Security Analyzer

Detects social engineering attack patterns in Python dependencies.
Analyzes typosquatting, metadata trust signals, and known vulnerabilities.
"""

import json
import subprocess
import sys
from datetime import datetime, timezone

import requests
from rapidfuzz import fuzz
from rapidfuzz.distance import Levenshtein


# Top 50 most popular PyPI packages (common targets for typosquatting)
POPULAR_PACKAGES = [
    "requests", "boto3", "urllib3", "setuptools", "typing-extensions",
    "botocore", "certifi", "charset-normalizer", "idna", "numpy",
    "python-dateutil", "packaging", "aiobotocore", "s3transfer", "pip",
    "pyyaml", "six", "cryptography", "jmespath", "colorama",
    "pydantic", "platformdirs", "awscli", "jinja2", "pandas",
    "pyasn1", "pluggy", "virtualenv", "filelock", "markupsafe",
    "tomli", "pygments", "pytz", "wheel", "click",
    "aiohttp", "scipy", "pillow", "wrapt", "flask",
    "sqlalchemy", "grpcio", "django", "fastapi", "matplotlib",
    "scikit-learn", "celery", "redis", "psycopg2", "httpx",
]

TYPOSQUAT_THRESHOLD = 85
SUSPICIOUS_AGE_DAYS = 30


def load_requirements(filepath):
    """Parse a requirements.txt file and extract package names."""
    packages = []
    try:
        with open(filepath, "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("-"):
                    continue
                for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]:
                    if sep in line:
                        line = line.split(sep)[0]
                        break
                packages.append(line.strip())
    except FileNotFoundError:
        print(f"Error: File '{filepath}' not found.")
        sys.exit(1)
    return packages


def check_typosquatting(package_name):
    """Check if a package name is suspiciously similar to a popular package."""
    findings = []
    package_lower = package_name.lower()

    for popular in POPULAR_PACKAGES:
        if package_lower == popular:
            continue
        similarity = fuzz.ratio(package_lower, popular)
        distance = Levenshtein.distance(package_lower, popular)

        if similarity >= TYPOSQUAT_THRESHOLD and distance <= 2:
            findings.append({
                "popular_package": popular,
                "similarity_score": similarity,
                "edit_distance": distance,
                "risk": "HIGH" if distance == 1 else "MEDIUM",
            })

    return findings


def fetch_package_metadata(package_name):
    """Fetch package metadata from PyPI JSON API."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    try:
        response = requests.get(url, timeout=10)
        if response.status_code == 404:
            return {"error": "Package not found on PyPI"}
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e)}


def analyze_metadata(package_name, metadata):
    """Analyze package metadata for social engineering red flags."""
    findings = []

    if "error" in metadata:
        findings.append({
            "check": "package_existence",
            "risk": "HIGH",
            "detail": metadata["error"],
        })
        return findings

    info = metadata.get("info", {})
    releases = metadata.get("releases", {})

    # Check 1: Package age
    if releases:
        first_release = None
        for version, files in releases.items():
            if files:
                upload_time = files[0].get("upload_time")
                if upload_time:
                    try:
                        release_date = datetime.fromisoformat(
                            upload_time.replace("Z", "+00:00")
                        )
                        if first_release is None or release_date < first_release:
                            first_release = release_date
                    except ValueError:
                        continue

        if first_release:
            age_days = (datetime.now(timezone.utc) - first_release).days
            if age_days < SUSPICIOUS_AGE_DAYS:
                findings.append({
                    "check": "package_age",
                    "risk": "MEDIUM",
                    "detail": f"Package is only {age_days} days old",
                })

    # Check 2: Very few releases
    if len(releases) <= 1:
        findings.append({
            "check": "release_count",
            "risk": "LOW",
            "detail": f"Only {len(releases)} release(s) published",
        })

    # Check 3: Missing project URLs
    project_urls = info.get("project_urls") or {}
    home_page = info.get("home_page", "")
    if not project_urls and not home_page:
        findings.append({
            "check": "missing_urls",
            "risk": "MEDIUM",
            "detail": "No project URLs or homepage provided",
        })

    # Check 4: Missing author information
    author = info.get("author", "")
    maintainer = info.get("maintainer", "")
    if not author and not maintainer:
        findings.append({
            "check": "missing_author",
            "risk": "LOW",
            "detail": "No author or maintainer information provided",
        })

    # Check 5: Suspicious email domains
    suspicious_domains = [
        "proton.me", "protonmail.com", "tutanota.com", "guerrillamail.com"
    ]
    author_email = info.get("author_email", "") or ""
    maintainer_email = info.get("maintainer_email", "") or ""
    for email in [author_email, maintainer_email]:
        if email:
            for domain in suspicious_domains:
                if domain in email.lower():
                    findings.append({
                        "check": "suspicious_email",
                        "risk": "MEDIUM",
                        "detail": f"Maintainer uses privacy-focused email: {domain}",
                    })

    # Check 6: Description references popular packages (impersonation)
    summary = (info.get("summary") or "").lower()
    for popular in POPULAR_PACKAGES[:20]:
        if popular in summary and popular != package_name.lower():
            findings.append({
                "check": "description_impersonation",
                "risk": "LOW",
                "detail": f"Description references popular package '{popular}'",
            })
            break

    return findings

And here is what sample_requirements.txt should look like:

# Legitimate packages (should pass clean)
requests==2.34.2
flask==3.1.1
numpy==2.3.0

# Suspicious names for testing typosquatting detection
# (These simulate what an attacker might register)
requets
numpyy
httpx-client

Your typosquatting detector is catching fake packages. Next up, you will integrate pip-audit for known vulnerability scanning and generate a full security threat report that combines all three analysis layers.

Integrate pip-audit and Generate the Threat Report

Your scanner can already detect typosquatting and analyze package metadata for red flags. Those two passes catch social engineering patterns, but they miss a critical third layer: known vulnerabilities that have already been reported and catalogued.

In this step, you will connect pip-audit for automated vulnerability scanning, then combine all three analysis passes into a single structured threat report. This is where the tool becomes production-ready.

In this step, get ready to:
  • Write a function that runs pip-audit as a subprocess and parses its JSON output.
  • Build a report generator that categorizes findings by risk level.
  • Wire everything together in a main function and test the complete scanner.
Write the pip-audit integration function

Rather than importing pip-audit as a Python library, you will run it as a subprocess. This mirrors how security tools are integrated into real CI/CD pipelines.

Why run pip-audit as a subprocess?

pip-audit is designed as a CLI tool with a stable JSON output format. Running it via subprocess means your scanner stays decoupled from pip-audit's internals. If pip-audit updates, your integration still works as long as the JSON schema stays the same.

  • In supply_chain_scanner.py, scroll to the bottom of your file (after the analyze_metadata() function).
  • Add the run_pip_audit() function by copying the following code:
def run_pip_audit(filepath):
    """Run pip-audit on a requirements file and return results."""
    try:
        result = subprocess.run(
            [
                "pip-audit", "-r", filepath,
                "-f", "json",
                "--progress-spinner", "off",
            ],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.stdout:
            try:
                data = json.loads(result.stdout)
                if isinstance(data, dict):
                    return data.get("dependencies", [])
                return data
            except json.JSONDecodeError:
                return []
        return []
    except FileNotFoundError:
        print("  Warning: pip-audit not found. Skipping vulnerability scan.")
        return []
    except subprocess.TimeoutExpired:
        print("  Warning: pip-audit timed out.")
        return []

What does this code do?

This function shells out to pip-audit with the -r flag (pointing at your requirements file), -f json for machine-readable output, and --progress-spinner off to keep the output clean.

  • If pip-audit returns JSON, the function parses it and extracts the dependencies list (each entry contains vulnerability info).
  • If pip-audit isn't installed (FileNotFoundError), the function prints a warning and returns an empty list instead of crashing.
  • The 120-second timeout prevents the scanner from hanging if pip-audit stalls on a network call.
  • Save supply_chain_scanner.py.

You won't see a change yet. The next function uses this to display vulnerability results in the report.

Write the report generator

The report generator is the user-facing layer of your scanner. It takes raw findings from all three analysis passes and formats them into a structured, readable terminal report.

  • Below the run_pip_audit() function you just added, paste the generate_report() function:
def generate_report(package_results, audit_results):
    """Generate a combined security threat report."""
    print("\n" + "=" * 60)
    print("  SUPPLY CHAIN SECURITY REPORT")
    print("=" * 60)

    total_high = 0
    total_medium = 0
    total_low = 0

    for pkg_name, results in package_results.items():
        typosquat_findings = results.get("typosquatting", [])
        metadata_findings = results.get("metadata", [])

        if not typosquat_findings and not metadata_findings:
            continue

        print(f"\n  Package: {pkg_name}")
        print("  " + "-" * 40)

        if typosquat_findings:
            print("  [TYPOSQUATTING RISK]")
            for finding in typosquat_findings:
                risk = finding["risk"]
                if risk == "HIGH":
                    total_high += 1
                elif risk == "MEDIUM":
                    total_medium += 1
                print(
                    f"    [{risk}] Similar to '{finding['popular_package']}' "
                    f"(similarity: {finding['similarity_score']}%, "
                    f"edit distance: {finding['edit_distance']})"
                )

        if metadata_findings:
            print("  [METADATA ANALYSIS]")
            for finding in metadata_findings:
                risk = finding["risk"]
                if risk == "HIGH":
                    total_high += 1
                elif risk == "MEDIUM":
                    total_medium += 1
                else:
                    total_low += 1
                print(f"    [{risk}] {finding['check']}: {finding['detail']}")

    # Vulnerability scan results
    if audit_results:
        vuln_found = False
        for dep in audit_results:
            vulns = dep.get("vulns", [])
            if vulns:
                if not vuln_found:
                    print(f"\n  KNOWN VULNERABILITIES (via pip-audit)")
                    print("  " + "-" * 40)
                    vuln_found = True
                name = dep.get("name", "unknown")
                version = dep.get("version", "unknown")
                for v in vulns:
                    vuln_id = v.get("id", "N/A")
                    fix_versions = v.get("fix_versions", [])
                    fix_str = (
                        ", ".join(fix_versions)
                        if fix_versions
                        else "No fix available"
                    )
                    print(
                        f"    [HIGH] {name}=={version}: "
                        f"{vuln_id} (fix: {fix_str})"
                    )
                    total_high += 1

    # Summary
    print("\n" + "=" * 60)
    print("  SUMMARY")
    print(f"    HIGH risk findings:   {total_high}")
    print(f"    MEDIUM risk findings: {total_medium}")
    print(f"    LOW risk findings:    {total_low}")
    print("=" * 60)

    if total_high > 0:
        print("\n  ACTION REQUIRED: Review HIGH risk findings immediately.")
        print("  These packages may be part of a supply chain attack.")
    elif total_medium > 0:
        print("\n  CAUTION: Review MEDIUM risk findings before deploying.")
    else:
        print("\n  All clear. No significant supply chain risks detected.")

    return {"high": total_high, "medium": total_medium, "low": total_low}

What does this code do?

This function builds the terminal report in three sections:

  • Per-package findings: For each package that has issues, it prints typosquatting alerts (with similarity scores) and metadata red flags (with the specific check that triggered).
  • Vulnerability results: Any known CVEs found by pip-audit are displayed with their fix versions so the developer knows exactly what to upgrade.
  • Summary counts: A final tally of HIGH, MEDIUM, and LOW findings gives a quick pass/fail signal. If any HIGH findings exist, the report prints an action-required message.
  • Save supply_chain_scanner.py.

You won't see output yet. The next function wires everything together and makes the tool runnable.

Write the main function and test the complete scanner

The main() function is the entry point that ties your three analysis passes together. It parses CLI arguments, loads the requirements file, runs each check per package, and hands everything to the report generator.

  • Below generate_report(), add the main() function and the entry point guard:
def main():
    """Main entry point for the supply chain scanner."""
    if len(sys.argv) < 2:
        print("Usage: python supply_chain_scanner.py <requirements.txt>")
        print("\nExample:")
        print("  python supply_chain_scanner.py sample_requirements.txt")
        sys.exit(1)

    filepath = sys.argv[1]
    print(f"\n  Scanning: {filepath}")
    print("=" * 60)

    # Load packages
    packages = load_requirements(filepath)
    print(f"  Found {len(packages)} package(s) to analyze.\n")

    # Analyze each package
    package_results = {}
    for pkg_name in packages:
        print(f"  Analyzing: {pkg_name}...", end=" ")

        typosquat_findings = check_typosquatting(pkg_name)
        metadata = fetch_package_metadata(pkg_name)
        metadata_findings = analyze_metadata(pkg_name, metadata)

        package_results[pkg_name] = {
            "typosquatting": typosquat_findings,
            "metadata": metadata_findings,
        }

        finding_count = len(typosquat_findings) + len(metadata_findings)
        if finding_count > 0:
            print(f"{finding_count} finding(s)")
        else:
            print("OK")

    # Run pip-audit
    print("\n  Running vulnerability scan (pip-audit)...")
    audit_results = run_pip_audit(filepath)

    # Generate report
    generate_report(package_results, audit_results)


if __name__ == "__main__":
    main()

What does this code do?

  • The argument check ensures the learner provides a requirements file path. Without it, the script prints usage instructions and exits.
  • The main loop iterates over each package name, running typosquatting detection and metadata analysis. It prints progress as it goes ("OK" for clean packages, a finding count for flagged ones).
  • After the per-package loop, it runs pip-audit once against the entire requirements file for known vulnerability scanning.
  • The if __name__ == "__main__": guard means the script only runs when executed directly, not when imported as a module.
  • Save supply_chain_scanner.py.
  • Run the complete scanner against your test file by running this command:
python supply_chain_scanner.py sample_requirements.txt

You should see the scanner analyze each package, then print a formatted SUPPLY CHAIN SECURITY REPORT. Suspicious packages like requets and numpyy should show typosquatting alerts, and the summary at the bottom should count HIGH, MEDIUM, and LOW findings.

Not seeing the report?

  • If you see a SyntaxError, check that your function definitions are at the top level (no accidental indentation under analyze_metadata()).
  • If you see NameError: name 'run_pip_audit' is not defined, make sure run_pip_audit() appears before main() in the file.
  • If the scanner hangs during the vulnerability scan, your network may be blocking pip-audit's connection. The 120-second timeout will eventually kick in.

Why isn't my supply chain scanner producing a report?

✔️ Awesome, I've got everything!

Great. Double check you have saved supply_chain_scanner.py with all three new functions.

ⓧ I'd like to double check the full code

Here is the complete supply_chain_scanner.py file as it should look now:

"""Supply Chain Security Analyzer

Detects social engineering attack patterns in Python dependencies.
Analyzes typosquatting, metadata trust signals, and known vulnerabilities.
"""

import json
import subprocess
import sys
from datetime import datetime, timezone

import requests
from rapidfuzz import fuzz
from rapidfuzz.distance import Levenshtein


# Top 50 most popular PyPI packages (common targets for typosquatting)
POPULAR_PACKAGES = [
    "requests", "boto3", "urllib3", "setuptools", "typing-extensions",
    "botocore", "certifi", "charset-normalizer", "idna", "numpy",
    "python-dateutil", "packaging", "aiobotocore", "s3transfer", "pip",
    "pyyaml", "six", "cryptography", "jmespath", "colorama",
    "pydantic", "platformdirs", "awscli", "jinja2", "pandas",
    "pyasn1", "pluggy", "virtualenv", "filelock", "markupsafe",
    "tomli", "pygments", "pytz", "wheel", "click",
    "aiohttp", "scipy", "pillow", "wrapt", "flask",
    "sqlalchemy", "grpcio", "django", "fastapi", "matplotlib",
    "scikit-learn", "celery", "redis", "psycopg2", "httpx",
]

TYPOSQUAT_THRESHOLD = 85
SUSPICIOUS_AGE_DAYS = 30


def load_requirements(filepath):
    """Parse a requirements.txt file and extract package names."""
    packages = []
    try:
        with open(filepath, "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("-"):
                    continue
                for sep in ["==", ">=", "<=", "!=", "~=", ">", "<"]:
                    if sep in line:
                        line = line.split(sep)[0]
                        break
                packages.append(line.strip())
    except FileNotFoundError:
        print(f"Error: File '{filepath}' not found.")
        sys.exit(1)
    return packages


def check_typosquatting(package_name):
    """Check if a package name is suspiciously similar to a popular package."""
    findings = []
    package_lower = package_name.lower()

    for popular in POPULAR_PACKAGES:
        if package_lower == popular:
            continue
        similarity = fuzz.ratio(package_lower, popular)
        distance = Levenshtein.distance(package_lower, popular)

        if similarity >= TYPOSQUAT_THRESHOLD and distance <= 2:
            findings.append({
                "popular_package": popular,
                "similarity_score": similarity,
                "edit_distance": distance,
                "risk": "HIGH" if distance == 1 else "MEDIUM",
            })

    return findings


def fetch_package_metadata(package_name):
    """Fetch package metadata from PyPI JSON API."""
    url = f"https://pypi.org/pypi/{package_name}/json"
    try:
        response = requests.get(url, timeout=10)
        if response.status_code == 404:
            return {"error": "Package not found on PyPI"}
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        return {"error": str(e)}


def analyze_metadata(package_name, metadata):
    """Analyze package metadata for social engineering red flags."""
    findings = []

    if "error" in metadata:
        findings.append({
            "check": "package_existence",
            "risk": "HIGH",
            "detail": metadata["error"],
        })
        return findings

    info = metadata.get("info", {})
    releases = metadata.get("releases", {})

    # Check 1: Package age
    if releases:
        first_release = None
        for version, files in releases.items():
            if files:
                upload_time = files[0].get("upload_time")
                if upload_time:
                    try:
                        release_date = datetime.fromisoformat(
                            upload_time.replace("Z", "+00:00")
                        )
                        if first_release is None or release_date < first_release:
                            first_release = release_date
                    except ValueError:
                        continue

        if first_release:
            age_days = (datetime.now(timezone.utc) - first_release).days
            if age_days < SUSPICIOUS_AGE_DAYS:
                findings.append({
                    "check": "package_age",
                    "risk": "MEDIUM",
                    "detail": f"Package is only {age_days} days old",
                })

    # Check 2: Very few releases
    if len(releases) <= 1:
        findings.append({
            "check": "release_count",
            "risk": "LOW",
            "detail": f"Only {len(releases)} release(s) published",
        })

    # Check 3: Missing project URLs
    project_urls = info.get("project_urls") or {}
    home_page = info.get("home_page", "")
    if not project_urls and not home_page:
        findings.append({
            "check": "missing_urls",
            "risk": "MEDIUM",
            "detail": "No project URLs or homepage provided",
        })

    # Check 4: Missing author information
    author = info.get("author", "")
    maintainer = info.get("maintainer", "")
    if not author and not maintainer:
        findings.append({
            "check": "missing_author",
            "risk": "LOW",
            "detail": "No author or maintainer information provided",
        })

    # Check 5: Suspicious email domains
    suspicious_domains = [
        "proton.me", "protonmail.com", "tutanota.com", "guerrillamail.com"
    ]
    author_email = info.get("author_email", "") or ""
    maintainer_email = info.get("maintainer_email", "") or ""
    for email in [author_email, maintainer_email]:
        if email:
            for domain in suspicious_domains:
                if domain in email.lower():
                    findings.append({
                        "check": "suspicious_email",
                        "risk": "MEDIUM",
                        "detail": f"Maintainer uses privacy-focused email: {domain}",
                    })

    # Check 6: Description references popular packages (impersonation)
    summary = (info.get("summary") or "").lower()
    for popular in POPULAR_PACKAGES[:20]:
        if popular in summary and popular != package_name.lower():
            findings.append({
                "check": "description_impersonation",
                "risk": "LOW",
                "detail": f"Description references popular package '{popular}'",
            })
            break

    return findings


def run_pip_audit(filepath):
    """Run pip-audit on a requirements file and return results."""
    try:
        result = subprocess.run(
            [
                "pip-audit", "-r", filepath,
                "-f", "json",
                "--progress-spinner", "off",
            ],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.stdout:
            try:
                data = json.loads(result.stdout)
                if isinstance(data, dict):
                    return data.get("dependencies", [])
                return data
            except json.JSONDecodeError:
                return []
        return []
    except FileNotFoundError:
        print("  Warning: pip-audit not found. Skipping vulnerability scan.")
        return []
    except subprocess.TimeoutExpired:
        print("  Warning: pip-audit timed out.")
        return []


def generate_report(package_results, audit_results):
    """Generate a combined security threat report."""
    print("\n" + "=" * 60)
    print("  SUPPLY CHAIN SECURITY REPORT")
    print("=" * 60)

    total_high = 0
    total_medium = 0
    total_low = 0

    for pkg_name, results in package_results.items():
        typosquat_findings = results.get("typosquatting", [])
        metadata_findings = results.get("metadata", [])

        if not typosquat_findings and not metadata_findings:
            continue

        print(f"\n  Package: {pkg_name}")
        print("  " + "-" * 40)

        if typosquat_findings:
            print("  [TYPOSQUATTING RISK]")
            for finding in typosquat_findings:
                risk = finding["risk"]
                if risk == "HIGH":
                    total_high += 1
                elif risk == "MEDIUM":
                    total_medium += 1
                print(
                    f"    [{risk}] Similar to '{finding['popular_package']}' "
                    f"(similarity: {finding['similarity_score']}%, "
                    f"edit distance: {finding['edit_distance']})"
                )

        if metadata_findings:
            print("  [METADATA ANALYSIS]")
            for finding in metadata_findings:
                risk = finding["risk"]
                if risk == "HIGH":
                    total_high += 1
                elif risk == "MEDIUM":
                    total_medium += 1
                else:
                    total_low += 1
                print(f"    [{risk}] {finding['check']}: {finding['detail']}")

    # Vulnerability scan results
    if audit_results:
        vuln_found = False
        for dep in audit_results:
            vulns = dep.get("vulns", [])
            if vulns:
                if not vuln_found:
                    print(f"\n  KNOWN VULNERABILITIES (via pip-audit)")
                    print("  " + "-" * 40)
                    vuln_found = True
                name = dep.get("name", "unknown")
                version = dep.get("version", "unknown")
                for v in vulns:
                    vuln_id = v.get("id", "N/A")
                    fix_versions = v.get("fix_versions", [])
                    fix_str = (
                        ", ".join(fix_versions)
                        if fix_versions
                        else "No fix available"
                    )
                    print(
                        f"    [HIGH] {name}=={version}: "
                        f"{vuln_id} (fix: {fix_str})"
                    )
                    total_high += 1

    # Summary
    print("\n" + "=" * 60)
    print("  SUMMARY")
    print(f"    HIGH risk findings:   {total_high}")
    print(f"    MEDIUM risk findings: {total_medium}")
    print(f"    LOW risk findings:    {total_low}")
    print("=" * 60)

    if total_high > 0:
        print("\n  ACTION REQUIRED: Review HIGH risk findings immediately.")
        print("  These packages may be part of a supply chain attack.")
    elif total_medium > 0:
        print("\n  CAUTION: Review MEDIUM risk findings before deploying.")
    else:
        print("\n  All clear. No significant supply chain risks detected.")

    return {"high": total_high, "medium": total_medium, "low": total_low}


def main():
    """Main entry point for the supply chain scanner."""
    if len(sys.argv) < 2:
        print("Usage: python supply_chain_scanner.py <requirements.txt>")
        print("\nExample:")
        print("  python supply_chain_scanner.py sample_requirements.txt")
        sys.exit(1)

    filepath = sys.argv[1]
    print(f"\n  Scanning: {filepath}")
    print("=" * 60)

    # Load packages
    packages = load_requirements(filepath)
    print(f"  Found {len(packages)} package(s) to analyze.\n")

    # Analyze each package
    package_results = {}
    for pkg_name in packages:
        print(f"  Analyzing: {pkg_name}...", end=" ")

        typosquat_findings = check_typosquatting(pkg_name)
        metadata = fetch_package_metadata(pkg_name)
        metadata_findings = analyze_metadata(pkg_name, metadata)

        package_results[pkg_name] = {
            "typosquatting": typosquat_findings,
            "metadata": metadata_findings,
        }

        finding_count = len(typosquat_findings) + len(metadata_findings)
        if finding_count > 0:
            print(f"{finding_count} finding(s)")
        else:
            print("OK")

    # Run pip-audit
    print("\n  Running vulnerability scan (pip-audit)...")
    audit_results = run_pip_audit(filepath)

    # Generate report
    generate_report(package_results, audit_results)


if __name__ == "__main__":
    main()

Your supply chain scanner is fully operational. It detects typosquatting, analyzes metadata for social engineering red flags, scans for known vulnerabilities, and combines everything into a single actionable report. Next up, you will push it even further with a secret mission that adds dependency confusion detection.

Secret mission

Add Dependency Confusion Detection

Your scanner detects typosquatting and metadata red flags. But there's a third attack vector that hit Microsoft in May 2026: dependency confusion. Attackers register public packages whose names mimic internal corporate namespaces, hoping automated build systems will pull the malicious public version instead of the private one. Extend your scanner with a fourth analysis layer that catches these attacks before they reach production.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The supply-chain-scanner project directory (contains your virtual environment, scanner script, and sample requirements file).
  • Python packages installed inside the virtual environment: pip-audit, rapidfuzz, and requests.

Keep everything running

No action needed. Choose this if you plan to keep developing your supply chain scanner or want to run it against real project dependencies.

  • Your virtual environment and installed packages stay in the supply-chain-scanner folder on your Desktop.
  • Next time you want to use the scanner, activate the virtual environment again and run the script.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files and packages stay on disk for next time.

  • Deactivate the virtual environment by running:
deactivate
  • When you return, navigate back to the supply-chain-scanner directory and reactivate with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project files, the virtual environment, and installed packages. This frees up disk space completely.

  • Deactivate the virtual environment first by running:
deactivate
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the entire project directory by running:

macOS

rm -rf supply-chain-scanner

Windows

rmdir /s /q supply-chain-scanner
  • Confirm the folder is gone by running:
ls
  • You should no longer see supply-chain-scanner in the output.

Nice Work!

Nice Work!

You just built a production-ready supply chain security scanner that detects real-world social engineering attacks targeting Python developers.

What you learned:

  • Built a typosquatting detector using Levenshtein distance via rapidfuzz to flag packages with names suspiciously similar to popular libraries.
  • Integrated the PyPI JSON API to analyze package metadata for trust signals including age, release count, author info, and suspicious email domains.
  • Connected pip-audit for automated known vulnerability scanning and generated structured security threat reports categorizing findings by HIGH, MEDIUM, and LOW risk levels.
  • Secret Mission: Added dependency confusion detection to flag packages mimicking internal corporate namespaces.

Ready to quiz yourself?