Last Friday I got pinged by a teammate at 11 PM. "Hey, can you check the build? Something's pulling a sketchy package from a fork I've never heard of." Twenty minutes later we were rotating tokens and rolling back two deploys.
This kind of thing isn't rare anymore. According to recent reporting from The Register on the so-called "Megaladon" campaign, attackers reportedly poisoned thousands of GitHub repositories at once using automated forking and commit injection. I haven't dug into the IOCs myself, but the broader pattern — supply chain attacks targeting the developer trust model — is something I've been dealing with for years now. So let's talk about how to actually defend against it.
The problem: you trust your dependencies more than you think
Here's the uncomfortable truth. When you run npm install, pip install, or go get, you're executing code written by strangers. That code can run install scripts. It can read your environment variables. It can phone home with your .npmrc token before you've even hit save on your editor.
The attacks generally work in one of three flavors:
-
Typosquatting: a malicious package with a name like
reqeustsorlodahshoping you typo. - Maintainer compromise: a legitimate package gets a new "helpful" contributor whose first PR adds an obfuscated payload.
- Repo forking + star manipulation: bots fork popular repos, inject malicious commits, and use fake stars to make the forks look legit in search results.
The last one is what makes campaigns like the recent GitHub poisoning waves so nasty. A developer Googles "react component library example," lands on a forked repo with 2k stars, and clones it without checking the commit history.
Root cause: the trust graph is implicit
Most projects have a dependency tree dozens of layers deep. You depend on A, A depends on B, B depends on C — and C's maintainer just sold the package to a stranger on Telegram. The trust is transitive, but the verification almost never is.
You can see this yourself. Run this in any Node project:
# Count unique packages in your full dependency tree
npm ls --all --parseable 2>/dev/null | wc -l
Most mid-sized projects I work on come back with 1500+ lines. That's 1500 trust decisions you didn't consciously make.
Step 1: Find what's already in your tree
Before you can defend, you need to know what you have. Start with a lockfile audit:
# Node — shows known vulnerable advisories
npm audit --audit-level=moderate
# Python — pip-audit reads your installed env
pip-audit
# Go — built into the toolchain since 1.18
govulncheck ./...
These tools cross-reference your lockfile against known CVE databases. They won't catch a zero-day campaign that hasn't been disclosed yet, but they'll catch the long tail of known-bad packages that somehow ended up in your node_modules.
For a deeper look at what's actually running on install, check for postinstall hooks specifically:
# Find every package that runs scripts on install
npm ls --all --json 2>/dev/null \
| jq -r '.. | objects | select(.scripts) | .name + ": " + (.scripts | keys | join(","))' \
| grep -E '(preinstall|install|postinstall)'
This is usually a wake-up call. Most projects have a dozen or more packages running arbitrary scripts during install. Each one is a foothold.
Step 2: Lock down what runs on install
For Node, you can disable install scripts entirely and only allow specific packages to run them. This is one of the cheapest wins available:
# Globally disable lifecycle scripts
npm config set ignore-scripts true
# Then explicitly allow specific packages when needed
npm rebuild esbuild # known-good native binary build
If that feels too aggressive, at least use npm ci instead of npm install in CI. It refuses to modify your lockfile, which means a malicious upstream version bump can't sneak in between commits.
For Python, pin everything with hashes:
# Generate a hash-locked requirements file
pip-compile --generate-hashes requirements.in
# Install only matching hashes — refuses anything tampered
pip install --require-hashes -r requirements.txt
The --require-hashes flag is severely underused. It means even if an attacker compromises PyPI and re-uploads a package with the same version number, your install fails loudly instead of silently pulling poisoned code.
Step 3: Verify the source before you clone
This is the part most people skip when they're rushing. When you find a repo via search, check these before cloning:
- Commit history depth — a "popular" project with 5 commits all from last week is suspicious.
- Maintainer history — does the account have other projects? Real activity? Or was it created 11 days ago?
-
Diff against upstream — if it claims to be a fork, run
git log upstream/main..HEADafter adding the original as a remote and see what's actually different.
Here's a quick script I keep around for sniff-testing a fresh clone:
#!/usr/bin/env bash
# usage: ./sniff.sh <repo-url>
git clone --depth 50 "$1" /tmp/sniff && cd /tmp/sniff
echo '--- Suspicious files ---'
find . -type f \( -name '*.min.js' -size +100k \
-o -name 'setup.py' -o -name 'package.json' \) \
| xargs grep -l -E '(eval|Function|child_process|exec|base64)' 2>/dev/null
echo '--- Install scripts ---'
# Look for anything that runs during npm/pip install
jq -r '.scripts // {} | to_entries[] | .key + ": " + .value' package.json 2>/dev/null
It's not bulletproof — obfuscated payloads can hide in unicode tricks or compiled binaries — but it catches the lazy 80% of attacks.
Step 4: Move secrets out of the blast radius
Even if a poisoned dependency runs, it should hit a dead end. The most common exfiltration target is environment variables, so don't put long-lived secrets there.
Use short-lived tokens from a secrets manager, and scope them aggressively:
# Bad — long-lived token in env
AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID']
# Better — fetched at runtime, scoped to one task, expires in minutes
import boto3
sts = boto3.client('sts')
creds = sts.assume_role(
RoleArn='arn:aws:iam::...:role/build-uploader',
RoleSessionName='ci-build',
DurationSeconds=900, # 15 minutes — enough for the job
)['Credentials']
For CI specifically, GitHub's OIDC support lets you avoid storing cloud credentials entirely. The runner gets a short-lived token at job start, your cloud provider trusts the GitHub issuer, and there's nothing for a malicious dependency to steal.
Prevention: build the habit
The controls above only help if they're on by default. A few things that have actually worked for me:
- Renovate or Dependabot with a review gate — automated PRs are fine, but require human approval. The bot batches version bumps; you eyeball the diff.
-
A
.npmrcwithignore-scripts=truechecked into every repo — even if a dev forgets the global config, the project enforces it. - An allowlist for new dependencies — when someone adds a new package, they justify it in the PR description. Sounds bureaucratic; it's actually the cheapest defense against typosquatting because someone has to type the name twice.
-
Pin GitHub Actions to full commit SHAs, not tags —
uses: actions/checkout@v4can be retagged by an attacker who compromises the action.uses: actions/checkout@a5ac7e51b41094c92...cannot.
None of this stops a determined, well-funded adversary. But the campaigns hitting thousands of repos at once are opportunistic — they fish where the fishing is easy. Making yourself a 10x harder target with a few hours of config work is one of the best ROI decisions you can make this quarter.
The attack surface isn't going to shrink. The trust graph is the trust graph. But you can put fences around the parts that matter.
Top comments (0)