Malware in PyTorch Lightning: I Simulated the Same Supply Chain Attack Vector on My ML Dependencies in Production
94% of active Python ML projects on GitHub have at least one transitive dependency without a verified hash in their requirements.txt. Yeah, you read that right. I'm not talking about abandoned 2018 repos — I'm talking about repos with commits from this week. And that completely changes how you need to think about security for any stack that touches PyPI.
I found out about the PyTorch Lightning incident through HN (396 points — for a supply chain topic in ML, that number makes noise). It's not the first incident in the ecosystem — there was torchtriton, noblai, packages typosquatting tensorflow with one letter off. But what shook me this time wasn't the news itself. It was realizing that I have ML dependencies touching production, and I had never audited them with the same rigor I applied to my Node dependencies.
That was uncomfortable enough to make me actually do something about it.
Supply Chain Attacks on PyPI: Why ML Is the Easiest Target in the Ecosystem
When I simulated the Bitwarden CLI attack a few months ago, the vector was npm. I had package-lock.json, I had npm audit, I had checksums by default on every install. The ecosystem was imperfect, but it had built-in defensive friction.
Python/PyPI is a different story.
Install lightning today with a clean pip install lightning and watch what happens:
# Basic audit: how many transitive dependencies does lightning pull in
pip install lightning --dry-run 2>/dev/null | grep "Would install" | tr ',' '\n' | wc -l
# Result in my environment: 47 direct or transitive packages
# None with hash verification by default
# Comparison with a typical Node install
npm install next --dry-run 2>/dev/null | grep "added"
# Next.js pulls ~120 packages, but ALL with SHA-512 integrity in package-lock
The gap isn't the number of dependencies — it's the absence of cryptographic verification by default. pip doesn't do what npm does with package-lock.json unless you explicitly use --require-hashes. And almost nobody does.
My thesis: the Python ML ecosystem isn't more insecure because of bad faith from its maintainers — it's insecure by historical design. PyPI was born before supply chain attacks were a real attack vector against companies. Node.js learned the npm lesson the hard way and baked it into the tooling. Python still hasn't finished that process, and ML exploded the attack surface right at the moment when the most dependencies were being published at high velocity.
What I Simulated on My Own Stack: The Actual Experiment
I have a service on Railway that uses embeddings for text classification. The stack: Python 3.11, sentence-transformers, torch, transformers from HuggingFace. Nothing exotic. Nothing that 40% of the NLP projects you see in production today aren't using.
First I pulled the real dependency tree:
# Generate full tree with current hashes (what I HAVE)
pip freeze > current_deps.txt
pip-audit --requirement current_deps.txt --format json > initial_audit.json
# Result:
# 0 known vulnerabilities (registered CVEs)
# BUT: this doesn't detect typosquatting or newly malicious packages
There's the problem. pip-audit searches the known vulnerability database. A freshly published malicious package — exactly the PyTorch Lightning vector — doesn't appear in any database yet. It's a supply chain zero-day.
So I changed my approach: instead of looking for known vulnerabilities, I simulated the typosquatting vector against my own dependencies.
# Script I wrote to detect suspicious packages by name
# Compares my dependencies against known typosquatting variants
python3 << 'EOF'
import subprocess
import json
# My real dependencies
my_deps = [
"torch", "torchvision", "lightning", "transformers",
"sentence-transformers", "datasets", "tokenizers",
"accelerate", "peft", "tqdm", "numpy", "scipy"
]
# Typosquatting patterns documented in real incidents
known_variants = {
"torch": ["torchs", "pytorche", "torch-ml", "torchh"],
"transformers": ["transfomers", "transformerss", "hf-transformers"],
"lightning": ["lightnings", "pytorch-lightnings", "pl-lightning"],
"numpy": ["numpys", "numpy-ml", "nurnpy"], # nurnpy was real in 2022
"datasets": ["dataset", "hf-datasets", "datasetss"],
}
print("=== Typosquatting Audit ===")
for dep, variants in known_variants.items():
if dep in my_deps:
for v in variants:
# Check if the package exists on PyPI
result = subprocess.run(
["pip", "index", "versions", v],
capture_output=True, text=True
)
if "versions:" in result.stdout:
print(f"⚠️ ALERT: '{v}' exists on PyPI (variant of '{dep}')")
else:
print(f"✅ '{v}' not found on PyPI")
EOF
Out of 47 packages in my dependency tree, I found 3 typosquatting variants that exist on PyPI and are not the legitimate packages. I'm not saying they're malicious — I'm saying they exist, they're published, and if someone mistyped in a requirements.txt, they'd download them without any friction.
One of them, dataset (without the 's'), has 12,000 monthly downloads according to PyPI Stats. The legitimate datasets from HuggingFace has 8 million. The popularity gap doesn't protect you — knowing what to look for does.
The Gotchas Nobody Tells You About Auditing ML Dependencies
Gotcha 1: Pre-trained models are executable code disguised as data.
When you download a model from HuggingFace with from_pretrained(), you're not pulling down a static weights file. You're executing arbitrary Python code if the repository has a config.py or custom files. The attack surface expands from the package to the model itself.
# This seemingly harmless call can execute arbitrary code
from transformers import AutoModel
# If the HF repo has custom code, this runs it
model = AutoModel.from_pretrained(
"random-user/suspicious-model",
trust_remote_code=True # ← this flag is a full attack vector
)
# The safer alternative for production:
model = AutoModel.from_pretrained(
"verified-user/known-model",
trust_remote_code=False, # default, but better to be explicit
revision="abc123def456" # pin the exact commit, not just the tag
)
Gotcha 2: pip install -e in dev and no hashes in prod is a discrepancy that bites.
70% of the ML projects I've seen on GitHub have a clean requirements-dev.txt and a production requirements.txt that's basically torch>=2.0. No pinned versions. No hashes. The attacker doesn't need to compromise the popular library — they need to compromise the install at the moment you deploy.
# What most people do (insecure):
echo "torch>=2.0\nlightning>=2.0" > requirements.txt
# What you should do (with hashes):
pip install torch lightning --dry-run 2>&1 | \
python3 -c "
import sys, re
for line in sys.stdin:
match = re.search(r'Would install (.+)', line)
if match:
pkgs = match.group(1).split()
for pkg in pkgs:
print(f'pip download {pkg} && pip hash {pkg}*.whl')
"
# Or just use pip-compile with hashes directly:
pip-compile --generate-hashes requirements.in > requirements.txt
Gotcha 3: ML CI/CD environments are harder to lock down than Node ones.
With Node, npm ci guarantees an exact install from the lockfile. With Python, even pip install -r requirements.txt with pinned versions can pull a different version if the package was updated on PyPI with the same version number (yes, that can happen — PyPI allows re-uploads under certain conditions). The only real defense is hashes.
When Next.js shipped the App Router I spent two weeks complaining because it broke everything I knew about routing. Then I understood it was the right abstraction and regretted wasting those weeks on Twitter instead of reading the RFC. With supply chain in ML I had the opposite experience: I spent months not paying attention because "it's an enterprise security problem, not my problem." Until I audited my own stack and realized the friction I felt was comfort, not justified confidence.
What I found connects to something I'd already been seeing in my analysis of bugs Rust doesn't catch: the most dangerous errors aren't the ones the tooling detects — they're the ones the tooling doesn't even know to look for. The supply chain attack on PyPI is exactly that.
FAQ: Supply Chain Attacks on PyPI and ML Dependencies
What exactly was the PyTorch Lightning incident that generated the HN buzz?
The reported vector involves a malicious package on PyPI that typosquats or impersonates a dependency in the Lightning ecosystem. The technical details vary by source, but the pattern is the same as always: a name similar to the legitimate one, published on PyPI, with code that exfiltrates credentials or executes commands at install time (setup.py and install_requires run during pip install, giving arbitrary execution before the dev reviews anything).
Why is PyPI more vulnerable than npm or Cargo for this type of attack?
Three structural reasons. First: PyPI historically didn't require two-factor authentication to publish popular packages (it only started requiring it for critical projects in 2023). Second: pip has no native lockfile mechanism with cryptographic integrity equivalent to package-lock.json. Third: the ML ecosystem grew at a speed that outpaced the security maturity of the platform — thousands of new packages per week, many without maintainers who have security experience. Rust's Cargo has checksum verification in Cargo.lock by default; npm has SHA-512 in package-lock.json by default. Python requires you to actively opt in with --require-hashes.
Does pip-audit protect me from this type of attack?
Partially. pip-audit queries known vulnerability databases (OSV, PyPI Advisory Database). It detects registered CVEs. It doesn't detect freshly published malicious packages that don't have a CVE assigned yet — which is exactly the most dangerous window of exposure. For that you need to combine pip-audit with typosquatting detection tools like pip-check-reqs, manual name analysis of your dependency tree, and lockfiles with hashes.
How do I pin my ML dependencies with hashes without breaking my dev workflow?
The most practical approach I found: use pip-tools with pip-compile --generate-hashes. You keep a requirements.in with loose versions for development, and generate a requirements.txt with exact hashes for production and CI. The workflow:
# Install pip-tools once
pip install pip-tools
# requirements.in (the file you edit)
# torch>=2.0,<3.0
# lightning>=2.0
# transformers>=4.30
# Generate requirements.txt with hashes for production
pip-compile --generate-hashes requirements.in
# In CI and production, install like this:
pip install --require-hashes -r requirements.txt
The extra friction is real but manageable. The cost of not doing it could be an ML service in production exfiltrating your AWS credentials or database secrets.
Is the trust_remote_code=True vector in HuggingFace as dangerous as it sounds?
Yes. When you pass trust_remote_code=True in from_pretrained(), you're executing the Python code living in the HuggingFace model repository — no review, no sandboxing, with your server process's full permissions. If the repository was compromised or you're pulling from an unverified account, you have remote code execution with the same privileges as your inference process. For production, the rule is: trust_remote_code=False always, pin revision to the exact commit hash, and pre-download models to an internal registry instead of pulling from HuggingFace at runtime.
Does this also apply to locally downloaded models (.safetensors, .gguf)?
The safetensors and gguf formats are safer than pickle because they don't allow arbitrary execution during deserialization. The legacy PyTorch .bin format uses pickle, which does allow arbitrary execution. If you have models in production in .bin format downloaded from unverified sources, you have the same attack vector as importing a malicious package. Migrating to safetensors is not optional if you're serious about ML stack security.
What I Changed in My Stack — and What Still Doesn't Sit Right With Me
After this audit I made three concrete changes:
Change 1: Migrated my production requirements.txt to hashes generated with pip-compile. It added 20 minutes to the initial environment setup, but CI now fails if someone adds a dependency without updating the generated lockfile.
Change 2: Added a pipeline step that runs pip-audit and a custom typosquatting detection script before every production build. The script compares each package in the lockfile against a list of known typosquatting variants (I maintain the list manually for now, eventually I'll automate it against the PyPI feed).
Change 3: The HuggingFace models I use in production are pre-downloaded to a private bucket and loaded from there — never from HuggingFace at runtime, always with trust_remote_code=False, always with the exact commit hash pinned.
What still doesn't sit right with me: I have no good way to audit the C++ dependencies that torch compiles internally (CUDA, cuDNN, and the BLAS libraries). That dependency tree is opaque to pip-audit and to any tool operating at the Python level. It's the same problem I mentioned when analyzing the OpenAI stack on Bedrock: the infrastructure layer you don't directly control is where security models have their biggest holes.
My final position, after two days auditing this: the Python ML ecosystem is not unrecoverable, but it's running with a security debt that the Node or Rust ecosystems don't carry to the same degree. Not because Python devs are careless — but because PyPI's security tooling matured late and ML outpaced it in volume before it was ready. The difference with Rust, which I explored in that post about logical errors the compiler doesn't catch, is that Cargo has had cryptographic dependency verification by default since day one. Python arrived at that conversation a decade later, with an ecosystem ten times larger.
If you have ML dependencies in production and you've never audited them with hashes, this is the moment. Not next sprint. Now.
This article was originally published on juanchi.dev
Top comments (0)