timReynolds/vouch
GitHub: timReynolds/vouch
Vouch是一个策略感知的包镜像和供应链验证代理,用于保护npm、PyPI等包管理器免受供应链攻击。
Stars: 1 | Forks: 0
# Vouch
[](https://github.com/timreynolds/vouch/actions/workflows/ci.yml)
[](https://pkg.go.dev/github.com/timreynolds/vouch)
[](https://goreportcard.com/report/github.com/timreynolds/vouch)
## 为什么选择 Vouch
大多数供应链攻击都利用刚发布的合法软件包版本:维护者的令牌泄露、域名抢注攻击、postinstall 脚本传播恶意软件。等到注册表下架有问题的版本时,你的 CI 可能已经安装了它。
Vouch 位于你的开发者/CI 和公共注册表之间,拒绝提供任何未达到你信任标准的软件包:
- **冷却期。** 阻止发布少于 _N_ 天的版本。大多数恶意软件发布后的前 24 小时风险最高——让别人先去发现它们。
- **哈希验证。** 每个压缩包在接触你的构建之前,都会根据注册表声明的摘要重新进行哈希验证。
- **Sigstore 来源证明。** 可选择要求可验证的注册表来源证明,以确凿地证明制品来自已知的发布者工作流。
- **无静默降级。** 一旦 Vouch 以特定信任级别(仅哈希、哈希+来源证明等)提供了某个软件包,对该软件包的后续请求必须至少通过相同的验证门槛——防止被攻陷的维护者回退到更弱的验证状态。
- **审计日志。** 每个决策(`allow`、`deny`、`allow_with_warning`)都会记录软件包、版本、HTTP 方法 + 状态码、缓存结果和验证结果。Vouch 不运行自己的认证层——除非上游认证代理通过 `audit.client_identity_header` 中指定的请求头(通常是 `X-Forwarded-User`)填充了 `client_identity`,否则其值为 `anonymous`。
## 功能
| 功能 | npm | PyPI | Maven | RubyGems | crates.io |
|---|---|---|---|---|---|
| 透传代理 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 元数据 URL 重写(制品留在代理后方) | ✅ | ✅ | — | — | ✅ (稀疏 `config.json` `dl`) |
| 从元数据中隐藏策略拒绝的版本 | ✅ | — | — | — | — |
| 制品获取时的冷却期策略 | ✅ | ✅ | ✅ (通过 search.maven.org) | ✅ (通过 rubygems.org API) | ✅ (通过 crates.io API) |
| 哈希验证 | ✅ (`dist.integrity` / `dist.shasum`) | ✅ (`digests.sha256`) | ✅ (`.sha1` 辅助文件) | ✅ (压缩索引 `checksum`) | ✅ (稀疏索引 `cksum`) |
| Sigstore 来源证明验证 | ✅ (可选) | ✅ (可选, PEP 740) | — | — | — |
| PEP 691 JSON 简单内容协商 | — | ✅ | — | — | — |
| PEP 658 `.metadata` 辅助文件 | — | ✅ | — | — | — |
| 条件 GET (`If-None-Match`, `If-Modified-Since`) | ✅ | ✅ | ✅ | ✅ | ✅ |
| `Range` 请求支持 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 冷缓存(文件/内存/S3/GCS/Azure 通过 [gocloud.dev/blob](https://gocloud.dev/howto/blob/)) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Prometheus 指标 (`/metrics`) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 健康检查端点 (`/livez`, `/readyz`) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 构建标识 (`/version`, `vouch_build_info`) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 审计日志 (noop / JSONL / Postgres) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 最佳信任级别跟踪(拒绝降级, noop/内存/Postgres) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 基于 CEL 的策略包 | ✅ | ✅ | ✅ | ✅ | ✅ |
## 架构
```
┌──────────────────────── 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) ││
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
└───────────────────────────────────────────────────────┘
```
核心负责编排。生态系统适配器只负责在原生注册表协议和规范化的 `PackageRequest` 之间进行翻译——它们不涉及策略、缓存或审计。
## 快速入门
### 本地运行
```
go run ./cmd/vouch -config config.example.yaml
```
### 通过代理安装 npm 包
```
npm install --registry=http://127.0.0.1:18080/npm/ is-number@7.0.0
```
### 通过代理安装 Python 包
在你的配置中启用 PyPI (`ecosystems.pypi.enabled: true`),然后:
```
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
```
### 其他生态系统
**Maven** — 通过 `~/.m2/settings.xml`(或使用 `-s` 传递的项目本地文件)中的镜像声明将 Maven 指向代理:
```
vouch
central
http://127.0.0.1:18080/maven/
```
```
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** (需要 cargo ≥ 1.68 以支持稀疏索引) — 在 `~/.cargo/config.toml` 或项目本地的 `.cargo/config.toml` 中:
```
[registries.vouch]
index = "sparse+http://127.0.0.1:18080/crates/"
[source.crates-io]
replace-with = "vouch"
```
Vouch 会重写稀疏索引 `config.json` 中的 `dl` 字段,使 cargo 通过代理下载 `.crate` 文件。
### 尝试冷却期
选择一个上周发布的软件包版本,观察代理拒绝它:
```
$ 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` (以秒为单位) 允许符合规范的客户端在冷却期结束前退避。`Cache-Control: public, max-age=` 使中间 HTTP 缓存 (CDN 边缘、浏览器、企业代理) 与相同的截止时间对齐,从而在过期后不再提供拒绝响应。
## Docker
镜像内嵌了一个 Docker 就绪的配置,监听 `0.0.0.0:18080` 并将元数据 URL 重写为 `http://127.0.0.1:18080`。适用于快速体验,但不适合生产环境。请挂载你自己的配置以覆盖:
```
docker build -t vouch:local .
docker run --rm \
-p 18080:18080 \
-v vouch-cache:/app/.cache \
vouch:local
```
对于生产环境,请将你自己的配置挂载到 `/etc/vouch/config.yaml` 并将 `server.public_url` 设置为客户端实际使用的 URL。
## 配置
最小化 `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"
```
| 配置节 | 键 | 默认值 | 说明 |
|---|---|---|---|
| `server` | `listen_addr` | `127.0.0.1:8080` | HTTP 监听地址 |
| `server` | `public_url` | 派生自 `listen_addr` | 用于重写上游压缩包 URL 的基础 URL |
| `server` | `read_timeout_seconds` | `30` | `http.Server.ReadTimeout` |
| `server` | `write_timeout_seconds` | `60` | `http.Server.WriteTimeout` (压缩包可能较慢) |
| `server` | `idle_timeout_seconds` | `5` | `http.Server.IdleTimeout` |
| `server` | `max_header_kb` | `64` | `http.Server.MaxHeaderBytes` |
| `server` | `max_request_body_kb` | `1024` | 每个请求体大小限制;超过此值返回 413 |
| `server` | `drain_seconds` | `5` | 收到 SIGTERM 后,在 `http.Server.Shutdown` 前在 `/readyz` 上提供 503 的时间 |
| `server` | `shutdown_seconds` | `30` | drain 后 `http.Server.Shutdown` 的截止时间 |
| `ecosystems.` | `enabled` | `false` | 生态系统之一:`npm`, `pypi`, `maven`, `rubygems`, `crates` |
| `ecosystems.` | `upstream` | 注册表默认值 | 上游注册表基础 URL |
| `ecosystems.` | `path_prefix` | `//` | 代理上的挂载点 |
| `ecosystems.npm` | `hide_denied_versions` | `true` | 从 npm packument 响应中剥离策略拒绝的版本 |
| `policy` | `cooldown_days` | `7` | 阻止发布时间早于此天数的制品 |
| `policy` | `allow_unknown_publish_time` | `false` | 如果为 false,当无法确定发布时间时拒绝 |
| `policy.capabilities` | `require_provenance` | `false` | 对于支持的生态系统 (`npm`, `pypi`) 要求 Sigstore 来源证明 |
| `policy.bundle` | `path` | — | CEL 策略包 YAML 文件路径。覆盖内置默认值。 |
| `policy.bundle` | `inline` | — | 拼接到配置中的 CEL 策略包 YAML。与 `path` 互斥。 |
| `audit` | `driver` | `noop` | `noop`, `jsonl`, `postgres` |
| `audit` | `path` | — | JSONL 输出路径 |
| `audit` | `dsn` | — | Postgres DSN |
| `audit` | `async` | `false` | 用有界队列的异步写入器包装记录器;丢弃数量显示为 `vouch_audit_dropped_total` |
| `audit` | `queue_size` | `1000` | 异步队列容量 |
| `audit` | `batch_size` | `50` | 每批刷新的条目数 |
| `audit` | `flush_interval_ms` | `1000` | 刷新之间的最大间隔时间 |
| `audit` | `max_open_conns` | pgx 默认值 | Postgres 连接池最大打开连接数 |
| `audit` | `max_idle_conns` | pgx 默认值 | Postgres 连接池最大空闲连接数 |
| `audit` | `conn_max_idle_seconds` | pgx 默认值 | Postgres 连接池最大空闲时间 |
| `audit` | `run_migrations` | `false` | 启动时对 `audit.dsn` 应用嵌入式迁移 |
| `audit` | `retention_days` | `0` | 自动修剪超过此天数的 `audit_log` 行 (每小时批量删除)。`0` 表示禁用。仅 `postgres` 驱动。 |
| `audit` | `client_identity_header` | — | 上游认证代理可能设置的请求头名称 (例如 `X-Forwarded-User`)。当设置且存在时,该请求头的值将记录为审计日志的 `client_identity` (截断至 256 字符,剥离控制字符)。为空时:每条记录的值为 `anonymous`。 |
| `cache.object_store` | `url` | — | gocloud.dev URL (`file:///path`, `mem://`, `s3://bucket`, `gs://bucket`, `azblob://container`)。优先于 `driver`/`root`。 |
| `cache.object_store` | `driver` | `filesystem` | `filesystem`, `noop` |
| `cache.object_store` | `root` | `.cache/objects` | 文件系统根目录 (当 `driver=filesystem` 且 `url` 未设置时使用) |
| `cache` | `metadata_ttl_seconds` | `300` | 元数据缓存 TTL |
| `cache` | `ha_mode` | `false` | 断言使用共享缓存后端。当设置为 true 且驱动程序是 Pod 本地时,启动失败。 |
| `trust_state` | `driver` | `noop` | `noop`, `memory`, `postgres`。设置后,代理将拒绝那些会使软件包信任级别低于先前所达到的级别的请求。 |
| `trust_state` | `dsn` | — | Postgres DSN。为空时回退到 `audit.dsn`。 |
### 策略包
每个策略决策都由一个 CEL 包进行评估。当 `policy.bundle` 未设置时,Vouch 会加载其内置默认包,该包重现了历史上的硬编码行为(冷却期、未知发布时间、能力检查、信任降级)。运维人员可以通过 `policy.bundle.path` 用自定义 YAML 文件覆盖该包,或通过 `policy.bundle.inline` 内联拼接。
包是按阶段列出的规则列表。在给定阶段,第一个 `when` 条件为真的规则生效;如果没有匹配的规则,请求将因 `policy_no_match` 原因而被拒绝(失败关闭)。
```
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"'
```
可用的 CEL 变量:
| 变量 | 类型 | 说明 |
|---|---|---|
| `request.{ecosystem,name,version,kind}` | string | `kind` 是 `metadata` / `blob` / `other` |
| `has_published_at` | bool | 当注册表返回发布时间时为 true |
| `published_at` | timestamp | 仅在 `has_published_at` 为真时有意义 |
| `now` | timestamp | |
| `required.{hash,provenance}` | bool | 由预获取决策填充 |
| `verification.{hash,provenance}` | string | `"ok"` / `"missing"` / `"invalid"` / `"unsupported"` / `"not_required"` |
| `verification.trust_level` | int | `0` 无, `1` 哈希通过, `2` 来源证明通过 (仅 `ok` 计数) |
| `verification.{signer_identity,subject_digest,failure_reason}` | string | |
| `cache_outcome` | string | `bypass`, `miss`, `hit_cold` (本次请求的冷缓存状态) |
| `best_trust_level` | int | 此软件包先前观察到的最高级别 |
| `ecosystem_supports_provenance` | bool | 当 Vouch 有该请求生态系统的来源证明验证器时为 true |
| `config.{cooldown_days,allow_unknown_publish_time,require_provenance}` | mixed | `policy:` 中的静态配置项 |
所有 CEL 程序在启动时编译。编译错误(表达式错误、类型不匹配、未知决策)将中止进程。
### 缓存后端
冷缓存由 [gocloud.dev/blob](https://gocloud.dev/howto/blob/) 支持。默认构建注册了 `file://` 和 `mem://`。要使用 S3、GCS 或 Azure,请使用 `-tags=cloud` 构建:
```
go build -tags=cloud ./cmd/vouch
```
然后将 `cache.object_store.url` 指向存储桶:
```
cache:
object_store:
url: "s3://my-vouch-cache?region=us-east-1"
# or "gs://my-vouch-cache"
# or "azblob://my-vouch-cache"
```
参见 [gocloud.dev URL 参考](https://gocloud.dev/concepts/urls/) 了解各驱动程序支持的查询参数(区域、前缀、凭据等)。
#### 通过环境变量传递密钥
YAML 值在加载时会经过 `os.ExpandEnv` 处理:任何 `${VAR}` 或 `$VAR` 引用都会在解析前替换为进程环境变量。Helm 图表使用此方法从 Secret 注入 Postgres DSN,而不是在 `values.yaml` 中嵌入凭据。常规环境变量:
| 环境变量 | YAML 引用 | 使用场景 |
|---|---|---|
| `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` 且需要单独 DSN 时 |
图表会自动渲染这些引用;运维人员通过 `audit.dsnSecretName` 配置 Secret 名称。
#### 高可用部署
当在负载均衡器后运行 2 个以上副本时,缓存必须是共享的对象存储(S3 / GCS / Azure)。默认的 `filesystem` 驱动程序是 Pod 本地的——不同的 Pod 会看到不同的缓存状态,导致对同一请求做出不一致的决策。设置 `cache.ha_mode: true` 可使 Vouch 拒绝使用 Pod 本地后端启动;此外,当 `replicaCount > 1` 且驱动程序为 Pod 本地时,Helm 图表也会拒绝渲染。
## 可观测性
- `GET /metrics` — 位于 `vouch_` 命名空间下的 Prometheus 指标:
- `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}` (预注册为零)
- `vouch_audit_dropped_total{driver}` (预注册为零)
- `vouch_audit_pruned_total{driver}` (预注册为零;由保留策略修剪器递增)
- `vouch_build_info{go,revision}` — 始终为 1 的 gauge;标签携带值
- `vouch_response_latency_seconds`, `vouch_upstream_latency_seconds` — 延迟直方图
- `GET /livez` — 存活探针 (始终 200;`/healthz` 是为了向后兼容的别名)。
- `GET /readyz` — 就绪探针。当已注册的依赖检查(Postgres 审计、Postgres 信任状态)通过时报告 200;在优雅排空期间(收到 SIGTERM 后)或任何检查失败时报告 503。响应体为 JSON,列出每个检查的状态。
- `GET /version` — JSON 格式,包含运行二进制的 Go 运行时版本,以及当从 git 树构建时,包含 VCS `revision` 和提交 `time`。只有在构建时工作树有未提交更改时才会出现 `modified` 标志。数据来源于 `runtime/debug.BuildInfo`(Go 1.18+ 自动嵌入 VCS 信息)。
- 通过 `slog` 在标准输出上输出结构化 JSON 日志。每个请求日志包含 `method`、`status_code`、`policy_decision`、`policy_reason`、`verification_result`、`cache_outcome`、`upstream_ms`、`request_ms`。
- 每个请求的审计日志 — Postgres `audit_log` 表包含 `method`、`status_code`、`cache_outcome`、`policy_decision` 以及完整的 `verification` jsonb。表结构参见 `migrations/000001_audit_log.sql`。
- 每个响应都设置 `X-Content-Type-Options: nosniff` 作为纵深防御,防止对压缩包/jar 文件进行 MIME 类型嗅探。
### 追踪
Vouch 可以通过 OTLP/gRPC 发出 OpenTelemetry span,覆盖完整的请求路径
(HTTP 处理器 → 策略评估 → 上游获取 → 元数据/制品缓存 →
哈希与来源证明验证 → 审计记录)。当收集器不可达时,
代理仍能正常启动;span 会被静默丢弃。
```
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)
```
| 配置节 | 键 | 默认值 | 说明 |
|---|---|---|---|
| `otel` | `enabled` | `false` | 开启/关闭追踪 |
| `otel` | `endpoint` | `localhost:4317` | OTLP/gRPC 收集器地址 |
| `otel` | `service_name` | `vouch` | 报告为 `service.name` |
| `otel` | `sampler_ratio` | `1.0` | `1.0` 始终采样, `0.0` 从不采样, 其他值基于 `TraceIDRatioBased` |
| `otel` | `insecure` | `true` | 使用明文 gRPC (本地开发) |
## 部署
- Docker — 仓库根目录的 `Dockerfile`,使用 `-tags=cloud` 构建以支持 S3/GCS/Azure。
- Kubernetes — [`deploy/helm/`](deploy/helm/) 图表。
- Fly.io — 一个工作示例位于 [`deploy/fly/`](deploy/fly/);如果你想将客户端指向它,可以在 访问一个公共实例。
## 开发
```
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
```
## 贡献
欢迎提交 Issue 和 PR。在发起 PR 前,请运行 `go test ./...` 和 `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`;如何报告漏洞请参阅 [`SECURITY.md`](SECURITY.md)(请勿为这些漏洞提交公开 Issue)。
## 许可证
Apache License 2.0 — 参见 [`LICENSE`](LICENSE)。
标签:crates.io验证, EVTX分析, Go模块安全, Go语言, Maven安全, npm代理, PyPI镜像, RubyGems代理, Sigstore来源, 代理服务, 供应链攻击防护, 冷却时间, 包镜像, 哈希验证, 子域名突变, 安全代理, 审计日志, 日志审计, 漏洞探索, 用户代理, 程序破解, 策略感知, 请求响应过滤, 请求拦截, 软件包安全, 软件包管理