LabHub

Blog

Python PyPI Supply Chain Attack Defense Strategy: From Typosquatting to Multi-layered Security

한국어English日本語

Introduction: Why PyPI Supply Chain Security Now

From the second half of 2025 through early 2026, supply chain attacks targeting Python PyPI surged to an unprecedented level. Between July 2025 and January 2026 alone, 128 phantom packages were downloaded 121,539 times in total, and an average of 3,903 malicious installs occurred per week. According to The Hacker News, sophisticated attacks that combine cryptocurrency wallet theft with RAT (remote access trojan) delivery, such as the dYdX supply chain attack of February 2026, are emerging.

This article analyzes attacks that actually occurred and presents, together with practical code, a multi-layered defense strategy that a development team can apply immediately.

Analyzing the Types of PyPI Supply Chain Attack

Attack Type Comparison Table

Attack typeDescriptionRepresentative caseRisk
TyposquattingRegistering a typo variant of a well-known package nametermncolor (posing as termcolor), sisaws (posing as sisa)High
Dependency ConfusionRegistering a public package with the same name as an internal oneHijacking an internal corporate package nameVery high
Malicious build scriptInjecting malicious code into the build hook of setup.py/pyproject.tomlA backdoor that runs automatically at install timeHigh
Account HijackingTaking over a maintainer account and publishing a malicious versiondYdX (2026.02), Ultralytics (2024.12)Very high
Phantom PackageRegistering large numbers of fake packages that look usefulPackages posing as AI/ML toolsMedium
StarJackingStealing the URL of a popular GitHub repository to fake credibilityPyPI metadata manipulationMedium

1. Typosquatting

This is the most frequent attack type. Attackers register malicious packages under names close to well-known ones, such as reqeusts instead of requests or colorizr instead of colorama. termncolor, discovered in July 2025, posed as the legitimate termcolor package, and the sisaws and secmeasure packages were confirmed to deliver the SilentSync RAT.

PyPI has now introduced a feature that automatically detects and flags typosquatting attempts at project creation time, but it does not block every variant.

2. Dependency Confusion

This attack, first disclosed by Alex Birsan in 2021, works by registering a package on public PyPI under the same name as a private package that a company uses internally. Because pip by default prefers the higher version number from the public index, if an attacker registers an extremely high version such as 9999.0.0, the malicious package is installed instead of the internal one.

3. Tampering with a Legitimate Package via Account Takeover

This is the most destructive attack type. The attacker steals the maintainer credentials of a legitimate package and publishes a malicious version. Because the package name itself is legitimate in this case, detection is very difficult.

Failure Case Analysis

Case 1: The dYdX Supply Chain Attack (February 2026)

In this incident, disclosed on January 28, 2026, the attacker stole developer credentials from dYdX, a decentralized cryptocurrency exchange, and published malicious versions of the npm package (@dydxprotocol/v4-client-js) and the PyPI package (dydx-v4-client).

Attack characteristics:

Lesson: dYdX advised users to isolate infected machines, move funds to a new wallet from a clean system, and rotate every API key and credential. This case shows the importance of 2FA (two-factor authentication) and of configuring a Trusted Publisher.

Case 2: The Ultralytics Supply Chain Attack (December 2024)

Ultralytics (YOLO), the world's leading computer vision AI library, was attacked through a compromise of its GitHub Actions workflow.

Attack timeline:

Attack mechanism:

Lesson: limit the scope of a PyPI API token to a specific project and version, and validate external input (branch names, PR titles and so on) in GitHub Actions workflows. Using a Trusted Publisher also prevents token theft in the first place.

Multi-layered Defense Strategy

┌──────────────────────────────────────────────────────────────┐
PyPI Supply Chain Security in Depth├──────────────┬───────────────┬─────────────┬─────────────────┤
Layer 1Layer 2Layer 3Layer 4│              │               │             │                 │
DependencyVulnerabilityBuild env   │ Runtime│ management   │ scanning      │ security    │ monitoring      │
│              │               │             │                 │
Lockfile     │ pip-audit     │ TrustedSBOMPinning      │ safety        │ Publisher   │ tracking        │
│              │               │             │                 │
HashGitHubPEP 740Dependency│ verification │ DependabotAttestation │ auditing        │
│              │               │             │                 │
PrivateSnyk /        │ 2FA /AnomalyIndexSocket.devOIDC        │ detection       │
└──────────────┴───────────────┴─────────────┴─────────────────┘

Layer 1: Strengthening Dependency Management

