A package firewall for your dependency installs. It sits between your package manager (npm / pnpm / yarn / pip / cargo / go) and the public registries as a policy-enforcing proxy, and decides per request whether to serve, block, or rewrite each package against a signed policy, before anything reaches a developer machine or CI.
Beyond plain block/allow it can enforce a minimum release age, hide blocked versions from registry metadata, verify Sigstore / PEP 740 provenance, strip risky npm lifecycle scripts, match artifacts against a known-bad hash list, isolate cache per tenant, and emit an audit decision for every request.
It is a single Rust binary with no database.
This repo holds three crates:
| Crate | What |
|---|---|
herot-bundle |
Pure wire types for the signed policy bundle. The single source of truth. |
herot-firewall |
The firewall server. Reads policy, enforces, audits. |
herot-cli |
herot command for signing bundles, verifying signatures, and checking firewall health. |
# generate a signing keypair
openssl genpkey -algorithm ed25519 -out priv.pem
openssl pkey -in priv.pem -pubout -out pub.pem
# write a policy bundle and sign it
cat > bundle.json <<'EOF'
{
"bundle_id": "demo",
"bundle_version": 1,
"decisions": [],
"blocks": [
{"ecosystem": "npm", "package": "left-pad", "version": "1.3.0",
"reason": "compromised release", "incident_id": "DEMO-1"}
]
}
EOF
cargo run -p herot-cli -- bundle sign bundle.json --key priv.pem --out signed.json
# run the firewall against the signed bundle (a local path or an HTTPS URL)
export POLICY_FILE=/abs/path/to/signed.json # or POLICY_FILE=https://your.host/signed.json
export POLICY_PUBLIC_KEY_PATH=/abs/path/to/pub.pem
export NPM_UPSTREAM_URL=https://registry.npmjs.org
export PROXY_ALLOW_ANONYMOUS=1
cargo run -p herot-firewallPoint your package manager at http://localhost:4873. Installing left-pad@1.3.0
will be blocked; other versions and packages pass through.
For the simplest setup, skip signing and point POLICY_FILE at a local TOML
policy instead. See examples/block-a-package/
and docs/configuration.md.
Three orthogonal axes, each chosen via env vars:
- Bundle source -
POLICY_FILEaccepts a path orhttps://URL. A URL source can be authenticated so the bundle need not be public: setPOLICY_URL_BEARER(orPOLICY_URL_BEARER_FILEto read it from a mounted secret) forAuthorization: Bearer, orPOLICY_URL_HEADER="Name: value"for any other scheme. Auth requires anhttpsURL and the credential is redacted in logs; the bundle signature is verified regardless. - Decision sink -
DECISION_SINK=stdout|file|urlwithDECISION_SINK_PATH=/DECISION_SINK_URL=. - Principal auth -
PROXY_ALLOW_ANONYMOUS=1for dev,PRINCIPALS_FILE=for bearer tokens,OIDC_TRUST_FILE=for GitHub Actions JWTs.
Private upstream registries: UPSTREAM_CREDENTIALS_FILE for direct-host
credentials, PRIVATE_UPSTREAMS_FILE for scope-based routing (npm @scope,
pypi/cargo prefix).
See firewall/README.md and
docs/configuration.md for the full configuration and env
reference.
This is a pre-release (0.0.x) standalone project. Treat it as experimental: the
policy bundle format and the API are still changing, and every 0.0.x build may
break compatibility. Worth knowing before you deploy:
- npm is the most complete ecosystem. PyPI, Go modules, and Cargo are served by the same binary and cover the install path, but with fewer controls today.
- Two policy modes ship here. A local policy file (TOML, filesystem trust) and a signed bundle loaded from a path or HTTPS URL (verified against a pinned Ed25519 key).
- Heavy subsystems are opt-in. The Redis shared cache (
redisfeature) and OpenTelemetry OTLP export (otelfeature) are off by default to keep the binary and its dependency surface small.
What each ecosystem enforces today:
| Capability | npm | PyPI | Go | Cargo |
|---|---|---|---|---|
| Block exact version / version range | yes | yes | yes | yes |
| Hide blocked versions from metadata | yes | yes | version list only | no (download blocked, index unfiltered) |
| Minimum release age | yes | no | no | no |
| Sigstore / provenance attestation | yes | yes (PEP 740) | no | no |
| Artifact integrity check | yes | yes | n/a | n/a |
| Strip install / lifecycle scripts | yes | no | no | no |
| Private-upstream routing | yes (@scope) |
yes (prefix) | no | yes (prefix) |
A blocked package is refused with a per-ecosystem status: npm and PyPI return
403, Go returns 410, Cargo returns 451. The same status applies whether
the denial came from a policy block or an inline-detection match; quarantine
is an audit distinction, not a separate status. Signed-decision lanes are
allow and block only. Version ranges (version_range) use Cargo-style
semver parsing across npm, Go, and Cargo, so npm-dialect range syntax
(^1 || ^2, 1.x, dist-tags) is not supported in ranges; exact-version blocks
are unaffected.
MAX_BODY_MBdefaults to 16. Artifacts are buffered in full to run integrity and inline checks; a package larger than the limit is rejected with502. Raise it for ecosystems with large wheels/zips.- Cache-miss and prefetch are rate-limited (
PROXY_CACHE_MISS_MAX_RPS,PROXY_ARTIFACT_PREFETCH_MAX_RPS); a large cold-cache install can hit429. Tune these for CI fan-out. - No-policy behavior is asymmetric. With
PROXY_ALLOW_NO_POLICY=1(no policy loaded), npm/PyPI/Cargo pass through while Go fails closed. Run with a loaded policy in production. - Redis and audit-sink failures. A configured Redis backend that cannot initialize now fails startup (rather than silently degrading); at runtime, shared-cache errors fall back to direct upstream fetch. The decision sink is at-most-once (see SECURITY.md).
See SECURITY.md for the threat model, trust boundaries, and residual risks.
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targetsDual-licensed under MIT or Apache-2.0 at your option. See LICENSE-MIT and
LICENSE-APACHE.