Skip to content

Repository files navigation

Vouch

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.

CI Go Reference Go Report Card


Why Vouch

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_identity is anonymous unless an upstream auth proxy populates it via the header named in audit.client_identity_header (typically X-Forwarded-User).

Features

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

Architecture

                ┌──────────────────────── 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.

Quickstart

Run locally

go run ./cmd/vouch -config config.example.yaml

Install an npm package through the proxy

npm install --registry=http://127.0.0.1:18080/npm/ is-number@7.0.0

Install a Python package through the proxy

Enable 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 requests

Other ecosystems

Maven — 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.36

RubyGems:

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

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

Try the cooldown

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.

Docker

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

For production, mount your own config at /etc/vouch/config.yaml and set server.public_url to the URL clients actually use.

Configuration

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.

Policy bundles

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.

Cache backends

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/vouch

Then 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.).

Secrets via environment variables

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.

HA deployments

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.

Observability

  • GET /metrics — Prometheus metrics under the vouch_ 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 value
    • vouch_response_latency_seconds, vouch_upstream_latency_seconds — latency histograms
  • GET /livez — liveness probe (always 200; /healthz is 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 VCS revision and commit time. A modified flag appears only when the working tree was dirty at build time. Sourced from runtime/debug.BuildInfo (Go 1.18+ embeds VCS info automatically).
  • Structured JSON logs via slog on stdout. Each request log carries method, status_code, policy_decision, policy_reason, verification_result, cache_outcome, upstream_ms, request_ms.
  • Audit log per request — Postgres audit_log table includes method, status_code, cache_outcome, policy_decision, and the full verification jsonb. See migrations/000001_audit_log.sql for the schema.
  • Every response sets X-Content-Type-Options: nosniff as defence-in-depth against MIME sniffing on tarballs/jars.

Tracing

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)

Deployment

  • Docker — Dockerfile at repo root, builds with -tags=cloud for 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.

Development

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

Contributing

Issues 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).

License

Apache License 2.0 — see LICENSE.

About

Policy-aware package mirror and supply-chain verification proxy for npm, PyPI, Maven, RubyGems, crates.io, and Go modules

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages