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

四个结论:

  1. 同一依赖版本,风险可以差 2 条 vs 0 条。修复优先级只看符号级那一列。
  2. 模块级计数会随扫描入口变化(15 vs 17),它衡量的是“依赖树有多大”,不是“你有多危险”。
  3. 连 hello-world 都有 13 条模块级告警:Go 标准库自身也有已披露漏洞。所以“把数字降到 0”既不可能,也不是目标。
  4. 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、业务逻辑漏洞 未验证

三条选型原则:

  1. 一个真相源:漏洞数据统一用 OSV 格式(govulncheck 的漏洞库也是这个格式),不要三个扫描器三份清单。
  2. 一个 SBOM 格式:CycloneDX 或 SPDX 二选一,进制品仓库,和镜像同生命周期(见 54.2 镜像与仓库)。
  3. 分层用:依赖可达性(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"] }
}

四条规则:

  1. patch 才自动合并,前提是测试 + 扫描 + 来源校验全绿;minor 人工,major 默认关闭。
  2. 按类型分组:40 个依赖每周 15 个 patch PR,分组后降到 1–2 个,评审成本降约 90%(示例量级)。
  3. 0.x 的 minor 当成 major 看:语义化版本在 0.x 阶段不保证兼容,规则见 51.3 语义化版本与变更日志。
  4. 配置本身要校验: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 发布策略)。缺一条,自动合并就只是把攻击面自动化。

关联