在 CI/CD 中用 SBOM 与可达性分析区分可利用漏洞:从依赖清单到 VEX 分级处置

枫少@KillBoy
枫少@KillBoy
枫少@KillBoy
管理员
303
文章
0
粉丝
安全开发1 37字数 3239阅读10分47秒阅读模式
AI智能摘要
CI/CD 扫描报出一片高危漏洞,真正需要阻断合并的风险却可能被告警淹没:依赖版本命中,并不等于漏洞代码能被触及。文章梳理从构建阶段生成并归档 SBOM、分析漏洞函数可达性,到用 VEX 记录判定依据的降噪闭环;门禁据此区分需阻断、待调查和留档复审的告警。面对反射调用、动态加载等分析盲区,团队该如何避免把“不确定”误判为“不可利用”?
— AI 生成的文章内容摘要

在流水线里接入 SCA 的团队,很快都会遇到同一个尴尬:扫描一跑完,报告里躺着几十上百条 CVE,红黄一片,但真正值得当晚停下合并去修的可能只有两三条。问题的根源在于绝大多数扫描器默认只做一件事——把 SBOM 里声明的组件版本去比对漏洞数据库,只要版本落在"受影响区间"就报出来。这种"版本匹配报警"完全不关心那段有漏洞的代码在你的应用里到底会不会被执行。于是一个只在某个你从未调用的解析函数里才触发的反序列化漏洞,和一个暴露在对外接口上的远程代码执行,会以同样的 critical 标签并排出现,长此以往就是告警疲劳,真正的高危反而被淹没。

流水线中依赖树与可达调用链示意

本文要讲的是一条可复核的降噪闭环:在构建阶段生成 SBOM,用可达性/可利用性分析判断受影响组件是否真的被调用,再用 VEX 声明把判定结果固化成"不受影响/正在调查/已修复"等状态供人复核,最后让分级结果去驱动合并阻断和通知。需要先说清边界:这是一套降噪与分级方法,不替代完整的依赖治理;SBOM 生成工具与可达性引擎的语言覆盖、版本行为差异都需要你自己验证;文中涉及的格式状态、工具默认行为等时效事实,请以官方文档为准。

为什么"版本匹配"会制造噪声

传统 SCA 的逻辑可以压缩成一句话:组件 == 受影响版本 → 报警。它不看代码调用关系。举个最小的例子,假设你的 Node 项目依赖了某个工具库,这个库里有十几个导出函数,其中只有一个 parseLegacyFormat 存在已知漏洞:

// 你的业务代码只用到了这一个入口
const { formatDate } = require('some-utils');

// 从未 import 的那个才是出问题的函数
// const { parseLegacyFormat } = require('some-utils');

版本匹配会告诉你"some-utils 受 CVE-XXXX 影响",但你的调用链根本到不了 parseLegacyFormat。这类漏洞就是典型的"不可达漏洞"——存在于依赖里,却没有从你的代码出发的任何路径能触及它。可达性分析的价值,就是把这类告警从"必须修"降级为"记录在案"。

需要强调一个判定前提:这套区分能成立的基础是你手里有一份准确的 SBOM。没有组件清单,你根本分不清一个组件是"被实际加载"还是"只是扫描器从锁文件里猜出来的"。SBOM 不是做给审计看的摆设,它是后续所有分级决策的数据底座。

准备:生成可用于分析的 SBOM

第一步是在构建阶段生成 SBOM,而不是事后补一份。常见格式有两类:SPDX(已成为 ISO/IEC 5962 国际标准,在操作系统、法务合规和招投标场景更主流)和 CycloneDX(在 DevSecOps 和安全工具里增长更快,结构更轻量,对漏洞和可利用性的描述能力更强)。多数现代 SCA 工具(如 Trivy、Grype)默认或推荐输出 CycloneDX,因为它更容易接进自动化流水线。如果你的目标是漏洞扫描与分级,优先选 CycloneDX 通常更省事;如果是合规审计出证据,SPDX 更正式。两者可以在不同环节并存。

以 Syft 为例,在 CI 里生成一份 SBOM 并归档:

# 从构建产物生成 CycloneDX 格式 SBOM
syft dir:. -o cyclonedx-json=sbom.cdx.json

# 把 SBOM 作为流水线制品归档,不要只留在日志里

这里有几个边界需要注意。SBOM 的准确性取决于工具能否正确解析你的依赖声明文件和锁文件;对于动态加载、运行时下载、或通过非标准方式引入的组件,工具可能漏采。生成时机也很关键——基础镜像或依赖一旦变化,就要重新生成 SBOM,旧清单会直接误导后续判定。把 SBOM 自动归档到独立制品库并保留足够历史周期,审计和回溯时才有据可查。

从构建生成清单到可达性判定的流水线分支示意

配置:接入可达性与可利用性判定

有了 SBOM,下一步是让扫描结果带上可达性维度。实现思路有两类:一类是扫描器本身内置可达性能力,在匹配到漏洞组件后进一步分析漏洞所在符号是否出现在你的调用图里;另一类是扫描和可达性分析分开,扫描器出 CVE 列表,再用调用图分析工具去标注哪些 CVE 的漏洞函数是可达的。

无论哪种方式,核心判定都是构建一张从应用入口出发的调用图(call graph),然后看漏洞对应的具体函数/符号是否落在图里。一个被标为"受影响但不可达"的组件,就可以进入 VEX 的 not_exploited(不可利用)状态。

VEX(漏洞可利用性交换)标准定义了四种常用状态,这是分级落地的词汇表:

VEX 状态含义典型场景
exploitable该漏洞在当前环境中可被利用远程代码执行漏洞暴露在对外接口上
not_exploited漏洞存在但当前配置下无法被利用漏洞所在代码路径不会被执行
under_investigation正在评估可利用性新披露 CVE,尚需时间分析影响范围
fixed已通过补丁修复已升级到安全版本

配置时要把这张状态表和你的分级规则绑定。一个务实的分档方式是按"几项判定条件同时成立"来定级:判定条件可以包括漏洞函数是否可达、组件是否对外暴露、是否存在公开利用等。全都成立的当天处理,部分成立的本周或排期处理,全不成立的记录后定期复审。这里不给死板的阈值,因为不同业务的风险权重不同,关键是把判定依据写进 VEX,让人能复核,而不是让工具一锤定音。

执行:在流水线里跑完整判定

把前面的环节串成一条可执行的流水线,大致是这样几步:

  1. 构建产物,打上基于 commit 的 tag;
  2. 用 Syft 生成 SBOM 并归档;
  3. 用扫描器(Trivy / Grype 等)比对漏洞,产出原始 CVE 列表;
  4. 跑可达性分析,给每条 CVE 标注"可达 / 不可达";
  5. 结合暴露面等条件生成 VEX,给出 exploitable / not_exploited / under_investigation / fixed;
  6. 用 VEX 分级结果驱动门禁决策,而不是用原始 CVE 数量。

关键改动在第 6 步:门禁的输入从"有没有 critical CVE"变成"有没有 exploitable 状态的 CVE"。被判为 not_exploited 的漏洞不再阻断合并,但必须保留 VEX 记录;被判为 under_investigation 的,进入一条有时限的待办,而不是直接放行也不是直接拦死。

# 伪示例:只在存在 exploitable 状态时让门禁失败
# 真实字段路径取决于你所用工具的 VEX 输出结构,请以其文档为准
exploitable=$(jq '[.vulnerabilities[]
  | select(.analysis.state == "exploitable")] | length' scan.vex.json)

if [ "$exploitable" -gt 0 ]; then
  echo "存在可利用漏洞,阻断合并"
  exit 1
fi

这段脚本只是示意门禁逻辑的切换点:适用语言是 shell + jq,边界条件是你的工具确实输出了带 analysis.state 的 VEX 字段;潜在副作用是如果可达性分析本身覆盖不全,把真实可利用漏洞误判成 not_exploited,门禁就会漏放,所以这个判定的可信度直接取决于分析引擎的质量。

