52.3 依赖与漏洞扫描
一句话
依赖扫描的产出不是“漏洞数量”,而是在可被触达的代码路径上、有修复版本的漏洞清单;把 17 条模块级告警压成 0 条可达漏洞,才是扫描器该干的活。
先破除五个误区
| 误区 | 现实 |
|---|---|
| “CVE 数越少越安全” | 数量由依赖树大小、扫描口径、漏洞库覆盖共同决定。同一份代码、同一个依赖版本,模块级 17 条、可达 0 条(见下),数字大小与风险无关 |
| “升级到最新版就安全” | 最新版也可能有未披露漏洞;升级还会引入 breaking change。要看的是“修复版本区间”,不是“最新” |
| “发布前扫一次就够了” | 漏洞披露是持续的:代码没变,风险每周都在变。必须定时重扫(至少每天一次) |
| “高危必须立刻全修” | 高危但不可达、或没有修复版本,硬修是浪费;优先级要按“可达性 × 严重度 × 修复可用性”排 |
| “SBOM 是合规摆设” | SBOM 是出事后第一时间回答“我到底用了哪些版本”的唯一手段;没有它,应急只能靠猜 |
扫描是一条流水线,不是一次命令
| 阶段 | 输入 | 工具 / 动作 | 典型失败模式 |
|---|---|---|---|
| 1 清单 | go.mod / go.sum、package-lock.json、镜像层 | go list -m all、Syft 生成 SBOM | 漏间接依赖、漏 replace、漏基础镜像 |
| 2 匹配 | 模块 + 版本 | OSV / NVD / GHSA 数据库 | 数据库有延迟;版本区间判断错 |
| 3 可达 | 调用图 / 包图 | govulncheck(Go 符号级) | 反射、接口、模板、插件绕过静态调用图 |
| 4 策略 | 可达 + 严重度 + 修复 | 门禁 / SLA / VEX | 全阻断导致绕过;只看严重度导致噪声 |
| 5 修复 | 升级 PR | Renovate / Dependabot | major 升级破坏兼容 |
| 6 验证 | 新版本 + 锁文件 | 重扫 + 全量测试 | 改了 go.mod 没更新 go.sum,假修复 |
🔑 一条铁律:漏洞优先级 = 可达性 × 严重度 × 修复可用性。三个因子缺一个,排序就是错的。
大多数团队的流水线只做了 1、2 两步,然后在第 4 步被几百条告警淹没。真正把噪声降下来的是第 3 步。
数字:同一依赖,17 条模块告警,0 条可达
构造一个最小模块,只有一个依赖:
// go.mod
module example.com/vuln
go 1.22
require golang.org/x/text v0.3.6
两个入口,调用同一个模块的不同函数:
// cmd/reachable/main.go
package main
import (
"fmt"
"golang.org/x/text/language"
)
func main() {
tags, _, err := language.ParseAcceptLanguage("en-US,zh-CN;q=0.8")
fmt.Println(tags, err)
}
// cmd/safe/main.go
package main
import (
"fmt"
"golang.org/x/text/encoding/unicode"
)
func main() {
d := unicode.UTF8.NewDecoder()
fmt.Println(d != nil)
}
$ govulncheck ./cmd/reachable
=== Symbol Results ===
Vulnerability #1: GO-2022-1059
Denial of service via crafted Accept-Language header in
golang.org/x/text/language
Module: golang.org/x/text
Found in: golang.org/x/text@v0.3.6
Fixed in: golang.org/x/text@v0.3.8
Example traces found:
#1: cmd/reachable/main.go:10:46: reachable.main calls language.ParseAcceptLanguage
Vulnerability #2: GO-2021-0113
Out-of-bounds read in golang.org/x/text/language
Module: golang.org/x/text
Found in: golang.org/x/text@v0.3.6
Fixed in: golang.org/x/text@v0.3.7
Your code is affected by 2 vulnerabilities from 1 module.
This scan also found 1 vulnerability in packages you import and 15
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
$ govulncheck ./cmd/safe
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 1 vulnerability in packages you import and 17
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
| 入口 | 符号级(可达) | 包级 | 模块级 | govulncheck 退出码 |
|---|---|---|---|---|
./cmd/reachable | 2 | 1 | 15 | 3 |
./cmd/safe | 0 | 1 | 17 | 0 |
空 main(只 import fmt) | 0 | 1 | 13 | 0 |
四个结论:
- 同一依赖版本,风险可以差 2 条 vs 0 条。修复优先级只看符号级那一列。
- 模块级计数会随扫描入口变化(15 vs 17),它衡量的是“依赖树有多大”,不是“你有多危险”。
- 连 hello-world 都有 13 条模块级告警:Go 标准库自身也有已披露漏洞。所以“把数字降到 0”既不可能,也不是目标。
govulncheck退出码就是门禁信号:3 = 有可达漏洞,0 = 没有。CI 不需要解析文本,直接看退出码。
⚠️ 可达 ≠ 可利用,不可达 ≠ 安全。调用图看不到反射、接口动态分派、模板、插件、反序列化;高危但“不可达”的漏洞仍要人工复核入口是否可被外部触发。
可达性为什么不是银弹
| 调用方式 | 静态调用图能覆盖吗 | 例子 |
|---|---|---|
| 直接函数调用 | 能 | language.ParseAcceptLanguage |
| 接口方法 | 部分(依赖指针分析精度) | io.Reader 的运行时实现 |
| 反射 | 不能 | reflect.Value.Call、encoding/json |
| 插件 / 动态加载 | 不能 | plugin.Open |
| 模板 / DSL | 不能 | text/template、规则引擎 |
| CGO / 手写汇编 | 不能 | 加密、编解码库 |
| 生成代码 | 能(生成之后) | protobuf / mock |
所以正确的用法是:用可达性降噪,不用它做安全证明。策略上仍要保留一条“不可达但高危”的人工复核通道。
扫描器怎么选
| 工具 | 覆盖 | 粒度 | 适合 | 本机验证 |
|---|---|---|---|---|
govulncheck v1.9.0 | Go 模块 + 标准库 | 符号级 | Go 服务的合并门禁 | ✅ |
| Trivy | OS 包、语言依赖、镜像、SBOM | 包级 | 容器镜像、文件系统 | 未验证 |
| Grype + Syft | SBOM 生成与扫描 | 包级 | 离线环境、合规归档 | 未验证 |
| OSV-Scanner | 多生态锁文件 | 包级 | 多语言单仓 | 未验证 |
| Renovate / Dependabot | 版本升级 PR | 版本级 | 修复自动化 | 未验证 |
| Semgrep / CodeQL | 源码污点分析 | 行级 | 0day、业务逻辑漏洞 | 未验证 |
三条选型原则:
- 一个真相源:漏洞数据统一用 OSV 格式(
govulncheck的漏洞库也是这个格式),不要三个扫描器三份清单。 - 一个 SBOM 格式:CycloneDX 或 SPDX 二选一,进制品仓库,和镜像同生命周期(见 54.2 镜像与仓库)。
- 分层用:依赖可达性(
govulncheck)+ 镜像包(Trivy)+ 代码污点(Semgrep)各管一段,不要互相替代。
策略:按“可达 + 修复”分级
CVSS v3.1 的区间:Critical 9.0–10.0,High 7.0–8.9,Medium 4.0–6.9。下面的 SLA 是建议基线,不是法规;行业规范可能更严(见 3.4 行业规范)。
| 条件 | 动作 | 建议 SLA |
|---|---|---|
| 可达 + Critical + 有修复 | 阻断合并,立即升级 | 24–72 小时 |
| 可达 + High + 有修复 | 阻断合并 | 7 天 |
| 可达 + 无修复 | 缓解(关入口 / WAF / 降级)+ 例外记录 + 每周复核 | 7 天内给出决策 |
| 不可达 + Critical/High | 工单,随下个版本升级 | 30 天 |
| 不可达 + 中低危 | 记录 VEX not_affected,不排期 | 90 天复核 |
| 仅开发依赖、不进生产镜像 | 单独队列,不阻断发布 | 30 天 |
两个关键点:
- VEX(Vulnerability Exploitability eXchange):机器可读的“这个漏洞对我不可达/不受影响”声明,避免每次重扫都重新 triage 同一批告警。
- 例外必须有到期日:没有到期日的例外就是永久豁免,半年后没人记得为什么放行。
自动修复:Renovate 的批量与门禁
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":dependencyDashboard"],
"packageRules": [
{ "matchUpdateTypes": ["patch"], "groupName": "patch", "automerge": true },
{ "matchUpdateTypes": ["minor"], "groupName": "minor", "automerge": false },
{ "matchUpdateTypes": ["major"], "enabled": false }
],
"vulnerabilityAlerts": { "enabled": true, "labels": ["security"] }
}
四条规则:
- patch 才自动合并,前提是测试 + 扫描 + 来源校验全绿;minor 人工,major 默认关闭。
- 按类型分组:40 个依赖每周 15 个 patch PR,分组后降到 1–2 个,评审成本降约 90%(示例量级)。
- 0.x 的 minor 当成 major 看:语义化版本在 0.x 阶段不保证兼容,规则见 51.3 语义化版本与变更日志。
- 配置本身要校验:CI 里跑
renovate-config-validator,别让一条拼错的规则静默失效(本机未跑过 Renovate,语法以官方 schema 为准)。
自动升级越顺滑,供应链攻击的收益越高:typosquatting、dependency confusion、账号被盗后发版,都会伪装成一次普通 patch。信任模型与防护见 38.10 供应链安全。
生产落地:指名 → 可抄 → 可验 → 可退
指名:govulncheck v1.9.0(Go 可达性,本机验证)+ Trivy(镜像 / 文件系统,未验证)+ Renovate(升级自动化,未验证)+ Syft(SBOM,未验证)。替代品:Grype + Syft 可完全离线;OSV-Scanner 适合多语言单仓。
可抄(security.sh,Go 部分本机验证):
#!/usr/bin/env bash
set -euo pipefail
# 1) 依赖完整性:go.sum 与下载内容一致
go mod verify
# 2) 可达漏洞:退出码 3 = 有可达漏洞,CI 直接失败
govulncheck ./...
# 3) 依赖清单:SBOM 的最小替代
go list -m -json all > deps.json
可验(本机真实输出):
$ go mod verify
all modules verified
$ govulncheck ./cmd/reachable; echo $?
...
Your code is affected by 2 vulnerabilities from 1 module.
3
$ govulncheck ./cmd/safe; echo $?
No vulnerabilities found.
0
注意第二条与第三条的差别:同一个模块,退出码 3 vs 0,CI 的行为完全不同——这就是可达性带来的降噪。
可退:
- 紧急发布:
SECURITY_BLOCKING=false临时放行,必须开 ticket,24 小时内恢复门禁。 - 升级导致故障:回滚升级 PR 或镜像 digest,不要回滚扫描步骤。
- 数据库误报 / 无修复版本:走 VEX 或 allowlist,条目带到期日,不要直接删扫描。
- 无修复且可利用:缓解开关(关闭受影响入口 / 降级 / 加 WAF 规则)+ 监控,按事故处理。
govulncheck ./... || {
code=$?
[ "$code" = 3 ] && [ "${SECURITY_BLOCKING:-true}" = "false" ] && exit 0
exit $code
}
反模式
| 反模式 | 后果 |
|---|---|
| 把 CVE 总数当团队 KPI | 团队比“谁依赖少”,不是比“谁修得快”;告警被隐藏 |
| 所有 High 一律阻断 | 无修复版本的依赖卡死发布,最后加 \|\| true,门禁永久失效 |
| 只在发布前扫一次 | 漏洞披露滞后于发布,线上长期带洞 |
| 扫镜像不扫基础镜像 / 构建上下文 | 漏掉 OS 包和构建期依赖 |
| 不锁版本、不提交 lockfile | 每次构建依赖漂移,SBOM 与事实不符 |
基础镜像用 latest | 不可复现,扫描结果无法对应到具体制品 |
| 三个扫描器三份清单 | 修复不同步,同一条漏洞被修三次或一次没修 |
| 自动合并所有 patch | 供应链投毒面扩大(typosquatting / dependency confusion) |
| 改了依赖不重扫、不更新 lock | 假修复:版本号变了,实际加载的还是旧包 |
| devDependencies 打进生产镜像 | 攻击面白送,构建体积也变大 |
自检
Q1 同一个 golang.org/x/text@v0.3.6,为什么一个入口报 2 条可达漏洞,另一个报 0 条?
▸ 答案:因为 govulncheck 做的是符号级匹配:它从 main/init 出发构建调用图,只有真正调用到“漏洞所在函数”时才计入 Symbol Results。ParseAcceptLanguage 命中了 GO-2022-1059 和 GO-2021-0113,unicode.UTF8.NewDecoder 没有命中任何漏洞符号。模块级计数反而更多(17 vs 15),因为它把整个模块的已知漏洞都算上,与你的调用无关。结论:修复优先级看符号级,模块级只用来评估依赖树规模。
Q2 govulncheck 退出码 0,能否证明这个服务没有漏洞?
▸ 答案:只能证明“没有已知的、可通过静态调用图到达的漏洞”。三个盲区仍在:① 反射、接口、模板、插件可能绕过调用图;② 漏洞库有披露延迟,0day 不在库里;③ 扫描范围只覆盖 Go 模块和标准库,镜像里的 OS 包、CGO 依赖不在内。所以 0 表示“可以发布”,不表示“没有风险”;还要配合 SBOM、镜像扫描和定期重扫。
Q3 一个高危漏洞没有修复版本,团队该怎么办?
▸ 答案:三步,不是“忽略”也不是“硬等”。① 判断可达性与外部触发条件:入口是否暴露、参数是否可控、是否只在管理网;② 做缓解:关闭入口、加 WAF 规则、降级功能、加监控与限流;③ 记录例外:VEX not_affected 或 ticket,写清缓解措施、责任人和复核日期(建议每周)。最差的做法是加到 allowlist 后不再看——那等于把“暂时无法修”变成“永久不知道”。
Q4 为什么不能把“CVE 数量”当团队 KPI?
▸ 答案:因为这个数字不由安全水平决定,而由依赖树大小、扫描器口径、漏洞库覆盖决定。同一份代码换个入口,模块级数字就能从 15 变 17;引入一个大依赖,数字立刻上涨,但风险未必增加。KPI 一旦落到“数量”,理性反应是减少依赖、拆小扫描范围、隐藏告警,而不是修漏洞。更好的指标是“可达的高危漏洞平均修复时长”和“超 SLA 的例外数量”。
Q5 Renovate 自动合并 patch,风险在哪?什么条件下才可以开?
▸ 答案:patch 也可能含破坏性变更(尤其 0.x)或恶意代码(供应链投毒、账号被盗后发版)。可以开自动合并的前提是四条同时成立:① 测试与集成测试能在合并前跑完并覆盖关键路径;② 镜像扫描和签名/来源校验通过;③ 只在可信命名空间与固定 registry 内;④ 有快速回滚能力(不可变制品 + 一键回滚,见 55.1 发布策略)。缺一条,自动合并就只是把攻击面自动化。