0%

Grype 源码分析 11:结果过滤、VEX 与报告输出

我们已经沿着 Grype 的扫描链路,我们从 main() 一路跟踪到了漏洞匹配和风险评分。当 VulnerabilityMatcher.FindMatchesContext() 执行完毕,返回了 remainingMatches(有效匹配)和 ignoredMatches(被忽略匹配)。那么问题来了:从这两组匹配结果到用户最终看到的报告,中间还发生了什么?

本文将从 VulnerabilityMatcher.FindMatchesContext() 的内部过滤步骤出发,依次分析三件事:

  1. Grype 的过滤体系——漏洞匹配结果如何被逐层筛选,什么样的匹配会被"留下来",什么样的会被"忽略掉";
  2. VEX 处理机制——OpenVEX 和 CSAF 两份标准文档如何进入 Grype、如何与匹配结果交互;
  3. 报告输出系统——table、JSON、CycloneDX、SARIF 各格式如何生成,--fail-on 退出码如何计算。

读完之后,你将完整地理解一次 grype dir:. 从命令行到输出报告的闭环。

过滤体系全景:匹配完成后发生了什么

先回顾一下 VulnerabilityMatcher.FindMatchesContext() 的内部流程(简化版):

1
2
3
4
5
6
7
8
9
10
11
12
13
// grype/vulnerability_matcher.go
func (m *VulnerabilityMatcher) FindMatchesContext(...) {
// 第一步:在数据库中搜索漏洞匹配
remainingMatches, ignoredMatches, _ = m.findDBMatches(ctx, pkgs, progressMonitor)

// 第二步:应用 VEX 文档过滤
remainingMatches, ignoredMatches, _ = m.findVEXMatches(pkgContext, remainingMatches, ignoredMatches, progressMonitor)

// 第三步:检查 fail-on 条件
if m.FailSeverity != nil && hasSeverityAtOrAbove(...) {
err = grypeerr.ErrAboveSeverityThreshold
}
}

这三步的背后,实际上对应着一套四层过滤系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
原始匹配


第一层:内置精确忽略规则 (ApplyExplicitIgnoreRules)
│ 基于包类型、包名、CVE将已知误报排除


第二层:Matcher 返回的忽略规则 (ignoredMatchFilter)
│ 基于文件路径重叠去重多个 Matcher 的重复匹配


第三层:用户自定义忽略规则 (ApplyIgnoreRules)
│ 基于 --ignore-states、--only-fixed、配置文件 ignore 规则


第四层:VEX 文档处理 (ApplyVEX)
│ 基于 VEX 文档的 "not_affected" / "fixed" 状态过滤
│ 基于 VEX 文档的 "affected" / "under_investigation" 状态恢复


最终匹配结果

让我们重点分析第一层和第三层——因为这两层使用的是同一套 IgnoreRule + ApplyIgnoreRules 引擎,只是规则的来源不同。VEX 作为第四层有独立的处理通路,我们放在后面专门讨论。

IgnoreRule:一条规则的完整表达

忽略体系的血液是 IgnoreRule,它的设计体现了 Grype 的核心理念:一个忽略规则由多个维度组成,所有指定的维度必须同时满足(AND 逻辑),未指定的维度不做限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// grype/match/ignore.go
type IgnoreRule struct {
Vulnerability string // 精确漏洞 ID,如 "CVE-2021-44228"
IncludeAliases bool // 是否同时匹配漏洞别名
Reason string // 忽略原因,用于报告展示
Namespace string // 漏洞命名空间,如 "nvd:cpe"
FixState string // 修复状态过滤:fixed/not-fixed/unknown/wont-fix
Package IgnoreRulePackage // 包维度过滤条件
VexStatus string // VEX 状态(由 vex processor 处理)
VexJustification string // VEX 豁免理由
MatchType Type // 匹配类型过滤
}

type IgnoreRulePackage struct {
Name string // 包名(支持正则)
Version string // 精确版本
Language string // 语言生态
Type string // 包类型
Location string // 文件路径(支持 glob)
UpstreamName string // 上游包名(支持正则)
}

你可以看到,一个规则可以同时组合"某个 CVE" + “某个包名” + “某种包类型” + “某个文件位置”——只有当一个匹配在所有这些维度上同时命中时,才会被忽略。

忽略匹配的条件函数体系

IgnoreRule.IgnoreMatch() 方法的核心思路很清晰:

  1. 遍历规则中所有非空字段,为每个字段构造一个条件函数;
  2. 如果一个条件返回 false,规则立即不生效;
  3. 如果所有条件都返回 true,则规则生效,返回自身。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (r IgnoreRule) IgnoreMatch(match Match) []IgnoreRule {
// VEX 规则由 vex processor 单独处理
if r.VexStatus != "" {
return nil
}

ignoreConditions := getIgnoreConditionsForRule(r)
if len(ignoreConditions) == 0 {
return nil // 规则没有指定任何条件,不生效
}

for _, condition := range ignoreConditions {
if !condition(match) {
return nil // 任何一个条件不满足,规则不生效
}
}

return []IgnoreRule{r} // 所有条件满足,规则生效
}

每个条件函数都是一个 func(match Match) bool 闭包。值得关注的是其中几个有特殊逻辑的条件:

包名匹配支持正则探测。Grype 会检测字符串中是否包含正则特殊字符(^$*+?[]{}(\)| 等),如果有则编译为正则表达式,否则走精确字符串匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func ifPackageNameApplies(name string) ignoreCondition {
if isLikelyARegex(name) {
pattern, err := packageNameRegex(name)
if err != nil || pattern == nil {
return func(Match) bool { return false }
}
return func(match Match) bool {
return pattern.MatchString(match.Package.Name)
}
}
return func(match Match) bool {
return name == match.Package.Name
}
}

一个有趣的设计是:. 被刻意排除在正则检测之外。注释解释道这个字符在包名和版本号中太常见了,如果把它当作正则,会产生大量不需要的编译和误判。

文件路径匹配使用 glob 模式。通过 doublestar.Match 支持 ** 递归通配,这意味着你可以在配置文件中写 **/test/** 来忽略所有测试目录下的匹配:

1
2
3
4
5
6
7
func ruleLocationAppliesToPath(location, path string) bool {
doesMatch, err := doublestar.Match(location, path)
if err != nil {
return false
}
return doesMatch
}

FixState 的特殊处理unknown 状态同时匹配 FixStateUnknown 和空字符串(表示修复状态未指定):

1
2
3
4
5
6
7
8
9
func ifFixStateApplies(fs string) ignoreCondition {
return func(match Match) bool {
if fs == string(vulnerability.FixStateUnknown) &&
match.Vulnerability.Fix.State == "" {
return true
}
return fs == string(match.Vulnerability.Fix.State)
}
}

忽略规则的批量应用

有了单个规则的匹配逻辑,批量应用就很简单了。ApplyIgnoreFilters 遍历所有匹配,对每个匹配尝试所有规则,一旦命中就将其从"有效匹配"移到"忽略匹配"中,并附带记录哪些规则导致了忽略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func ApplyIgnoreFilters[T IgnoreFilter](matches []Match, filters ...T) ([]Match, []IgnoredMatch) {
var out []Match
var ignoredMatches []IgnoredMatch

for _, match := range matches {
var applicableRules []IgnoreRule
for _, filter := range filters {
applicableRules = append(applicableRules, filter.IgnoreMatch(match)...)
}
if len(applicableRules) > 0 {
ignoredMatches = append(ignoredMatches, IgnoredMatch{
Match: match,
AppliedIgnoreRules: applicableRules,
})
continue
}
out = append(out, match)
}
return out, ignoredMatches
}

IgnoredMatch 是一个包装结构——它在 Match 之上附加了 AppliedIgnoreRules 列表,让用户清楚地知道"这条漏洞为什么被忽略了":

1
2
3
4
type IgnoredMatch struct {
Match
AppliedIgnoreRules []IgnoreRule
}

内置忽略:从硬编码误报到命令行快捷过滤

理解引擎之后,再看 Grype 自带哪些过滤规则。它们分为三层:硬编码的精确忽略规则、命令行快捷过滤、用户配置文件中的自定义规则。

硬编码的精确忽略

Grype 的 explicitIgnoreRules 是在 init() 中硬编码的一组规则,处理的都是已知的包名混淆场景——一个 CVE 影响的是某个特定产品,但因为共享相同的包名,被错误地关联到了不相关的包上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// grype/match/explicit_ignores.go
func init() {
var explicitIgnores = []ignoreValues{
// CVE-2021-44228 影响 log4j,但被关联到了某些不相关的 java-archive 包
{
typ: "java-archive",
vulnerabilities: []string{"CVE-2021-44228", "CVE-2021-45046", /* ... */},
packages: []string{"log4j-api", "log4j-slf4j-impl", /* ... */},
},
// CVE-2017-14196 影响 Squiz Matrix CMS,与 ruby gem "matrix" 无关
{
typ: "gem",
vulnerabilities: []string{"CVE-2017-14196", /* ... */},
packages: []string{"matrix"},
},
// CVE-1999-1338 影响 DeleGate 代理服务器,与 ruby gem "delegate" 无关
{
typ: "gem",
vulnerabilities: []string{"CVE-1999-1338", /* ... */},
packages: []string{"delegate"},
},
}
// 对每条记录,按 vuln × package 笛卡尔积生成 IgnoreRule
}