结果判断:边界条件与误判风险

可达性分析不是银弹,它的判定有明确的失效边界,必须讲清楚,否则降噪会变成漏判。

静态调用图分析对动态特性天然薄弱。反射、依赖注入、eval、运行时按字符串加载类/模块、以及通过配置决定调用哪条分支的场景,静态分析可能看不到那条到漏洞函数的路径,从而把真正可达的漏洞误判为不可达。这是最危险的一类误判——假阴性,它会让一个本该阻断的漏洞被静悄悄放行。

另一个边界是语言与生态覆盖。可达性引擎对不同语言的支持成熟度差异很大,对某些语言的间接调用、原生扩展、跨语言调用可能根本不建图。对这些覆盖不到的部分,正确做法是"覆盖不到就不降级"——缺乏可达性证据时默认保留原始严重度,而不是默认判为不可达。

还有传递依赖的问题。你直接调用的函数不可达,不代表它内部不会在某个条件下再去调用那个漏洞函数。调用图的深度和对条件分支的处理方式会影响结论,这也是为什么分级结果需要以 VEX 形式留痕、让人复核,而不是完全交给自动判定。

一个安全的原则是:把可达性当成"降级的理由",而不是"消除的理由"。能证明不可达,就降档;不能证明,就维持原状。宁可多留几条待复审,也不要把假阴性放进生产。

修复回归:阻断、通知与回退

分级落地后,它会实实在在改变三个流程。

合并阻断策略从"按 CVE 严重度一刀切"变成"按 VEX 状态分档"。只有 exploitable 才无条件阻断合并;under_investigation 走限时待办,超时未结论则升级为阻断,避免"永远在调查"变成绕过门禁的后门;not_exploited 和 fixed 不阻断,但都要有可追溯的 VEX 记录。建议分阶段上线:先用监控模式跑一段时间,只记录新的分级结果不做阻断,摸清你这套判定在真实项目上的误判基线,再逐步把阻断打开——这和运行时告警先摸基线再启用阻断是同一个思路。

通知流程也要跟着调整。过去是把全部 CVE 一股脑推给开发,现在应该只对 exploitable 做即时、点名到人的通知;under_investigation 推给负责复核的安全/架构角色并带上到期时间;not_exploited 不打扰开发,进归档供审计查阅。通知量下来之后,被通知的人才会认真看,这才是降噪的真正收益。

最后是回退方式,这一点必须预留。如果上线后发现可达性引擎误判率偏高,或某次升级后它对你主要语言的覆盖退化,要能一键把门禁判定依据从"VEX 状态"切回"原始 CVE 严重度",让流水线退回到更保守但不漏判的旧行为。实现上通常是一个开关变量控制门禁读取哪个字段,再配合保留原始扫描结果作为兜底。VEX 文件和 SBOM 都应随每次构建归档,出现误判争议时可以逐条回看判定依据,而不是只能凭记忆争论。

小结与适用范围

这套方法把漏洞扫描从"版本匹配报警"升级为"可达性驱动的可利用性分级",用 SBOM 提供数据底座、用可达性分析提供降级理由、用 VEX 把判定固化成可复核的状态,再让分级去驱动门禁和通知,形成一个闭环。它解决的是告警疲劳,而不是依赖治理的全部——组件更新策略、许可证合规、构建来源证明等仍需各自的机制。

要再强调三条边界:一是 SBOM 生成工具的采集能力和可达性引擎的语言覆盖都要你自己在目标项目上验证,别照搬别人的结论;二是静态可达性对动态调用有系统性盲区,务必把"证明不了不可达就不降级"作为默认原则;三是文中涉及的格式标准状态、工具默认行为、法规时限等时效事实,以官方文档和一手资料为准。把降噪建立在可复核的证据上,它才是减负,而不是埋雷。

 
枫少@KillBoy
    • 星屑回忆
      星屑回忆 1

      满屏红色告警时,确实很难找出最急的那条

    匿名

    发表评论

    匿名网友

    拖动滑块以完成验证