Lockfile Pinning and Hash Verification

Pinning dependencies to an exact version and hash lets you block installation when a package has been tampered with.

# pyproject.toml - uv/pip compatible dependency management
[project]
name = "my-secure-app"
requires-python = ">=3.11"
dependencies = [
    "requests==2.31.0",
    "cryptography==42.0.5",
    "pydantic==2.6.1",
]

[tool.uv]
# Private index takes priority (defense against dependency confusion)
index-url = "https://my-company.jfrog.io/pypi/simple/"
extra-index-url = "https://pypi.org/simple/"

[tool.uv.pip]
# Require hash verification
require-hashes = true
# requirements.txt - hash pinning example
requests==2.31.0 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7f0edf3fcb0fce8aea3fbd5951d bdf0f4
cryptography==42.0.5 \
    --hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608c3cb2b6f56af2f20f2132764710 68e5c
pydantic==2.6.1 \
    --hash=sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5 d1fa9

Giving the Private Index Priority (Defense Against Dependency Confusion)

# pip.conf - private index takes priority
[global]
index-url = https://my-company.jfrog.io/pypi/simple/
extra-index-url = https://pypi.org/simple/

[install]
# Enable hash verification by default
require-hashes = true

If you use uv, an even stronger defense against dependency confusion is possible.

# pyproject.toml - uv index strategy configuration
[tool.uv]
# "first-match" strategy: once a package is found in the first index, no other index is searched
index-strategy = "first-match"

[[tool.uv.index]]
name = "internal"
url = "https://my-company.jfrog.io/pypi/simple/"
default = true

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple/"

Layer 2: Vulnerability Scanning

Checking for Known Vulnerabilities with pip-audit

pip-audit is an open source tool sponsored by Google and developed by Trail of Bits. It looks up vulnerability information from the Python Packaging Advisory Database through the PyPI JSON API.

# Install and run pip-audit
pip install pip-audit

# Scan the current environment
pip-audit

# Scan based on requirements.txt
pip-audit -r requirements.txt

# Fix vulnerabilities automatically (upgrade to a safe latest version)
pip-audit --fix

# JSON output (for CI/CD pipeline integration)
pip-audit -f json -o audit-report.json

# Ignore a specific vulnerability (false positive or not applicable)
pip-audit --ignore-vuln PYSEC-2024-XXXX

Detecting Malicious Packages with Safety CLI

Beyond vulnerability checking, Safety also provides malicious package detection.

# Install and run Safety
pip install safety

# Scan the current environment
safety check

# Scan based on requirements.txt
safety check -r requirements.txt

# JSON output format
safety check --output json

# Scan the whole project directory (includes malicious package detection)
safety scan --target ./my-project/

Comparison of Vulnerability Scanning Tools

Featurepip-auditSafety CLISnyk
Vulnerability DBPyPI Advisory DB (OSV)SafetyDB (PyUp)Snyk Vulnerability DB
Malicious package detectionNot supportedSupportedSupported
Automatic fixSupported (--fix)Not supportedSupported
License checkingNot supportedSupported in the paid versionSupported
CVSS scoreNot supportedSupported in the paid versionSupported
CI/CD integrationGitHub Actions providedGitHub Actions providedNative integration
CostFree (Apache 2.0)Free/paidFree/paid
Recommended useAutomated CI/CD checksDevelopment environment securityEnterprise

Layer 3: Build Environment Security - Trusted Publisher and PEP 740

Configuring a Trusted Publisher

PyPI Trusted Publisher uses OpenID Connect (OIDC) so that a CI/CD platform such as GitHub Actions can publish packages safely without a token. Because no API token exists, theft itself is impossible.

# .github/workflows/publish.yml - Trusted Publisher based publishing
name: Publish to PyPI

on:
  release:
    types: [published]

permissions:
  id-token: write # Required to issue the OIDC token
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install build dependencies
        run: pip install build

      - name: Build package
        run: python -m build

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        # No password/token needed when using a Trusted Publisher
        # The GitHub repository must be registered as a Trusted Publisher on PyPI
        with:
          attestations: true # Automatically generates PEP 740 digital attestations

  verify:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Verify attestation
        run: |
          pip install pypi-attestations
          python -m pypi_attestations verify my-package

PEP 740 Digital Attestations

PEP 740 defines cryptographically verifiable attestations for PyPI packages. It uses Sigstore-based keyless signing and lets you verify which source repository a package was built from.

# Verifying an attestation (consumer side)
pip install pypi-attestations