这些规则通过 ApplyExplicitIgnoreRules() 在 Matcher 匹配期间就介入——它发生在每个包的数据库查询完成之后(即调用这个设计反映了 Grype 的一个务实原则:与其在数据库中修改(可能影响其他使用者),不如在扫描时透明地排除已知误报,并允许用户通过 ExclusionProvider 接口从数据库中加载更多规则

命令行快捷过滤

runGrype() 函数中,Grype 将几个命令行选项翻译为 IgnoreRule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// cmd/grype/cli/commands/root.go

// --only-fixed:只显示有修复的漏洞
var ignoreNonFixedMatches = []match.IgnoreRule{
{FixState: string(vulnerability.FixStateNotFixed)},
{FixState: string(vulnerability.FixStateWontFix)},
{FixState: string(vulnerability.FixStateUnknown)},
}

// --only-notfixed:只显示尚未修复的漏洞
var ignoreFixedMatches = []match.IgnoreRule{
{FixState: string(vulnerability.FixStateFixed)},
}

// --ignore-states:通用的修复状态过滤
for _, ignoreState := range stringutil.SplitCommaSeparatedString(opts.IgnoreStates) {
opts.Ignore = append(opts.Ignore, match.IgnoreRule{FixState: ignoreState})
}

--only-fixed--only-notfixed 这两个选项本质上不过是预制的 IgnoreRule 组合。这解释了为什么命令行有这么多的 filter 选项,但底层都走同一套 ApplyIgnoreRules 引擎。

另外,内核头文件匹配也有自己的一组默认忽略规则:

1
2
3
4
5
6
7
var ignoreLinuxKernelHeaders = []match.IgnoreRule{
{Package: match.IgnoreRulePackage{
Name: "kernel-headers", UpstreamName: "kernel",
Type: string(syftPkg.RpmPkg),
}, MatchType: match.ExactIndirectMatch},
// ... deb 内核头文件包同样被忽略
}

当用户没有启用 --match-upstream-kernel-headers 时,这些规则被静默添加。为什么默认排除?因为内核头文件包通常只包含头文件而非可执行代码,linux 内核的漏洞几乎永远不可能通过这些头文件包被利用——这是一个典型的"技术上匹配但业务上无意义"的场景。

忽略规则中的性能优化

当存在大量忽略规则时,逐条遍历匹配可能带来性能问题。Grype 通过 ignoredMatchFilter() 对规则做了按维度索引的优化:

1
2
3
4
5
6
type ignoreRulesByIndex struct {
remainingFilters []match.IgnoreFilter // 不适用索引的规则
locationIgnoreRules map[string][]match.IgnoreRule // 按文件路径索引
packageNameIgnoreRules map[string][]match.IgnoreRule // 按包名索引
vulnIDFilters map[string][]match.IgnoreFilter // 按漏洞ID索引
}

对于不含通配符的路径规则、包名规则、漏洞 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
2
3
4
5
6
// grype/vex/processor.go
type vexProcessorImplementation interface {
ReadVexDocuments(docs []string) (any, error)
FilterMatches(any, []match.IgnoreRule, *pkg.Context, *match.Matches, []match.IgnoredMatch) (*match.Matches, []match.IgnoredMatch, error)
AugmentMatches(any, []match.IgnoreRule, *pkg.Context, *match.Matches, []match.IgnoredMatch) (*match.Matches, []match.IgnoredMatch, error)
}

ApplyVEX() 方法统一编排了这两个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (vm *Processor) ApplyVEX(pkgContext *pkg.Context, remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch) (*match.Matches, []match.IgnoredMatch, error) {
// 1. 读取 VEX 文档
rawVexData, _ := vm.impl.ReadVexDocuments(vm.Options.Documents)

vexRules := extractVexRules(vm.Options.IgnoreRules)

// 2. FilterMatches:将 not_affected / fixed 的匹配移到忽略列表
remainingMatches, ignoredMatches, _ = vm.impl.FilterMatches(rawVexData, vexRules, pkgContext, remainingMatches, ignoredMatches)

// 3. AugmentMatches:将 affected / under_investigation 的匹配从忽略列表移回
remainingMatches, ignoredMatches, _ = vm.impl.AugmentMatches(rawVexData, vexRules, pkgContext, remainingMatches, ignoredMatches)

return remainingMatches, ignoredMatches, nil
}

为什么要分两步?因为 Filter 和 Augment 的视角是相反的:

  • FilterMatches 面向"这个漏洞其实不可利用"的场景。VEX 说 not_affectedfixed,那你之前基于版本号的匹配就是假阳性,应当从有效匹配中移除。
  • AugmentMatches 面向"这个漏洞其实很重要"的场景。如果一条匹配之前因为用户 ignore 规则被抑制了,但 VEX 明确指出 affectedunder_investigation,那它应该被恢复到有效匹配中——VEX 的权威声明可以覆盖之前所有的忽略规则。

两步走的设计还有一个实用的效果:如果同一个漏洞在两个 VEX 文件中有冲突的声明(比如一份说 not_affected,另一份说 affected),那么 AugmentMatches 在 FilterMatches 之后执行affected 会覆盖之前的 not_affected

格式检测由 getVexImplementation() 自动完成——它嗅探第一个文档文件,判断是 CSAF 还是 OpenVEX:

1
2
3
4
5
6
7
8
9
10
11
12
func getVexImplementation(documents []string) (vexProcessorImplementation, error) {
if len(documents) == 0 {
return nil, nil
}
if csaf.IsCSAF(firstDoc) {
return csaf.New(), nil
}
if openvex.IsOpenVex(firstDoc) {
return openvex.New(), nil
}
return nil, fmt.Errorf("unsupported VEX document format")
}

所有 VEX 文件必须是同一格式——你不能在一次扫描中混合使用 OpenVEX 和 CSAF。

OpenVEX:产品身份识别与两阶段匹配

OpenVEX 实现中最复杂的部分不是语句匹配本身,而是产品身份识别——Grype 如何将扫描到的软件包与 VEX Statement 中的 Product 关联起来。

productIdentifiersFromContext() 为这个关联构建了身份标识列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func productIdentifiersFromContext(pkgContext *pkg.Context) []string {
switch v := pkgContext.Source.Metadata.(type) {
case source.ImageMetadata:
// 镜像扫描:从 Tags + RepoDigests 构建标识符
tagIdentifiers := identifiersFromTags(v.Tags, pkgContext.Source.Name)
digestIdentifiers := identifiersFromDigests(v.RepoDigests)
return slices.Concat(tagIdentifiers, digestIdentifiers)
default:
// 目录/文件扫描:用 name@version 构造 generic PURL
if pkgContext.Source.Name != "" && pkgContext.Source.Version != "" {
return []string{"pkg:generic/" + strings.ToLower(pkgContext.Source.Name) + "@" + pkgContext.Source.Version}
}
return []string{}
}
}

对于镜像扫描,Grype 从每个 tag 和 digest 构造多个标识符,包括原始 tag 字符串和 OCI PURL(带 ?tag= qualifier 或 ?repository_url= qualifier)。Docker Hub 的多种 URL 形式(docker.ioindex.docker.ioregistry-1.docker.io)被统一规范化为 index.docker.io

对于目录扫描,Grype 用 pkg:generic/<name>@<version> 作为产品标识——这允许 VEX 文档直接以包 PURL 作为产品声明。

有了产品标识之后,findMatchingStatement() 执行一次两阶段匹配

  1. 第一轮:以扫描上下文的产品标识(镜像、目录)作为产品去查 VEX,以当前包的 PURL 作为子组件过滤。这处理了"镜像即产品、包即子组件"的容器扫描场景。
  2. 第二轮:如果第一轮没找到,以包的 PURL 自身作为产品去查 VEX。这处理了"包即产品"的目录扫描场景——VEX 文档中的产品声明可能就是 pkg:npm/lodash@4.17.21

CSAF:标准化安全公告的处理

CSAF 的实现比 OpenVEX 多了一层——多份公告的排序和聚合

1
2
3
4
5
6
7
8
9
func (*Processor) ReadVexDocuments(docs []string) (any, error) {
var advs advisories
for _, doc := range docs {
adv, _ := csaf.LoadAdvisory(doc)
advs = append(advs, adv)
}
slices.SortStableFunc(advs, newerCurrentReleaseDateFirst)
return advs, nil
}

当多个 CSAF 公告都声明了同一个 CVE 的状态时,Grype 按 CurrentReleaseDate 降序排列——最新发布的公告优先。这是一个务实的决策:最新发布的公告最有可能反映最新的分析结论。

CSAF 的状态体系比 OpenVEX 更丰富——它有 8 种产品状态(first_affectedknown_affectedlast_affectedrecommendedfirst_fixedfixedknown_not_affectedunder_investigation),但都被映射到了 CISA 定义的 4 个标准状态之一:

1
2
3
4
5
// grype/vex/csaf/status.go (逻辑精简)
// first_affected, known_affected, last_affected, recommended → affected
// first_fixed, fixed → fixed
// known_not_affected → not_affected
// under_investigation → under_investigation

VEX 规则:默认行为与自定义

当用户通过 --vex 提供了 VEX 文件时,Grype 自动认为用户希望用 VEX 来过滤结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func applyVexRules(opts *options.Grype) error {
if len(opts.VexDocuments) > 0 {
// 默认:VEX 文档中声明 not_affected 和 fixed 的都会被过滤
opts.Ignore = append(opts.Ignore, ignoreVEXFixedNotAffected...)
}
// --vex-add 允许用户额外指定要基于哪些状态做 Augment
for _, status := range opts.VexAdd {
switch status {
case string(vexStatus.Affected):
opts.Ignore = append(opts.Ignore, match.IgnoreRule{VexStatus: string(vexStatus.Affected)})
case string(vexStatus.UnderInvestigation):
opts.Ignore = append(opts.Ignore, match.IgnoreRule{VexStatus: string(vexStatus.UnderInvestigation)})
}
}
}

注意一个重要的区别: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.DocumentNewDocument()match.Matches[]match.IgnoredMatch 转换为独立的模型层数据:

1
2
3
4
5
6
7
8
9
// grype/presenter/models/document.go
type Document struct {
Matches []Match `json:"matches"`
IgnoredMatches []IgnoredMatch `json:"ignoredMatches,omitempty"`
AlertsByPackage []PackageAlerts `json:"alertsByPackage,omitempty"`
Source *source `json:"source"`
Distro distribution `json:"distro"`
Descriptor descriptor `json:"descriptor"`
}

IgnoredMatches 使用 json:"ignoredMatches,omitempty" 标签——当没有忽略匹配时,JSON 输出中不会出现这个字段。每个 IgnoredMatch 包含完整的漏洞信息和触发忽略的规则列表:

1
2
3
4
type IgnoredMatch struct {
Match
AppliedIgnoreRules []IgnoreRule `json:"appliedIgnoreRules"`
}

这个模型层转换的要点是 有效的匹配和忽略的匹配都要放进报告——区别只在于前者在 matches 中,后者在 ignoredMatches 中。这意味着 JSON 输出中,用户可以完整地看到"哪些漏洞被匹配到了,哪些被忽略了,为什么被忽略"。

Writer 系统:一个文档,多个目标

format.ScanResultWriter 是输出层的核心抽象:

1
2
3
type ScanResultWriter interface {
Write(result models.PresenterConfig) error
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func GetPresenter(format Format, c PresentationConfig, pb models.PresenterConfig) presenter.Presenter {
switch format {
case JSONFormat:
return json.NewPresenter(pb)
case TableFormat:
return table.NewPresenter(pb, c.ShowSuppressed)
case CycloneDXJSON:
return cyclonedx.NewJSONPresenter(pb)
case CycloneDXXML:
return cyclonedx.NewXMLPresenter(pb)
case SarifFormat:
return sarif.NewPresenter(pb)
case TemplateFormat:
return template.NewPresenter(pb, c.TemplateFilePath)
}
}

每个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (p *Presenter) getRows(doc models.Document, showSuppressed bool) rows {
var rs rows
for _, m := range doc.Matches {
rs = append(rs, p.newRow(m, "", multipleDistros))
}
if showSuppressed {
for _, m := range doc.IgnoredMatches {
msg := appendSuppressed
for i := range m.AppliedIgnoreRules {
if m.AppliedIgnoreRules[i].Namespace == "vex" {
msg = appendSuppressedVEX
}
}
rs = append(rs, p.newRow(m.Match, msg, multipleDistros))
}
}
return rs
}

颜色处理使用了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (p *Presenter) Present(output io.Writer) error {
cyclonedxBOM := cyclonedxhelpers.ToFormatModel(*p.sbom)
cyclonedxBOM.Metadata.Tools = &cyclonedx.ToolsChoice{
Components: &[]cyclonedx.Component{{
Type: cyclonedx.ComponentTypeApplication,
Name: p.id.Name, Version: p.id.Version,
}},
}
vulns := make([]cyclonedx.Vulnerability, 0)
for _, m := range p.document.Matches {
v, _ := NewVulnerability(m)
vulns = append(vulns, v)
}
cyclonedxBOM.Vulnerabilities = &vulns
enc := cyclonedx.NewBOMEncoder(output, p.format)
return enc.EncodeVersion(cyclonedxBOM, cyclonedxBOM.SpecVersion)
}

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 的全部字段,包括 MatchesIgnoredMatches

一个细节:忽略匹配在各格式中的呈现差异

各种输出格式对 IgnoredMatches 的处理策略并不一致:

  • JSONIgnoredMatches 作为 Document 的一个字段自然呈现,每个忽略匹配都自带 AppliedIgnoreRules
  • Table:需要 --show-suppressed 显式开启,忽略匹配以灰色标注追加到表格末尾。
  • SARIF / CycloneDX不包含忽略的匹配——它们只输出有效的 Matches。这是一个务实的选择:SARIF 和 CycloneDX 作为标准化交换格式,应只包含"确认存在的"漏洞。
  • Template:模板作者可以自由选择是否渲染忽略的匹配,灵活性最高。

–fail-on:用退出码驱动 CI 闸门

在 CI/CD 流水线中,扫描结果需要被量化为一个布尔值——“扫描通过了吗?”。Grype 用 --fail-on 选项和退出码实现了这个判断。

--fail-on 接收一个 Severity 级别字符串(negligiblelowmediumhighcritical),Grepe 在匹配完成后调用 hasSeverityAtOrAbove() 检查剩余的有效匹配:

1
2
3
4
5
6
7
8
9
10
11
12
func hasSeverityAtOrAbove(store vulnerability.MetadataProvider, severity vulnerability.Severity, matches match.Matches) bool {
if severity == vulnerability.UnknownSeverity {
return false
}
for m := range matches.Enumerate() {
metadata, _ := store.VulnerabilityMetadata(m.Vulnerability.Reference)
if vulnerability.ParseSeverity(metadata.Severity) >= severity {
return true
}
}
return false
}

如果有任何一条匹配的 Severity 达到了阈值,Grype 返回 grypeerr.ErrAboveSeverityThreshold 错误,然后在 CLI 层映射为退出码 2:

1
2
3
4
5
6
7
8
9
10
// cmd/grype/cli/cli.go
WithMapExitCode(func(err error) int {
if errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
return 2 // 有漏洞的 Severity 达到或超过 --fail-on 指定的级别
}
if errors.Is(err, grypeerr.ErrDBUpgradeAvailable) {
return 100 // 数据库需要更新
}
return 1 // 其他错误
})

退出码设计

  • 0:扫描完成,没有 Severity 达到阈值的漏洞(或没有设置 --fail-on);
  • 1:扫描过程出错(数据库加载失败、解析错误等);
  • 2:扫描完成,但存在至少一条 Severity 达到或超过 --fail-on 阈值;
  • 100:数据库升级可用(仅用于 grype db check)。

这个退出码设计让 CI 流水线可以做差异化处理:

1
2
3
4
5
6
grype dir:. --fail-on high
case $? in
0) echo "No high or critical vulnerabilities" ;;
1) echo "Scan failed" && exit 1 ;;
2) echo "High/Critical vulnerabilities found! Blocking deploy." && exit 2 ;;
esac

完整链路回顾

本文覆盖的是 Grype 扫描流程中的最后一个阶段。回顾从匹配到输出的完整链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
匹配完成 (matches + ignoredMatches)

├── 第一层过滤:ApplyExplicitIgnoreRules
│ 硬编码误报规则 + 数据库 ExclusionProvider

├── 第二层过滤:ignoredMatchFilter
│ 基于文件位置/包名/漏洞 ID 的去重和 Matcher 级忽略

├── 第三层过滤:用户 IgnoreRules
│ --only-fixed / --only-notfixed / --ignore-states / 配置文件

├── 第四层处理:VEX
│ FilterMatches: not_affected/fixed → 忽略
│ AugmentMatches: affected/under_investigation → 恢复

├── fail-on 判断
│ 如果有匹配的 Severity >= 阈值 → 返回退出码 2

└── Presenter 输出
Table / JSON / CycloneDX / SARIF / Template

整套系统的设计哲学可以概括为:

  • 过滤是声明式的。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 流水线的最后一个桥接点。