Skip to content

timReynolds/vouch

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

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages