0%

Grype 源码分析 07:Matcher 匹配框架

在上一篇文章中,我们详细分析了 Grype 漏洞数据库 v6 的实现原理——它如何下载、存储和查询漏洞数据。数据库提供了 vulnerability.Provider 接口,Matcher 通过这个接口查询候选漏洞记录。但是,一个关键问题还没有回答:拿到一批软件包后,Grype 如何为每个包选择正确的 Matcher?每个 Matcher 又如何构造查询条件、执行搜索、过滤结果,并最终生成 Match 对象?

这就是本文要分析的主题——Grype 的 Matcher 匹配框架。它不是一个孤立的算法,而是一套完整的分层架构:上层 VulnerabilityMatcher 负责调度和编排,中间层的具体 Matcher 实现负责按生态特点选择搜索策略,下层的 result.Setmatch.Matches 则负责中间结果的收集、去重和合并。

为了便于理解,本文从一个具体问题出发:VulnerabilityMatcher.FindMatchesContext() 被调用后,内部究竟发生了什么?

Matcher 的组建:类型注册与分发

在深入匹配流程之前,首先需要理解 Matcher 是如何被组织起来的。Grype 为不同的包类型提供了不同的 Matcher 实现——deb 包用 dpkg-matcher,npm 包用 javascript-matcher,RPM 包用 rpm-matcher,等等。这引出了两个基本问题:每个 Matcher 如何声明自己"负责什么类型的包"?框架又如何根据包类型找到对应的 Matcher?

Matcher 接口

所有 Matcher 都实现同一个接口,定义在 grype/match/matcher.go 中:

1
2
3
4
5
type Matcher interface {
PackageTypes() []syftPkg.Type // 声明自己负责哪些包类型
Type() MatcherType // 返回 Matcher 自身的标识名
Match(vp vulnerability.Provider, p pkg.Package) ([]Match, []IgnoreFilter, error)
}

这三个方法各司其职:

  • PackageTypes() 是注册机制的核心。例如 DpkgMatcher 返回 []syftPkg.Type{syftPkg.DebPkg},表示它只处理 Debian 软件包;而 StockMatcher 返回 nil,意味着它不主动认领任何包类型,而是作为"兜底"匹配器存在。
  • Type() 返回 Matcher 的标识名(如 "dpkg-matcher"),这个标识名最终会出现在 Match Details 的 Matcher 字段中,让用户知道是哪一种匹配器做出了判断。
  • Match() 是对单个包执行漏洞匹配的入口。它接收漏洞 Provider 和一个 Package,返回匹配结果、忽略过滤器和可能的错误。

Matcher 注册与索引

root.go 中,Grype 通过 matcher.NewDefaultMatchers() 一次性创建全部 16 个 Matcher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func NewDefaultMatchers(mc Config) []match.Matcher {
return []match.Matcher{
dpkg.NewDpkgMatcher(mc.Dpkg),
ruby.NewRubyMatcher(mc.Ruby),
python.NewPythonMatcher(mc.Python),
dotnet.NewDotnetMatcher(mc.Dotnet),
rpm.NewRpmMatcher(mc.Rpm),
java.NewJavaMatcher(mc.Java),
javascript.NewJavascriptMatcher(mc.Javascript),
&apk.Matcher{},
golang.NewGolangMatcher(mc.Golang),
&msrc.Matcher{},
&portage.Matcher{},
rust.NewRustMatcher(mc.Rust),
hex.NewHexMatcher(mc.Hex),
stock.NewStockMatcher(mc.Stock),
&bitnami.Matcher{},
&pacman.Matcher{},
}
}

这份列表被传入 VulnerabilityMatcher,但并非直接存储在切片中使用。searchDBForMatches() 在处理第一个包之前,会调用 newMatcherIndex() 将列表转换为一个索引映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func newMatcherIndex(matchers []match.Matcher) (map[syftPkg.Type][]match.Matcher, match.Matcher) {
matcherIndex := make(map[syftPkg.Type][]match.Matcher)
var defaultMatcher match.Matcher
for _, m := range matchers {
if m.Type() == match.StockMatcher {
defaultMatcher = m
continue // StockMatcher 不进入索引,单独处理
}
for _, t := range m.PackageTypes() {
matcherIndex[t] = append(matcherIndex[t], m)
}
}
return matcherIndex, defaultMatcher
}

