chainstrip Book a demo

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.

One live open-source tree · 75 advisories · four verdicts

not in what you ship — 56 of 75

  1. 55 not shipped package/version never made it into the hardened artifact
  2. 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

  1. 14 reached present and on a live path: patch these (2 CRIT · 5 HIGH · 5 MOD · 2 LOW)
  2. 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.

§01

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:

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.

§02

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.

the cold score (illustrative) — output depends on the scanned tree
$ 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
§03

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.

verdictmeaningstrength
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.

No analysis-only vendor transforms your tree. Snyk, Endor, Socket and the rest can rank reachability. They can't hand you an artifact with the dead code actually removed.
The gate is the receipt. Every removal ships only after your own tests and production build pass without it, and a hard invariant rejects any artifact that tries to remove a function coverage ever saw execute. That invariant has already caught real analysis bugs before they could ship.
Function-level removal. File-level pruning misses the core CVE code — on axios, 0 of 21 advisories are prunable that way. ChainStrip cuts at symbol level: a method no caller names is provably dead, and anything a dynamic dispatch might reach stays held.

Won't this break my app?

Held by default. Absence of a test is not proof of death. Reachable but unexecuted code still ships — hash-pinned, update-gated, and named in the report next to the missing evidence that would clear it. Stubbing held code so it fails loud is available as an explicit opt-in, --trim-at-risk.
Error paths are expected to be untested. Error handling, retries, fallbacks — the code that only runs when something goes wrong — normally shows zero coverage. ChainStrip recognizes it and holds it from removal unconditionally, so a hardening pass can't delete the code that only matters during an outage.
Shared singletons stay shared. ChainStrip won't inline a private copy of a shared library (React, zod, moment) that would diverge from the instance your app uses. An oracle rejects the artifact before that class of invisible runtime crash can ship.

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.

§04

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.

the coverage worklist — your under-tested code, ranked (Cal.diy, run of 2026-07-28)
$ 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.

§05

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 (.chainstrip under 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.

the reachability report — Cal.diy, run of 2026-07-31
$ 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
the exceptions view (illustrative) — every gap answers “what do we do now?”
$ 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
§06

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.

classificationmeaningwhat 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
§07

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.

first look
$ 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.

every PR
$ chainstrip drift
$ chainstrip pack --check

~1 second: compares usage and lockfile fingerprints against the stored artifact, then either passes or names what drifted.

on fingerprint change
$ 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.

release
$ 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.

§08

Adoption modes

Start with a cold score and stop wherever your risk tolerance is met; each mode is useful on its own.

mode 1

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
mode 2

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
mode 3

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.

§09

Release announcements