Skip to content

About

Open-core of Chainsaw — an install-path firewall for npm/PyPI/Go/Rust/Ruby. The chainsaw CLI + embeddable proxy/policy/intelligence libraries. Apache-2.0.

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

177 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chainsaw

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.

License Go Reference Go 1.25+

Chainsaw refusing a typosquatted npm package at install time

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 installed

Exit 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 installed

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


Contents

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


What Chainsaw does

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.


Ecosystems

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.


Risk signals

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 and enforcement

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=aaf8cc651c8d

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


Quickstart

curl -fsSL https://chain305.com/install.sh | sh

One 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/chainsaw

Prebuilt 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 --install

That 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't

First run in a real terminal

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


How the guard works

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.


What gets checked

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.


CI

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: true

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


CLI reference

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 do

Configuration

Environment 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 unavailable

Scope and limits

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


Measured performance

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.


False positives, and the escape hatch

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 it

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


How it compares

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.

Go libraries

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 workspace

Start at pkg.go.dev.


Open core

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 extensions

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


Telemetry

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 | off

Non-interactive runs (CI) never prompt and never send. CHAINSAW_TELEMETRY_DISABLED=1 forces it off. chainsaw telemetry reset forgets the install id.


Security

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.

About

Open-core of Chainsaw — an install-path firewall for npm/PyPI/Go/Rust/Ruby. The chainsaw CLI + embeddable proxy/policy/intelligence libraries. Apache-2.0.

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages