A self-hosted, verifying registry proxy for npm, PyPI, Maven Central, RubyGems, and crates.io — enforces cooldown windows, hash checks, and Sigstore provenance before serving any artifact.
Most supply-chain attacks ride on freshly-published versions of legitimate packages: a maintainer's token leaks, a typosquat lands, a postinstall script ships malware. By the time the registry yanks the bad version, your CI has already installed it.
Vouch sits between your developers/CI and the public registry and refuses to serve anything that doesn't meet your trust bar:
- Cooldown. Block versions younger than N days. The 24-hour window after most malicious publishes is the riskiest — let someone else find them first.
- Hash verification. Every tarball is rehashed against the registry's declared digest before it touches your build.
- Sigstore provenance. Optionally require verifiable registry provenance attestations, so the artifact provably came from a known publisher workflow.
- No silent downgrades. Once vouch has served a package at a given trust level (hash-only, hash+provenance, …), future requests for that package must clear at least the same bar — preventing a compromised maintainer from rolling back to a weaker verification posture.
- Audit log. Every decision (
allow,deny,allow_with_warning) is recorded with package, version, HTTP method + status code, cache outcome, and the verification result. Vouch doesn't run its own auth layer —client_identityisanonymousunless an upstream auth proxy populates it via the header named inaudit.client_identity_header(typicallyX-Forwarded-User).
| Capability | npm | PyPI | Maven | RubyGems | crates.io |
|---|---|---|---|---|---|
| Pull-through proxy | ✅ | ✅ | ✅ | ✅ | ✅ |
| Metadata URL rewriting (artifacts stay behind proxy) | ✅ | ✅ | — | — | ✅ (sparse config.json dl) |
| Hide policy-denied versions from metadata | ✅ | — | — | — | — |
| Cooldown policy on artifact fetches | ✅ | ✅ | ✅ (via search.maven.org) | ✅ (via rubygems.org API) | ✅ (via crates.io API) |
| Hash verification | ✅ (dist.integrity / dist.shasum) |
✅ (digests.sha256) |
✅ (.sha1 sidecar) |
✅ (compact-index checksum) |
✅ (sparse-index cksum) |
| Sigstore provenance verification | ✅ (opt-in) | ✅ (opt-in, PEP 740) | — | — | — |
| PEP 691 JSON Simple content negotiation | — | ✅ | — | — | — |
PEP 658 .metadata sidecars |
— | ✅ | — | — | — |
Conditional GET (If-None-Match, If-Modified-Since) |
✅ | ✅ | ✅ | ✅ | ✅ |
Range request support |
✅ | ✅ | ✅ | ✅ | ✅ |
| Cold cache (file / mem / S3 / GCS / Azure via gocloud.dev/blob) | ✅ | ✅ | ✅ | ✅ | ✅ |
Prometheus metrics (/metrics) |
✅ | ✅ | ✅ | ✅ | ✅ |
Health endpoints (/livez, /readyz) |
✅ | ✅ | ✅ | ✅ | ✅ |
Build identity (/version, vouch_build_info) |
✅ | ✅ | ✅ | ✅ | ✅ |
| Audit log (noop / JSONL / Postgres) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Best-trust-level tracking (deny on downgrade, noop / memory / Postgres) | ✅ | ✅ | ✅ | ✅ | ✅ |
| CEL-based policy bundles | ✅ | ✅ | ✅ | ✅ | ✅ |
┌──────────────────────── vouch ────────────────────────┐
│ │
npm / pip / │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌──────┐ │ ┌──────────────────────┐
mvn / gem ──►─┼─►│ adapter │──►│ policy │──►│ upstream │──►│ veri-│──┼──►│ registry.npmjs │
/ cargo │ │ (proto) │ │ (pre) │ │ client │ │ fier │ │ │ pypi.org │
install │ └─────────┘ └─────────┘ └──────────┘ └──────┘ │ │ repo1.maven.org │
│ │ │ ▲ │ │ │ index.rubygems.org │
│ ▼ ▼ │ ▼ │ │ index.crates.io │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│ └──────────────────────┘
│ │ native │ │ audit │ │ cold │ │ policy ││
│ │ response│ │ log │ │ cache │ │ (post) ││
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
└───────────────────────────────────────────────────────┘
The core owns orchestration. Ecosystem adapters only translate between the native registry protocol and a normalized PackageRequest — they never touch policy, cache, or audit.
go run ./cmd/vouch -config config.example.yamlnpm install --registry=http://127.0.0.1:18080/npm/ is-number@7.0.0Enable PyPI in your config (ecosystems.pypi.enabled: true), then:
pip install --index-url=http://127.0.0.1:18080/pypi/simple/ requests
# uv works the same; pip ≥22.3 and uv prefer PEP 691 JSON Simple — vouch
# content-negotiates and serves either form with the same URL rewriting.
UV_INDEX_URL=http://127.0.0.1:18080/pypi/simple/ uv pip install requestsMaven — point Maven at the proxy via a mirror declaration in ~/.m2/settings.xml (or a project-local file passed with -s):
<settings>
<mirrors>
<mirror>
<id>vouch</id>
<mirrorOf>central</mirrorOf>
<url>http://127.0.0.1:18080/maven/</url>
</mirror>
</mirrors>
</settings>mvn -s settings.xml dependency:get -Dartifact=org.slf4j:slf4j-api:1.7.36RubyGems:
gem fetch rake --source http://127.0.0.1:18080/rubygems/Cargo (requires cargo ≥ 1.68 for sparse-index support) — in ~/.cargo/config.toml or a project-local .cargo/config.toml:
[registries.vouch]
index = "sparse+http://127.0.0.1:18080/crates/"
[source.crates-io]
replace-with = "vouch"Vouch rewrites the sparse-index config.json dl field so cargo downloads .crate files through the proxy.
Go modules:
GOPROXY=http://127.0.0.1:18080/go/ go mod download golang.org/x/mod@v0.35.0The Go adapter mirrors the GOPROXY protocol used by https://proxy.golang.org: /@v/list, .info, .mod, .zip, and /@latest paths are passed through using Go's case-encoded module and version path elements. Vouch uses the .info timestamp as the publish time for cooldown checks on .zip downloads.
Pick a package version published in the last week and watch the proxy reject it:
$ curl -i http://127.0.0.1:18080/npm/some-pkg/-/some-pkg-1.2.3.tgz
HTTP/1.1 403 Forbidden
content-type: application/json
cache-control: public, max-age=233836
retry-after: 233836
x-content-type-options: nosniff
{"error":"package age is below cooldown; retry after 64h57m0s","reason":"package age is below cooldown; retry after 64h57m0s"}Retry-After (in seconds) lets compliant clients back off until the cooldown expires. Cache-Control: public, max-age=<same> aligns intermediate HTTP caches (CDN edges, browsers, corporate proxies) with the same deadline so the deny isn't served past its expiry.
The image bakes in a Docker-ready config that listens on 0.0.0.0:18080 and rewrites metadata URLs to http://127.0.0.1:18080. It is useful for a quick spin but not for production. Mount your own config to override:
docker build -t vouch:local .
docker run --rm \
-p 18080:18080 \
-v vouch-cache:/app/.cache \
vouch:localFor production, mount your own config at /etc/vouch/config.yaml and set server.public_url to the URL clients actually use.
Minimal config.yaml:
server:
listen_addr: "127.0.0.1:18080"
public_url: "http://127.0.0.1:18080"
ecosystems:
npm:
enabled: true
upstream: "https://registry.npmjs.org/"
path_prefix: "/npm/"
policy:
cooldown_days: 7
capabilities:
require_provenance: false
audit:
driver: "jsonl"
path: ".cache/audit/audit.jsonl"
cache:
object_store:
driver: "filesystem"
root: ".cache/objects"| Section | Key | Default | Notes |
|---|---|---|---|
server |
listen_addr |
127.0.0.1:8080 |
HTTP listen address |
server |
public_url |
derived from listen_addr |
Base URL used when rewriting upstream tarball URLs |
server |
read_timeout_seconds |
30 |
http.Server.ReadTimeout |
server |
write_timeout_seconds |
60 |
http.Server.WriteTimeout (tarballs can be slow) |
server |
idle_timeout_seconds |
5 |
http.Server.IdleTimeout |
server |
max_header_kb |
64 |
http.Server.MaxHeaderBytes |
server |
max_request_body_kb |
1024 |
Per-request body limit; serves 413 above this |
server |
drain_seconds |
5 |
After SIGTERM, time spent serving 503 on /readyz before http.Server.Shutdown |
server |
shutdown_seconds |
30 |
http.Server.Shutdown deadline after drain |
ecosystems.<name> |
enabled |
false |
One of npm, pypi, maven, rubygems, crates |
ecosystems.<name> |
upstream |
registry default | Upstream registry base URL |
ecosystems.<name> |
path_prefix |
/<name>/ |
Mount point on the proxy |
ecosystems.npm |
hide_denied_versions |
true |
Strip policy-denied versions from npm packument responses |
policy |
cooldown_days |
7 |
Block artifacts younger than this |
policy |
allow_unknown_publish_time |
false |
If false, deny when publish time can't be determined |
policy.capabilities |
require_provenance |
false |
Require Sigstore provenance for supported ecosystems (npm, pypi) |
policy.bundle |
path |
— | Path to a CEL policy bundle YAML file. Overrides the built-in default. |
policy.bundle |
inline |
— | CEL policy bundle YAML spliced into the config. Mutually exclusive with path. |
audit |
driver |
noop |
noop, jsonl, postgres |
audit |
path |
— | JSONL output path |
audit |
dsn |
— | Postgres DSN |
audit |
async |
false |
Wrap the recorder in a bounded-queue async writer; drops surface as vouch_audit_dropped_total |
audit |
queue_size |
1000 |
Async queue capacity |
audit |
batch_size |
50 |
Entries flushed per batch |
audit |
flush_interval_ms |
1000 |
Max wall time between flushes |
audit |
max_open_conns |
pgx default | Postgres pool max open connections |
audit |
max_idle_conns |
pgx default | Postgres pool max idle connections |
audit |
conn_max_idle_seconds |
pgx default | Postgres pool max idle time |
audit |
run_migrations |
false |
Apply embedded migrations against audit.dsn at startup |
audit |
retention_days |
0 |
Auto-prune audit_log rows older than this many days (hourly batched DELETEs). 0 disables. postgres driver only. |
audit |
client_identity_header |
— | Name of a request header an upstream auth proxy may set (e.g. X-Forwarded-User). When set and present, the header's value is recorded as the audit log's client_identity (truncated to 256 chars, control bytes stripped). Empty: every entry is anonymous. |
cache.object_store |
url |
— | gocloud.dev URL (file:///path, mem://, s3://bucket, gs://bucket, azblob://container). Takes precedence over driver/root. |
cache.object_store |
driver |
filesystem |
filesystem, noop |
cache.object_store |
root |
.cache/objects |
Filesystem root (used when driver=filesystem and url unset) |
cache |
metadata_ttl_seconds |
300 |
Metadata cache TTL |
cache |
ha_mode |
false |
Assert a shared cache backend. Startup fails when true and the driver is pod-local. |
trust_state |
driver |
noop |
noop, memory, postgres. When set, the proxy denies requests that would serve a package at a lower trust level than previously achieved. |
trust_state |
dsn |
— | Postgres DSN. Falls back to audit.dsn when empty. |
Every policy decision is evaluated by a CEL bundle. When policy.bundle is unset Vouch loads its built-in default bundle, which reproduces the historical hardcoded behavior (cooldown, unknown-publish-time, capability checks, trust-downgrade). Operators can override the bundle with a custom YAML file via policy.bundle.path or splice it inline with policy.bundle.inline.
A bundle is a list of rules per phase. The first rule whose when is true in a given phase wins; if none match, the request is denied with reason policy_no_match (fail closed).
pre_fetch:
- id: deny_evil
when: 'request.name.startsWith("evil-")'
decision: deny
reason: '"name on local blocklist"'
post_fetch:
- id: require_provenance
when: 'request.kind == "blob" && verification.provenance != "ok"'
decision: deny
reason: '"provenance is mandatory for this deployment"'Available CEL variables:
| Variable | Type | Notes |
|---|---|---|
request.{ecosystem,name,version,kind} |
string | kind is metadata / blob / other |
has_published_at |
bool | true when the registry returned a publish time |
published_at |
timestamp | only meaningful when has_published_at |
now |
timestamp | |
required.{hash,provenance} |
bool | populated by the pre-fetch decision |
verification.{hash,provenance} |
string | "ok" / "missing" / "invalid" / "unsupported" / "not_required" |
verification.trust_level |
int | 0 none, 1 hash ok, 2 provenance ok (only ok counts) |
verification.{signer_identity,subject_digest,failure_reason} |
string | |
cache_outcome |
string | bypass, miss, hit_cold (cold-cache state for this request) |
best_trust_level |
int | highest level previously observed for this package |
ecosystem_supports_provenance |
bool | true when Vouch has a provenance verifier for the request ecosystem |
config.{cooldown_days,allow_unknown_publish_time,require_provenance} |
mixed | static knobs from policy: |
All CEL programs are compiled at startup. Compile errors (bad expression, type mismatch, unknown decision) abort the process.
The cold cache is backed by gocloud.dev/blob. The default build registers file:// and mem://. To use S3, GCS, or Azure, build with -tags=cloud:
go build -tags=cloud ./cmd/vouchThen point cache.object_store.url at the bucket:
cache:
object_store:
url: "s3://my-vouch-cache?region=us-east-1"
# or "gs://my-vouch-cache"
# or "azblob://my-vouch-cache"See the gocloud.dev URL reference for query parameters each driver supports (region, prefix, credentials, etc.).
YAML values undergo os.ExpandEnv at load time: any ${VAR} or $VAR reference is replaced with the process environment before parsing. The Helm chart uses this to inject the Postgres DSN from a Secret rather than embedding credentials in values.yaml. The conventional env vars:
| Env var | YAML reference | Used when |
|---|---|---|
VOUCH_AUDIT_DSN |
audit.dsn: "${VOUCH_AUDIT_DSN}" |
audit.driver=postgres |
VOUCH_TRUST_STATE_DSN |
trust_state.dsn: "${VOUCH_TRUST_STATE_DSN}" |
trust_state.driver=postgres and a separate DSN is desired |
The chart renders these references automatically; operators wire the Secret name with audit.dsnSecretName.
When running 2+ replicas behind a load balancer, the cache MUST be a shared object store (S3 / GCS / Azure). The default filesystem driver is pod-local — different pods will see different cached state, producing inconsistent decisions for the same request. Set cache.ha_mode: true to make Vouch refuse to start with a pod-local backend; the Helm chart additionally refuses to render when replicaCount > 1 and the driver is pod-local.
GET /metrics— Prometheus metrics under thevouch_namespace:vouch_requests_total{ecosystem,kind,method,status_code}vouch_policy_decisions_total{ecosystem,kind,phase,decision,reason}vouch_verification_outcomes_total{ecosystem,kind,verification,status}vouch_cache_outcomes_total{ecosystem,kind,outcome}vouch_upstream_dedup_total{ecosystem,kind}(pre-registered at zero)vouch_audit_dropped_total{driver}(pre-registered at zero)vouch_audit_pruned_total{driver}(pre-registered at zero; incremented by the retention pruner)vouch_build_info{go,revision}— gauge always 1; labels carry the valuevouch_response_latency_seconds,vouch_upstream_latency_seconds— latency histograms
GET /livez— liveness probe (always 200;/healthzis a back-compat alias).GET /readyz— readiness probe. Reports 200 when registered dependency checks (Postgres audit, Postgres trust state) are passing; 503 during graceful drain (after SIGTERM) or when any check is failing. Body is JSON listing per-check status.GET /version— JSON with the running binary's Go runtime version plus, when built from a git tree, the VCSrevisionand committime. Amodifiedflag appears only when the working tree was dirty at build time. Sourced fromruntime/debug.BuildInfo(Go 1.18+ embeds VCS info automatically).- Structured JSON logs via
slogon stdout. Each request log carriesmethod,status_code,policy_decision,policy_reason,verification_result,cache_outcome,upstream_ms,request_ms. - Audit log per request — Postgres
audit_logtable includesmethod,status_code,cache_outcome,policy_decision, and the fullverificationjsonb. Seemigrations/000001_audit_log.sqlfor the schema. - Every response sets
X-Content-Type-Options: nosniffas defence-in-depth against MIME sniffing on tarballs/jars.
Vouch can emit OpenTelemetry spans over OTLP/gRPC for the full request path (HTTP handler → policy evaluation → upstream fetch → metadata/blob cache → hash & provenance verification → audit record). When a collector is unreachable the proxy still starts cleanly; spans are dropped silently.
otel:
enabled: true
endpoint: "localhost:4317" # OTLP/gRPC endpoint (host:port)
service_name: "vouch" # service.name resource attribute
sampler_ratio: 1.0 # 1.0 always, 0.0 never, else TraceIDRatioBased
insecure: true # disable TLS (local collectors)| Section | Key | Default | Notes |
|---|---|---|---|
otel |
enabled |
false |
Toggle tracing on/off |
otel |
endpoint |
localhost:4317 |
OTLP/gRPC collector address |
otel |
service_name |
vouch |
Reported as service.name |
otel |
sampler_ratio |
1.0 |
1.0 always, 0.0 never, otherwise TraceIDRatioBased |
otel |
insecure |
true |
Use plaintext gRPC (local dev) |
- Docker —
Dockerfileat repo root, builds with-tags=cloudfor S3/GCS/Azure support. - Kubernetes —
deploy/helm/chart. - Fly.io — a working example lives in
deploy/fly/; a public instance runs at https://vouch-proxy.fly.dev if you want to point a client at it.
go test ./... # fast unit tests
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
go test -tags=integration ./... # also hits live npm/pypi/maven/rubygems/crates (slow, network-dependent)
go test -tags=cloud ./internal/cache/ # exercises the s3/gcs/azure blob driver registration
go run ./cmd/vouch -config config.example.yamlIssues and PRs welcome. Run go test ./... and go run golang.org/x/vuln/cmd/govulncheck@latest ./... before opening a PR; see SECURITY.md for how to report vulnerabilities (please don't file public issues for those).
Apache License 2.0 — see LICENSE.