README
ΒΆ
escrow
Supply-chain proxy for package managers β age gates, CVE scanning, and a real-time operator dashboard. Single binary, 7 ecosystems.
developer / CI β escrow proxy β upstream registry
β
policy engine
ββββββββββββββΌββββββββββββββ
age osv publisher Β· popularity
Packages that fail policy are blocked at the proxy level β they never reach the tool. Operators review blocked events in the real-time dashboard and approve with a single click.
| npm | PyPI | Go | Cargo | NuGet | Maven / Gradle | Composer |
|---|---|---|---|---|---|---|
| β | β | β | β | β | β | β |
π Quick Install
πΊ Homebrew (macOS β recommended)
brew tap jverhoeks/tap
brew install escrow
Run as a background service (auto-starts on login):
brew services start escrow
# β http://localhost:7888/dashboard
# Dashboard credentials are in: $(brew --prefix)/var/log/escrow.log
brew services stop escrow # stop
brew services restart escrow # reload config
Config lives at $(brew --prefix)/etc/escrow/escrow.toml β edit it to enable more ecosystems, then restart the service.
The Homebrew formula also installs escrow-cli, the companion tool for routing your development environment's traffic through the proxy. See Routing Traffic to Escrow for setup options.
escrow-cli setup --dry-run # preview system setup
escrow-cli config write # configure all tools globally
escrow-cli status # check proxy + config + firewall state
π³ Docker
docker run -p 7888:7888 ghcr.io/jverhoeks/escrow:latest
Debug config (all 7 ecosystems, full policy, admin / escrow):
cd docker/
mkdir -p data && cp escrow.debug.toml data/escrow.toml
docker compose up -d
# β http://localhost:7888/dashboard
π¦ Binary
# macOS arm64
curl -L https://github.com/jverhoeks/escrow/releases/latest/download/escrow-darwin-arm64 -o escrow
chmod +x escrow && ./escrow
# macOS amd64
curl -L https://github.com/jverhoeks/escrow/releases/latest/download/escrow-darwin-amd64 -o escrow
chmod +x escrow && ./escrow
# Linux amd64
curl -L https://github.com/jverhoeks/escrow/releases/latest/download/escrow-linux-amd64 -o escrow
chmod +x escrow && ./escrow
On first boot escrow generates escrow.toml with a random dashboard password and prints credentials to stdout.
βοΈ Flags
./escrow # binds to 127.0.0.1:7888 (localhost only)
./escrow --host=0.0.0.0 # listen on all interfaces (team/CI use)
./escrow --config=/etc/escrow/escrow.toml
./escrow --host=0.0.0.0 escrow.toml # flag + positional config path
π‘ On first boot, credentials are printed to stdout. Save them β or find them in the generated
escrow.toml.
π Supported Ecosystems
| Ecosystem | Tools | Proxy URL | Config key |
|---|---|---|---|
| npm | npm, pnpm, yarn, bun | http://localhost:7888/ |
npm = true |
| PyPI | pip, uv | http://localhost:7888/pypi/simple/ |
pypi = true |
| Go modules | go | http://localhost:7888/go/ |
go = true |
| Cargo | cargo | http://localhost:7888/cargo/ |
cargo = true |
| Composer | composer | http://localhost:7888/composer/ |
composer = true |
| NuGet | dotnet, nuget | http://localhost:7888/nuget/index.json |
nuget = true |
| Maven / Gradle | mvn, gradle | http://localhost:7888/maven2/ |
maven = true |
β‘ GitHub Actions
Use escrow as a one-step supply-chain gate in any CI pipeline. Add it before your install steps β no other changes needed:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jverhoeks/escrow@v1
with:
ecosystems: 'npm'
min-days: '7'
osv-severity: 'HIGH'
- uses: actions/setup-node@v6
with:
node-version: '20'
- run: npm install --ignore-scripts
# npm automatically uses the escrow registry β no other changes needed
Escrow sets NPM_CONFIG_REGISTRY, PIP_INDEX_URL, GOPROXY, etc. automatically so every install command routes through the proxy. The package cache is stored in GitHub Actions cache and restored on every run β warm cache runs require zero upstream calls.
Composing with Renovate
Renovate discovers new versions using its own HTTP client β it does not use the same registry config as your package managers. The escrow security check still fires at install/build time:
Renovate: finds "lodash 4.17.22 exists" β queries registry directly (discovery only)
Developer: npm install after accepting PR β escrow checks age + OSV β this is the gate
What Renovate auto-detects (no extra config):
| Ecosystem | Auto-detected? | Notes |
|---|---|---|
| npm | β | Renovate explicitly rejects localhost registries from .npmrc (security feature) |
| PyPI | β | Reads PIP_INDEX_URL env var |
| Go | β | Reads GOPROXY env var |
| Cargo | β | Uses git-clone for custom registries; sparse HTTP not supported |
| Maven | β οΈ | Only if pom.xml has <repositories> pointing to escrow |
| NuGet / Composer | β οΈ | Merge/hunt strategy; need project config |
For maven/nuget/composer with a network-accessible escrow instance, generate and commit a renovate.json:
escrow-cli config write-renovate --ecosystems maven,nuget,composer
In GitHub Actions β PyPI and Go are covered by the exported env vars; for maven/nuget/composer use the renovate-config output:
- uses: jverhoeks/escrow@v1
id: escrow
with:
ecosystems: 'npm,pypi,go,cargo,maven,nuget,composer'
- uses: renovatebot/github-action@v40
env:
RENOVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PyPI + Go auto-detected via PIP_INDEX_URL / GOPROXY already exported above
# Maven/NuGet/Composer: explicit registryUrls (npm/cargo: go direct by design)
RENOVATE_CONFIG: ${{ steps.escrow.outputs.renovate-config }}
| Input | Default | Description |
|---|---|---|
ecosystems |
npm,pypi,go,cargo |
Comma-separated list to enable |
min-days |
7 |
Age gate threshold |
osv-severity |
HIGH |
Minimum CVE severity to block (off to disable) |
version |
v1.4.1 |
Escrow binary version |
port |
7888 |
Local proxy port |
cache-key-suffix |
`` | Append to cache key for manual busting |
Output: proxy-url β the base URL (http://127.0.0.1:7888).
β Full guide: docs/github-actions.md
π Per-Tool Quickstarts
Step-by-step guides for global setup, per-project setup, verify, and remove for each tool:
| Tool | Guide |
|---|---|
| npm | docs/quickstart/npm.md |
| pnpm | docs/quickstart/pnpm.md |
| yarn | docs/quickstart/yarn.md |
| bun | docs/quickstart/bun.md |
| pip | docs/quickstart/pip.md |
| uv | docs/quickstart/uv.md |
| go | docs/quickstart/go.md |
| cargo | docs/quickstart/cargo.md |
| composer | docs/quickstart/composer.md |
| dotnet / NuGet | docs/quickstart/dotnet.md |
| maven | docs/quickstart/maven.md |
| gradle | docs/quickstart/gradle.md |
| GitHub Actions | docs/github-actions.md |
π Comparison with alternatives
| Feature | JFrog Curation | escrow |
|---|---|---|
| Server-side age gate | β configurable | β
min_days |
| OSV / malware scan | β via Xray | β osv.dev |
| npm | β | β |
| PyPI | β | β |
| Go modules | β | β |
| Maven / Gradle | β | β |
| Cargo / Rust | β οΈ "varying levels of support" | β full age + OSV |
| NuGet | β not confirmed | β |
| Composer | β not confirmed | β |
| On block | silently substitutes safe older version | blocks + dashboard approval |
| Cost | π° commercial (Artifactory add-on) | free / OSS |
| Self-hosted | β | β |
Notable difference: Curation silently swaps a blocked package for an older safe version β frictionless but invisible to developers. Escrow blocks outright and surfaces it in the dashboard, requiring an explicit human approval decision. Which is better depends on your workflow.
Cargo is the key gap to watch. JFrog documents Cargo as having "varying levels of support", which likely means its time-delay policy does not apply to Cargo yet. Escrow is currently the only proxy with confirmed server-side age enforcement for Cargo.
π‘οΈ What Escrow Protects Against
| Threat | Protected? |
|---|---|
| β Same-day injection attacks (packages published and spread within hours) | blocked by age gate |
| β Packages with known CVEs (MEDIUM/HIGH/CRITICAL by default) | blocked by OSV scan |
| β Packages from brand-new publisher accounts | flagged by publisher signal |
| β Packages with sudden download spikes (possible hijacking) | flagged by popularity signal |
| β Packages on your explicit blocklist | blocked at allowlist/blocklist check |
| β Air-gap: packages that haven't been reviewed never reach developer machines | proxy-level enforcement |
| β Postinstall hooks in packages that do pass the gate | use ignore-scripts=true per tool |
| β Typosquatting on packages that pass age/vuln gates | not yet implemented |
β Git-protocol npm deps (npm install github:user/pkg) |
bypass the registry entirely |
| β Composer ZIP archives (artifact air-gap) | metadata filtered; archives fetched from CDN |
| β Publisher signal for Go, Cargo, NuGet, Maven | no public API equivalent |
β οΈ Postinstall hooks are the most important gap. Escrow filters packages from manifests but does not strip
postinstallhooks from packages that pass. Setignore-scripts=true(npm/pnpm),enableScripts: false(yarn), oronly-binary = [":all:"](uv) on every developer machine.
π Routing Traffic to Escrow
escrow-cli is a companion tool (installed alongside escrow via Homebrew) that routes your development environment's package traffic through the proxy. Four methods are available β use one or combine several for complete coverage.
| Method | Catches | Root? | Platform | |
|---|---|---|---|---|
| 1 | Global config files | CLI tools reading standard configs | No | All |
| 2 | Local project config | Per-project, checked-in | No | All |
| 3 | Shell / launch env | CLI + GUI apps (VSCode, Zed...) | No | macOS / Linux |
| 4 | Network redirect | Every process, no config needed | Yes | macOS / Linux |
Recommended combination for a developer machine
escrow-cli config write # 1. write tool config files globally
escrow-cli config write-env # 3. LaunchAgent / profile.d β covers GUI apps
escrow-cli config write-shell # 3. .zshrc + .bashrc for new terminals
sudo escrow-cli setup # 4. system account + pf anchor (macOS)
sudo escrow-cli fw-enable # 4. network-level redirect rules
Method 1 β Global config files
Writes per-tool registry config to your home directory. Covers every package manager that honours standard config files.
escrow-cli config write [--ecosystems npm,pypi,go,cargo,nuget,maven,composer] \
[--proxy-url http://127.0.0.1:7888]
What gets written:
| Tool | File written |
|---|---|
| npm, pnpm | ~/.npmrc |
| yarn v1 | ~/.yarnrc |
| yarn v2+ | ~/.yarnrc.yml |
| bun | ~/.bunfig.toml |
| pip | ~/.pip/pip.conf |
| uv | ~/.config/uv/uv.toml |
| poetry | PIP_INDEX_URL block in shell profile |
| go | GOPROXY block in shell profile |
| cargo | ~/.cargo/config.toml |
| nuget | ~/.nuget/NuGet/NuGet.Config |
| maven | ~/.m2/settings.xml |
| gradle | ~/.gradle/init.d/escrow-mirror.gradle |
| composer | ~/.config/composer/config.json |
Each file is backed up to <file>.escrow-backup before being written.
escrow-cli config check # show which tools are configured
escrow-cli config restore # restore all backups
escrow-cli config restore --ecosystems npm,pypi # restore specific ecosystems
β οΈ Go: use
GOPROXY=http://127.0.0.1:7888/go,offnot,direct. Theofffallback causes builds to fail loudly when escrow is unreachable rather than silently bypassing it.
Method 2 β Local project config
Writes config files into the current working directory. Useful for per-project opt-in without changing global settings.
cd your-project/
escrow-cli config write-local [--ecosystems npm,cargo,nuget,pypi,composer]
Files written in CWD:
| Tool | File |
|---|---|
| npm, pnpm | .npmrc |
| yarn v1 | .yarnrc |
| yarn v2+ | .yarnrc.yml |
| bun | bunfig.toml |
| uv | uv.toml |
| cargo | .cargo/config.toml |
| nuget | nuget.config |
| composer | composer.json |
Go, pip, maven, gradle have no project-local config equivalent β use Method 1 for those.
escrow-cli config check-local # show which local files are configured
escrow-cli config restore-local # restore all local backups
Method 3 β Shell / launch environment
Injects proxy env vars at the OS level so GUI apps (VSCode, Zed, Cursor) and processes launched outside a terminal also see the proxy settings.
macOS LaunchAgent (recommended β survives reboot, covers GUI apps)
escrow-cli config write-env [--ecosystems npm,pypi,go]
# Check what's active in the launch environment:
escrow-cli config check-env
Writes ~/Library/LaunchAgents/com.escrow.environment.plist. The agent runs at every login and injects these env vars into the macOS launch environment so every spawned process inherits them β including VSCode, Zed, and bundled runtimes.
Shell profiles (.zshrc / .bashrc)
escrow-cli config write-shell [--profiles zshrc,bashrc] [--ecosystems npm,pypi,go]
# Activate in the current terminal immediately (no new window needed):
source ~/.zshrc
# Check which profiles have the block:
escrow-cli config check-shell
--profiles accepts: zshrc, bashrc, zprofile, bash_profile, profile.
Env vars injected by both commands:
NPM_CONFIG_REGISTRY=http://127.0.0.1:7888/ # npm, pnpm
YARN_REGISTRY=http://127.0.0.1:7888/ # yarn v1
PIP_INDEX_URL=http://127.0.0.1:7888/pypi/simple/ # pip, poetry
UV_INDEX_URL=http://127.0.0.1:7888/pypi/simple/ # uv
GOPROXY=http://127.0.0.1:7888/go,off # go
GONOSUMDB=*
Undo:
escrow-cli config restore-env # remove LaunchAgent
escrow-cli config restore-shell # remove shell profile block
Method 4 β Network redirect
The network backstop: intercepts all TCP connections to registry hosts at the kernel level using pf (macOS) or iptables / nftables (Linux). Catches every process regardless of config files or environment variables.
One-time system setup (run once per machine)
# Preview what will happen without making changes:
escrow-cli setup --dry-run
# Apply (creates _escrow service account, patches pf.conf, sets up iptables chain):
sudo escrow-cli setup
# Optional: install passwordless sudo so EscrowManager.app can enable/disable without prompting:
sudo escrow-cli setup --sudoers
Enable / disable redirect rules
sudo escrow-cli fw-enable [--ecosystems npm,pypi,go,cargo,nuget,maven,composer] \
[--proxy-port 7888] [--proxy-user _escrow]
sudo escrow-cli fw-disable
Verify interception is working
escrow-cli fw-test [--ecosystems npm,pypi]
Output:
proxy: β 127.0.0.1:7888 reachable
npm β registry.npmjs.org:443 β proxy
npm ~ npm.pkg.github.com:443 rule loaded, CDN IP rotated (likely OK)
pypi ~ pypi.org:443 rule loaded, CDN IP rotated (likely OK)
ββ redirect confirmed via live TCP test~β pf rule is loaded, CDN IP changed sincefw-enableran (redirect will work when IP aligns again)ββ no rule loaded, runsudo escrow-cli fw-enable
Overall status
escrow-cli status # pf rules, config files, proxy health
escrow-cli status --json # machine-readable
Known limitations
pf and iptables resolve hostnames to IP addresses at rule-load time. This means:
| Limitation | Impact | Mitigation |
|---|---|---|
| CDN IP rotation | Rules stale after TTL expires (proxy.golang.org TTL: 8s) |
Re-run fw-enable after network change |
| HTTP/3 / QUIC | UDP port 443 bypasses TCP redirect | Package managers use TCP today; monitor as HTTP/3 adoption grows |
| VPN split-tunnelling | Corporate VPN may mark registry IPs as "direct", bypassing redirect | Methods 1β3 remain effective |
| New bundled runtimes | Tool that ignores config and bypasses TCP (e.g. custom go binary) | Methods 1β3 provide defence-in-depth |
For complete hostname-based interception immune to IP rotation, a macOS Network Extension (
NETransparentProxyProvider) is the path forward. Seedocs/specs/swift-network-extension-prompt.md.
Coverage summary
| Tool | Method 1 (config) | Method 2 (local) | Method 3 (env) | Method 4 (network) |
|---|---|---|---|---|
| npm | β | β | β | β |
| pnpm | β | β | β | β |
| yarn v1 | β | β | β | β |
| yarn v2+ | β | β | β | β |
| bun | β | β | β | β |
| pip | β | β | β | β |
| uv | β | β | β | β |
| poetry | β (env) | β | β | β |
| go | β | β | β | β |
| cargo | β | β | β | β |
| nuget | β | β | β | β |
| maven | β | β | β | β |
| gradle | β | β | β | β |
| composer | β | β | β | β |
| VSCode bundled npm | β | β | β | β |
| Any rogue script | β | β | β | β |
π Dashboard
Real-time package event stream with approve/block controls.

Access at http://localhost:7888/dashboard. Credentials are printed on first boot and stored in $(brew --prefix)/var/log/escrow.log.
Approve a blocked package: click β next to any blocked event. Added to
escrow-allowlist.json immediately. No restart needed.
Remove from allowlist: DELETE /dashboard/api/allow with {"ecosystem","name","version"}.
All changes are recorded in the live feed with the operator's username.
Block a package manually: POST /dashboard/api/block. Same format.
βοΈ Policy Configuration
All policy lives in escrow.toml. Without a [policy] section escrow proxies
transparently (with a startup warning).
ποΈ Age gate
Blocks packages published fewer than N days ago. Catches injection attacks that publish and spread quickly before the community notices.
[policy.age]
min_days = 7 # packages must be at least 7 days old
action = "block" # block | warn | allow
min_days |
Use case |
|---|---|
| 1 | Catch same-day injections |
| 7 | Recommended baseline |
| 30 | High-security environments |
π OSV vulnerability scan
Checks every package version against the Open Source Vulnerability database.
[policy.osv]
min_severity = "MEDIUM" # LOW | MEDIUM | HIGH | CRITICAL
action = "block"
Results are cached 24 hours per version.
π‘ Fail-open: If the OSV API is unreachable or returns a non-200 response, the vulnerability signal returns
skipand the package is allowed through. This is intentional β a transient OSV outage should not block all package installs. If you need fail-closed behavior, mirror the OSV database locally or useaction = "warn"so blocks require an explicit allowlist entry rather than automatic OSV approval.
π€ Publisher account age
[policy.publisher]
max_account_age_days = 30
action = "warn"
For npm: reads _npmUser (set by the registry at publish time). Publisher lookup
results are cached 1 hour per account.
π‘ Fail-open: If the npm registry API is unreachable, the publisher signal returns
skipand the package passes through.
π Download spike detection
[policy.popularity]
spike_factor = 10.0 # warn if downloads increased >10Γ week-over-week
action = "warn"
π Block source distributions (PyPI)
[policy.pypi]
block_sdist = true # wheel-only; never run setup.py at install time
π¦ Policy actions
action |
Effect |
|---|---|
block |
Removed from manifest/metadata β tools see it as non-existent |
warn |
Allowed through; event logged with WARN status |
allow |
Signal evaluated but never blocks (monitoring mode) |
β Allowlist and Blocklist
Via dashboard
Click Approve on any blocked event β added to escrow-allowlist.json immediately.
Click Block on any allowed event β added to escrow-blocklist.json.
Via JSON files
escrow-allowlist.json:
[
{
"ecosystem": "npm",
"name": "lodash",
"version": "4.17.21",
"reason": "pinned to known-good version, reviewed by security team",
"added_by": "admin",
"added_at": "2026-05-16T14:00:00Z"
}
]
"version": "" is a wildcard β approves all versions of the package.
Allowlist is checked before any policy signal. Approved packages bypass all
trust checks and are recorded with signal: override.
π’ Deployment
π TLS (optional)
Provide a certificate and key to serve HTTPS directly:
[server]
tls_cert_file = "/etc/ssl/escrow.crt"
tls_key_file = "/etc/ssl/escrow.key"
Or terminate TLS at nginx/caddy and set X-Forwarded-Proto: https β escrow
detects this and sets Secure on session cookies automatically.
β±οΈ Write timeout
For large .crate or wheel downloads to slow clients, increase the write timeout:
[server]
write_timeout_seconds = 300 # default 120
π§ Rate limiting
Limit requests per IP on proxy endpoints:
[server]
proxy_rate_limit_per_min = 600 # 0 = disabled (default)
π Internal mirrors
Override the upstream URL per ecosystem to point at Nexus, Artifactory, etc.:
[ecosystems]
npm = true
npm_upstream = "https://nexus.corp.internal/repository/npm-proxy/"
pypi = true
pypi_upstream = "https://nexus.corp.internal/repository/pypi-proxy"
go = true
go_upstream = "https://nexus.corp.internal/repository/go-proxy"
maven = true
maven_upstream = "https://nexus.corp.internal/repository/maven-releases"
maven_snapshot_upstream = "https://nexus.corp.internal/repository/maven-snapshots"
Maven SNAPSHOT requests (path contains SNAPSHOT) are routed to maven_snapshot_upstream
when set; other requests go to maven_upstream. Without it, all requests share one upstream.
π©Ί Health check
GET /healthz probes each enabled upstream with a 3-second HEAD request:
{
"status": "ok",
"uptime": "2h34m",
"storage_backend": "disk",
"upstream_status": {
"npm": true, "pypi": true, "go": true,
"nuget": true, "maven": true
}
}
Returns HTTP 503 with "status": "degraded" if any upstream is unreachable.
πΎ Disk cache
Blobs (tarballs, wheels, JARs) are cached permanently β they never expire. Monitor disk usage and plan capacity accordingly:
du -sh ./escrow-cache/blobs/ # how much blob storage is used
find ./escrow-cache/meta/ -name "*.json" | wc -l # number of metadata entries
There is no built-in eviction. When disk fills, SetBlob fails silently and packages stop being cached (clients still receive them from upstream, but without the cache benefit). The /healthz endpoint returns "cache_writable": false when the cache directory is not writable β wire this to your alerting.
For long-running deployments, periodically clean old metadata files:
find ./escrow-cache/meta/ -name "*.json" -mtime +7 -delete
β οΈ Blobs should not be deleted β they are the cached packages and their keys are content-addressed.
π₯οΈ systemd unit
[Unit]
Description=Escrow package proxy
After=network.target
[Service]
ExecStart=/usr/local/bin/escrow --config=/etc/escrow/escrow.toml
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
ποΈ Storage
[storage]
backend = "disk" # disk | s3 | memory
[storage.disk]
path = "./escrow-cache"
[storage.s3]
bucket = "my-escrow-cache"
region = "eu-west-1"
endpoint = "" # blank = AWS; set for MinIO/Ceph
# S3 uploads use temp files, not RAM buffers
What is cached:
| Content | Backend | TTL |
|---|---|---|
| npm / PyPI / Composer manifests (filtered) | meta | 5 min |
| NuGet registration (filtered) | meta | 5 min |
Maven maven-metadata.xml (filtered) |
meta | 5 min |
Go .mod files |
meta | 24h |
Go list responses |
meta | 5 min |
| OSV vulnerability results | meta | 24h |
| Publisher account lookups | meta | 1h |
| Maven Central version timestamps | meta | 1h |
| npm / PyPI / Cargo tarballs, wheels, .crate | blob | permanent |
Go .zip source archives |
blob | permanent |
NuGet .nupkg files |
blob | permanent |
| Maven JARs, POMs, checksums | blob | permanent |
Concurrent cold-cache requests for the same manifest are deduplicated via singleflight β only one upstream fetch runs regardless of how many clients ask simultaneously.
Event log persistence β add eventlog_path to persist events across restarts:
eventlog_path = "escrow-events.jsonl" # JSONL append file; empty = in-memory only
Events are loaded from the file on startup (last 500). New events are appended atomically.
π Alerts
Send a webhook on every block (Slack, Teams, PagerDuty, custom endpoint):
[alerts]
webhook_url = "https://hooks.slack.com/services/..."
Payload:
{
"ecosystem": "npm",
"package": "malicious@1.0.0",
"signal": "age",
"reason": "published 0 day(s) ago (minimum: 7)",
"action": "block",
"timestamp": "2026-05-17T14:03:12Z"
}
Webhooks are deduplicated per package per signal during a manifest filter β a 200-version package blocked by age sends one webhook, not 200.
π Security Model
Block by removal, not by error
For npm, PyPI, Composer, NuGet, and Maven, escrow filters blocked versions from
the package manifest before returning it. The package manager never learns the
version exists β it cannot be installed by --force or by a dependency resolver
fallback. For Go modules, escrow returns HTTP 403 on .info and @latest
endpoints. For Cargo, blocked versions are omitted from the sparse index NDJSON.
Trust pipeline
request β allowlist β blocklist β age β osv β publisher β popularity β allow/warn/block
Allowlist is checked first and short-circuits all other checks β an allowlist entry
(including wildcard "version": "") bypasses the blocklist and all trust signals.
Blocklist is checked second; it can block packages not on the allowlist. Signals run
in order; the first block decision terminates the pipeline.
Dashboard security
- β
HMAC-SHA256 session cookies (HttpOnly,
SameSite=Strict, 24h TTL) - β
Timing-safe credential and HMAC comparison (
crypto/subtle,hmac.Equal) - β Login rate limiting: 10 failures β 15-minute IP lockout
- β CSRF protection: Origin header checked on all mutating endpoints
- β Request body limit: 64 KB on all POST/DELETE endpoints
- β
Security headers:
Content-Security-Policy,X-Frame-Options,X-Content-Type-Options
No credentials forwarded
Escrow does not store, log, or forward authentication tokens. It acts as an
anonymous read-only client to public upstream registries. Private registry
authentication (.npmrc tokens, PyPI API keys) is not affected.
Audit trail
Every package evaluation is recorded in the in-memory event log (last 500 events).
Dashboard allow/block/remove actions are also recorded with the operator's username.
The event stream is available via SSE (/dashboard/api/stream) and REST
(/dashboard/api/events).
β οΈ Known Limitations
What escrow does NOT protect against
Postinstall hooks β Escrow filters packages from manifests but does not strip postinstall hooks from packages that do pass. You still need ignore-scripts=true (npm/pnpm), enableScripts: false (yarn), or only-binary = [":all:"] (uv) on every developer machine. See the per-tool quickstart guides in docs/quickstart/.
Typosquatting on allowed packages β If a package passes the age and vulnerability gates, escrow serves it. Detecting typosquatting requires manual allowlisting or an additional signal not yet implemented.
git dependencies β npm git-protocol dependencies (npm install github:user/pkg) bypass the package registry entirely and are not routed through escrow.
Ecosystem limitations
Composer ZIP archives β The Composer handler proxies and filters the Packagist v2 metadata (which versions are visible). However, the actual ZIP archive downloads happen via dist.url values in the metadata, which point directly to Packagist's CDN or GitHub. Composer package archives are NOT routed through escrow and are not cached locally. Metadata air-gap is achieved; artifact air-gap is not. If Packagist CDN is unreachable, Composer installs fail.
Unknown publish times β When a package's publish time cannot be determined (e.g., Maven Central Search API unavailable, old Packagist entries without timestamps), the age gate treats the package as "ancient" and allows it through. This is fail-open by design to avoid blocking legitimate packages during upstream API outages.
Publisher signal β Publisher account age is checked for npm and PyPI only. No equivalent public API exists for Go, Cargo, NuGet, or Maven.
OSV vulnerability scan β When the OSV API is unreachable, the signal returns skip and the package passes through (fail-open). See the OSV section above for details.
π¨ Building from Source
git clone https://github.com/jverhoeks/escrow
cd escrow
# proxy server
go build -o escrow ./cmd/escrow
# system configuration CLI (macOS / Linux)
go build -o escrow-cli ./cmd/escrow-cli
go test ./...
π Full Configuration Reference
[server]
host = "127.0.0.1" # 0.0.0.0 or --host flag for all interfaces
port = 7888
log_level = "info" # debug | info | warn | error
write_timeout_seconds = 120 # increase for slow clients downloading large archives
read_header_timeout_seconds = 10 # time to receive full HTTP request headers (Slowloris defense)
idle_timeout_seconds = 120 # keep-alive connection idle limit
tls_cert_file = "" # blank = HTTP only
tls_key_file = ""
proxy_rate_limit_per_min = 0 # requests/min per IP on proxy endpoints; 0 = disabled
[ecosystems]
npm = true
npm_upstream = "" # default https://registry.npmjs.org
pypi = true
pypi_upstream = "" # default https://pypi.org
go = false
go_upstream = "" # default https://proxy.golang.org
cargo = false
composer = false
composer_upstream = "" # default https://repo.packagist.org
nuget = false
nuget_upstream = "" # default https://api.nuget.org/v3
nuget_flatcontainer_url = "" # optional; derived from nuget_upstream for NuGet.org;
# set explicitly for Nexus/Azure Artifacts which use
# different URL schemes (e.g. .../repository/nuget/download)
maven = false # also covers Gradle
maven_upstream = "" # default https://repo1.maven.org/maven2
maven_snapshot_upstream = "" # route -SNAPSHOT paths here; default: same as maven_upstream
[storage]
backend = "disk" # disk | s3 | memory
[storage.disk]
path = "./escrow-cache"
[storage.s3]
bucket = ""
region = "eu-west-1"
endpoint = "" # blank = AWS S3; set for MinIO
[policy]
[policy.age]
min_days = 7
action = "block" # block | warn | allow
[policy.osv]
min_severity = "MEDIUM"
action = "block"
[policy.publisher]
max_account_age_days = 30
action = "warn"
[policy.popularity]
spike_factor = 10.0
action = "warn"
[policy.pypi]
block_sdist = false # true = wheel-only installs
[dashboard]
enabled = true
path = "/dashboard"
username = "admin"
password = "" # generated on first boot
secret = "" # HMAC session-cookie secret; generated on first boot
[alerts]
webhook_url = ""
allowlist_path = "escrow-allowlist.json"
blocklist_path = "escrow-blocklist.json"
eventlog_path = "" # JSONL file for persistent event log; empty = in-memory only
Directories
ΒΆ
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
escrow
command
|
|
|
escrow-cli
command
|
|
|
internal
|
|
|
accesslog
Package accesslog keeps a bounded, in-memory record of HTTP requests handled by the escrow server.
|
Package accesslog keeps a bounded, in-memory record of HTTP requests handled by the escrow server. |
|
upstreamlog
Package upstreamlog keeps a bounded, in-memory record of escrowβupstream fetches.
|
Package upstreamlog keeps a bounded, in-memory record of escrowβupstream fetches. |