# Check the attestations of a specific package
python -c "
import requests
resp = requests.get(
    'https://pypi.org/integrity/requests/2.31.0/'
)
attestations = resp.json()
print(f'Attestation count: {len(attestations)}')
for att in attestations:
    print(f'  Publisher: {att.get(\"publisher\", \"unknown\")}')
"

Layer 4: Building a CI/CD Security Pipeline

A Comprehensive GitHub Actions Security Pipeline

# .github/workflows/security.yml
name: Python Supply Chain Security

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    # Scheduled scan every day at 9 a.m. KST
    - cron: '0 0 * * *'

permissions:
  contents: read
  security-events: write

jobs:
  dependency-audit:
    name: Dependency Audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install pip-audit safety cyclonedx-bom

      - name: Run pip-audit
        run: |
          pip-audit -r requirements.txt \
            -f json \
            -o pip-audit-report.json \
            --desc on
        continue-on-error: false

      - name: Run Safety check
        run: |
          safety check -r requirements.txt \
            --output json \
            > safety-report.json
        continue-on-error: true

      - name: Check for critical vulnerabilities
        run: |
          python3 -c "
          import json, sys
          with open('pip-audit-report.json') as f:
              report = json.load(f)
          vulns = report.get('dependencies', [])
          critical = [v for v in vulns if v.get('vulns')]
          if critical:
              print(f'CRITICAL: {len(critical)} vulnerable packages found')
              for pkg in critical:
                  name = pkg['name']
                  version = pkg['version']
                  for vuln in pkg['vulns']:
                      vid = vuln['id']
                      fix = vuln.get('fix_versions', ['N/A'])
                      print(f'  - {name}=={version}: {vid} (fix: {fix})')
              sys.exit(1)
          print('No vulnerabilities found')
          "

      - name: Upload audit reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: security-reports
          path: |
            pip-audit-report.json
            safety-report.json

  sbom-generation:
    name: Generate SBOM
    runs-on: ubuntu-latest
    needs: dependency-audit
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install cyclonedx-bom

      - name: Generate CycloneDX SBOM
        run: |
          cyclonedx-py environment \
            --output sbom.json \
            --output-format json \
            --schema-version 1.5

      - name: Validate SBOM
        run: |
          python3 -c "
          import json
          with open('sbom.json') as f:
              sbom = json.load(f)
          components = sbom.get('components', [])
          print(f'SBOM generated: {len(components)} components')
          print(f'Format: CycloneDX {sbom.get(\"specVersion\", \"unknown\")}')
          for comp in components[:5]:
              name = comp.get('name', 'unknown')
              version = comp.get('version', 'unknown')
              print(f'  - {name}@{version}')
          if len(components) > 5:
              print(f'  ... and {len(components) - 5} more')
          "

      - name: Upload SBOM
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.json

  lockfile-integrity:
    name: Lockfile Integrity Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Verify hash-pinned dependencies
        run: |
          pip install --require-hashes \
            -r requirements.txt \
            --dry-run \
            --no-deps
        continue-on-error: false

      - name: Check for unpinned dependencies
        run: |
          python3 -c "
          import re, sys
          unpinned = []
          with open('requirements.txt') as f:
              for line in f:
                  line = line.strip()
                  if line and not line.startswith('#'):
                      if '==' not in line and '--hash' not in line:
                          unpinned.append(line)
          if unpinned:
              print('WARNING: Unpinned dependencies found:')
              for dep in unpinned:
                  print(f'  - {dep}')
              sys.exit(1)
          print('All dependencies are version-pinned')
          "

Generating and Managing an SBOM

An SBOM (Software Bill of Materials) is a list documenting every component contained in a piece of software. Since US Executive Order 14028 it has become a core element of supply chain transparency.

# Generate an SBOM for a Python project with CycloneDX
pip install cyclonedx-bom

# Generate an SBOM from the current virtual environment
cyclonedx-py environment \
  --output sbom.json \
  --output-format json \
  --schema-version 1.5

# Generate an SBOM from requirements.txt
cyclonedx-py requirements \
  --input-file requirements.txt \
  --output sbom-requirements.json \
  --output-format json

# SPDX format can be generated as well
pip install spdx-tools
# sbom_validator.py - SBOM validation and analysis script
import json
import sys
from datetime import datetime


