npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cache-poisoning-pwn-demo

v0.1.32

Published

Educational demo: a deliberately vulnerable npm package showing how GitHub Actions cache poisoning can produce a malicious release without stealing any credential. Do NOT use in production.

Downloads

1,882

Readme

cache-poisoning-pwn-demo

Educational demo, do NOT use in production. Installing this package opens Calculator (or the platform equivalent) on the consumer's machine. That's the point — it's a real supply-chain attack against an npm package built with GitHub Actions, end-to-end on real infrastructure.

Published to npm: cache-poisoning-pwn-demo · Flagship demo in the series.

A faithfully-reproducible cache-poisoning attack modeled on the May 2026 TanStack npm compromise. The package is real, on public npm, and the attack chain runs end-to-end through real GitHub Actions and real npm OIDC trusted publishing.

No credential is stolen. No maintainer is compromised. A stranger's pull request — closed without merging — causes the maintainer's own CI to publish a malicious release on the next innocent push to main. The published version carries a correctly-issued npm provenance attestation, because the attack works inside the trusted workflow, not against its keys.

This is the orchestrator's GitHub Actions cache seam: caches are shared across triggers, fork PRs can plant entries that a later release restores, and cache writes ignore permissions: contents: read. The same kitchen analogy as the orchestrator's shared spice rack.

Why this repo exists

Reading the TanStack postmortem communicates the shape of the attack; running this demo communicates the speed and the invisibility. Audiences who watch a malicious version appear on the npm UI seconds after an innocuous git push rarely forget the demo. It also doubles as a hardening reference: every link in the unsafe chain has a paired safe alternative in fix/, and every "bad practice" has an empathy note in why/README.md.

If you found this organically (via npm or search)

The package on npm is real. Installing it triggers the educational payload — it opens Calculator and prints a marker. It does not exfiltrate, persist, or do anything else. It is not a typosquat; the name is intentionally demo-flagged. If you maintain an npm package built with Actions, fix/README.md has the three workflow changes that make this attack class structurally impossible. If you consume npm packages in CI, set npm config set min-release-age 7 (see below).

The attack in one diagram

Attacker fork PR (or any same-repo branch from a write-access account)
    │
    ▼
.github/workflows/vulnerable-bundle-size.yml
    │  triggers on pull_request_target → runs in BASE repo trust context
    │  checks out PR HEAD; npm install fires attacker's prepare hook
    │  caches node_modules — INCLUDING the poisoned is-number/index.js
    ▼
[ shared GitHub Actions cache key: nm-<hash-of-package-lock.json> ]
    │  cache persists even after the PR is closed
    ▼
Any future push to main (a typo fix, a Dependabot bump, anything)
    │
    ▼
.github/workflows/vulnerable-release.yml
    │  restores the poisoned cache; builds; bundles poisoned is-number into dist/postinstall.js
    │  mints OIDC token; publishes v0.1.N with provenance attestation
    ▼
npm registry: [email protected] is now MALICIOUS
    │
    ▼
Anyone running `npm install cache-poisoning-pwn-demo`
    → postinstall executes → calculator opens → "[supply-chain-demo] supply-chain attack PoC triggered."

The four stages map to what the audience sees:

| Stage | What happens | Audience-visible | |-------|--------------|------------------| | Baseline | v0.1.0 published cleanly by the maintainer | npm install prints "thanks for installing" | | Attack | Fork PR fires pull_request_target, plants payload into node_modules/is-number, cache poisoned, PR closed | Innocuous-looking workflow log on the PR | | Detonation | Maintainer pushes any change to main; release workflow restores cache, builds, publishes v0.1.1 | New version on npm with provenance | | Pwn | Audience runs npm install | Calculator opens on every audience machine |

File tour

