Supply-chain enforcement for package installs — on the developer's machine, in CI, at the registry proxy, at publish time, and at Kubernetes admission. It refuses known-malicious and typosquatted packages, known-vulnerable versions, bad licences, and anything else your policy declines: 98 risk signals, 16 package ecosystems, one policy language, five enforcement surfaces.
The install-time guard runs entirely offline with no account, no server and no daemon. That is the part most people meet first, and it is what the demo below shows. Everything broader — CVE and EPSS data, licence and provenance signals, org policy, SBOM export, admission control — is served by the Chainsaw proxy or control plane. This repository holds the open-core of both: the guard, the detection engines, the policy engine, the registry proxy, and the parsers.
Real session, real binary — only the keystroke timing is simulated. The
script is assets/record-demo.sh and the raw cast is
assets/chainsaw-demo.cast, so you can replay or
re-record it yourself.
The block, as text:
$ npm install expresss
chainsaw ✗ blocked npm:expresss — looks like a typosquat of "express" (distance 1, edit-distance, target rank #262)
chainsaw if you have verified this package is real: chainsaw guard allow npm:expresss
chainsaw ✗ refused at the install path — nothing was installedExit code 1. Nothing was fetched, nothing was executed, nothing left the
machine. You typed npm — a shell function routed it through the guard first.
That verdict came from name analysis alone: no feed, no lookup, and no prior
sighting of expresss by anyone.
The same run, untrimmed — it is noisier than the excerpt above
Chainsaw states on every run which checks did not run. This is a fresh install with no feed downloaded yet:
$ npm install expresss
chainsaw offline known-malicious + typosquat active; run `chainsaw guard update` for the full OpenSSF malicious-package set
chainsaw ✗ blocked npm:expresss — looks like a typosquat of "express" (distance 1, edit-distance, target rank #262)
chainsaw if you have verified this package is real: chainsaw guard allow npm:expresss
chainsaw ✗ refused at the install path — nothing was installedThere is no byte-scan coverage line here, and its absence is meaningful: the name lane refused this install before acquisition was ever attempted, so the guard has nothing to report about bytes. See Scope and limits for the runs where it does appear.
An interactive terminal adds two more lines the first time around: a one-off telemetry notice (Telemetry) and, once you are past the first few blocks, an occasional prompt to sign up. Neither appears in CI.
Everything here is Apache-2.0 and runs standalone — no licence check, no plan gate, no phone-home. What is and isn't in this repo.
Understand it — What Chainsaw does · Ecosystems · Risk signals · Policy and enforcement
Use it — Quickstart · How the guard works · What gets checked · CI · CLI reference · Configuration
Evaluate it — Scope and limits · Measured performance · False positives · How it compares
Build on it — Go libraries · Open core · Telemetry · Security
Full reference — docs/ — CLI · Configuration · Ecosystems · Signals · Policy · Measurement
Chainsaw evaluates a package and decides whether it may enter your environment. The same decision engine runs at five points, so a rule you write once fires wherever its inputs exist.
| Capability | Local CLI, no account | Server-backed |
|---|---|---|
| Typosquat detection (4 methods, embedded corpus) | ✅ | ✅ |
| Known-malicious floor (11 curated incidents, embedded) | ✅ | ✅ |
| Full OpenSSF malicious-package set (~231k entries) | after guard update |
✅ |
| Byte-level behavioural scan (IOC + hidden-Unicode) | ✅ opt-in | ✅ |
| Blocking installs of npm/pip/go/cargo/gem | ✅ | ✅ |
Lockfile and manifest diffing in CI (pr-scan) |
✅ | ✅ |
Bypass-config detection (scan-repo) |
✅ | ✅ |
GitHub Actions workflow risk (scan-actions) |
✅ | ✅ |
| SBOM diffing | ✅ | ✅ |
| Rego policy evaluation against a local bundle | ✅ | ✅ |
| CVE / CVSS / EPSS / KEV vulnerability data | — | ✅ |
| Licence, maintenance, provenance, capability signals | — | ✅ |
| Registry pull-through proxy (16 ecosystems) | — | ✅ |
| Org policy, exceptions, audit trail, findings triage | — | ✅ |
| SBOM / VEX export, provenance attestation chains | — | ✅ |
| Kubernetes admission control, publish-time gating | — | ✅ |
| Air-gapped signed intelligence bundles | — | ✅ |
"Server-backed" means a Chainsaw proxy or control plane — which you can run yourself, including fully air-gapped, or use hosted. The client code for all of it is in this repository; the multi-tenant server is the commercial module. See Open core.
Three surfaces with three different reaches. Conflating them is the easiest way to mis-state what Chainsaw covers, so they are listed apart.
| Surface | Reach |
|---|---|
| Offline install guard | 5 package managers — npm, pip, Go, Cargo, RubyGems |
Local lockfile parsing (pr-scan) |
12 manifest formats across those 5 |
| Registry proxy + risk engine | 16 ecosystems |
The sixteen: npm, PyPI, Maven, Cargo, Composer, RubyGems, NuGet, Go, Hugging Face, CocoaPods, Swift, Pub (Dart), Docker/OCI, APT, Yum, DNF.
Coverage across them is not uniform — of 54 policy conditions, npm supports
43 fully and APT supports 12, because an upstream that publishes no maintainer
metadata cannot produce a maintainer signal. The matrix is compiled into the
binary (policy/proxy_matrix.go), queryable at
GET /api/policies/support-matrix, and a rule skipped for this reason emits a
policy.rule.skipped audit event rather than silently never firing.
Per-ecosystem numbers and every supported manifest format: docs/ecosystems.md.
98 signals are registered (risk/registry.go), each
scored with a severity and a weight rather than a bare boolean.
| Category | Count | Examples |
|---|---|---|
| Supply chain | 68 | known-malicious, typosquat (3 confidence tiers), install script fetches remote, manifest confusion, publisher changed, provenance downgrade, hidden Unicode, transitive malware, release after long dormancy, capability flags (network / shell / filesystem / eval / URL strings / debug modules / telemetry), unsafe pickle opcodes, unpinned GitHub Action refs, pwn-request / unguarded comment-trigger / script-injection workflow shapes |
| Licence | 8 | copyleft, non-permissive, missing, unidentified, ambiguous classifier, changed from previous version |
| Vulnerability | 8 | critical / high / medium / low CVSS, known-vulnerable with no published severity, EPSS high exploit probability, KEV known-exploited, fix available |
| Quality | 6 | checksum mismatch, minified code, version anomaly, declared MCP server / agent tool |
| Maintenance | 8 | abandoned repo, no recent release, outdated pinned version, relocated coordinate, single maintainer, very new package, very low downloads |
The full set is evaluated server-side. The offline guard runs the subset needing no external data: typosquat, known-malicious, and — opt-in — the byte-level checks.
All 98 with severities and weights: docs/signals.md.
Policy is Rego (Open Policy Agent). One bundle, one input schema, and the same decision call at every surface — a rule you write once fires wherever its input fields are populated.
| Surface | Where it runs |
|---|---|
runtime |
package-manager install hook — the local guard |
pr |
GitHub Actions pull-request check |
proxy |
registry pull-through fetch |
publish |
pre-publish gate |
deploy |
Kubernetes admission webhook |
Evaluate a bundle locally, no server, no account:
$ chainsaw policy gate proxy --bundle ./policies --input event.json
surface=proxy action=allow violations=0 bundle=aaf8cc651c8dThe registry proxy is the enforcement point a shell wrapper cannot be: it
covers every client including CI runners and machines you do not administer, and
cannot be routed around by calling the real binary directly. It runs in your own
network — proxy/, policy/ and policyengine/ here are its open core.
Air-gapped, intelligence ships as a Sigstore-signed bundle with per-file
content hashes, verified before it is trusted (chainsaw bundle verify).
Org policy management, monitor-mode rollout with would-block previews, scoped exceptions with expiry, Kubernetes admission and the audit trail are server-backed. Details: docs/policy.md.
curl -fsSL https://chain305.com/install.sh | shOne command: it installs the binary and turns the install guard on, so every
npm / pip / go / cargo / gem install in a new terminal is checked
before it downloads. On Windows, iwr -useb https://chain305.com/install.ps1 | iex
does the same. The output ends with a Guard: line; set CHAINSAW_NO_GUARD=1
on the sh side of the pipe to install the binary only (CI skips it too).
Other install methods
# Go toolchain
go install github.com/chain305/chainsaw-core/cmd/chainsaw@latest
# From source
git clone https://github.com/Chain305/chainsaw-core && cd chainsaw-core
go build ./cmd/chainsawPrebuilt binaries: macOS, Linux and Windows on amd64 and arm64, statically
linked (CGO_ENABLED=0).
Installed with go install or from source, or skipped by the installer? Wire
the guard into your shell yourself — one command, idempotent, safe to re-run:
chainsaw guard init --installThat appends a single eval line to your shell rc file (it's the same step the
installer runs). Use --dry-run to see
the exact file and line first, or skip it entirely and put
eval "$(chainsaw guard init zsh)" in your rc yourself. bash, zsh, fish,
powershell and pwsh are supported; on PowerShell the activation line is
chainsaw guard init powershell | Invoke-Expression and --install writes to
your PowerShell profile.
cmd is print-only. cmd.exe has no startup file, and doskey macros are not
expanded inside .bat/.cmd scripts — so --install cmd refuses instead of
persisting a wiring that would miss every scripted install. On Windows, either
use PowerShell or call the guard explicitly: chainsaw npm install <package>.
Then just use your package manager normally:
npm install expresss # typosquat — refused
npm ci # every package in the resolved lockfile
pip install -r req.txt # every pinned requirement
chainsaw why npm expresss # explain any verdict
chainsaw doctor # read-only: what's wired, what isn'tAfter your first block you get two prompts. Both default to Yes, so a blind Enter accepts them. Saying no to either costs you nothing — the offline typosquat check is unaffected.
| Prompt | What it does |
|---|---|
| Download the full OpenSSF malicious-package feed? | ~40MB from GitHub, replaces the 11-entry embedded floor with ~231k entries. Takes a minute or two. Same as running chainsaw guard update. |
| Share detection signals? | Turns on telemetry — see Telemetry for exactly what that sends. |
export CHAINSAW_OFFLINE=1 skips both. Neither prompt ever appears in a
non-interactive shell, so CI never fetches and never sends.
This section is about the local, offline install guard. There is no daemon, no
background process, and no network call on the default path. guard init prints
seven lines of shell:
$ chainsaw guard init zsh
# chainsaw install guard — https://chain305.com
npm() { command chainsaw npm "$@"; }
pip() { command chainsaw pip "$@"; }
pip3() { command chainsaw pip "$@"; }
go() { command chainsaw go "$@"; }
cargo() { command chainsaw cargo "$@"; }
gem() { command chainsaw gem "$@"; }That is the entire integration. It is recursion-safe by construction: chainsaw
resolves the real npm through exec.LookPath, and a shell function cannot
shadow a PATH lookup — so npm (function) → chainsaw npm → real npm binary.
you type: npm install expresss
│
▼
shell function ──► chainsaw npm install expresss
│
├─ 1. parse the verb + coordinates (no network)
├─ 2. known-malicious index (no network)
├─ 3. typosquat detector (no network)
├─ 4. local allowlist (no network)
│
┌──────┴──────┐
clear blocked
│ │
▼ ▼
exec real npm exit 1, nothing installed
The known-malicious index. 11 curated historic incidents are compiled into
the binary as a floor (event-stream/flatmap-stream, ua-parser-js and
similar). chainsaw guard update replaces that floor with the full OpenSSF
malicious-packages set (~231k entries), cached on disk and read offline
thereafter. The feed comes from
OpenSSF's repository, not from us.
The typosquat detector. Four methods — edit-distance, homoglyph,
combosquat and reorder — run against an embedded popularity corpus. No feed,
no network, and crucially no prior sighting of the attacking name: a brand-new
typosquat is on nobody's blocklist, but it is still one edit away from lodash.
Corpus depth differs by ecosystem, and it matters:
| Ecosystem | Embedded corpus |
|---|---|
| npm | 5,000 names |
| PyPI | 3,000 names |
| Go | 244 modules |
| Cargo | 177 crates |
| RubyGems | 158 gems |
Corpus membership grants an exact-match exemption, so it is a trust decision. It rides reviewed data — a seed PR in this repo, or a Sigstore-verified bundle — never a live per-client fetch, whose ranking would be attacker-gameable.
Config, the local allowlist and the cached feed are three plain files you can read, diff and back up; paths are in docs/configuration.md.
Still the local guard. (For the wider server-backed surfaces, see What Chainsaw does.)
Guarded verbs. npm install|i|add|ci, pip install, cargo install|add,
gem install, go get, go mod download. Every other invocation delegates
straight to the real tool, untouched.
| You run | Chainsaw evaluates |
|---|---|
npm install <name> |
that coordinate only — not its dependency tree |
npm install / npm ci |
every package in the resolved lockfile |
pip install -r req.txt |
every pinned requirement |
go mod download |
everything in go.sum |
The verb is located defensively: a chainsaw flag can never swallow an install verb as its value and silently downgrade the command to an unscanned passthrough — that path fails closed and is covered by tests.
A GitHub Action ships from this repository. It diffs the dependencies a PR adds
or upgrades and comments on the PR; flip mode: block and wire it as a required
status check when you're ready to enforce.
# .github/workflows/chainsaw.yml
name: Chainsaw Guard
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
chainsaw-preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required: pr-scan needs the base commit to diff against
- uses: Chain305/chainsaw-core/enforcement/github-actions@action-v1
with:
mode: warn # warn | block
scan-repo: truepr-scan exit codes, so you can gate on exactly what you mean:
| Code | Meaning |
|---|---|
0 |
clean |
10 |
a dependency was added or upgraded (fires on most PRs — opt in deliberately) |
20 |
a blocking finding |
30 |
a manifest failed to parse |
Full inputs: enforcement/github-actions/action.yml.
49 top-level commands. Full table, marked local vs server-backed, plus global flags and exit codes: docs/cli.md.
The ones you need on day one:
chainsaw guard status # is the guard on? (the installer turns it on)
chainsaw why npm <pkg> # explain any verdict
chainsaw guard allow <coord> # clear a false typosquat block
chainsaw doctor # what's wired, what isn't
chainsaw pr-scan --base main # CI: flag deps added or upgraded in a PR
chainsaw features # what this binary can doEnvironment variables, on-disk state locations, and the opt-in fail-closed coverage gate: docs/configuration.md.
The two most load-bearing:
export CHAINSAW_OFFLINE=1 # never touch the network; suppresses both first-run prompts
export CHAINSAW_COVERAGE_MODE=warn # off (default) | warn | closed — refuse when a signal is unavailableRead this before you decide whether it is useful to you. Being wrong about scope is worse than being narrow.
npx and npm exec are not covered. The download-and-execute path is not
guarded today.
It is bypassable, on purpose. The shell hook wraps your package manager; a
determined developer types /usr/local/bin/npm install and routes around it.
Our own test suite asserts exactly that
(cli/bypass_matrix_test.go). Closing the gap is a
registry-proxy and lockfile-pinning property, not something a local wrapper can
promise — that is exactly what the registry proxy is for.
Treat the local guard as a seatbelt on your own machine, not a control you can
attest to an auditor.
It fails open. If a signal cannot be evaluated it prints a notice and lets
the install proceed. A thin feed must never break npm install. That default is
changeable — see fail-closed coverage.
Byte-level analysis is opt-in and off by default. It needs either a staged
artifact directory (CHAINSAW_GUARD_ARTIFACT_DIR — you supply the archives,
still offline) or CHAINSAW_GUARD_DEEP=1, which fetches the archive over the
network and therefore waives the offline guarantee. Deep fetch covers npm and
cargo only, and only for a version-pinned install: chainsaw npm install foo
gets no byte scan even with deep mode on.
CVE lookup, registry metadata and provenance need the server. Never available offline, in any mode.
The binary tells you on every run what it actually did, derived from the bytes it was able to obtain rather than from configuration. Three of its five acquisition sources read the npm, cargo and pip caches and are fully offline, so on a machine that has already fetched a package the byte scan runs with no configuration at all:
chainsaw behavioral byte scan read and analyzed the bytes of all 12 packages (offline, local disk only)
chainsaw behavioral byte scan analyzed 5 of 12 packages; the rest had no bytes on this machine (stage artifacts in CHAINSAW_GUARD_ARTIFACT_DIR or set CHAINSAW_GUARD_DEEP=1 for byte-level coverage)
chainsaw behavioral byte scan not run: no local bytes for any of 12 packages; using name/feed/typosquat checks only (…)
The offline claim is testable, and we test it adversarially. Run the guard under a network-denying sandbox and every block fires identically. Nothing on the default path requires egress. What the tool does on a TTY is ask whether you want the feed.
A catch rate without a false-positive rate is marketing. Both are published, and the typosquat harnesses ship in this repository.
Name-level typosquat — the default install path:
| False-block rate | 1.02% on 24,206 real names held out of the detector's own seed index |
| Still refused | 247 of 24,206 |
| Recall cost of that reduction | 8.2% of the typosquat lane's blocks on the OpenSSF feed (92 of 1,122) |
Quoting either number without the other misrepresents the trade.
Byte-level scanner — the opt-in deep mode, not the default path:
| Real malware (238 samples) | Benign corpus (860 packages) | |
|---|---|---|
| Hard-block | 43.7% (104/238) | 0.47% false-block (4/860) |
| Any signal (surfaces, does not block) | 69% — dated, not re-measured | 5% — dated |
We do not claim zero false positives. We published a 0.00% once, it did not
reproduce on a wider corpus, and we retired it. The four packages still refused
are named in the acceptedBenignFalseBlocks map in
cli/benign_fp_eval_test.go, which is the source
of truth for both the rate and the names.
Every caveat that belongs with these numbers — what the corpora are, why 0.47% is a floor rather than an estimate, the npm 0/600 vs PyPI 1.54% split, that detector changes were made against this corpus and then re-measured on it, why the "any signal" row is dated, and what is not yet independently reproducible — is in docs/measurement.md. Please read it before quoting any of the above.
Name-similarity inference refuses legitimate packages. It is a real cost, and it is designed for rather than hand-waved: a block with no way forward gets the guard uninstalled, so every refusal the local allowlist can clear prints the way out on the same screen.
Real packages still refused include npm:jsdoc (against jsdom), npm:stylus
(against stylis), npm:tslint (against eslint) and the whole
pypi:nvidia-*-cu11 family. The class is narrow, not gone.
If one bites you, clear that single coordinate on this machine — offline, permanently, without turning the guard off:
chainsaw guard allow npm:jsdoc # clear one false typosquat block
chainsaw guard allow --list # what this machine has allowed, and why
chainsaw guard allow --remove npm:jsdoc # undo itA waiver is scoped and never silent. It clears typosquat verdicts only —
known-malicious feed hits and byte-level checks are refused by guard allow and
keep blocking. Every install of a waived coordinate reprints the suppressed
verdict and how to undo it, and that line survives --quiet, so a stale or
planted entry shows up in the CI log rather than only under guard allow --list:
chainsaw ~ waived npm:jsdoc — looks like a typosquat of "jsdom" (distance 1, edit-distance, target rank #451) (a local allowlist entry cleared this — undo: chainsaw guard allow --remove npm:jsdoc)
A waiver deliberately does not enter the local chainsaw why ring or the
telemetry stream. It was not a refusal, so recording it as one would report a
block that never happened and corrupt the blocked counts — and a waived name is
more sensitive than a blocked one, not less.
chainsaw why npm <name> explains any verdict:
$ chainsaw why npm expresss
Package: npm/expresss@(unpinned)
Outcome: BLOCKED (local install guard)
Severity: typosquat-high
Reason: looks like a typosquat of "express" (distance 1, edit-distance, target rank #262)
Source: this machine's offline guard (known-malicious floor + typosquat)Why the detector was narrowed, and what that cost in recall: docs/measurement.md. Reports of other false blocks are welcome in the issue tracker.
Blocking at install time is not unique — JFrog Curation, Sonatype Repository Firewall, Socket and Aikido all do a version of it, and the commercial engines in this category run deeper behavioural analysis than the byte scanner here. What is unusual about this project:
- No account, no server, no daemon. Your dependency list is not uploaded anywhere to get an answer. The default path makes zero network calls.
- The deciding code is open. Every verdict in this README traces to source in this repository — you can read why a package was refused, and disagree with it in a pull request.
- It self-hosts. The proxy tier runs in your own network, including air-gapped.
- The numbers come with their caveats attached, and the harnesses ship with the code.
This is a library set, not just a CLI. Everything below is importable under
github.com/chain305/chainsaw-core/…:
| Area | Packages |
|---|---|
| Pull-through policy proxy | proxy/ policy/ policyengine/ |
| Detection & risk | typosquat/ malware/ risk/ intelligence/ trustscore/ iocscan/ hiddenunicode/ |
| Supply chain | sbom/ provenance/ attestation/ depgraph/ kev/ |
| Ecosystem parsers | formats/ depparser/ |
Parsers cover npm, PyPI, Go, Cargo, RubyGems, Maven, Gradle, NuGet, Composer, Docker/OCI, Swift, CocoaPods, Dart, Hugging Face, APT and Yum/DNF.
go build ./cmd/chainsaw
go test ./...
GOWORK=off go build ./... # builds standalone, outside the monorepo workspaceStart at pkg.go.dev.
Everything in this repository is Apache-2.0 and runs standalone. There is no
license check, no plan gate, and no kill switch in this code. guard update
fetches from OpenSSF's repository, not from us. chainsaw features will tell you
exactly what your binary can do:
$ chainsaw features
Edition: community
Local capabilities
✓ Offline install-time guard
✗ Enterprise build extensionsWhat lives in the separate commercial module: the multi-tenant server and dashboard, premium intelligence, SSO/SCIM, signed-policy bundles, SIEM connectors, and billing. The control-plane commands in the reference above are clients for it — they are open source clients for a closed server, which is why they ship here and tell you when no server is configured.
What that means in practice: the guard, the detectors, the parsers, the proxy and every verdict you have read about here are yours under Apache-2.0, today, with no strings. The commercial tier is for teams that need one place to see and enforce across many machines.
Off until you opt in. On the first guard block in an interactive terminal you get a consent prompt, and pressing Enter accepts it — so read it.
When enabled it sends anonymous usage plus blocked package names to
https://chain305.com/chainproxy/api/telemetry/ingest. A blocked name could be an internal
package of yours; that is the honest reason to think about it before saying yes.
Clean installs are never sent.
chainsaw telemetry status | on | offNon-interactive runs (CI) never prompt and never send. CHAINSAW_TELEMETRY_DISABLED=1
forces it off. chainsaw telemetry reset forgets the install id.
Vulnerability reports: SECURITY.md.
Binaries ship with SHA-256 checksums over TLS. They are not signed — Sigstore signing with SLSA provenance is pending a release-signer identity, and the installer's signature check is opt-in and says so rather than pretending to verify. In a supply-chain security tool that gap is worth naming plainly: verify the checksum, or build from source, until it closes.
Docs · chain305.com · Contributing · Changelog
Apache-2.0 — see LICENSE.
