我们已经沿着 Grype 的扫描链路,我们从 main() 一路跟踪到了漏洞匹配和风险评分。当 VulnerabilityMatcher.FindMatchesContext() 执行完毕,返回了 remainingMatches(有效匹配)和 ignoredMatches(被忽略匹配)。那么问题来了:从这两组匹配结果到用户最终看到的报告,中间还发生了什么?
本文将从 VulnerabilityMatcher.FindMatchesContext() 的内部过滤步骤出发,依次分析三件事:
- Grype 的过滤体系——漏洞匹配结果如何被逐层筛选,什么样的匹配会被"留下来",什么样的会被"忽略掉";
- VEX 处理机制——OpenVEX 和 CSAF 两份标准文档如何进入 Grype、如何与匹配结果交互;
- 报告输出系统——table、JSON、CycloneDX、SARIF 各格式如何生成,
--fail-on退出码如何计算。
读完之后,你将完整地理解一次 grype dir:. 从命令行到输出报告的闭环。
过滤体系全景:匹配完成后发生了什么
先回顾一下 VulnerabilityMatcher.FindMatchesContext() 的内部流程(简化版):
1 | // grype/vulnerability_matcher.go |
这三步的背后,实际上对应着一套四层过滤系统:
1 | 原始匹配 |
让我们重点分析第一层和第三层——因为这两层使用的是同一套 IgnoreRule + ApplyIgnoreRules 引擎,只是规则的来源不同。VEX 作为第四层有独立的处理通路,我们放在后面专门讨论。
IgnoreRule:一条规则的完整表达
忽略体系的血液是 IgnoreRule,它的设计体现了 Grype 的核心理念:一个忽略规则由多个维度组成,所有指定的维度必须同时满足(AND 逻辑),未指定的维度不做限制。
1 | // grype/match/ignore.go |
你可以看到,一个规则可以同时组合"某个 CVE" + “某个包名” + “某种包类型” + “某个文件位置”——只有当一个匹配在所有这些维度上同时命中时,才会被忽略。
忽略匹配的条件函数体系
IgnoreRule.IgnoreMatch() 方法的核心思路很清晰:
- 遍历规则中所有非空字段,为每个字段构造一个条件函数;
- 如果一个条件返回
false,规则立即不生效; - 如果所有条件都返回
true,则规则生效,返回自身。
1 | func (r IgnoreRule) IgnoreMatch(match Match) []IgnoreRule { |
每个条件函数都是一个 func(match Match) bool 闭包。值得关注的是其中几个有特殊逻辑的条件:
包名匹配支持正则探测。Grype 会检测字符串中是否包含正则特殊字符(^$*+?[]{}(\)| 等),如果有则编译为正则表达式,否则走精确字符串匹配:
1 | func ifPackageNameApplies(name string) ignoreCondition { |
一个有趣的设计是:. 被刻意排除在正则检测之外。注释解释道这个字符在包名和版本号中太常见了,如果把它当作正则,会产生大量不需要的编译和误判。
文件路径匹配使用 glob 模式。通过 doublestar.Match 支持 ** 递归通配,这意味着你可以在配置文件中写 **/test/** 来忽略所有测试目录下的匹配:
1 | func ruleLocationAppliesToPath(location, path string) bool { |
FixState 的特殊处理:unknown 状态同时匹配 FixStateUnknown 和空字符串(表示修复状态未指定):
1 | func ifFixStateApplies(fs string) ignoreCondition { |
忽略规则的批量应用
有了单个规则的匹配逻辑,批量应用就很简单了。ApplyIgnoreFilters 遍历所有匹配,对每个匹配尝试所有规则,一旦命中就将其从"有效匹配"移到"忽略匹配"中,并附带记录哪些规则导致了忽略:
1 | func ApplyIgnoreFilters[T IgnoreFilter](matches []Match, filters ...T) ([]Match, []IgnoredMatch) { |
IgnoredMatch 是一个包装结构——它在 Match 之上附加了 AppliedIgnoreRules 列表,让用户清楚地知道"这条漏洞为什么被忽略了":
1 | type IgnoredMatch struct { |
内置忽略:从硬编码误报到命令行快捷过滤
理解引擎之后,再看 Grype 自带哪些过滤规则。它们分为三层:硬编码的精确忽略规则、命令行快捷过滤、用户配置文件中的自定义规则。
硬编码的精确忽略
Grype 的 explicitIgnoreRules 是在 init() 中硬编码的一组规则,处理的都是已知的包名混淆场景——一个 CVE 影响的是某个特定产品,但因为共享相同的包名,被错误地关联到了不相关的包上:
1 | // grype/match/explicit_ignores.go |
这些规则通过 ApplyExplicitIgnoreRules() 在 Matcher 匹配期间就介入——它发生在每个包的数据库查询完成之后(即调用这个设计反映了 Grype 的一个务实原则:与其在数据库中修改(可能影响其他使用者),不如在扫描时透明地排除已知误报,并允许用户通过 ExclusionProvider 接口从数据库中加载更多规则。
命令行快捷过滤
在 runGrype() 函数中,Grype 将几个命令行选项翻译为 IgnoreRule:
1 | // cmd/grype/cli/commands/root.go |
--only-fixed 和 --only-notfixed 这两个选项本质上不过是预制的 IgnoreRule 组合。这解释了为什么命令行有这么多的 filter 选项,但底层都走同一套 ApplyIgnoreRules 引擎。
另外,内核头文件匹配也有自己的一组默认忽略规则:
1 | var ignoreLinuxKernelHeaders = []match.IgnoreRule{ |
当用户没有启用 --match-upstream-kernel-headers 时,这些规则被静默添加。为什么默认排除?因为内核头文件包通常只包含头文件而非可执行代码,linux 内核的漏洞几乎永远不可能通过这些头文件包被利用——这是一个典型的"技术上匹配但业务上无意义"的场景。
忽略规则中的性能优化
当存在大量忽略规则时,逐条遍历匹配可能带来性能问题。Grype 通过 ignoredMatchFilter() 对规则做了按维度索引的优化:
1 | type ignoreRulesByIndex struct { |
对于不含通配符的路径规则、包名规则、漏洞 ID 规则,Grype 将其从线性列表中摘出,放入哈希索引按 O(1) 命中。只有那些需要正则/glob 匹配的复杂规则,才会走原始的线性遍历。
VEX:从"有这个漏洞"到"这个漏洞真的能利用吗"
理解了忽略体系之后,我们进入本文最有意思的话题——VEX。
问题:为什么需要 VEX
传统的漏洞扫描对"匹配"的理解很简单:软件包的名称和版本落在漏洞记录的影响范围内,就产生一条告警。这个逻辑有两个盲区:
- 假阳性无法自证。假如一个镜像打包了
openssl,但它使用的接口完全不受某个 CVE 影响。扫描工具只知道版本在范围内,不知道使用的是哪些 API。 - 状态判断需要外部信息。一个漏洞可能是"已验证受影响"(affected),也可能是"经分析不受影响"(not_affected),也可能是"已修复"(fixed),或者是"正在调查中"(under_investigation)。这些状态不是扫描工具能从版本号中推断的。
**VEX(Vulnerability Exploitability eXchange,漏洞可利用性交换)**正是为解决这些问题而生的标准。它允许软件供应商、组件开发者在软件之外额外发布一份文档,声明某个 CVE 对某个产品/组件实际的可利用状态。
Grype 支持两种 VEX 格式:
- OpenVEX:由 OpenSSF 推动,格式简洁,基于 JSON。Statement 包含 vulnerability(漏洞)、product(受影响产品)、status(受影响状态)和可选的 subcomponents(子组件)。
- CSAF(Common Security Advisory Framework):OASIS 标准,基于 JSON 的安全公告格式。比 OpenVEX 更重量级,包含了完整的安全公告元数据(文档追踪、发布者、修订历史等)。
整体架构:Processor 的双阶段模型
Grype 通过 vex.Processor 统一了两种格式的处理。无论用户提供的是 OpenVEX 还是 CSAF 文件,都通过同一接口处理:
1 | // grype/vex/processor.go |
ApplyVEX() 方法统一编排了这两个阶段:
1 | func (vm *Processor) ApplyVEX(pkgContext *pkg.Context, remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch) (*match.Matches, []match.IgnoredMatch, error) { |
为什么要分两步?因为 Filter 和 Augment 的视角是相反的:
- FilterMatches 面向"这个漏洞其实不可利用"的场景。VEX 说
not_affected或fixed,那你之前基于版本号的匹配就是假阳性,应当从有效匹配中移除。 - AugmentMatches 面向"这个漏洞其实很重要"的场景。如果一条匹配之前因为用户 ignore 规则被抑制了,但 VEX 明确指出
affected或under_investigation,那它应该被恢复到有效匹配中——VEX 的权威声明可以覆盖之前所有的忽略规则。
两步走的设计还有一个实用的效果:如果同一个漏洞在两个 VEX 文件中有冲突的声明(比如一份说 not_affected,另一份说 affected),那么 AugmentMatches 在 FilterMatches 之后执行,affected 会覆盖之前的 not_affected。
格式检测由 getVexImplementation() 自动完成——它嗅探第一个文档文件,判断是 CSAF 还是 OpenVEX:
1 | func getVexImplementation(documents []string) (vexProcessorImplementation, error) { |
所有 VEX 文件必须是同一格式——你不能在一次扫描中混合使用 OpenVEX 和 CSAF。
OpenVEX:产品身份识别与两阶段匹配
OpenVEX 实现中最复杂的部分不是语句匹配本身,而是产品身份识别——Grype 如何将扫描到的软件包与 VEX Statement 中的 Product 关联起来。
productIdentifiersFromContext() 为这个关联构建了身份标识列表:
1 | func productIdentifiersFromContext(pkgContext *pkg.Context) []string { |
对于镜像扫描,Grype 从每个 tag 和 digest 构造多个标识符,包括原始 tag 字符串和 OCI PURL(带 ?tag= qualifier 或 ?repository_url= qualifier)。Docker Hub 的多种 URL 形式(docker.io、index.docker.io、registry-1.docker.io)被统一规范化为 index.docker.io。
对于目录扫描,Grype 用 pkg:generic/<name>@<version> 作为产品标识——这允许 VEX 文档直接以包 PURL 作为产品声明。
有了产品标识之后,findMatchingStatement() 执行一次两阶段匹配:
- 第一轮:以扫描上下文的产品标识(镜像、目录)作为产品去查 VEX,以当前包的 PURL 作为子组件过滤。这处理了"镜像即产品、包即子组件"的容器扫描场景。
- 第二轮:如果第一轮没找到,以包的 PURL 自身作为产品去查 VEX。这处理了"包即产品"的目录扫描场景——VEX 文档中的产品声明可能就是
pkg:npm/lodash@4.17.21。
CSAF:标准化安全公告的处理
CSAF 的实现比 OpenVEX 多了一层——多份公告的排序和聚合:
1 | func (*Processor) ReadVexDocuments(docs []string) (any, error) { |
当多个 CSAF 公告都声明了同一个 CVE 的状态时,Grype 按 CurrentReleaseDate 降序排列——最新发布的公告优先。这是一个务实的决策:最新发布的公告最有可能反映最新的分析结论。
CSAF 的状态体系比 OpenVEX 更丰富——它有 8 种产品状态(first_affected、known_affected、last_affected、recommended、first_fixed、fixed、known_not_affected、under_investigation),但都被映射到了 CISA 定义的 4 个标准状态之一:
1 | // grype/vex/csaf/status.go (逻辑精简) |
VEX 规则:默认行为与自定义
当用户通过 --vex 提供了 VEX 文件时,Grype 自动认为用户希望用 VEX 来过滤结果:
1 | func applyVexRules(opts *options.Grype) error { |
注意一个重要的区别:not_affected/fixed 被隐式添加为过滤规则(无需用户显式配置),而 affected/under_investigation 需要用户通过 --vex-add 显式启用。这个设计背后是安全考量——默认只抑制误报是安全的(减少噪音),但默认主动恢复已忽略的匹配可能引入意外结果,所以需要用户明确声明。
四种 VEX 状态的实际效果
至此,VEX 的四种标准状态与实际行为的对应关系就清晰了:
| VEX 状态 | 方向 | 作用 | 需要显式启用? |
|---|---|---|---|
not_affected |
Filter | 匹配被移到忽略列表 | 否(默认启用) |
fixed |
Filter | 匹配被移到忽略列表 | 否(默认启用) |
affected |
Augment | 已忽略的匹配被恢复 | 是(--vex-add affected) |
under_investigation |
Augment | 已忽略的匹配被恢复 | 是(--vex-add under_investigation) |
报告输出系统:从内存数据到多种格式
匹配结果经过过滤和 VEX 处理后,进入 runGrype() 的最后阶段——生成报告。
Document:报告的通用数据模型
所有输出格式共享同一个数据源:models.Document。NewDocument() 将 match.Matches 和 []match.IgnoredMatch 转换为独立的模型层数据:
1 | // grype/presenter/models/document.go |
IgnoredMatches 使用 json:"ignoredMatches,omitempty" 标签——当没有忽略匹配时,JSON 输出中不会出现这个字段。每个 IgnoredMatch 包含完整的漏洞信息和触发忽略的规则列表:
1 | type IgnoredMatch struct { |
这个模型层转换的要点是 有效的匹配和忽略的匹配都要放进报告——区别只在于前者在 matches 中,后者在 ignoredMatches 中。这意味着 JSON 输出中,用户可以完整地看到"哪些漏洞被匹配到了,哪些被忽略了,为什么被忽略"。
Writer 系统:一个文档,多个目标
format.ScanResultWriter 是输出层的核心抽象:
1 | type ScanResultWriter interface { |
Grype 支持三种 Writer:
scanResultMultiWriter:将同一份结果写到多个目标。这对应了-o json=result.json -o table=report.txt这样的用法——一次扫描,多种输出。scanResultStreamWriter:写到文件或 stdout。scanResultPublisher:发布到事件总线(供交互式 UI 消费)。
输出路径的解析逻辑在 parseOutputFlags() 中:它将 -o <format>=<path> 字符串拆解,格式名映射到 Format 枚举,路径经过 homedir.Expand() 处理(支持 ~),如果目录不存在则自动创建。
格式工厂
GetPresenter() 是一个工厂函数,根据格式枚举创建对应的 Presenter:
1 | func GetPresenter(format Format, c PresentationConfig, pb models.PresenterConfig) presenter.Presenter { |
每个 Presenter 都实现了 go-presenter 库的 Presenter 接口(Present(io.Writer) error),各格式的实现差异很大。
各格式输出实现一览
Table 格式。使用 tablewriter 库渲染无边框表格,列包括:Name、Installed、Fixed In、Type、Vulnerability、Severity、EPSS、Risk。getRows() 是核心方法——它先生成有效匹配的行,如果 showSuppressed(通过 --show-suppressed 开启)为 true,再将忽略匹配追加进去,用 “(suppressed)” 或 “(suppressed by VEX)” 标注:
1 | func (p *Presenter) getRows(doc models.Document, showSuppressed bool) rows { |
颜色处理使用了 lipgloss 库——Critical 是粗体粉色,High 是红色,Medium 是金色,Low 是青色,Negligible 是深灰色。KEV 漏洞用白底粉字渲染,非常显眼。
JSON 格式。最简单——直接 json.Encoder.Encode(document)。支持 Pretty 模式(缩进)。
CycloneDX 格式。将 Syft 的 SBOM 转换为 CycloneDX 格式,然后在 BOM 上附加漏洞信息——包括评分(CVSS + EPSS + KEV)、来源、参考链接、修复建议、受影响组件 BOM ref。这种方式保留了完整的 SBOM 物料清单,让下游工具(如 Dependency-Track)可以同时消费组件信息和漏洞信息:
1 | func (p *Presenter) Present(output io.Writer) error { |
SARIF 格式。输出 sarif.Version210 格式的报告。它为每个唯一的"漏洞-包"组合生成一条 Rule,使用 SHA-256 为每个结果生成 Partial Fingerprints(用于跨扫描追踪告警),并区分了四种位置来源(镜像、文件、目录、PURL/SBOM)。SARIF level 的映射遵循了 GitHub Code Scanning 的惯例:
- Critical / High →
"error" - Medium →
"warning" - Low / Negligible / Unknown →
"note"
Template 格式。允许用户使用 Go template(带 Sprig 函数库)自定义输出格式。模板可以直接访问 Document 的全部字段,包括 Matches 和 IgnoredMatches。
一个细节:忽略匹配在各格式中的呈现差异
各种输出格式对 IgnoredMatches 的处理策略并不一致:
- JSON:
IgnoredMatches作为Document的一个字段自然呈现,每个忽略匹配都自带AppliedIgnoreRules。 - Table:需要
--show-suppressed显式开启,忽略匹配以灰色标注追加到表格末尾。 - SARIF / CycloneDX:不包含忽略的匹配——它们只输出有效的
Matches。这是一个务实的选择:SARIF 和 CycloneDX 作为标准化交换格式,应只包含"确认存在的"漏洞。 - Template:模板作者可以自由选择是否渲染忽略的匹配,灵活性最高。
–fail-on:用退出码驱动 CI 闸门
在 CI/CD 流水线中,扫描结果需要被量化为一个布尔值——“扫描通过了吗?”。Grype 用 --fail-on 选项和退出码实现了这个判断。
--fail-on 接收一个 Severity 级别字符串(negligible、low、medium、high、critical),Grepe 在匹配完成后调用 hasSeverityAtOrAbove() 检查剩余的有效匹配:
1 | func hasSeverityAtOrAbove(store vulnerability.MetadataProvider, severity vulnerability.Severity, matches match.Matches) bool { |
如果有任何一条匹配的 Severity 达到了阈值,Grype 返回 grypeerr.ErrAboveSeverityThreshold 错误,然后在 CLI 层映射为退出码 2:
1 | // cmd/grype/cli/cli.go |
退出码设计:
0:扫描完成,没有 Severity 达到阈值的漏洞(或没有设置--fail-on);1:扫描过程出错(数据库加载失败、解析错误等);2:扫描完成,但存在至少一条 Severity 达到或超过--fail-on阈值;100:数据库升级可用(仅用于grype db check)。
这个退出码设计让 CI 流水线可以做差异化处理:
1 | grype dir:. --fail-on high |
完整链路回顾
本文覆盖的是 Grype 扫描流程中的最后一个阶段。回顾从匹配到输出的完整链路:
1 | 匹配完成 (matches + ignoredMatches) |
整套系统的设计哲学可以概括为:
- 过滤是声明式的。IgnoreRule 描述"什么样的匹配应该被忽略",而不是命令式的过滤步骤。
- VEX 是外部的权威补充。扫描工具只负责"能匹配到什么",VEX 负责"确认哪些真的有问题、哪些真的没问题"。
- 输出是数据驱动的。所有格式共享同一个 Document 模型,每个 Presenter 只是同一份数据的不同视图。
- 退出码是策略的标准化接口。
--fail-on将"漏洞严重程度"翻译为流水线可消费的整数,连接扫描工具和 CI 系统的最后一段。
总结
这篇文章我们分析了漏洞匹配完成后经历的三个关键环节:
-
首先,过滤系统通过一套统一的
IgnoreRule引擎处理了四种不同来源的规则——硬编码的精确误报排除、Matcher 级别的路径去重、命令行和配置文件的用户过滤、以及 VEX 文档的状态过滤。每一层都有自己的来源和用途,但它们都被统一翻译为IgnoreRule,通过同一个ApplyIgnoreRules引擎处理。 -
其次,VEX 处理让 Grype 能够消费外部权威声明,将纯版本号的匹配结果与"漏洞是否真的可被利用"关联起来。OpenVEX 和 CSAF 两种格式自动检测,FilterMatches/AugmentMatches 两个阶段处理了状态声明之间的冲突,确保最后生效的是最权威的判断。
-
最后,报告输出系统通过 Document 模型和 Presenter 工厂,将匹配结果转换为 table、JSON、CycloneDX、SARIF 等多种格式。
--fail-on退出码提供了与 CI 流水线的最后一个桥接点。