- Introduction: Why PyPI Supply Chain Security Now
- Analyzing the Types of PyPI Supply Chain Attack
- Failure Case Analysis
- Multi-layered Defense Strategy
- pyproject.toml Security Configuration Best Practices
- Additional Defense Techniques
- Security Checklist
- Conclusion
- References
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 type | Description | Representative case | Risk |
|---|---|---|---|
| Typosquatting | Registering a typo variant of a well-known package name | termncolor (posing as termcolor), sisaws (posing as sisa) | High |
| Dependency Confusion | Registering a public package with the same name as an internal one | Hijacking an internal corporate package name | Very high |
| Malicious build script | Injecting malicious code into the build hook of setup.py/pyproject.toml | A backdoor that runs automatically at install time | High |
| Account Hijacking | Taking over a maintainer account and publishing a malicious version | dYdX (2026.02), Ultralytics (2024.12) | Very high |
| Phantom Package | Registering large numbers of fake packages that look useful | Packages posing as AI/ML tools | Medium |
| StarJacking | Stealing the URL of a popular GitHub repository to fake credibility | PyPI metadata manipulation | Medium |
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:
- Malicious code obfuscated through 100 iterations was injected into the PyPI package
- A cross-ecosystem attack that published to npm and PyPI at the same time
- Theft of cryptocurrency wallet credentials plus simultaneous delivery of a RAT (remote access trojan)
- Direct access to the package management infrastructure, bypassing the normal build process
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:
- First attack (December 4-5): malicious versions 8.3.41 and 8.3.42 published (roughly 12 hours of exposure)
- Second attack (December 7): versions 8.3.45 and 8.3.46 published directly to PyPI, bypassing GitHub Actions
Attack mechanism:
- The attacker abused a git branch name to steal the credentials of the GitHub Actions CI/CD pipeline
- The stolen PyPI API token was used to publish malicious versions containing XMRig (a Monero cryptocurrency miner)
- This technique (Pwn Request) had been known since 2021, yet no defense was in place
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 1 │ Layer 2 │ Layer 3 │ Layer 4 │
│ │ │ │ │
│ Dependency │ Vulnerability │ Build env │ Runtime │
│ management │ scanning │ security │ monitoring │
│ │ │ │ │
│ Lockfile │ pip-audit │ Trusted │ SBOM │
│ Pinning │ safety │ Publisher │ tracking │
│ │ │ │ │
│ Hash │ GitHub │ PEP 740 │ Dependency │
│ verification │ Dependabot │ Attestation │ auditing │
│ │ │ │ │
│ Private │ Snyk / │ 2FA / │ Anomaly │
│ Index │ Socket.dev │ OIDC │ 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
| Feature | pip-audit | Safety CLI | Snyk |
|---|---|---|---|
| Vulnerability DB | PyPI Advisory DB (OSV) | SafetyDB (PyUp) | Snyk Vulnerability DB |
| Malicious package detection | Not supported | Supported | Supported |
| Automatic fix | Supported (--fix) | Not supported | Supported |
| License checking | Not supported | Supported in the paid version | Supported |
| CVSS score | Not supported | Supported in the paid version | Supported |
| CI/CD integration | GitHub Actions provided | GitHub Actions provided | Native integration |
| Cost | Free (Apache 2.0) | Free/paid | Free/paid |
| Recommended use | Automated CI/CD checks | Development environment security | Enterprise |
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
- Are all dependencies pinned to an exact version (using
==) - Do requirements.txt or the lockfile include hashes (
--hash) - Is the internal index given priority for private packages
- Have unused dependencies been removed
- Is a dependency update cadence defined (Dependabot/Renovate)
CI/CD Security
- Is pip-audit or Safety integrated into the CI pipeline
- Is the build configured to fail when a CRITICAL/HIGH vulnerability is found
- Is an SBOM generated automatically on every build
- Is a Trusted Publisher used in GitHub Actions
- Is the scope of the PyPI API token limited to least privilege
Account Security
- Is 2FA (two-factor authentication) enabled on the PyPI account
- Are PyPI API tokens separated per project
- Are API tokens kept out of the code repository rather than hardcoded
- Are GitHub Actions secrets managed securely
Monitoring
- Are notifications configured for dependency changes
- Is SBOM-based vulnerability monitoring in operation
- Is there an impact analysis process for when a new CVE is announced
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.
- 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
- Use PEP 740 attestations: verify the origin of a package cryptographically to confirm whether it has been tampered with
- 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
- PyPI 2025 in Review - Official PyPI blog
- PyPI Now Supports Digital Attestations - Official PyPI blog
- PEP 740 - Index support for digital attestations - Python PEP
- Compromised dYdX npm and PyPI packages - The Hacker News
- Ultralytics supply chain attack analysis - Official PyPI blog
- How the Ultralytics supply chain attack happened and how to prevent it - Legit Security
- pip-audit - vulnerability auditing tool for Python environments - PyPA GitHub
- Safety CLI - vulnerability scanner for Python dependencies - PyPI
- CycloneDX Python SBOM generator - CycloneDX GitHub
- Supply chain attacks are exploiting our assumptions - Trail of Bits Blog
- Attestations: a new generation of signatures on PyPI - Trail of Bits Blog
- Are we PEP 740 yet? - Trail of Bits
- Sigstore-based PyPI attestations GA - Sigstore Blog