| Path | What it is | |------|------------| | package.json, src/, scripts/build.js | A real, tiny npm package wrapping is-number. esbuild bundles node_modules into dist/. | | .github/workflows/vulnerable-bundle-size.yml | pull_request_target workflow that writes the poisoned cache | | .github/workflows/vulnerable-release.yml | push: main workflow that restores the cache, builds, publishes to public npm via OIDC | | .github/workflows/safe-*.yml | Fixed versions | | attack/README.md | Full attacker walkthrough | | attack/fork-changes/ | The exact files the attacker commits; apply-attack.sh scaffolds a fork in one command | | attack/simulate-attack.js | Local-only simulation (no GitHub, no npm) — opens Calculator on the demoer's machine | | SETUP.md | One-time setup (npm account, package name, OIDC trusted publisher, repo push) | | DEMO-SCRIPT.md | Live demo timing (~6 min), what to click, what to say | | fix/README.md | Line-by-line explanation of the fix | | why/README.md | Why the unsafe pattern exists |

Quick start

Three paths, fastest to most dramatic:

Path A — local only, ~30 seconds. npm install && node attack/simulate-attack.js. Calculator opens. Useful for proving the chain works before staging the live version.

Path B — staged live (recommended). Follow SETUP.md once. Pre-stage Acts 1–3 of DEMO-SCRIPT.md so v0.1.1 is already on npm. During the demo, do Acts 4–5 — audience installs and gets pwned, then reveal.

Path C — fully live, ~6 min. Follow DEMO-SCRIPT.md end-to-end with the audience watching the attack PR open, the cache poison, the release publish, and the install.

What ends up on npm

node_modules/is-number/index.js   ← attacker plants payload here via fork PR
              ↓ esbuild
dist/postinstall.js               ← bundled output; shipped in package files
              ↓ npm install <pkg>
       postinstall hook runs
              ↓
  child_process.exec('open -a Calculator')

A clean node_modules produces a dist/postinstall.js that prints "thanks for installing". A poisoned node_modules produces one that also opens Calculator. The maintainer's source code does not change. Only the bundle changes — because its inputs were tampered with.

Why this is hard to detect

A maintainer-side audit shows nothing: no malicious commits on main, the release commit is a release-bot's npm version patch, and the published version has GitHub-attested provenance pointing to a real, clean commit. The malicious PR was closed and may not even appear in the repo's main UI — the poisoning step ran in a workflow log archived under the closed PR. The cached diff looks innocuous: a new scripts/dev-hook.js with a "pre-warm the build cache" comment, a one-character prepare-hook tweak, a README typo fix in the same PR for reviewer focus. And npm provenance signed it: the attestation is correct — it accurately states which workflow built which commit. The workflow just happened to bundle attacker-poisoned cache contents.

What the fix changes

Three independent changes, each closing one link:

  1. pull_request_targetpull_request. Fork PRs no longer run in base trust context.
  2. Scope cache keys with ${{ github.event_name }} (or use separate workflows). PR and release runs can't share caches.
  3. npm ci --ignore-scripts + split build/publish jobs. Even a compromised dep can't reach id-token: write.

Details in fix/README.md.

Consumer-side mitigation

The single most-effective defense from the consumer side is refusing to install package versions that were published very recently. npm 10+ supports this natively:

npm config set min-release-age 7    # 7 days

A malicious version published via this chain then has 7 days to be detected and unpublished before any consumer's CI installs it. Combine with npm audit / osv-scanner in CI and most known-compromise scenarios get caught cleanly.

To install this demo package despite the setting, append --min-release-age=0 to the install command — a per-invocation override that doesn't touch your default config.

How the demo publishes without a token

The release workflow uses npm OIDC trusted publishing — no NPM_TOKEN exists anywhere. GitHub Actions mints a short-lived JWT, npm verifies it against the package's trusted-publisher config, npm publish succeeds, and Sigstore signs the provenance attestation. This is the modern recommended pattern, used by major OSS projects. The demo's point is that this modernization does not save you from cache poisoning — if attacker bytes reach the publish job's dist/, they get the registry's blessing for free.

Cleanup

npm unpublish <your-package>@0.1.1     # within 72h, or any time if no dependents
npm unpublish <your-package> --force    # nuclear option
git restore . && rm -rf node_modules dist && npm install

References