def validate_sbom(sbom_path: str) -> dict:
    """Validate an SBOM file and generate a summary report."""
    with open(sbom_path) as f:
        sbom = json.load(f)

    components = sbom.get("components", [])
    metadata = sbom.get("metadata", {})

    report = {
        "timestamp": datetime.now().isoformat(),
        "spec_version": sbom.get("specVersion", "unknown"),
        "total_components": len(components),
        "components_without_version": [],
        "components_without_license": [],
        "components_without_purl": [],
    }

    for comp in components:
        name = comp.get("name", "unknown")
        if not comp.get("version"):
            report["components_without_version"].append(name)
        if not comp.get("licenses"):
            report["components_without_license"].append(name)
        if not comp.get("purl"):
            report["components_without_purl"].append(name)

    # Print the validation result
    print(f"SBOM Validation Report")
    print(f"=" * 50)
    print(f"Spec Version: {report['spec_version']}")
    print(f"Total Components: {report['total_components']}")
    print(f"Missing Versions: {len(report['components_without_version'])}")
    print(f"Missing Licenses: {len(report['components_without_license'])}")
    print(f"Missing PURLs: {len(report['components_without_purl'])}")

    # Calculate the quality score
    total = report["total_components"]
    if total > 0:
        quality_score = (
            1
            - (
                len(report["components_without_version"])
                + len(report["components_without_license"])
                + len(report["components_without_purl"])
            )
            / (total * 3)
        ) * 100
        print(f"Quality Score: {quality_score:.1f}%")
        report["quality_score"] = quality_score

    return report


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python sbom_validator.py sbom.json")
        sys.exit(1)
    validate_sbom(sys.argv[1])

pyproject.toml Security Configuration Best Practices

[project]
name = "my-secure-app"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31.0,<3.0",
    "cryptography>=42.0.0,<43.0",
    "pydantic>=2.6.0,<3.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# Security-related tool configuration
[tool.pip-audit]
# pip-audit configuration
desc = "on"
progress-spinner = "on"
output = "json"

[tool.safety]
# Safety CLI configuration
output = "json"
continue-on-error = false

[tool.ruff]
# Enable security-related lint rules
select = [
    "S",     # flake8-bandit (security vulnerability detection)
    "B",     # flake8-bugbear
]

[tool.bandit]
# Bandit static security analysis configuration
exclude_dirs = ["tests", "venv"]
skips = []

Additional Defense Techniques

Inspecting the setup.py Build Script

A considerable share of malicious packages inject malicious code into the install hook of setup.py. It is worth making a habit of inspecting the contents of setup.py before installing a package.

# Inspect the source code before installing the package
pip download --no-binary :all: --no-deps suspect-package
# Unpack the downloaded source, then inspect setup.py

# Or restrict build script execution with the pip --no-build-isolation option
pip install --no-build-isolation --only-binary :all: package-name

GitHub Dependabot Configuration

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: 'pip'
    directory: '/'
    schedule:
      interval: 'daily'
    reviewers:
      - 'security-team'
    labels:
      - 'dependencies'
      - 'security'
    open-pull-requests-limit: 10
    # Open automatic PRs for security updates only
    allow:
      - dependency-type: 'direct'
    # Major version updates are reviewed manually
    ignore:
      - dependency-name: '*'
        update-types: ['version-update:semver-major']

Local Checks with a pre-commit Hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pypa/pip-audit
    rev: v2.7.3
    hooks:
      - id: pip-audit
        args: ['-r', 'requirements.txt']

  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.8
    hooks:
      - id: bandit
        args: ['-r', 'src/', '-ll']

  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

Security Checklist

This is a supply chain security checklist you can apply to a project immediately.

Dependency Management

CI/CD Security

Account Security

Monitoring

Conclusion

PyPI supply chain attacks cannot be stopped by a single tool or a single policy. What is needed is a multi-layered defense strategy that combines dependency locking (Lockfile Pinning + Hash Verification), vulnerability scanning (pip-audit + Safety), build environment security (Trusted Publisher + PEP 740 Attestation) and runtime monitoring (SBOM tracking).

The three key measures to focus on as of 2026 are as follows.

  1. Move to Trusted Publisher: use an OIDC-based Trusted Publisher instead of a PyPI API token, removing the risk of credential theft at the root
  2. Use PEP 740 attestations: verify the origin of a package cryptographically to confirm whether it has been tampered with
  3. Automate SBOM: generate an SBOM on every build and carry out vulnerability monitoring continuously

Supply chain security is not something you set up once and are done with; it is an operational process that has to be updated and watched continuously. Take the checklist above and review the security posture of your team today.

References

Comments

No comments yet.

Sign in to leave a comment