jwang996/runtime-vulnerability-scan
GitHub: jwang996/runtime-vulnerability-scan
轻量级自托管的 Docker 主机运行时漏洞扫描器,通过 cron 定时调用 Trivy 扫描所有运行容器镜像并输出结构化 JSON 日志。
Stars: 1 | Forks: 0
# runtime-vul-detect
[](https://github.com/jwang996/runtime-vulnerability-scan/actions/workflows/checks.yaml)
[](https://github.com/jwang996/runtime-vulnerability-scan/actions/workflows/verify.yaml)
[](https://github.com/jwang996/runtime-vulnerability-scan/actions/workflows/release.yaml)









一个轻量级、自托管的 Docker 主机漏洞扫描器。它会发现主机上所有容器中正在使用的每个 image,通过 cron 定时任务使用 [Trivy](https://github.com/aquasecurity/trivy) 对它们进行扫描,并输出结构化的 JSON 日志 —— 随时准备被 [Grafana Alloy](https://grafana.com/docs/alloy/latest/)、Promtail 或任何输入到 Loki 或类似后端的日志收集器收集。
## 什么是漏洞?
**软件漏洞** 是程序中的一个弱点或缺陷,可被利用以破坏系统安全。业界通过一套众所周知的标准来跟踪它们:
| 术语 | 含义 |
|---|---|
| **CVE** | Common Vulnerabilities and Exposures(通用漏洞披露)—— 分配给每个已知缺陷的唯一标识符(例如 `CVE-2021-44228`)。由 [MITRE](https://www.cve.org/) 编目。 |
| **NVD** | National Vulnerability Database(国家漏洞数据库)—— NIST 提供的丰富 CVE 数据源,包含 CVSS 分数、受影响的 package 范围和补丁可用性。 |
| **CVSS** | Common Vulnerability Scoring System(通用漏洞评分系统)—— 反映可利用性和影响的 0–10 分数。分数 ≥ 9.0 为**严重**,7.0–8.9 为**高危**。 |
| **SCA** | Software Composition Analysis(软件成分分析)—— 将软件构件(container image、lock file)与已知的 CVE 数据库进行比对扫描,以发现存在漏洞的依赖项。 |
Trivy 对每个 container image 内部的 OS package 层和语言生态系统 package(pip、npm、gem 等)执行 SCA,将安装的版本与 NVD 和供应商公告进行匹配。
## 为什么选择 runtime-vul-detect?
Container image 会随着时间的推移积累 CVE。在构建时干净的 base image,当其某个 package 被发布了新的 CVE 后,几天后可能就会变得脆弱。大多数团队只在 CI 阶段进行扫描 —— 当漏洞被披露时,image 已经在生产环境中运行,且没有任何程序会重新检查它。
### 市场格局
| 工具 | 自托管 | 独立 Docker | 定时任务 | 结构化日志 | 备注 |
|---|---|---|---|---|---|
| **runtime-vul-detect** | 是 | 是 | 是 | JSON | 本项目 |
| [Trivy CLI](https://github.com/aquasecurity/trivy) | 是 | 是 | 否 | JSON | 优秀的扫描器;无 scheduler,无去重,无日志传送 |
| [Grype](https://github.com/anchore/grype) | 是 | 是 | 否 | JSON | 强大的替代扫描器(Go binary);与 Trivy CLI 存在相同缺口 |
| [Trivy Operator](https://github.com/aquasecurity/trivy-operator) | 是 | **否 — 仅限 k8s** | 是 | CRD 事件 | 基于 Kubernetes CRD;需要 cluster |
| [Kubescape](https://kubescape.io/) | 是 | **否 — 仅限 k8s** | 是 | – | 底层使用 Grype;与 cluster 耦合 |
| [Docker Scout](https://docs.docker.com/scout/) | 否 (SaaS) | 是 | 否 | – | 仅限云端;免费层有限 |
| [Snyk Container](https://snyk.io/product/container-vulnerability-management/) | 否 (SaaS) | 是 | 否 | – | 需将数据发回 Snyk 云端进行分析 |
| [Clair](https://github.com/quay/clair) | 是 | 是 | 否 | API | 优先考虑 Registry 的微服务;需要 Postgres;无 CLI |
| [Aqua Security](https://www.aquasec.com/) | 是 (付费) | 是 | 是 | – | 基于 Trivy 构建的商业平台;重量级 |
| [Falco](https://falco.org/) | 是 | 是 | – | JSON | runtime 行为检测,而非 CVE 扫描 |
**市场缺口**:对于独立的 Docker 主机(没有 Kubernetes),市面上没有一款轻量级的自托管工具能够开箱即用地结合定时任务、image 去重和结构化日志输出。runtime-vul-detect 填补了这一空白。未来的 Kubernetes 支持已列入路线图,届时它将与 Trivy Operator 并驾齐驱,但运营模式更简单(无需 CRD)。
## 架构
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Docker host │
│ │
│ ┌──────────────────────┐ tcp:2375 ┌──────────────────────────────┐ │
│ │ runtime-vul-detect │◄────────────│ docker-socket-proxy │ │
│ │ │ GET-only │ wollomatic/socket-proxy:1.12.1 │ │
│ │ APScheduler │ allowlist │ │ │
│ │ ├── scan (cron) │ │ UID 65534 (nobody:nobody) │ │
│ │ └── db-update │ │ read-only rootfs │ │
│ │ │ │ │ ALL capabilities dropped │ │
│ │ ▼ │ └──────────────┬───────────────┘ │
│ │ Trivy binary │ │ │
│ │ │ │ /var/run/docker.sock (ro) │
│ │ ▼ │ │
│ │ JSON log lines ────┼──► stdout │
│ └──────────────────────┘ │ │
│ ▼ │
│ Grafana Alloy / Promtail │
│ │ │
│ ▼ │
│ Loki / SIEM │
└──────────────────────────────────────────────────────────────────────────┘
```
### Docker socket 代理
扫描器永远不会直接挂载 `/var/run/docker.sock`。相反,一个 `wollomatic/socket-proxy` sidecar 持有真正的 socket,并在仅限内部的 Docker bridge 网络(`socket-proxy`,`internal: true`)上暴露一个 TCP endpoint。
**为什么这很重要**:Docker socket 实际上等同于主机上的 root 权限 —— 任何能够对其进行写入操作的进程都可以启动特权 container、挂载主机文件系统或直接逃逸到主机上。如果扫描器被攻破(例如通过恶意的 Trivy DB 或依赖项中的 CVE),它将拥有完整的 daemon 访问权限。代理则彻底消除了这一风险途径。
**代理强制执行的限制:**
| 控制项 | 详情 |
|---|---|
| 协议 | 仅限 TCP;不对扫描器暴露 Unix socket |
| 方法 | 仅限 `GET` —— 没有 `POST`、`DELETE`、`PUT` |
| Endpoint | `containers/json`、`images/sha256:.../json`、`version`、`/_ping` —— 其他所有请求均返回 403 |
| 网络 | `internal: true` bridge —— 代理 container 没有互联网路由 |
| 权限 | UID 65534 (nobody),丢弃所有 Linux capabilities,只读 rootfs |
即使扫描器 container 被完全攻破,攻击者也只能枚举 container 名称和 image SHA —— 他们无法在主机上启动、停止、执行或删除任何内容。
## 资源使用情况
| 资源 | 空闲时 | 扫描期间 | 备注 |
|---|---|---|---|
| **内存 (RSS)** | ~60–80 MB | ~300–500 MB 峰值 | Python 解释器和库在空闲时;Trivy 每次调用加载其 DB(~250 MB) |
| **CPU** | ~0% | 单核 100%(每个 image 5–30 秒) | Trivy 是 Go binary —— 扫描对 CPU 消耗很高效;否则 Python orchestrator 处于空闲状态 |
| **磁盘** | ~250 MB | – | Python 3.13-alpine + Trivy binary + Cython `.so` 文件 |
| **磁盘 (缓存卷)** | ~300–500 MB | – | Trivy 漏洞 DB + layer cache;随着 DB 更新缓慢增长 |
| **网络** | 0 | DB 更新时 ~50–100 MB | 从 GitHub 增量下载 DB;image 扫描仅使用本地 Docker socket |
| **Docker socket** | 未挂载 | 未挂载 | 通过 socket 代理 sidecar 访问;扫描器从不直接触碰 socket |
| **Socket 代理** | ~2–4 MB | ~2–4 MB | `wollomatic/socket-proxy:1.12.1`;distroless,UID 65534,丢弃所有 caps;64 MB 内存限制 |
**Cython 的影响**:将 `.py` 编译为 `.so` 稍微减少了 container 启动时间(导入时无需进行字节码编译),但并不能实质性地减少稳态内存,因为 Python 解释器及其 C 扩展基础设施依然存在。其主要好处是保护了源代码。
**Trivy 子进程开销**:每次扫描都会生成一个 `trivy image` 子进程。Trivy 在每次调用时都会加载其漏洞 DB(从磁盘读取约 100 MB)。对于运行许多不同 image 的主机,持久的 Trivy server 模式(`trivy server`)可以分摊此成本 —— 已计划作为未来的优化项。
## 快速开始
```
git clone
cd runtime-vul-detect
chmod +x *.sh && ./up.sh python # or: ./up.sh go
```
所有的编排脚本都位于**仓库根目录**(这样 `python/` 和 `go/`
文件夹就只保留纯粹的代码和 Docker 资产)。`./up.sh ` 会构建 image,
启动 container,等待 Docker healthcheck 通过,然后输出日志。
使用 `./down.sh python`(或 `go`)停止它 —— 添加 `--purge` 可以删除缓存
卷。`up.sh` 和 `down.sh` 作用于单个变体;`build.sh`、
`scan_vul.sh`、`test_up.sh` 和 `verify_metrics.sh` 也支持 `all`。
Trivy 版本固定在 `/docker-compose.yml` 中的
`build.args.TRIVY_VERSION` 下。在此处更新它以进行升级。运行 `./build.sh python`
(或 `go` / `all`)重新构建并立即扫描生成的 image 以查找
漏洞。
## 实现方式:Python(主要)与 Go
该项目为同一个扫描器提供了**两种可互换的实现**。它们具有相同的行为、配置(`python/config/config.yaml` + `python/config/ignore.yaml`)和 JSON 日志 schema —— 选择适合您运营限制的即可。
| | **Python**(主要 / 参考) | **Go**(替代) |
|---|---|---|
| 源码 | [`python/`](python/) — `src/`、`tests/` | [`go/`](go/) — `cmd/`、`internal/` |
| 基础/运行时 | `python:3.13-alpine`,Cython 编译的 `.so` | `gcr.io/distroless/static-debian12:nonroot`,单一静态 binary |
| Scheduler | APScheduler | `robfig/cron/v3` (`SkipIfStillRunning`) |
| Docker 访问 | `docker` SDK | 通过 Unix socket 使用 Go 标准库 `net/http`(无 SDK,无 `containerd`/`otel` 依赖) |
| 日志 | `python-json-logger` | `log/slog`(标准库) |
| 发布的 tag | `ghcr.io/jwang996/runtime-vul-scanner:1.0.0-python-alpine` | `ghcr.io/jwang996/runtime-vul-scanner:1.0.0-go-distroless` |
| Image 大小 | ~250 MB | ~30–50 MB |
| 空闲内存 | ~60–80 MB | ~8–15 MB |
**Python 仍然是主要实现** —— 它是行为和文档的参考标准。Go 版本是一个直接可用的替代方案,适用于需要最小化 distroless image、更小攻击面(无 shell、无 package 管理器、无 Docker SDK 依赖树)以及更低的内存/启动成本的环境。
使用根目录下的脚本驱动任一实现 —— 变体文件夹仅包含
源码 + Docker 资产:
```
chmod +x *.sh
./up.sh python # build, start, wait for healthy, tail logs (one variant)
./build.sh python # rebuild + Trivy-scan the produced image (or: go / all)
./test_up.sh all # containerised unit tests + coverage gate (both variants)
./verify_metrics.sh all # bring up with all /metrics protections and assert them
./verify_webhook.sh all # bring up against the vulnerable target and assert webhook alerts
./down.sh python # stop the variant (add --purge to drop volumes)
```
`up.sh` 和 `down.sh` 作用于**单个**变体(`python` 或 `go`) —— 由于两者
共享 container 名称并绑定9090 端口,因此不需要也不支持同时
运行两者。`build.sh`、`scan_vul.sh`、`test_up.sh` 和
`verify_metrics.sh` 支持 `python | go | all`。
两种实现都在 CI 中独立进行构建、扫描(`Image Scan` workflow)、单元测试和发布(`CI` workflow)。Go 版本中的测试文件是同名的 `_test.go` 文件(Go 惯例),并且**绝不**会被编译到 runtime binary 中。
## 配置
所有的扫描器行为都由两个 YAML 文件控制,这些文件以只读方式挂载到 container 内部的 `/config/` 目录中。这两个文件开箱即用,提供了合理的默认值。
**热重载:** 扫描器每 15 秒轮询一次配置和忽略文件,并应用更改,**无需重启 container**。编辑 `scan_cron`、`db_update_cron`、`severity`、`ignore_files`、`db_update` 或 `timezone` 会就地重新调度正在运行的任务(会输出一行 `Config reloaded` 日志)。无效的新配置(无法解析的 YAML 或错误的 cron/timezone)会被记录为错误,并继续使用先前的配置运行。`scan_on_start`、`metrics_*` 设置、`webhook_*` 设置和 `events_*` 设置仅在启动时应用;更改这些设置需要重启。
### `python/config/config.yaml` — 扫描器设置
```
# 用于 cron schedule 解释的 IANA timezone
# 完整列表:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
timezone: "Europe/Berlin"
# 扫描所有 image 的频率(标准 5 字段 cron,使用上述 timezone)
scan_cron: "*/5 * * * *"
# 刷新 Trivy vulnerability database 的频率
db_update_cron: "0 0 * * *"
# 包含在结果中的严重级别 — 低于此阈值的条目将被静默丢弃
# 选项:CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
severity: "CRITICAL,HIGH"
# 自动保持 Trivy CVE database 为最新
db_update: true
# 在 container 启动时立即运行完整扫描(在第一次 cron 触发之前)
scan_on_start: true
# Trivy cache directory — 必须与 docker-compose.yml 中的 volume mount 匹配
cache_dir: "/tmp/trivy-cache"
# 启动时加载的 ignore 文件 — 多个文件将被合并;重复项将作为 error 记录
ignore_files:
- "/config/ignore.yaml"
# Prometheus /metrics endpoint(严重级别计数作为 gauges + 扫描元数据)
# 在启动时应用;更改这些需要重启 container
metrics_enabled: true
metrics_port: 9090
```
| 键 | 默认值 | 描述 |
|---|---|---|
| `timezone` | `Europe/Berlin` | 用于解释两个 cron 表达式的 IANA 时区字符串 |
| `scan_cron` | `0 * * * *` | 扫描所有 image 的频率(默认为每小时) |
| `db_update_cron` | `0 0 * * *` | 拉取最新 CVE 数据库的频率(默认为每天) |
| `severity` | `CRITICAL,HIGH` | 包含哪些严重级别;较低级别在记录日志前会被过滤掉 |
| `db_update` | `true` | 切换自动数据库更新;在气隙隔离环境下禁用 |
| `scan_on_start` | `true` | 在启动时立即扫描,而不是等待第一次 cron 触发 |
| `cache_dir` | `/tmp/trivy-cache` | Trivy 的本地数据库和 layer cache |
| `ignore_files` | `[/config/ignore.yaml]` | 忽略配置路径列表;所有路径在启动时合并 |
| `metrics_enabled` | `true` | 提供 Prometheus `/metrics` endpoint;设置为 `false` 以禁用 HTTP server |
| `metrics_port` | `9090` | container 内部 `/metrics` endpoint 监听的端口 |
| `metrics_basic_auth_user` | `""` | 设置后(连同密码一起)在 `/metrics` 上要求 HTTP Basic auth |
| `metrics_basic_auth_password` | `""` | Basic auth 的密码 |
| `metrics_tls_cert_file` | `""` | PEM 证书路径;与密钥一起使用时,通过 HTTPS 提供 `/metrics` 服务 |
| `metrics_tls_key_file` | `""` | TLS 证书的 PEM 私钥路径 |
| `metrics_tls_client_ca_file` | `""` | 要求并验证客户端证书的 CA bundle(双向 TLS) |
| `webhook_enabled` | `false` | 针对新出现发现的 webhook 警报的选填主开关;`false` = 完全没有 HTTP(纯监控) |
| `webhook_url` | `""` | 接收 POST 请求的 HTTP(S) endpoint |
| `webhook_timeout_seconds` | `10` | 以整数秒为单位的单次请求 HTTP 超时时间 |
| `webhook_auth_header` | `""` | 附加到每个 POST 请求的可选 HTTP 头**名称**(例如 `Authorization`) |
| `webhook_auth_token` | `""` | 可选的密钥值;仅当**两者**都非空时作为 `: ` 发送(绝不记录日志) |
| `events_enabled` | `false` | 通过 Docker `/events` 流进行事件驱动扫描的选填主开关;`false` = 完全没有 `/events` 连接(纯粹的 cron 行为) |
| `events_debounce_seconds` | `5` | 在每次针对不同 ref 触发一次定向扫描之前,合并事件 image ref 的空闲时间窗口 |
### `python/config/ignore.yaml` — 抑制已知的非问题
使用此文件将您环境中不可利用的 CVE 加入白名单,或者完全跳过对特定 image 的扫描。
```
# 完全跳过这些 image — 支持 fnmatch 通配符
images:
# - "busybox:*" # skip all busybox tags
# - "internal-tool:dev" # skip a specific dev image
# 抑制特定的 CVE — 使用 Trivy 的原生 .trivyignore.yaml 格式
# 参见:https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
vulnerabilities:
# - id: CVE-2021-44228
# statement: "log4j not reachable — no Java runtime in this image"
# expired_at: 2027-01-01 # optional: unquoted YAML date; re-surfaces after this date
#
# - id: CVE-2023-1234
# paths:
# - usr/local/lib/python3.13/site-packages/requests
# statement: "requests not exposed to untrusted input"
```
您可以通过在 `config.yaml` 中的 `ignore_files` 下列出多个忽略文件来提供它们。如果同一个 CVE ID 或 image 模式出现在多个文件中,启动时会输出一条 `ERROR` 日志 —— 这使得跨文件冲突能被立即发现,而不是以未定义的顺序默默地被覆盖。
## 日志输出
每次扫描结果都会作为单行 JSON 输出到 stdout:
```
{
"timestamp": "2026-05-28T12:00:00Z",
"level": "info",
"logger": "runtime-vul-detect",
"message": "scan_result",
"event": "scan_result",
"image": "nginx:1.21",
"image_id": "sha256:abc123...",
"containers": ["a1b2c3d4e5f6", "b2c3d4e5f6a1"],
"total_vulnerabilities": 3,
"severity_counts": {
"CRITICAL": 1,
"HIGH": 2,
"MEDIUM": 0,
"LOW": 0,
"UNKNOWN": 0
},
"vulnerabilities": [
{
"id": "CVE-2021-1234",
"package": "openssl",
"version": "1.1.1k-r0",
"fixed_version": "1.1.1l-r0",
"severity": "CRITICAL",
"title": "Memory corruption in openssl",
"target": "alpine:3.15"
}
]
}
```
`containers` 列出了共享该 image 的所有 container 短 ID,因此您可以将发现追踪到具体运行的工作负载上。
### 使用 Grafana Alloy 进行收集
```
// Discover the scanner container and forward its stdout to Loki
loki.source.docker "scanner" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
forward_to = [loki.process.parse_json.receiver]
labels = { job = "vulnerability-scanner" }
}
// Parse the JSON and promote scan fields as structured metadata
loki.process "parse_json" {
stage.json {
expressions = {
event = "event",
image = "image",
total_vulns = "total_vulnerabilities",
critical = "severity_counts.CRITICAL",
}
}
stage.labels {
values = { image = "", event = "" }
}
forward_to = [loki.write.default.receiver]
}
```
因为每一行都是有效的 JSON,Loki 的 `json` 解析器可以将 `severity_counts`、`image` 和 `total_vulnerabilities` 提取为结构化字段或[指标值](https://grafana.com/docs/loki/latest/query/metric_queries/),而无需使用正则表达式。
## Prometheus 指标
除了 JSON 日志之外,扫描器还在
`GET /metrics` 提供一个 Prometheus 展示 endpoint(默认启用;参见 `metrics_enabled` / `metrics_port`)。它
仅在主机 loopback 上发布 —— 在 `docker-compose.yml` 中为 `127.0.0.1:9090` —— 因此
不会暴露在公共接口上。Python 和 Go 实现均
输出字节完全相同的输出:
```
# TYPE runtime_vul_severity_count gauge
runtime_vul_severity_count{image="nginx:1.21",severity="CRITICAL"} 1
runtime_vul_severity_count{image="nginx:1.21",severity="HIGH"} 2
runtime_vul_severity_count{image="nginx:1.21",severity="MEDIUM"} 0
runtime_vul_severity_count{image="nginx:1.21",severity="LOW"} 0
runtime_vul_severity_count{image="nginx:1.21",severity="UNKNOWN"} 0
# TYPE runtime_vul_last_scan_timestamp_seconds gauge
runtime_vul_last_scan_timestamp_seconds 1749081600
# TYPE runtime_vul_images_scanned gauge
runtime_vul_images_scanned 3
# TYPE runtime_vul_scan_errors_total counter
runtime_vul_scan_errors_total 0
```
| 指标 | 类型 | 标签 | 含义 |
|---|---|---|---|
| `runtime_vul_severity_count` | gauge | `image`、`severity` | 最近一次扫描中每个 image 和严重级别发现的漏洞。完整的每个 image 的集合会在每个周期被原子性地替换,因此不再运行的 image 会消失 —— 没有过时的数据系列。 |
| `runtime_vul_last_scan_timestamp_seconds` | gauge | – | 上次完成扫描周期的 Unix 时间戳。使用 `time() - runtime_vul_last_scan_timestamp_seconds` 对陈旧度进行告警。 |
| `runtime_vul_images_scanned` | gauge | – | 上个周期扫描的 image 数量。 |
| `runtime_vul_scan_errors_total` | counter | – | 自启动以来每个 image 扫描错误的单调计数。 |
该展示是基于标准库 HTTP server 手动渲染的 —— 任何一个 image 都没有引入客户端
库,从而使 distroless Go binary 和
经过强化的 Alpine Python image 保持无依赖状态。
### 保护 endpoint
保护是**分层且可选的** —— 组合适合您抓取路径的层级。
所有选项均在启动时应用(更改它们需要重启);
配置错误(例如不可读的证书)会被记录下来,扫描器会继续保持 endpoint 禁用状态运行,
而不是崩溃。
| 层级 | 配置 | 效果 |
|---|---|---|
| **Basic auth** | `metrics_basic_auth_user` + `metrics_basic_auth_password` | 没有有效的 HTTP Basic 凭据,`/metrics` 返回 `401`(以恒定时间进行比较)。 |
| **TLS** | `metrics_tls_cert_file` + `metrics_tls_key_file` | 提供 HTTPS 服务。当抓取器设置 `insecure_skip_verify` / `tls_config.insecure_skip_verify: true`(“tls verify=false”)时,自签名证书即可工作;CA 签名的证书允许抓取器完全验证该证书。 |
| **双向 TLS** | 添加 `metrics_tls_client_ca_file` | 要求并验证由给定 CA 签名的客户端证书(`RequireAndVerifyClientCert`) —— 最强的一级。 |
```
# 示例:TLS + Basic auth(certs 以只读方式挂载在 /config 下)
metrics_basic_auth_user: "prometheus"
metrics_basic_auth_password: "change-me"
metrics_tls_cert_file: "/config/tls/metrics-cert.pem"
metrics_tls_key_file: "/config/tls/metrics-key.pem"
# metrics_tls_client_ca_file: "/config/tls/client-ca.pem" # 为 mutual TLS 添加
```
一个匹配的 Prometheus 抓取配置:
```
scrape_configs:
- job_name: runtime-vul-detect
scheme: https
basic_auth: { username: prometheus, password: change-me }
tls_config: { insecure_skip_verify: true } # or `ca_file:` for full validation
static_configs: [ { targets: ["127.0.0.1:9090"] } ]
```
`./verify_metrics.sh [python|go|all]`(仓库根目录)是一个集成检查,
它会启用所有三个层级启动一个变体 —— 生成一个一次性的 PKI ——
并断言 endpoint 强制执行 Basic auth、TLS 和双向 TLS。所有生成的
材料都位于 `/config/tls/` 下(被 gitignore 忽略)并在退出时删除。
## 技术评估
### Distroless 基础 image
Google 的 `gcr.io/distroless/python3-debian12` 对于此项目而言,**不能作为 `python:3.13-alpine` 的直接替代品**。主要障碍:
| 问题 | 详情 |
|---|---|
| Python 版本 | Distroless 固定在 Python 3.11(Debian 12 package)。不存在官方的 3.12/3.13 变体([issue #1703](https://github.com/GoogleContainerTools/distroless/issues/1703))。 |
| ctypes 缺失 | 基础的 `python3-debian12` image 不包含 ctypes,而 Cython 编译的 `.so` 文件可能依赖于此。`python3-plus` 变体添加了它,但也添加了 shell。 |
| musl vs glibc | 我们目前使用的 Alpine 基础使用 musl libc。针对 musl 编译的 Cython `.so` 文件无法在 glibc(Debian)环境中运行 —— 需要全面重新构建。 |
**结论**:Distroless Python 将需要将整个构建链从 Alpine/musl 切换到 Debian/glibc,固定到 Python 3.11,并验证 Cython 的兼容性。如今,通过 Cython 剥离 `.py` 源码并利用 Alpine 极简的攻击面,已经部分实现了安全收益(无 shell、无 package 管理器)。等 distroless 官方支持 Python 3.13 时再作考虑。
### Go 迁移
用 Go 重写 orchestrator **具有长远的重大意义,但在今天并非迫在眉睫**。
**Go 的具体好处:**
| 维度 | Python(当前) | Go(未来) |
|---|---|---|
| Container image | ~250 MB | ~30–50 MB(单一静态 binary) |
| 空闲时内存 | ~60–80 MB | ~8–15 MB |
| 启动时间 | ~500 ms | ~20 ms |
| 交叉编译 | 需要单独的环境 | `GOOS=linux GOARCH=arm64 go build` |
| 运行时依赖 | 需要 Python 解释器 | 无(静态链接) |
**Trivy Go 库**:Trivy 暴露了内部 package(`pkg/commands/artifact`、`pkg/fanal/artifact/image`),可以直接调用而无需子进程,从而消除了每次扫描的进程 fork 和 DB 重新加载的开销。但是,Trivy 的内部 API 并未被声明为稳定的 —— 升级需要修改代码。一种更安全的方法是使用 Trivy 的 `--server` 模式(gRPC),它提供了一个稳定的接口,同时将 Trivy 保留为一个独立的进程。
**实际工作量**:一个通过 shell 调用 Trivy binary 的最小化 Go 重写(等同于今天 Python 的行为)需要 1–2 周。将 Trivy 作为 Go 库嵌入,并进行适当的 DB 缓存和 gRPC server 集成需要 4–8 周。子进程的方法风险较低,并能跟上 Trivy CLI 契约的步伐。
**建议**:在添加 Kubernetes 支持时进行迁移 —— Go 的 `client-go` 和 Kubernetes informer 模式在适应 cluster 级别的工作负载监控方面,明显比 Python 的 `kubernetes` 客户端更具优势。
## 路线图
| 功能 | 状态 |
|---|---|
| 带有 JSON 日志输出的核心 cron 扫描 | 已完成 |
| `docker ps -a` image 发现(运行中 + 已停止) | 已完成 |
| 具有启动重复检测的多文件忽略 | 已完成 |
| 配置驱动的时区 (IANA) | 已完成 |
| Cython 编译的生产级 image(runtime 无 `.py` 源码) | 已完成 |
| 带有注入 logger 的单元测试套件(无需 patching) | 已完成 |
| Go 实现 —— distroless image,标准库 Docker 客户端(无 SDK) | 已完成 |
| Docker socket 代理 —— 仅限 GET 白名单,内部 bridge 网络,无直接 socket 挂载 | 已完成 |
| Prometheus `/metrics` endpoint —— 作为 gauge 的严重级别计数 | 已完成 |
| 针对新 CRITICAL 发现的 Webhook / 警报 | 已完成(可选) |
| 私有 Registry 支持(`TRIVY_USERNAME` / `TRIVY_PASSWORD`) | 已计划 |
| 文件更改时热重载配置,无需重启 container | 已完成 |
| image 拉取时的事件驱动扫描(订阅 Dockerevents` API) | 已完成(可选) |
| `scan_result` 日志行中的 Docker Compose 项目/服务标签 | 已计划 |
| container 重启间的扫描状态持久化(消除重启时的 webhook 误报) | 已计划 |
| 多架构 image 构建(`linux/amd64` + `linux/arm64`) | 已计划 |
| Kubernetes 支持(监控 Pod,通过 k8s API 扫描 image) | 已计划 |
| 使用 Trivy server 模式的 Go 重写(降低每次扫描的开销) | 评估中 |
## 针对新发现的 Webhook 警报
默认情况下,扫描器是**仅检测模式** —— 每个周期都会输出完整的发现集合
作为 JSON,对结果采取的行动(寻呼、开 ticket、Slack)被委派给
下游流水线(Alloy → Loki 告警规则,或 SIEM)。在此之上,一个
**可选的 webhook** 会对*新出现的* CRITICAL/HIGH 发现触发原生警报:
扫描器会在内存中保留上一周期为每个 image 看到的 CVE ID 集合,
将当前发现与其进行 diff,并仅 POST 自上次扫描以来的**新** CVE ——
因此,对于长时间运行的 image,新披露的严重漏洞会触发
通知,而无需先连接日志后端。
Webhook **默认禁用**(`webhook_enabled: false`)→ 行为与
今天的完全相同,零 HTTP。启用后,结果将与*现有的* `severity` 过滤器进行 diff
(没有单独的严重级别键);**第一次**看到 image 时,
它的所有发现都被视为新的(首次扫描时全部告警)。交付是
尽力而为的,且绝不中断扫描循环:配置的
`webhook_timeout_seconds` 适用于每个请求,任何错误(超时、拒绝连接、非 2xx)
都会作为结构化的 `webhook_error` 事件记录下来,周期
继续进行。HTTP 仅使用标准库(无第三方依赖)。
`webhook_auth_token` 绝不会被记录。`webhook_*` 设置在启动时应用;
更改它们需要重启 container。
在 `config.yaml` 中设置 `webhook_enabled: true` 和 `webhook_url`,可选择加上
`webhook_auth_header` + `webhook_auth_token`(例如 `Authorization` / `Bearer …`)。
每个周期**每个受影响的 image** 会发送一个 POST,仅当该 image 有新
发现时才发送。
```
POST
Content-Type: application/json
: # only when both are configured
```
```
{
"timestamp": "2026-06-06T12:00:00Z",
"event": "new_vulnerabilities",
"image": "python:3.8-slim",
"image_id": "sha256:abc123...",
"containers": ["a1b2c3d4e5f6"],
"new_vulnerabilities": [
{
"id": "CVE-2021-1234",
"package": "openssl",
"version": "1.1.1k-r0",
"fixed_version": "1.1.1l-r0",
"severity": "CRITICAL",
"title": "Memory corruption in openssl",
"target": "python:3.8-slim (debian 11.7)"
}
],
"new_severity_counts": { "CRITICAL": 1, "HIGH": 0 }
}
```
每个漏洞对象的形状与 `scan_result` 日志行中的 `vulnerabilities[]` 条目完全相同。
`new_severity_counts` 仅计算**新**
漏洞,并且始终包含 `CRITICAL` 和 `HIGH` 键(一个存储桶可以是
`0`)。Python 和 Go 变体产生逐字节完全相同的配置键和 POST
正文。
`./verify_webhook.sh [python|go|all]`(仓库根目录)是一个集成检查,它会针对故意留有漏洞的测试目标启用 webhook 并启动一个
变体 ——
启动一个一次性的接收器 —— 并断言包含
至少一个 CRITICAL/HIGH 级别的 `new_vulnerabilities` POST 被送达,然后使用 `webhook_enabled: false` 重新运行并
断言没有 POST 到达。所有生成的材料都位于 `/config/webhook/`
下(被 gitignore 忽略)并在退出时删除。
## 事件驱动扫描
cron 计划回答了“我*已经*在运行的东西最近有新的漏洞吗?” ——
但是新拉取的 image 或在两次触发之间启动的 container 需要等待下一个
周期才会被扫描。**事件驱动扫描**关闭了那个窗口:一个
**可选的** listener 订阅 Docker `/events` 流并对两种
事件类型做出反应 —— 一个 **image 拉取**(`type=image, action=pull`)和一个 **container
启动**(`type=container, action=start`)。对于 container 启动,image ref 直接
取自事件 payload(`Actor.Attributes.image`);无需进行额外的 Docker
API 调用。
每个符合条件的事件都会触发**仅针对该 image 的定向扫描** —— 而不是对每个 container 进行全面重扫。定向扫描会重用常规的按 image 处理路径,
因此它会输出相同的 `scan_result` 日志行,为 webhook 的新发现 diff 提供数据,并
更新该 image 的按 image 划分的严重性指标。它有意**不**触碰
周期级别的记录:health 标记、`runtime_vul_images_scanned` 和
`runtime_vul_last_scan_timestamp_seconds` 属于 cron 周期。事件驱动
扫描因此**作为 cron 计划的补充 —— 它并不能替代它**。
突发请求会被**去抖动(debounced)**:在事件持续到达时观察到的 image ref 会被
收集到一个集合中,只有当流空闲
达到 `events_debounce_seconds`(默认为 5)时,每个**不同的** ref 才会触发一次扫描。一个启动了来自三个 image 的十个 container 的 `docker compose up` 操作
将导致三次定向扫描,而不是
十次。适用与 cron 路径相同的排除规则 —— 遵循 `ignore.yaml` 的 image 模式
并且扫描器绝不会扫描自己的 image。Trivy 执行在
内部是串行化的,因此事件扫描和并发的 cron 周期绝不会
同时针对共享缓存运行 Trivy。如果 `/events` 流断开
(daemon 重启、代理故障),listener 会记录结构化的
`event_stream_error` 并在较小的退避后重新连接。
该 listener **默认禁用**(`events_enabled: false`)→ 完全没有
打开 `/events` 连接,与今天纯粹的 cron 行为完全一致。在
`config.yaml` 中启用它:
```
events_enabled: true
events_debounce_seconds: 5 # idle window before coalesced refs are scanned
```
`events_*` 设置在启动时应用;更改它们需要重启
container。由于扫描器仅通过仅支持 GET 的 socket
代理访问 Docker,因此代理的 `-allowGET` 白名单也必须允许事件 endpoint ——
每个变体附带的 `docker-compose.yml` 都包含 `events` 条目(包括
Python SDK 使用的基于 API 版本的 `/v1.NN/events` 形式和 Go 客户端使用的裸 `/events`
路径);如果没有它,流将被阻止,listener
将记录重连错误。
## 参考
- [Trivy](https://github.com/aquasecurity/trivy) — 为每次扫描提供动力的漏洞扫描器
- [Trivy 忽略文件 schema](https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/) — 完整的 `.trivyignore.yaml` 参考
- [Grype](https://github.com/anchore/grype) — 替代的基于 Go 的扫描器 (Anchore)
- [NVD — 国家漏洞数据库](https://nvd.nist.gov/) — 带有 CVSS 分数的权威 CVE 源
- [CVE 计划](https://www.cve.org/) — CVE 编号机构和完整的 CVE 列表
- [CVSS v3.1 规范](https://www.first.org/cvss/v3.1/specification-document) — 严重性分数的计算方式
- [IANA 时区数据库](https://www.iana.org/time-zones) — `timezone` 配置键的有效时区字符串
- [Grafana Alloy](https://grafana.com/docs/alloy/latest/) — 日志收集和流水线
- [APScheduler](https://apscheduler.readthedocs.io/) — 内部使用的 cron scheduler
- [distroless Python](https://github.com/GoogleContainerTools/distroless) — 最小化的 container 基础 image(未来考虑)
标签:Docker, EVTX分析, GET参数, OpenTelemetry, PB级数据处理, 安全运维, 安全防御评估, 定时任务, 日志审计, 日志收集, 请求拦截, 逆向工具