这个函数做了两件事:

  • 将每个 Matcher 按它声明的 PackageTypes() 注册到 matcherIndex 中——一个 map[syftPkg.Type][]match.Matcher。这样一来,当遇到一个 Type == "deb" 的包时,只需查 matcherIndex["deb"] 就能找到对应的 DpkgMatcher
  • StockMatcher 单独提取为 defaultMatcher。如果某个包类型在索引中找不到对应的 Matcher,就由 StockMatcher 兜底。StockMatcher 本身不限定包类型(PackageTypes() 返回 nil),但它会尝试通过语言生态和 CPE 两种路径进行匹配,确保没有包被遗漏。

这种设计使得新增一个生态的 Matcher 非常简单:只需实现 Matcher 接口,声明它处理的包类型,然后在 NewDefaultMatchers() 中注册即可。框架不需要任何额外修改。

匹配的总调度:VulnerabilityMatcher

理解了 Matcher 如何注册后,我们将视角上移,看匹配过程的总调度者——VulnerabilityMatcher

FindMatchesContext 的整体流程

VulnerabilityMatcher 定义在 grype/vulnerability_matcher.go 中,它的 FindMatchesContext() 方法是整个匹配过程的入口。这个方法并非简单地遍历包列表然后调用 Matcher,而是分两个大的阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (m *VulnerabilityMatcher) FindMatchesContext(
ctx context.Context,
pkgs []pkg.Package,
pkgContext pkg.Context,
) (remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch, err error) {
// 阶段一:对漏洞数据库进行匹配
remainingMatches, ignoredMatches, err = m.findDBMatches(ctx, pkgs, progressMonitor)

// 阶段二:应用 VEX 文档
remainingMatches, ignoredMatches, err = m.findVEXMatches(pkgContext, remainingMatches, ignoredMatches, progressMonitor)

// 阶段三:检查是否超过 FailSeverity 阈值
if m.FailSeverity != nil && hasSeverityAtOrAbove(...) {
err = grypeerr.ErrAboveSeverityThreshold
}
return remainingMatches, ignoredMatches, nil
}

本文重点分析阶段一 findDBMatches(),因为它是 Matcher 框架的核心。VEX 匹配将在后续文章中单独讨论。

findDBMatches 的内部阶段

findDBMatches() 又分为三个子阶段:

  1. 搜索阶段searchDBForMatches):遍历每个包,用对应的 Matcher 向漏洞数据库发起查询,收集所有的初步匹配结果;
  2. Ignorer 过滤阶段:应用 Matcher 返回的忽略过滤器(如"已修复的发行版漏洞不应在语言生态包上再次报告"),以及硬编码的显式排除规则(ApplyExplicitIgnoreRules);
  3. 用户忽略规则applyIgnoreRules):应用用户在 .grype.yaml 中配置的 ignore 规则,将被忽略的匹配从正式结果中分离出来。

如果启用了 --by-cve 选项(NormalizeByCVE),还会额外执行一个规范化步骤:将 GHSA 等非 CVE 的漏洞 ID 统一转换为其关联的 CVE 编号,然后再次应用忽略规则——确保转换前后用户的忽略规则都能生效。

searchDBForMatches:逐包搜索

searchDBForMatches() 是匹配框架中最核心的函数。它的流程虽然不长,但每一步都蕴含着重要的设计决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (m *VulnerabilityMatcher) searchDBForMatches(
ctx context.Context,
packages []pkg.Package,
progressMonitor *monitorWriter,
) (match.Matches, error) {
matcherIndex, defaultMatcher := newMatcherIndex(m.Matchers)

for _, p := range packages {
// 1. 按包类型查找对应的 Matcher
matchAgainst, ok := matcherIndex[p.Type]
if !ok {
matchAgainst = []match.Matcher{defaultMatcher}
}

// 2. 对每个适用的 Matcher,执行匹配
for _, theMatcher := range matchAgainst {
matches, ignorers, err := callMatcherSafely(theMatcher, m.VulnerabilityProvider, p)

// 3. 应用显式排除规则
filtered, dropped := match.ApplyExplicitIgnoreRules(m.ExclusionProvider, match.NewMatches(matches...))
allMatches = append(allMatches, filtered.Sorted()...)
allIgnorers = append(allIgnorers, ignorers...)
}
}

// 4. 收集所有 Matcher 的 IgnoreFilter,对全部匹配统一过滤
filtered, dropped := match.ApplyIgnoreFilters(allMatches, ignoredMatchFilter(allIgnorers))

// 5. 通过 Matches 集合完成指纹去重
res := match.NewMatches(filtered...)
return res, nil
}

这里有三个值得展开讨论的设计点。

