Your npm Install Just Ran Someone Else's Code: Why Dependency Security Has to Start Before CI
A pull request checkout, a poisoned GitHub Actions cache, and an OIDC token pulled straight out of runner memory. That's how 84 malicious versions of TanStack's packages hit npm in six minutes.
I've spent twenty years watching people trust code they never read. PHP includes, jQuery plugins, WordPress add-ons, npm packages, it's the same bet made in a different decade. Most of the time the bet pays off. On May 11, 2026, at 19:20 UTC, it didn't, and a widely used React routing library published malware to 42 packages in under six minutes.
No maintainer clicked publish. No npm token was stolen. The attacker never touched a password. They walked in through a GitHub Actions cache and a memory dump, and the workflow file that let them in is public right now, sitting in a blog post TanStack wrote about their own incident. That's the article. Not another "supply chain risk is rising" piece. The actual YAML, the actual timeline, the actual tool that would have flagged the fallout, and what changes when you stop treating dependency security as a CI checkbox.
The npm install You Ran This Morning Was a Trust Decision
Every npm install executes code you didn't write, on a machine you do control, on behalf of an application you're responsible for. That's the deal the whole ecosystem runs on. It's also, mechanically, indistinguishable from downloading and running an unsigned binary from a stranger, except it happens dozens of times a day and nobody blinks.
Dependency risk doesn't start when CI runs a scanner. It starts at the moment a developer, or increasingly an AI coding agent, decides a package is worth trusting. By the time a pull request fails a security gate, that decision has already shipped. The lockfile changed. The feature moved forward. CI is just the place where the consequences of an earlier decision get discovered, usually too late to be cheap to reverse.
Kapoor's argument isn't abstract. Three incidents from this year back it up, and one of them, the TanStack compromise, has a postmortem detailed enough that you can trace the exact YAML line that started the chain.
Are you enjoying what you are reading so far? Our staff of writers recommends reading One Employee. One OAuth Token. The Vercel Breach Explained after you finish this one.
March 2026: Axios and a Compromised Maintainer Account
We covered the Axios compromise in depth back in May, so the short version here: a maintainer account got compromised, malicious versions of the HTTP library nearly every Node.js project depends on somewhere in its tree got published to npm, and a postinstall hook fetched a platform-specific payload. The dropper staged a secondary package, plain-crypto-js, as an injected dependency, then used that foothold to pull down a remote access trojan tailored to whatever OS the install ran on. Any project that let axios auto-update within a minor or patch semver range picked up the malicious tag automatically, no pull request required, no diff to review. Attribution pointed to a North Korea-linked actor tracked as UNC1069. If you haven't run npm ls axios against your dependency tree since then, that's step one, not step two.
May 2026: TanStack, and the Attack This Article Actually Digs Into
The TanStack postmortem, written by maintainer Tanner Linsley and published within days of the incident, is one of the most technically complete public breakdowns of a real supply chain attack you'll find. It names the vulnerable workflow file, the exact cache key the malware targeted, and the memory-extraction technique used to steal a token that was never supposed to leave the runner. We're going to walk through it in the next section, with the real code.
May–June 2026: Mini Shai-Hulud Keeps Spreading
By early June, researchers at Wiz were tracking a variant called Miasma hitting more than 30 packages in Red Hat's @redhat-cloud-services npm namespace, averaging 80,000 weekly downloads combined. The payload traced back to malware TeamPCP had open-sourced after the original TanStack-adjacent Shai-Hulud campaigns, and it had grown new collectors for Google Cloud and Azure identity tokens. Wiz researchers noted the underlying functionality and tradecraft stayed close to earlier Mini Shai-Hulud releases, just with renamed variable strings pulled from Greek mythology instead of Dune.
None of these three incidents share a root cause. A stolen credential, a poisoned CI cache, a reused open-source malware kit. What they share is the moment they became irreversible: the second npm install ran the payload, not the second a scanner flagged it in CI.
What Actually Happened Inside TanStack's Pipeline
Here's the mental model you need before the code makes sense. GitHub Actions has two trigger types that matter for this story. pull_request runs a workflow with restricted permissions and, for first-time or fork contributors, requires a maintainer to click approve before it executes. pull_request_target runs with the base repository's permissions and secrets, and it does not require that approval, even though it's frequently used to check out and build code from the fork. That gap between "runs with trusted permissions" and "operates on untrusted code" is a known category of vulnerability called a Pwn Request, documented by GitHub's own Security Lab years before this incident.
TanStack's bundle-size.yml workflow used pull_request_target to build a benchmark from every incoming PR, fork PRs included. Here's the relevant section, reproduced from the TanStack postmortem with the maintainers' own annotations intact:
# GitHub: https://github.com/TanStack/router (bundle-size.yml, referenced
# in the TanStack npm supply-chain compromise postmortem)
# Author: TanStack / Tanner Linsley and the TanStack maintainer team
#
# All credit goes to the TanStack team. This snippet is reproduced from
# their public postmortem to illustrate a real-world CI trust boundary
# failure for educational purposes.
on:
pull_request_target:
paths: ['packages/**', 'benchmarks/**']
jobs:
benchmark-pr:
steps:
- uses: actions/[email protected]
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge # fork's merged code
- uses: TanStack/config/.github/setup@main # transitively calls actions/cache@v5
- run: pnpm nx run @benchmarks/bundle-size:build # executes fork-controlled code
Walk through what each line is actually doing, because the danger isn't in any single step, it's in what the steps add up to.
pull_request_target with a paths filter scoped to packages/** and benchmarks/** sounds cautious on paper. It isn't. The filter controls which file changes trigger the workflow, not what permissions the workflow runs with once triggered. Any fork PR touching those paths gets a run with base-repo credentials, no approval gate.
ref: refs/pull/.../merge checks out the simulated merge of the fork's PR into the base branch. That's the fork author's code, running in a job that has the base repository's token scope. The comment in TanStack's own file acknowledges this, which tells you the team knew the risk and split the workflow into a trusted comment-pr job and an untrusted benchmark-pr job specifically to contain it. The split was the right instinct. It missed one thing.
TanStack/config/.github/setup@main is a shared setup action, and it calls actions/cache@v5 internally. This is the detail that broke the containment. permissions: contents: read restricts what the GITHUB_TOKEN can do, but the cache action's post-job save step doesn't authenticate with that token. It uses a separate, runner-internal mechanism that isn't gated by the permissions: block at all. Restricting the token did nothing to stop the cache write.
pnpm nx run @benchmarks/bundle-size:build executes the fork's code to produce a benchmark. That's also the step that runs whatever the fork author put in vite_setup.mjs, a file the attacker added to their fork the night before, authored under a fabricated identity that impersonated Claude's GitHub bot account. The malicious file's actual job wasn't the benchmark. It was writing a payload into the pnpm store under a cache key TanStack's separate, legitimate release.yml workflow would compute and restore on its next run against main.
That's the whole first stage: fork code, running with elevated cache write access, planting a poisoned cache entry keyed to match what a completely different, trusted workflow would look for later. The PR itself was closed within twenty minutes, force-pushed back to a no-op diff, deleted branch, nothing left to review. The poison was already in the cache.
The Fix Nobody Sees Until It's the Fix You Needed
Here's where the OSV-backed remediation tooling from CVE Lite CLI, the OWASP-recognized dependency scanner Kapoor's InfoWorld piece points to, becomes relevant. It doesn't stop a cache-poisoning attack like TanStack's, and its own maintainers are explicit about that in the project's documented limitations: it does not perform behavioral malware detection or catch a malicious package before it lands in an advisory database. What it does is shrink the window between "a malicious or vulnerable version becomes known" and "your lockfile stops pointing at it," which is exactly the gap that let the Axios and Mini Shai-Hulud incidents linger in dependency trees for days after public disclosure.
The project ships a first-party GitHub Action for exactly this, and the workflow it recommends for automated remediation is worth reading end to end:
# GitHub: https://github.com/OWASP/cve-lite-cli (documented in the
# GitHub Marketplace listing for the project's first-party Action)
# Author: Sonu Kapoor, OWASP Foundation (CVE Lite CLI, OWASP Incubator Project)
#
# All credit goes to Sonu Kapoor and the OWASP CVE Lite CLI maintainers.
# We are using this snippet to provide a real-world example for
# educational purposes.
name: CVE Lite security fixes
on:
schedule:
- cron: '0 6 * * 1' # every Monday at 6am
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
fix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
- uses: OWASP/cve-lite-cli@v1
with:
fix: 'true'
create-pr: 'true'
This is a smaller file than the TanStack snippet, and that's the point worth noticing first. It runs on a schedule trigger, not pull_request_target, so there's no fork-supplied ref anywhere near it, no untrusted checkout. The only external input is the advisory data CVE Lite CLI pulls from OSV, which it validates against before generating a fix, and there's no code execution path that lets a compromised advisory entry write to the runner's cache or extract a token from memory. The fix: true flag tells the action to run CVE Lite CLI's --fix mode, which applies validated direct-dependency upgrades only, meaning it won't blindly bump a transitive dependency three levels down and hope nothing breaks. create-pr: true then commits the lockfile change and opens a pull request, so a human still reviews the diff before it merges. The permissions block is scoped to exactly what the job needs, contents: write to commit and pull-requests: write to open the PR, nothing broader.
Run locally, the same tool looks like this:
npm install -g cve-lite-cli
cve-lite /path/to/project --verbose
The --verbose flag is what turns a bare CVE ID into something a developer can act on without leaving the terminal: it shows the dependency path (direct or buried three levels into a transitive chain), a validated target version, and a copy-and-run install command scoped to whatever package manager the lockfile indicates, npm, pnpm, Yarn, or Bun. That output format is the actual argument in Kapoor's InfoWorld piece made concrete. A report that ends at a CVE identifier hands the developer a research assignment. A report that ends at a runnable command hands them a decision.
What This Doesn't Fix, and What Still Requires a Human
CVE Lite CLI's own documentation lists what it doesn't do, and the list is honest in a way most security tool marketing isn't: no malicious-package detection ahead of advisory publication, no behavioral analysis of install scripts, no proof of runtime exploitability. It's a lockfile-and-advisory tool, not a sandbox. That matters here because none of the three incidents in this piece would have been caught by advisory-database scanning at the moment of compromise. Axios's malicious versions weren't yet in OSV when they were published. TanStack's payload never touched a lockfile at all, it lived entirely inside a CI cache. Mini Shai-Hulud spreads through compromised publishing credentials, not a known-bad version string a scanner can match against.
What advisory-based scanning does catch is the second half of every one of these stories: the days after disclosure, when a compromised version is publicly known but still sitting in thousands of package-lock.json files because nobody's pipeline flagged it. TanStack's own postmortem calls out that gap directly. Unpublishing a compromised npm package is blocked by npm's policy once other packages depend on it, so the team had to email npm security and wait hours for tarballs to be pulled registry-side, hours during which every affected version remained installable by anyone who hadn't already scanned for it. A local, fast scan that developers actually run closes that specific gap. It doesn't touch the cache-poisoning gap at all.
That's also why TanStack's own remediation reads less like a scanner rollout and more like an infrastructure audit: restructuring bundle-size.yml to remove pull_request_target from the untrusted path, pinning third-party action references to commit SHAs instead of floating tags like @v6.0.2 or @main, and reconsidering whether OIDC trusted publishing, which mints a short-lived, publish-capable token to any code path in a workflow that has id-token: write, needs per-publish review rather than blanket trust in the workflow file. None of that is a dependency scanner's job. It's the architectural side of the same coin Kapoor is describing: trust decisions belong closer to the moment they're made, whether that moment is a developer running npm install or a maintainer merging a CI workflow that requests id-token: write.
When the Developer Making the Trust Decision Is an Agent
Kapoor's piece raises a point that's easy to skip past: AI coding assistants now suggest packages, generate install commands, and rewrite package.json as part of larger automated refactors. When a human adds one dependency, a teammate can look at the diff and ask why. When an agent modifies six dependencies as part of a broader task, the diff still looks like a normal feature PR. Nobody stops to ask which of those six packages actually get imported anywhere, or whether one of them is a name close enough to a popular package that autocomplete or an LLM's training data confused the two.
This is the specific gap CVE Lite CLI's --usage flag is built for, and it's worth understanding why the project's maintainer made it opt-in rather than the default. A plain lockfile scan is close to instant, it just reads package-lock.json or the equivalent and checks version strings against OSV advisories. Static reachability analysis, walking every source file to confirm a flagged package is actually imported anywhere rather than sitting unused in the tree, takes meaningfully longer and can miss packages invoked through build scripts or dynamic imports. Pairing --usage with --only-used filters a scan down to vulnerabilities in code paths your application genuinely executes, which is exactly the noise-reduction step that matters when an agent has just added a batch of dependencies and a human needs to know, in the next five minutes, which of them are worth a second look.
That's a narrower promise than "detects malicious AI-suggested packages," and the project doesn't claim otherwise. What it does is turn "the agent touched six dependencies" into "here are the two that are both reachable from your code and carrying a known issue," which is a question a developer can actually answer before merging.
The Memory-Dump Step Nobody Budgeted For
The part of the TanStack chain that separates it from a routine cache-poisoning bug is the third stage: getting from "attacker-controlled binary is now on the runner's disk" to "attacker has a valid npm publish token," without ever touching an actual npm credential.
release.yml, TanStack's legitimate publish workflow, declares id-token: write because it uses npm's OIDC trusted-publisher flow, a mechanism designed specifically to avoid storing long-lived npm tokens as GitHub secrets. GitHub mints a short-lived OIDC token for the job, lazily, in memory, only when a step actually requests one. That's a genuine security improvement over static tokens sitting in a secrets vault. It also means that if attacker-controlled code is already running on that same runner, thanks to the poisoned pnpm store restored earlier in the job, the token exists in the worker process's memory at some point during the run, whether or not the workflow's own publish step ever executes.
The postmortem describes the technique plainly: locate the Actions runner's worker process through /proc/*/cmdline, read /proc/<pid>/maps and /proc/<pid>/mem to dump that process's memory, and extract the token from the dump. TanStack's own writeup notes this wasn't novel tradecraft, it's the same memory-extraction approach, down to the Python script, used in the tj-actions/changed-files compromise from March of the previous year. The attacker didn't invent anything. They combined a known Pwn Request pattern, a documented cache-poisoning technique from 2024 security research, and a fifteen-month-old memory-dump script, and none of the three pieces individually required discovering a new vulnerability.
That's the detail worth sitting with longer than the rest of the timeline. Every stage of this attack was previously published, defensive, security research. The chain wasn't a zero-day. It was a checklist.
Where This Actually Leaves Node.js Teams
Kapoor's core claim in the InfoWorld piece is that the next phase of Node.js security has to live closer to the developer, not because CI enforcement is wrong, but because CI enforcement operates after the decision that mattered has already been made. The TanStack incident makes an uncomfortable addition to that argument: sometimes there is no developer decision to catch. Nobody at TanStack chose to trust vite_setup.mjs. It arrived through a benchmark job nobody was watching, on a workflow trigger designed for convenience, feeding a cache mechanism nobody thought to gate.
That's not a reason to abandon the developer-facing tooling Kapoor is building. A tool like CVE Lite CLI, run locally before a PR opens or on a scheduled PR against the lockfile, still closes the exact gap it's designed for: the days a known-bad version sits unpatched because nobody's workflow surfaced it in a form anyone could act on. But it sits alongside, not instead of, an audit of every pull_request_target workflow in a repository, every floating action reference, and every id-token: write permission that isn't specifically scoped to the one step that needs it. Dependency security and CI architecture security are the same discipline now. Treating them as two separate teams' problems is exactly the seam TanStack's attacker found.
The npm ecosystem isn't going to slow down, and nobody actually wants it to. What changes is where the checking happens: at the terminal before a push, in the workflow file before it's merged, and in the seconds after disclosure rather than the hours. Every one of those moments is earlier than a CI gate, and every one of them was available to catch this before it shipped.
Related reading from the CoderOasis library: For the credential-theft half of this story, and how a single stolen token turned into hundreds of exposed organizations, One Employee. One OAuth Token. The Vercel Breach Explained covers the mechanics OIDC and OAuth token theft share. If OIDC's design goals and failure modes aren't already second nature, What is OAuth? lays out the protocol this entire attack chain depended on exploiting. And for the maintainer-account side of npm supply chain risk, where the compromise starts with a stolen credential instead of a poisoned CI cache, CVE-2025-62718: The Axios Crisis is the companion incident to read next.
