timReynolds/vouch

GitHub: timReynolds/vouch

Vouch是一个策略感知的包镜像和供应链验证代理,用于保护npm、PyPI等包管理器免受供应链攻击。

Stars: 1 | Forks: 0

# Vouch [![CI](https://static.pigsec.cn/wp-content/uploads/repos/2026/05/d93351a13a071749.svg)](https://github.com/timreynolds/vouch/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/timreynolds/vouch.svg)](https://pkg.go.dev/github.com/timreynolds/vouch) [![Go Report Card](https://goreportcard.com/badge/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来源, 代理服务, 供应链攻击防护, 冷却时间, 包镜像, 哈希验证, 子域名突变, 安全代理, 审计日志, 日志审计, 漏洞探索, 用户代理, 程序破解, 策略感知, 请求响应过滤, 请求拦截, 软件包安全, 软件包管理