第一,一对多的 Matcher 映射。 matcherIndex 的值类型是 []match.Matcher 而不是单个 Matcher,这意味着一个包类型可以关联多个 Matcher。虽然目前每个类型只注册了一个 Matcher,但这种设计为未来扩展保留了空间——例如,一种新的语言生态可能需要同时由社区 Matcher 和官方 Matcher 共同处理。

第二,安全的调用封装。 callMatcherSafely() 通过 defer/recover 捕获 Matcher 内部的 panic,将其转化为 FatalError。这确保了单个 Matcher 的崩溃不会导致整个扫描任务失败——框架会记录错误日志,然后继续处理剩余的包。

第三,匹配结果的层层过滤。 每个 Matcher 返回的匹配并非最终结果,它们还要经过三层过滤:首先是硬编码的显式排除规则(如已知的误报 CVE),然后是 Matcher 自身返回的 IgnoreFilter(如发行版已修复的漏洞不应在语言生态包上重复报告),最后是用户的 ignore 配置。每一层过滤都有明确的职责边界,不会相互混淆。

搜索路径:三条匹配主线

现在,我们深入到单个 Matcher 的内部,看它如何对软件包执行漏洞搜索。尽管 Grype 有 16 个 Matcher,但它们的匹配逻辑并非各自为政——大部分 Matcher 最终都会调用 grype/matcher/internal 包中的几个公共函数。这些函数代表了三条不同的搜索路径。

语言生态匹配:MatchPackageByLanguage

语言生态匹配是处理 npm、Python、Ruby、Java、Go 等编程语言包的核心路径。MatchPackageByLanguage() 函数封装了这一完整过程。

搜索名称的展开。 匹配的第一步不是直接查数据库,而是调用 store.PackageSearchNames(p) 确定要搜索的包名列表。对于大多数包,这个列表只有一个元素——包自身的名称。但对于某些特殊场景,列表会扩展:

  • 一个名为 rootio-libssl3 的 Alpine 包,其搜索名会同时包含 rootio-libssl3libssl3
  • 一个 Java 包 com.fasterxml.jackson.core:jackson-databind 可能同时以 Maven 坐标和短名进行搜索。

这种多名称展开解决了同一个软件在不同漏洞数据源中使用不同命名的问题。对于每个搜索名称,Matcher 会构造一组查询条件并执行两次数据库查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for _, name := range store.PackageSearchNames(p) {
criteria := []vulnerability.Criteria{
search.ByEcosystem(p.Language, p.Type), // 限定生态
search.ByPackageName(name), // 限定包名
OnlyQualifiedPackages(p), // 检查 Qualifier
OnlyNonWithdrawnVulnerabilities(), // 排除已撤回的漏洞
}

// 第一次查询:获取该包名下的所有漏洞(受影响的)
all, err := provider.FindResults(criteria...)
disclosures = disclosures.Merge(all.Filter(versionCriteria))

// 第二次查询:获取明确声明"不受影响"的记录
nakCriteria := append(slices.Clone(criteria), search.ForUnaffected(), versionCriteria)
u, err := provider.FindResults(nakCriteria...)
unaffected = unaffected.Merge(u)
}

// 从受影响的集合中移除被明确标记为"不受影响"的条目
remaining := disclosures.Remove(unaffected)

为什么要查两次? 这是因为漏洞数据库中不仅记录了"哪些包受影响",还记录了"哪些包明确不受影响"。举个具体的例子:CVE-X 声称影响所有版本的 libfoo,但 Debian 安全团队分析了源代码后发现,Debian 的 libfoo 版本由于编译选项不同,实际不受该漏洞影响。此时数据库中会有一条 UnaffectedPackageHandle 记录,明确表示 libfoo 的 Debian 版本不受 CVE-X 的影响。第二次查询正是为了获取这些 NAK(Negative Acknowledgement,否定确认)记录。

两次查询的结果都收集完毕后,disclosures.Remove(unaffected) 会将被明确否定(unaffected)的漏洞从候选集合中移除。移除的依据是漏洞 ID 及其别名——如果某条漏洞记录说 libfoo 不受 CVE-X 影响,那么 disclosures 中所有 ID 为 CVE-X(或别名中包含 CVE-X)的结果都会被移除。

版本过滤。 在第一次查询中,all.Filter(versionCriteria) 是通过 OnlyVulnerableVersions() 实现的。它检查每个候选漏洞的版本约束是否与当前包的版本匹配。如果数据库返回了 10 条与 lodash 相关的漏洞记录,但其中 3 条的版本约束是 < 3.0.0(而 lodash 的版本是 4.17.20,不在这个范围内),那么这 3 条就会被过滤掉。

发行版匹配:MatchPackageByDistro

发行版匹配是处理 apk、deb、rpm 等操作系统包的核心路径。与语言生态匹配相比,它有两个显著区别:使用了发行版信息作为附加查询条件,以及引入了"已修复版本的忽略"机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func MatchPackageByDistro(provider vulnerability.Provider, searchPkg pkg.Package, ...) {
// 搜索条件增加了 search.ByDistro()
for _, name := range searchNames {
v, err := rp.FindResults(
search.ByPackageName(name),
search.ByDistro(*searchPkg.Distro), // 限定发行版及其版本
OnlyQualifiedPackages(searchPkg),
)
allVulns = allVulns.Merge(v)
}

// 将结果分为"受影响"和"已修复"两部分
vulnerable := allVulns.Filter(versionCriteria)
fixed := allVulns.Remove(vulnerable)

// 同时查询 unaffected 记录
unaffected := 查询 UnaffectedPackageHandle
vulnerable = vulnerable.Remove(unaffected)

// 为已修复的漏洞生成忽略规则
ignores := OwnershipIgnores(matchPackage(searchPkg, catalogPkg), "DistroPackageFixed", fixed.Vulnerabilities()...)
}

发行版匹配中最精妙的设计在于 fixedignores 的处理。以 Debian 为例,假设系统安装了 libssl3 1.1.1n-0+deb11u4,而数据库中有两条相关记录:

  • CVE-2023-0286:影响版本 < 1.1.1n-0+deb11u5(当前包受影响,因为 1.1.1n-0+deb11u4 < 1.1.1n-0+deb11u5
  • CVE-2022-4304:影响版本 < 1.1.1n-0+deb11u3(当前包不受影响,因为 1.1.1n-0+deb11u4 >= 1.1.1n-0+deb11u3

对于 CVE-2022-4304 这种情况——漏洞数据库中有记录,但当前包的版本已经包含了修复——Grype 不会直接在报告中显示这条漏洞,而是将其转变为一条 IgnoreFilterOwnershipIgnores() 会基于文件所有权关系,为所有与被修复包共享文件路径的其他包(例如在同一个路径下发现的 npm 包)标记忽略规则。这种设计的动机是:发行版包的修复信息是最权威的——如果 Debian 说这个包已经修复了 CVE-X,那么任何语言生态 Matcher 在同一文件位置上发现的同名 CVE 也应该被忽略,避免重复报告。

CPE 匹配:MatchPackageByCPEs

CPE(Common Platform Enumeration,通用平台枚举)匹配是第三条搜索路径。它不依赖语言生态或发行版信息,而是通过 CPE 标识符进行通用匹配。这条路径主要用于 StockMatcher(兜底匹配器),也在 apk 和 deb 等 Matcher 中作为辅助路径使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func MatchPackageByCPEs(vulnProvider vulnerability.Provider, p pkg.Package, ...) {
for _, c := range p.CPEs {
// 确定搜索版本:优先使用 CPE 中的版本,其次用包版本
searchVersion := c.Attributes.Version
if searchVersion == wfn.NA || searchVersion == wfn.Any {
searchVersion = p.Version
}

criteria := []vulnerability.Criteria{
search.ByCPE(c),
OnlyVulnerableTargets(p), // 检查目标软件是否匹配
OnlyQualifiedPackages(p),
OnlyNonWithdrawnVulnerabilities(),
}

all, err := provider.FindResults(criteria...)
vulns := all.Filter(versionCriteria)

// 同样查询 unaffected CPE 记录
unaffected, err := provider.FindResults(append(criteria, search.ForUnaffected(), versionCriteria)...)
}
}

CPE 匹配中有两个值得关注的细节。

一个是 Alpine 版本兼容处理。 Alpine 包的版本格式是 1.2.3-r21,其中 -r21 是构建索引。如果直接用这个版本去做 CPE 比较,nvdtools 的 WFN(Well-Formed Name)库会错误地将 -r21 识别为预发布(pre-release)后缀。因此 Grype 在 CPE 匹配前会剥离构建索引:

1
2
3
4
5
6
7
func alpineCPEComparableVersion(version string) string {
components := strings.Split(version, "-r")
if len(components) == 2 {
return components[0] // 只保留版本号部分
}
return version
}

另一个是目标软件过滤。 OnlyVulnerableTargets() 是 CPE 匹配独有的过滤条件。CPE 记录中包含 target_software 字段,标识了漏洞影响的软件类型(例如 nodejspythonjava)。如果漏洞记录声明它只影响 nodejs 目标,而当前包是一个 Python 包,那么这个漏洞就应该被排除。isVulnerableTarget() 函数通过比较漏洞 CPE 和包 CPE 的 TargetSW 字段来完成这一判断,同时考虑了多种特殊情况:

  • OS 包(apk、deb、rpm 等)可以直接跳过这一检查,因为它们可能嵌入任何类型的生态包;
  • Java 包特殊处理:因为 Java JAR 经常内嵌 JavaScript 等其他生态的组件,所以对 Java 包不做严格的目标软件过滤;
  • 二进制包(BinaryPkg)和未知类型包严格依赖 CPE 的 TargetSW 匹配,并支持 wildcard。

Criteria 体系:将"找什么样的漏洞"翻译为查询条件

分析完三条匹配路径后,有一个反复出现的概念需要单独展开——Criteria

Criteria 是 Grype 中"搜索条件"的抽象。它不是简单的过滤函数,而是一个接口,定义在 grype/vulnerability/provider.go 中:

1
2
3
type Criteria interface {
MatchesVulnerability(value Vulnerability) (bool, string, error)
}

但 Criteria 的作用远远不止在结果集上做过滤。在整个匹配流程中,Criteria 扮演了三重角色

角色一:数据库查询条件的构造

vulnerabilityProvider.FindVulnerabilities() 中,传入的 Criteria 会被转换为 SQL 查询条件。例如 search.ByPackageName("lodash") 会被翻译为 WHERE packages.name = 'lodash'search.ByEcosystem(JavaScript, NpmPkg) 会被翻译为 WHERE packages.ecosystem = 'npm'。那些无法翻译为 SQL 条件的 Criteria(如版本约束、Qualifier 检查、目标软件过滤)则被保留下来,在数据库返回结果后进行二次过滤。

这种"先 SQL 粗筛、后内存精筛"的策略,在保持数据库查询效率的同时,也保留了复杂过滤逻辑的灵活性。

角色二:AND/OR 逻辑的组合

多个 Criteria 可以组合使用。在匹配流程中,Matcher 传入的是一组 Criteria,它们之间的关系是 AND——所有条件必须同时满足。但如果需要在某些维度上实现"或"的语义,search.Or()search.And() 提供了显式的组合能力:

1
2
3
4
5
// AND 组合:所有条件都必须满足
search.And(search.ByPackageName("lodash"), search.ByEcosystem(JavaScript, NpmPkg))

// OR 组合:满足任意一个即可
search.Or(search.ByPackageName("lodash"), search.ByPackageName("lodash-es"))

更重要的是,CriteriaIterator() 能够将 AND/OR 嵌套的组合展开为多组扁平化的查询条件,每一组对应一次独立的数据库查询。这让 Matcher 可以用一次 FindResults() 调用覆盖多种搜索情况。

角色三:结果集的二次过滤

那些无法在数据库中执行的 Criteria(如 OnlyQualifiedPackagesOnlyVulnerableTargets 等),通过 result.Set.Filter() 在内存中执行。Filter() 遍历集合中的每个 Result,调用 filterVulns() 逐条检查漏洞是否满足所有 Criteria:

1
2
3
4
5
6
7
8
9
10
11
12
13
func filterVulns(vulnerabilities []vulnerability.Vulnerability, details match.Details, criteria []vulnerability.Criteria) {
nextVulnerability:
for _, v := range vulnerabilities {
for _, c := range criteria {
matches, dropReason, err := c.MatchesVulnerability(v)
if !matches {
continue nextVulnerability // 任一条不满足就跳过此漏洞
}
}
out = append(out, v)
}
return out, details, nil
}

下表总结了 Matcher 中常用的 Criteria 及其作用:

Criteria 作用 数据库层面 内存层面
ByPackageName 限定包名 翻译为 WHERE 条件 -
ByEcosystem 限定生态 翻译为 WHERE 条件 -
ByDistro 限定发行版 翻译为 WHERE 条件 -
ByCPE 限定 CPE 翻译为 JOIN 条件 -
ByVersion 版本约束检查 保留为二次过滤 调用 Constraint.Satisfied()
ForUnaffected 查询 unaffected 表 切换查询目标表 -
OnlyQualifiedPackages 包限定词检查 保留为二次过滤 调用每个 Qualifier 的 Satisfied()
OnlyVulnerableTargets 目标软件匹配 保留为二次过滤 比较 CPE TargetSW
OnlyNonWithdrawnVulnerabilities 排除撤回的漏洞 保留为二次过滤 检查状态是否 withdrawn/rejected

result.Set:中间结果的容器

在每条匹配路径中,provider.FindResults() 返回的不是 []match.Match,而是 result.Set。这是一个位于数据库查询结果和最终 Match 之间的中间数据结构。

为什么需要中间层

直接的想法是:数据库返回漏洞列表,Matcher 将其直接转换为 Match 对象即可。但实际需求更复杂:

  • 同一个漏洞 ID(如 CVE-X)可能通过不同的搜索路径(包名匹配、CPE 匹配、Upstream 包名匹配)被发现多次,每条路径对应的 match.Detail 不同;
  • 同一个漏洞 ID 可能影响了多个版本范围,每次版本约束的匹配会产生不同的 Vulnerability 对象;
  • 不同的漏洞 ID 可能互为别名(如 GHSA 和 CVE 的对应关系),需要按身份(ID + 别名)进行去重。

result.Set 正是为了解决这些问题而设计的。它的类型定义看似简单,实则承载了复杂的集合操作语义:

1
2
3
4
5
6
7
8
type Result struct {
ID string // 漏洞 ID
Vulnerabilities []vulnerability.Vulnerability // 属于此 ID 的漏洞记录
Details []match.Detail // 匹配证据
Package *pkg.Package // 匹配的包
}

type Set map[string][]Result

Set 以漏洞 ID 为键,每个键下可以存储多个 Result(因为同一个漏洞可以通过不同搜索路径被发现)。Set 提供了一组集合操作方法:

  • Merge():合并两个 Set。同一个 ID 下的 Result 会被合并,分别保留各自的 Details 和 Vulnerabilities;
  • Filter():对 Set 中每个 Result 的漏洞列表应用 Criteria 过滤;
  • Remove():按身份(ID + 别名)从当前 Set 中移除与传入 Set 重叠的条目。这是 disclosures.Remove(unaffected) 操作的实现基础——它将那些明确被标记为"不受影响"的漏洞从候选集中剔除;
  • Update():对符合条件的 Result 执行更新操作,例如用 unaffected 记录中的修复信息更新 disclosure 记录的 Fix 字段。

Remove() 的逻辑尤其值得注意——它不仅仅是按 ID 匹配,而是按身份匹配。身份包括漏洞 ID 和所有别名:

1
2
3
4
5
6
func getIdentity(id string, results []Result) *strset.Set {
identity := strset.New()
identity.Add(id)
identity.Add(extractAliases(results).List()...) // 包含 RelatedVulnerabilities
return identity
}

这意味着:如果一条 disclosure 的 ID 是 GHSA-xxxx,其 RelatedVulnerabilities 中包含 CVE-2023-xxxx,而一条 NAK 记录的 ID 正是 CVE-2023-xxxx,那么这条 disclosure 也会被移除——因为它们的身份重叠。这种设计确保了不同数据源(GitHub Advisory 和 NVD)对同一个漏洞的不同编号不会导致去重失效。

从 Set 到 Match

Set.ToMatches() 将中间结果转换为最终的 Match 列表。对于同一个 ID 下的多个 Result,它会先通过 unionIntoResult() 将它们合并为一个 Result(合并 Details 和 Vulnerabilities),然后将每个 Vulnerability 与 Package 和合并后的 Details 组合成一个 Match。这意味着:

  • 如果同一个漏洞 ID 有 3 条 Vulnerability 记录(不同的版本范围),会产生 3 个 Match;
  • 而这 3 个 Match 共享相同的 Details(合并了所有搜索路径的发现证据)。

合并与去重:从 result.Set 到 match.Matches

result.Set.ToMatches() 输出的还是 []match.Match,它们还需要经过最后一道关卡——match.Matches 集合的去重。

为什么需要二次去重

searchDBForMatches() 的循环中,每个包可能被多个 Matcher 处理(例如一个 deb 类型的包同时被 dpkg-matcher 直接匹配,其上游源包又被 apk-matcher 间接匹配)。同一个漏洞可能通过不同路径被发现,如果不做去重,报告中将出现重复行。

更重要的是,Grype 还支持通过不同的匹配维度(直接包名、上游包名、CPE)找到同一条漏洞记录。这些匹配结果的 Vulnerability 可能相同也可能不同(版本约束、修复版本可能不同),但它们的 Fingerprint 是相同的,因此应该被合并。

Fingerprint 与 Matches.Add()

match.MatchesAdd() 方法实现了三种情况的合并策略(在第四篇文章中已有分析,这里不再展开)。核心思路是:完整指纹相同则合并 Details;核心指纹相同但完整指纹不同时,优先保留直接匹配替换间接匹配;指纹完全不存在则直接插入。

IgnoreFilter 的跨 Matcher 去重

除了 Match 级别的去重,Grype 还有一个更隐蔽的去重机制——IgnoreFilter 的跨 Matcher 传播。

回顾发行版匹配中,OwnershipIgnores() 为已修复的发行版漏洞生成了 IgnoreRelatedPackage 类型的过滤器。这个过滤器的作用超出了当前 Matcher 的范围:

1
2
3
4
5
6
7
8
9
10
11
func (i IgnoreRelatedPackage) IgnoreMatch(m Match) []IgnoreRule {
// 检查当前 Match 的漏洞 ID 是否与过滤器的 ID 匹配
if m.Vulnerability.ID != i.VulnerabilityID { ... }

// 检查当前 Match 的包是否与过滤器中声明的包有文件所有权关系
relatedPackages := m.Package.RelatedPackages[i.RelationshipType]
for _, ownerPkg := range relatedPackages {
if ownerPkg.ID == i.RelatedPackageID { overlaps = true }
}
// ...
}

searchDBForMatches() 的最后阶段,所有 Matcher 产生的 IgnoreFilter 被收集到一起,通过 match.ApplyIgnoreFilters() 对全部匹配进行统一过滤。ignoredMatchFilter() 函数还进一步将这些过滤器按漏洞 ID、包名和文件路径做了索引,以提升大量规则下的过滤性能。

这就实现了一个跨 Matcher 的协同机制:dpkg-matcher 发现 libssl3 已经修复了 CVE-X,它不仅在自己的匹配中排除了这条记录,还通过 IgnoreFilter 让 javascript-matcherstock-matcher 在同文件位置的包上也排除同一个 CVE,避免了同一漏洞被多次报告。

不同生态 Matcher 的差异化策略

虽然大多数 Matcher 复用了 internal 包中的公共匹配函数,但它们在"如何组合这些函数"上表现出了各自的策略差异。理解这些差异,有助于理解为什么不同生态不能共用一种匹配规则。

简单生态 Matcher:javascript、python、ruby 等

JavascriptMatcher 为例,它的 Match() 方法只有一行:

1
2
3
func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
return internal.MatchPackageByEcosystemAndCPEs(store, p, m.Type(), m.cfg.UseCPEs)
}

MatchPackageByEcosystemAndCPEs() 组合了语言生态匹配和 CPE 匹配两条路径:先按语言生态(包名 + npm 生态)搜索,再按 CPE 搜索,将结果合并。这种简单策略适用于不涉及发行版信息的纯语言生态包——npm 包不存在"发行版修复忽略"这种概念。

与之相同模式的还有:Python、Ruby、Rust、Go、Java、Dotnet、Hex(Elixir)等。

带 Upstream 的发行版 Matcher:dpkg

DpkgMatcher 的匹配逻辑更加复杂。它首先通过 matchUpstreamPackages() 为每个上游源包(如从 libssl3 推导出的 openssl)执行间接匹配,然后再用 MatchPackageByDistro() 执行直接匹配。如果发行版已经 EOL(End of Life,生命周期终止),还会额外走 CPE 路径:

1
2
3
4
5
6
7
8
9
10
11
12
func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
// 1. 上游包间接匹配
matches, ignores, err := m.matchUpstreamPackages(store, p)

// 2. 直接包名匹配
exactMatches, exactIgnores, err := internal.MatchPackageByDistro(store, p, nil, m.Type(), &versionConfig)

// 3. EOL 发行版的 CPE 兜底匹配
if m.cfg.UseCPEsForEOL && internal.IsDistroEOL(store, p.Distro) {
cpeMatches, ignored, err := internal.MatchPackageByCPEs(store, p, m.Type())
}
}

EOL 兜底匹配的动机是:当发行版停止维护后,它的安全数据库不再更新。但 NVD 等通用漏洞库仍在收录新漏洞。此时通过 CPE 路径可以为 EOL 发行版的包补充发现那些发行版安全团队不再追踪的漏洞。

Alpine Matcher:最复杂的策略编排

ApkMatcher 是所有 Matcher 中逻辑最复杂的一个,因为它需要协调安全数据库(SecDB)和 CPE 两个数据源,并且引入了 NAK 机制SecDB 优先策略

1
2
3
4
5
6
7
8
9
10
func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
// 1. 直接匹配(包自身)
directMatches, directIgnores, err := m.findMatchesForPackage(store, p, nil)

// 2. 间接匹配(包的 origin package)
indirectMatches, indirectIgnores, err := m.findMatchesForOriginPackage(store, p)

// 3. NAK 查询:查找 SecDB 中明确标记为"不受影响"的记录
naks, err := m.findNaksForPackage(store, p)
}

在 CPE 匹配方面,cpeMatchesWithoutSecDBFixes() 实现了 SecDB 优先策略:如果某个 CVE 在 Alpine 的 SecDB 中有对应记录,并且该记录表明当前包的版本已经修复了此 CVE(!vulnerable),那么来自 CPE 路径的匹配就会被移除——Alpine 安全团队的判断优先于 NVD 的通用数据。只有当 CPE 路径发现了 SecDB 中没有收录的漏洞时,CPE 匹配才会被添加到最终结果中,同时这些"仅存在于 NVD 中"的记录的修复信息也会被清除(因为 NVD 不知道 Alpine 到底什么时候会修复)。

