npm supply-chain hardening · proof-gated removal · CLI · CI-native
ChainStrip
“Perfection is achieved when there is nothing left to take away.”— Antoine de Saint-Exupéry
New approach to npm supply-chain security: do not ship the vulnerable stuff. Delete it.
ChainStrip rebuilds your dependency tree into the smallest surface it can prove safe. Whole vulnerable packages never enter the artifact, proven-dead code is cut, and everything in between ships held: pinned and update-gated until execution proves it live or analysis proves it dead.
or Book on calendar ↗ — pick a slot directly, skip the email round-trip.
15 minutes: a live CVE elimination on an open-source tree, plus a rough cut of your own ladder from nothing but your lockfile. Know an open-source project shaped like your stack? Name it when you book.
Delivery is white glove: we integrate ChainStrip into your GitHub or GitLab CI/CD pipeline ourselves, free of charge.
not in what you ship — 56 of 75
- 55 not shipped package/version never made it into the hardened artifact
-
1
eliminated
GHSA-7f6v, a HIGH stored XSS: the sink
renderDocsHtml()pruned with its file; build + suite pass without it (§03)
still in your build — 19 of 75
- 14 reached present and on a live path: patch these (2 CRIT · 5 HIGH · 5 MOD · 2 LOW)
- 5 unresolved advisory too coarse to map to a symbol; it stays on the books as a gap
start-ui-web (BearStudio's actively maintained React starter), commit 81d5424 of 2026-07-18 · run of 2026-07-23. Its stock pnpm install carries `@orpc/openapi` 1.13.4, unpatched for the XSS above; the elimination holds because start-ui-web never imports the vulnerable plugin. Your counts depend on your tree and your tests.
The problem
A modern npm application installs hundreds of packages and executes a fraction of code in them. Every retained file and every retained function — used or not — is code your runtime will execute if an attacker can reach it.
The standard tools don't answer the question that matters.
Lockfiles pin versions; they don't shrink anything.
npm audit hands you a CVE list and says patch — for code your app
may never call, on someone else's release schedule. Reachability
scanners try to rank what matters, but they only read your tree; they
never change it, so a wrong verdict is silently wrong and teams learn to
distrust it. Tree shaking optimizes your client bundle; your server,
your build, and your lifecycle scripts run the unshaken tree.
The attacks, meanwhile, keep landing through malicious releases that version drift pulls in silently. Some fire on install; others wait for the moment your code imports them. Every example below broke this year:
-
Miasma & Hades wave Jun 2026
A week-long run of Shai-Hulud-descended waves — ~448 malicious npm and PyPI artifacts, zero CVEs. It poisoned 32
@redhat-cloud-servicespackages (~117K weekly downloads), a broad npm batch including@vapi-ai/server-sdk, then backdoored Microsoft GitHub repos — GitHub auto-disabled 73 of them in under two minutes. The payload ran from apreinstallscript (one wave hid it inbinding.gypto dodge install-script scanners), dropping an obfuscated credential stealer aimed at CI and developer machines.ChainStripChainStrip's release artifacts are unpacked into
node_modules, nevernpm install-ed — so nothing fires at install time, neither a lifecycle script nor a native-build trigger likebinding.gypthat runs even under--ignore-scripts. And the build consumes a hash-pinned, gated artifact, so the drifted patch releases that delivered every wave never resolve into it to begin with.The payload never runs: nothing executes at install time, and the poisoned releases never resolve into the build.
-
IronWorm May–Jun 2026
JFrog's "rustier cousin" of Shai-Hulud: ~37 packages in the Arweave/WeaveDB ecosystem, republished from one stolen maintainer account and cleaned within a day. A
preinstallhook launched a 976 KB native Rust binary — hidden in atools/setupdirectory, never imported — carrying an eBPF rootkit and Tor C2 to steal environment variables, cloud and AI-tool keys, and crypto-wallet seeds. It self-propagated by backdating commits and republishing through stolen credentials.ChainStripThe binary sits in a directory nothing imports, so additive extraction never pulls it into the artifact; the
preinstallhook that launches it is stripped; and pinning blocks the minor-version bumps it spread through.Blocked three ways — not shipped, not run at install, not pulled in by a pin.
-
node-ipc May 2026
Distinct from the 2022 protestware of the same name: on May 14, 2026 an attacker re-registered the lapsed domain behind the maintainer's email, took the account, and published three versions of node-ipc (~10M weekly downloads). An 80 KB obfuscated payload appended to
node-ipc.cjsfired on everyrequire("node-ipc")— no install script — harvesting 90+ categories of cloud, CI and SSH secrets; one version was hash-gated to a single targeted entry point.ChainStripChainStrip can't strip this one — code that runs on
requireof a package you use is part of your retained surface. But the poisoned versions never resolve into a pinned artifact, and evaluating the bump flags itretained-surface-changed: an 80 KB obfuscated blob appended tonode-ipc.cjsis a glaring diff on a module you call, forcing review instead of a blind upgrade.Not removable, still stopped: the bump lands in review instead of shipping blind.
-
axios Mar 2026
The maintainer account behind axios (~70M+ weekly downloads) was compromised and two patch versions published, live about three hours. The malware was not in axios at all: a phantom dependency,
plain-crypto-js, that axios never imports — itspostinstalldropped a cross-platform RAT, then deleted itself. Patch bumps over the safe versions meant^/~ranges resolved straight to the poisoned build.ChainStripA dependency axios never imports is outside its used closure, so extraction leaves it on the floor — and even installed, its
postinstallis stripped and the drifted version never resolves against a pinned artifact.The RAT's package was never in the used closure and never on the install path. There is nothing to ship.
A scanner hands you a list and says "patch." A reachability vendor reads your tree and guesses. ChainStrip changes the tree: it removes the dependency code it can prove you'll never run, validates every cut against your own suite and build, and gates every update so a malicious release can't reach production without passing it. The verdict on each card is deduced from those mechanics — extraction, lifecycle stripping, hash pinning, the gate — not demonstrated by replaying the attack.
Score any repo, cold
Start before any integration. chainstrip score runs cold, from static source
markers plus known CVEs: no tests, no build, no prior run. Point it at any
node_modules or even just a lockfile — npm, Yarn, and pnpm, JavaScript
and TypeScript trees — and a few minutes later you have an A–F risk card
per package@version, worst first.
Two things go into a score. Vulnerabilities counts known CVEs, weighted by
severity. Surface measures what a compromised version of the package would
be capable of: install scripts, native addons, eval, dynamic require/import,
network and filesystem access, obfuscation. How far that capability actually
reaches in your app is a reachability question, and answering it is what the
rest of this page is about. Every point traces back to a named signal, so the
card explains its own grade. And since a score depends only on
package@version, it caches across a fleet of services.
$ chainstrip score --dir ./node_modules 132 packages · 11 install scripts · 4 native · 38 with CVEs F axios@1.6.2 20 CVEs: 11 HIGH, 9 MODERATE surface: network · fs-write · dynamic require D nodemailer@6.9.8 6 CVEs: 1 HIGH, 3 MODERATE, 2 LOW surface: network · child_process · install script A lodash@4.17.21 no known CVEs · surface: none · no install scripts
Reachability elimination
Once ChainStrip has hardened your tree, every CVE in the installed dependencies
gets one of four verdicts, sorted by how much evidence stands behind each.
eliminated has the strictest bar: the vulnerable symbol provably dead, gone
from the artifact, and the gate green without it. The hero's one elimination
is dissected below, next to a run of ours that prints zero.
| verdict | meaning | strength |
|---|---|---|
eliminated |
vulnerable symbol lexically dead, removed from the artifact; build + full suite pass without it | issued only with proof of death |
reached |
vulnerable code present and on a live path: your app, its build, or the dependency's own internals | real exposure; patch it |
unresolved |
advisory too coarse to map to a symbol (e.g. “affects Next.js”) | stays an open item in every report |
not shipped |
affected package/version isn't in the artifact; most advisories live in dev- and build-only packages that never enter the production closure | free win from extraction |
Two eliminations, and the seven still held
The hero tree is real and current. start-ui-web ships @orpc/openapi 1.13.4;
the fix for GHSA-7f6v, a HIGH stored XSS in oRPC's opt-in reference-docs
plugin, landed upstream only in 1.13.9. start-ui-web never imports that
plugin, so the run pruned the unreached file, and the advisory's sink,
renderDocsHtml(), is provably absent from the hardened tree. The same run
calls 7 better-auth advisories reached, because better-auth resolves modules
dynamically and ships whole. Measured over 3,028 npm advisories with
locatable fix commits, about 2% sit entirely in an opt-in module like this
one; the tier is real, and its size depends on the shape of your tree.
Our bigger testbed lands the same verdict. Cal.diy, a production monorepo
carrying 189 advisories, prints eliminated: 1 on the run of 2026-07-31
(upstream commit 180ede28): glob's HIGH advisory lives in the package's
command-line entry file, a script nothing in Cal ever imports, and the run
pruned it with the gates green. The seven advisories an aggressive mode
would add stay held: stubbing the reachable-but-unexecuted functions behind
them would crash production the first time an error path called one —
nodemailer's own compose path calls the setRaw() sink internally, for
instance. Held-by-default ships those pinned and update-gated, and their
CVEs count as reached or unresolved.
Won't this break my app?
--trim-at-risk.The suite is no longer the ceiling
Every hold is one piece of missing evidence away from a removal, and the
ceiling moves from both sides: coverage --prompt names the exact suite
tests that would raise it, and ChainStrip gathers execution evidence from
three places the suite can't reach — probes it generates to make your app
run unproven code, an end-to-end pass against the served production build,
and browser-side coverage (all in §04). Held code graduates to
removed as the evidence lands.
"Eliminated" means removed from your artifact and proven by your gate; it does not mean "verified safe in production." The closest thing to that claim is an opt-in counter compiled into the shipped artifact that records, locally in your own infrastructure, whether production ever tried to call a removed function.
The evidence engine
ChainStrip runs your suite under coverage and records which dependency code
actually executes; that evidence is what lets a hardened dependency ship
validated instead of pinned whole. It also exposes the opposite: every path
your tests never touch. chainstrip coverage --callers ranks your under-tested
dependencies and names the function in your own code that uses each one, which
makes "improve coverage" a concrete to-do list. A live CVE floats a dependency
to the top — a priority signal, not a filter — and dependencies with no CVE
still show, because an untested path is worth knowing about either way.
$ chainstrip coverage --callers ⚠ live CVE + under-tested → write this test first 38% next-auth ⚠ 3×CRITICAL → Providers, AdminUser 5% kysely ⚠ HIGH → booking-query compilation 34% nodemailer ⚠ 6 CVEs → sendVerificationRequest.ts 31% js-yaml ⚠ 2×HIGH → parseFrontmatter() 53% dompurify ⚠ 13×MODERATE → markdownToSafeHTMLClient() 0% @urql/core no CVE → SalesforceGraphQLClient $ chainstrip coverage --prompt wrote a test-writing prompt → .chainstrip/coverage-prompt.md hand it to an LLM/agent to draft the gap-closing tests
Evidence beyond the unit suite
The unit suite is not the only witness. ChainStrip also serves the hardened
production build and runs your end-to-end suite against it, recording what a
real serve executes on both sides of the wire — that's how it knew that
i18next.dir(), called from Cal's app layout but exercised by no unit test and
no build step, must never be removed. Code that only runs in the browser gets
the same treatment: execution evidence captured in the browser and mapped back
through source maps, with staleness guards so a stale map can only ever err
toward keeping more code.
From finding to work order
This is also the handoff where dependency security usually stalls: the security
team finds the gap, files a ticket, and the test loses to sprint work every
time. chainstrip coverage --prompt writes the work order instead — a prompt
your engineers hand straight to a coding agent, with the call sites in your own
code, the payoff per test in bytes and CVEs, and flake warnings where a bad
test could do harm. Anything ChainStrip judges untestable stays on the list
with the reason, so a human can still override. Security hands engineering an
executable task, not a request, and what lands in your suite is a durable
test your team owns. Those tests earn their keep twice. On Cal.diy, one
session off one prompt covered 15 dependencies and surfaced two real bugs in
how the app used them: a phone-format fallback that can never fire because
the library call throws first, and a truthy -1 placeholder that shows a
connect flow to users who don't have the app installed. The tests document
both; the fixes stay the team's to make.
Or ChainStrip probes it directly
The dividing line between the two paths is one question: would you keep a
test for this dependency? For auth, payments, parsing — anywhere misusing
the library is a production bug — yes, and the work order above is the
path. For the long tail you only want to shrink, no: a permanent test
pinning vendor internals in CI helps nobody. chainstrip witness covers
that tail, after the work order has taken its picks; both paths work the
same worklist and feed the same evidence pool, so whatever one settles the
other stops asking about. witness generates probes — small scripts that
make your app execute the unproven code — runs them against the hardened
build, grades them, and keeps what they proved. The probes themselves are
set aside, kept for audit and never merged into your suite, because a
probe is not a test. A test asserts correctness, fails builds, and defines
expected behavior. A probe asserts only that the code ran under a
plausible input, so it can never catch a bug, and its evidence works in
one direction: it can keep code from removal, and it can never justify
one. A failed or empty probe licenses nothing. Air-gapped by default, the
probe brief arrives as a self-contained prompt in the hardening run's PR
thread, for your engineers or their coding agent to author. Connected,
ChainStrip drafts the probes with the one model you point it at and opens
a PR whose approval admits their evidence; the probe files themselves land
in ChainStrip's state directory, not in your test tree (a provider, base
URL, and model name in config; the API key stays in your environment; we
recommend Claude Sonnet 5). Either way, your ordinary PR review is the
gate. One campaign on Cal.diy
moved 41 dependencies from pinned whole, as npm shipped them, to validated
minimum-surface extractions.
Better evidence → more code provably removed → smaller surface → a shorter worklist next run.
How it works
Removal is possible because ChainStrip rebuilds your dependency tree from evidence of what your app runs, one validation-gated decision at a time.
-
Inventory dependencies and app usage
Scan project source for import sites; union with what other installed packages import from each shared dependency. The result is a usage fingerprint per package.
-
Extract dependency source into a controlled workspace
Your checkout is never modified. Validation runs in disposable copy-on-write clones; extraction emits vendored packages with licenses, notices, and per-file provenance preserved. Extraction is dual-engine: rollup by default (measured 48.9% fewer emitted bytes than esbuild on a six-dependency comparison set, 2026-06-10), with a per-dependency esbuild fallback for the cases where rollup would drop a named export.
-
Remove the provably dead, hold the rest
Each dependency lands in exactly one tier. Tier A — statically clean: bundle additively from your real import sites; tree shaking removes dead branches for free. Tier B — contained dynamism: subtractive, validation-gated file and function pruning. Tier C — frameworks, native modules, heavy dynamism: retained whole, lifecycle-stripped, hash-pinned, and update-gated. One disposition rules every tier: a function is removed only with lexical proof of death, and reachable-but-unexecuted code is held, because a missing test is not proof. Three precision passes keep the held set small without touching that bar: build-executed dependencies are held per function the toolchain actually runs rather than whole, a class method counts as reachable only where a caller names it (dynamic dispatch degrades to held), and identifiers resolve by exact lexical scope. On Cal.diy the precision work tripled proven-dead removals, from 5 dependencies to 16, with zero change to the guarantee.
-
Validate against the target project's test suite
Differential: baseline vs overlay, your full suite plus a production build run from scratch — no build cache, no cached replay, so a dependency that only builds because a stale cache remembers it can't slip through. Beyond pass/fail, an export-shape oracle proves every subpath still exports what your code reaches, the class of silent bundling breakage a green suite misses. A hardened dependency is accepted only at zero new failures; one that passes tests but breaks the build auto-demotes to a pruned-but-complete copy. A failed extraction demotes, it never blocks the run.
-
Publish reports and reusable hardened artifacts
Validated packages are packed into per-dependency tarballs keyed by content fingerprint. The store is a plain directory in ChainStrip's state (
.chainstripunder your repo by default; relocatable by config), so it travels through your normal CI cache; on Cal.diy it weighs about 124 MB. That state stays out of version control — the only ChainStrip files your repo tracks are the config and any committed waiver approvals. Builds consume artifacts; reports record every decision and the evidence behind it.
Evidence
The numbers below are from measured runs on our biggest testbed, Cal.diy (the Cal.com OSS release, a production monorepo), each recorded in a dated run report. The CVE verdicts, the proven-dead counts, and the surface-reduction bars all come from one full run of 2026-07-31; the bars cover the 152 dependencies whose extractions passed validation in it. The deploy side has its own measured strip: a burn-in of 2026-07-17 applied the artifact, ran the production build, then removed development-only build twins and non-runtime files (type declarations, source maps, docs; licenses always kept) — the served `node_modules` went from 3.3 GB to 2.0 GB with build, boot, and login e2e green, and zero strip-induced regressions. Your numbers depend on how much of each package you actually use.
$ chainstrip reachability
advisories 189 in the installed tree (OSV 2026-07-31)
not shipped 103 never entered the artifact
eliminated 1 glob HIGH: bin.mjs pruned, gates passed
reached 48 16 HIGH · 26 MODERATE · 6 LOW
unresolved 37 too coarse to map to a symbol
proven dead removals in 18 advisory-carrying deps;
glob's reached the advisory itself
validation full suite: differential pass
production build: from-scratch pass
$ chainstrip exceptions [no-test-evidence] cron-parser · 312 KB at stake No test imports this package, so test evidence is unavailable for it. do now add one test that imports it — the next sweep promotes it to full test evidence meanwhile production-build gate + update gate still cover it [build-gate] glob · overlay rejected Production build failed: require condition resolved as ESM (error persisted verbatim below). do now nothing — auto-demoted, stock copy pinned and update-gated meanwhile regression pinned; retried next sweep
The update gate
Dependency changes become classified diffs your CI can block
Run `chainstrip update` with no arguments and it becomes a PR gate: it classifies every dependency change in the diff — version bumps, brand-new dependencies, and new call-paths your code opens into existing ones — by building and validating a candidate artifact and diffing the retained surface, a diff small enough for a human to actually review. Blocking is policy-driven: an in-policy finding sets a non-zero exit so CI fails the check, and a reviewer waives it with a committed, fingerprint-bound approval that re-blocks the moment that dependency changes again. Compare that to the usual blind N-day version hold: instead of waiting and hoping, you learn in about an hour (measured on Cal.diy's ~230 runtime dependencies) whether the release added a capability or changed code you run — and that cost is paid only on PRs that change dependencies or usage; every other PR stays the one-second fingerprint check. Capability changes are caught from two directions. The new code is scanned for gained capability markers (network, spawn, filesystem writes, environment and secret reads), so a dormant payload is flagged without ever firing. And during validation, a runtime layer instruments the actual built-in surface and attributes every filesystem, network, DNS, and child-process call to the dependency that made it. Obfuscation is handled as its own signal: packing can hide what code does, but it can't hide that the code is packed, and a bump that puts packed code onto a path you run is a blockable finding in itself. The flag points a reviewer at hidden code you execute; it does not pronounce malware.
| classification | meaning | what you do |
|---|---|---|
no-usage-impact |
nothing your product executes changed | merge the bump |
retained-surface-changed |
code on your live call chains changed | review the retained-surface diff |
new-dependency |
the PR adds a package: its tier, capabilities, and known CVEs get flagged | review what it pulls in |
expanded-usage-vulnerable |
your code opens a new call-path into a dependency that carries live CVEs | block until patched or justified |
capability-escalation |
the new version gained a dangerous primitive (network, spawn, secret read) even though tests pass | block; a dormant payload still passes a suite |
validation-regression |
new version fails your suite against your usage | do not adopt; file or wait |
manual-review-required |
structure or dynamism changed beyond automatic comparison | a human decides, with the diff in hand |
Workflow fit
You do not run a full scan on every commit. The from-zero run behind the hero (start-ui-web, 67 dependencies) took 24 minutes on one machine; the one behind Cal.diy's numbers (232 runtime dependencies) took 70. After that, pruning runs when its inputs change; everything else is a fingerprint comparison.
$ chainstrip score --dir .
Cold, test-independent risk cards for any tree in minutes; there is nothing to configure. The first thing to run on a new repo.
$ chainstrip drift $ chainstrip pack --check
~1 second: compares usage and lockfile fingerprints against the stored artifact, then either passes or names what drifted.
$ chainstrip sweep --tier AB $ chainstrip reachability
Only changed dependencies re-enter the pipeline; unchanged fingerprints reuse stored tarballs. `reachability` re-classifies every CVE against the new artifact.
$ chainstrip apply --dest . $ chainstrip bundle --production
Release builds consume validated artifacts only — overlay into a checkout, or a hermetic node_modules tarball with embedded verifier. A malicious upstream release cannot reach production without passing the gate.
Adoption modes
Start with a cold score and stop wherever your risk tolerance is met; each mode is useful on its own.
Score & audit
Cold package risk cards plus the full inventory, tier classification, and attack-surface map. Nothing about your build changes; you just learn what you ship versus what you actually use.
$ chainstrip score --dir . $ chainstrip report
CI validation
Harden against your suite, give every CVE its verdict, and add the update gate to your PRs — one no-arg run blocks a risky bump, a new dependency, or a new call into a vulnerable one, on your policy. The coverage worklist drives the flywheel. Production still builds from stock node_modules.
$ chainstrip reachability --gaps $ chainstrip update
Enforcement
Release builds consume validated artifacts only. Lifecycle scripts stripped everywhere; unvendored deps hash-pinned. Native modules ship as the prebuilt binaries validation ran — built once on a machine matching the deploy platform, hash-verified, nothing compiles at install time. Upstream compromise has to pass your gate to ship.
$ chainstrip apply --dest . $ chainstrip bundle --production
How it ships
ChainStrip is self-hosted: a runnable bundle for x86 and arm64 that lives where your code lives; if Node runs there, ChainStrip runs there. Your source, lockfiles, and reports stay in your infrastructure. (The lockfile ask in the demo box above is for the demo itself; the product never sends anything out.) Onboarding is white glove: we integrate ChainStrip into your GitHub or GitLab CI/CD pipeline ourselves, free of charge. Licensing is commercial, with source available to customers. It isn't open source today for a plain organizational reason: a healthy OSS project takes maintenance resources we are putting into the tool instead. Pricing isn't public yet; ask in the demo.
Release announcements
Low volume: releases and measured run reports. We won't put you in a marketing sequence or resell your address.