匹配框架与整体流程的衔接

让我们回到整体扫描流程,看 Matcher 框架如何与前几篇文章中的组件衔接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
root.go

├── pkg.Provide(userInput) → []pkg.Package, pkg.Context

├── dbCurator.Reader() → vulnerability.Provider

├── matcher.NewDefaultMatchers(config) → []match.Matcher

└── VulnerabilityMatcher{
VulnerabilityProvider: vp,
Matchers: getMatchers(opts),
IgnoreRules: opts.Ignore,
VexProcessor: vexProcessor,
}

└── FindMatchesContext(pkgs, pkgContext)

├── findDBMatches()
│ │
│ ├── searchDBForMatches()
│ │ │
│ │ ├── newMatcherIndex() → 按包类型映射 Matcher
│ │ │
│ │ ├── 逐包逐 Matcher 调用 Match()
│ │ │ │
│ │ │ ├── MatchPackageByLanguage() → 语言生态
│ │ │ ├── MatchPackageByDistro() → 发行版
│ │ │ └── MatchPackageByCPEs() → CPE
│ │ │ │
│ │ │ └── result.Set (中间结果)
│ │ │ │
│ │ │ └── ToMatches() → []match.Match
│ │ │
│ │ ├── ApplyExplicitIgnoreRules()
│ │ ├── ApplyIgnoreFilters() (跨 Matcher 去重)
│ │ └── match.NewMatches() → Fingerprint 去重
│ │
│ └── applyIgnoreRules() → 用户 ignore 规则

├── findVEXMatches() → VEX 文档处理

└── hasSeverityAtOrAbove() → 失败阈值检查

从这个全景图中可以看到,Matcher 框架的设计遵循了"关注点分离"原则:

  • VulnerabilityMatcher 只关心调度流程,不关心具体怎么匹配;
  • 各个 Matcher 只关心搜索条件的选择和组合,不关心数据库怎么实现的;
  • result.Set 只关心中间结果的收集和集合操作,不关心最终去重;
  • match.Matches 只关心 Fingerprint 去重和合并策略,不关心匹配是怎么发生的。

总结

Grype 的 Matcher 匹配框架通过一套分层解耦的架构,将"为不同生态的软件包匹配漏洞"这件复杂的事情拆解为清晰的职责:

  1. Matcher 接口和索引提供了可扩展的 Matcher 注册机制,只需实现三个方法即可接入新生态;
  2. 三条匹配路径(语言生态、发行版、CPE)覆盖了从 npm 包到 Debian 包的全部场景,每条路径有各自的搜索条件和后处理逻辑;
  3. Criteria 体系将搜索条件与过滤条件统一为一组可组合的接口,既参与 SQL 构造又参与内存过滤;
  4. result.Set 作为数据库查询和最终 Match 之间的中间层,提供了 Merge、Filter、Remove 等集合操作,解决了按身份去重和多路径发现合并的问题;
  5. IgnoreFilter 跨 Matcher 传播实现了发行版修复信息向语言生态 Matcher 的传递,避免了同一个漏洞在不同 Matcher 中被重复报告;
  6. match.Matches 的 Fingerprint 去重确保了同一个包的同一个漏洞只产生一条结果,而 Details 中保留了所有发现路径的证据。

掌握了 Matcher 框架的整体架构后,下一篇我们将深入不同软件生态的匹配策略,看 apk、deb、rpm、npm、Go 等生态为什么不能共用一种匹配规则,以及它们各自面临的特殊挑战。