在上一篇文章中,我们分析了 Grype Matcher 匹配框架的整体架构:三条搜索路径(语言生态、发行版、CPE)、Criteria 查询条件体系、result.Set 中间结果容器,以及跨 Matcher 的 IgnoreFilter 去重机制。但上一篇文章重点在"框架如何运转",并没有深入回答一个更具体的问题:为什么 apk、deb、rpm、npm、Go 这些不同生态不能共用一种匹配规则?
如果你翻看 grype/matcher/ 目录下的源码,会发现 Grype 为此实现了 16 个 Matcher。乍看之下,很多 Matcher 的 Match() 方法只有短短几行,似乎只是对公共函数的简单封装。但当你把视角拉近,仔细审视每个 Matcher 在"如何组合三条搜索路径"和"如何处理本生态的特殊情况"上的差异时,就会发现这些差异不是偶然的——它们背后是不同生态在包命名、版本管理、安全数据发布方式上的本质区别。
本文将以这些差异为线索,逐层展开不同 Matcher 的实现策略。
从统一框架到差异化策略
让我们先回顾一下统一框架提供了什么。在 grype/matcher/internal 包中,有三个公共匹配函数作为所有 Matcher 的构建基块:
MatchPackageByLanguage:按语言生态 + 包名搜索,处理多名称展开和跨名称 NAK(Negative Acknowledgement)去重;MatchPackageByDistro:按发行版 + 包名搜索,将结果分为"受影响"和"已修复"两部分,并为已修复的漏洞生成跨生态的 IgnoreFilter;MatchPackageByCPEs:按 CPE(Common Platform Enumeration,通用平台枚举)标识符搜索,作为通用兜底路径。
此外还有两个组合函数:
MatchPackageByEcosystemAndCPEs:先按语言生态搜索,可选地再按 CPE 补充搜索,合并结果;MatchPackageByEcosystemPackageName:按指定的包名(而非包的自身名称)在语言生态中搜索。
所有 16 个 Matcher 的 Match() 方法,本质上都是在选择、编排和组合这些基块函数。但恰恰是这个"选择和组合"的过程,体现了不同生态的根本差异。
Matcher 的分类:三大阵营
从"如何利用基块函数"的角度,16 个 Matcher 可以清晰地分为三大阵营:
纯语言生态 Matcher:JavaScript(npm)、Python(pip)、Ruby(gem)、Rust(cargo)、Dotnet(NuGet)、Hex(Elixir)。它们只调用 MatchPackageByEcosystemAndCPEs,策略最简单。
发行版 Matcher:APK(Alpine)、DPKG(Debian)、RPM(RedHat/CentOS/AlmaLinux)、Portage(Gentoo)、Pacman(Arch)。它们以 MatchPackageByDistro 为核心,但各自叠加了上游包、Epoch、NAK 等差异化逻辑。
特殊生态 Matcher:Golang、Java、MSRC、Bitnami。它们虽然在形式上仍使用基块函数,但因为有特殊的匹配需求(伪版本、Maven 仓库查询、KB 补丁),策略无法归入前两类。
Stock Matcher 则是一个特殊的存在——它声明 PackageTypes() 返回 nil,不认领任何包类型,而是作为"兜底"匹配器为所有找不到专属 Matcher 的包提供 CPE 匹配。
下面我们按"从简单到复杂"的顺序,依次分析每个阵营中 Matcher 的实现特点。
纯语言生态 Matcher:一行代码背后的问题
JavaScript Matcher 的 Match() 方法极其简单:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
Python、Ruby、Rust、Dotnet、Hex 的 Matcher 与此完全相同——唯一的区别是 PackageTypes() 返回不同的包类型(NpmPkg、PythonPkg、GemPkg 等),以及 Type() 返回不同的 Matcher 标识名。
但"简单"不等于"没有设计"。当你深入 MatchPackageByEcosystemAndCPEs 内部,会发现它解决了两个纯语言生态特有的问题:
一是 CPE 匹配的可选性。 m.cfg.UseCPEs 是一个配置开关。以 JavaScript 为例,npm 包的漏洞信息主要来自 GitHub Advisory Database(GHSA),这些记录的生态字段被标记为 npm,而不是 NVD 的 CPE 格式。对于语言生态来说,生态匹配(MatchPackageByLanguage)是主要路径——数据库中有专门的 npm、pypi、gem 等命名空间,直接按包名搜索即可命中。CPE 匹配则是辅助路径——它通过 NVD 等通用漏洞库补充发现那些尚未被生态专属数据库收录的漏洞。
这引出了一个实际的问题:CPE 匹配可能引入误报。NVD 的 CPE 记录并不总是准确反映软件包的生态系统——一个名为 lodash 的 CPE 可能指向 npm 包,也可能指向其他平台上的同名软件。为了降低误报,MatchPackageByCPEs 中引入了 OnlyVulnerableTargets 过滤器,通过比较漏洞 CPE 和包 CPE 的 TargetSW(目标软件)字段,排除那些显然不是同一种语言的匹配。
二是搜索名称的展开。 MatchPackageByLanguage 内部通过 store.PackageSearchNames(p) 获取要搜索的包名列表。对于大多数语言生态包,这个列表只有一个元素——包自身的名称。但对于某些特殊场景,数据库会注册多个搜索名。在收集完所有名称的受影响(disclosure)记录后,还要查询对应名称的"不受影响"(unaffected)记录,并通过 disclosures.Remove(unaffected) 按漏洞 ID 和别名进行去重。这种"先收集全部、再排除不受影响"的两阶段查询模式,确保了不同数据源对同一漏洞的不同判断能够被正确处理。
Go Matcher:伪版本与主模块的困扰
Go Matcher 虽然也调用 MatchPackageByEcosystemAndCPEs,但在此之前有一段独特的预处理逻辑:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
这段逻辑解决的是 Go 语言生态特有的两个问题。
第一个问题是主模块的伪版本。 在 Go 编译产物中,主模块(即正在被编译的那个模块)的版本信息通常不会被嵌入到二进制文件中——这是 Go 工具链的一个已知限制(golang/go#50603)。Syft 在扫描 Go 二进制文件时会尝试用各种启发式方法推断主模块的版本,但如果推断失败,版本号就是 (devel) 或无意义的伪版本 v0.0.0-...。用这样的版本号去做漏洞匹配毫无意义——你无法判断 (devel) 是否小于 < 1.2.3。因此,Go Matcher 直接跳过主模块且版本信息无效的包,避免产生不可靠的匹配结果。
第二个问题是 Go 标准库(stdlib)的 CPE 匹配。 在标准 Go 编译产物中,标准库包不会被单独列出——它们被静态链接到了最终的二进制文件中。只有在某些特殊扫描场景下(如对 Go 源码目录的扫描),stdlib 才可能作为一个独立包出现。对于 stdlib,如果数据库中没有专属的 Go 生态漏洞记录,通过 CPE 路径去 NVD 中查找可能是有益的。searchByCPE() 函数体现了这一策略:
1 | func searchByCPE(name string, cfg MatcherConfig) bool { |
普通的 Go 第三方库(如 github.com/gin-gonic/gin)依赖 UseCPEs 开关;而 stdlib 有一个独立的开关 AlwaysUseCPEForStdlib,允许在不需要为所有 Go 包开启 CPE 匹配的情况下,单独为标准库开启。
Java Matcher:Maven 仓库的远程查询
Java Matcher 在所有语言生态 Matcher 中是最特殊的——它不仅使用数据库查询,还会实时访问 Maven 中央仓库。
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
为什么需要实时查询 Maven?这与 Java 包在扫描场景中的信息完整度有关。在扫描 JAR 文件时,Syft 有时只能提取到文件名和 SHA-1 摘要,而无法获取完整的 Maven 坐标(groupId:artifactId)。没有 Maven 坐标,就无法在数据库中以包名进行精确搜索。
Java Matcher 的解决方式是:如果 POM 信息缺失(PomArtifactID 或 PomGroupID 为空),则使用 JAR 文件的 SHA-1 摘要向 Maven 中央仓库发起查询:
1 | func (m *Matcher) shouldSearchMavenBySha(p pkg.Package) (bool, []string) { |
SHA-1 查询返回 Maven 坐标后,Java Matcher 构造一个间接包(indirect package),用它的 groupId:artifactId 作为包名进行语言生态匹配。这个间接匹配的结果会被标记为 ExactIndirectMatch 类型,表示漏洞是通过上游源包间接命中的,而非通过直接包名。
此外,Java Matcher 还有另一个独特的设计——在 CPE 版本比较中对 update 字段的特殊处理。Java 版本号如 1.8.0_201(Java 8 update 201)中的下划线表示的是 update 号,而 CPE 将其表示在 update 属性而非 version 属性中。transformJvmVersion() 负责将这两者合并为可比较的版本字符串。
发行版 Matcher:从简单到复杂的演化阶梯
如果说语言生态 Matcher 的差异主要体现在参数配置上,那么发行版 Matcher 的差异则是架构级别的——它们在"是否处理上游源包"“如何处理 Epoch”“是否有 NAK 机制”"是否有 EOL 兜底"等维度上各具特色。
发行版安全数据的组织和发布方式从根本上决定了匹配策略。以 Debian 为例,它的安全公告(DSA)以源包名(source package name)为索引——例如 openssl 源包的安全更新覆盖了 libssl-dev、libssl3 等多个二进制包。而 RedHat 的安全公告(RHSA)虽然以 RPM 包名为索引,但源 RPM(source RPM)包名与实际安装的二进制 RPM 包名常常不同。Alpine 则更进一步——它的 SecDB(Security Database)不仅记录了受影响的包,还包含明确标记为"不受影响"(NAK)的记录。这些差异意味着,不能简单地用一个"按包名 + 发行版搜索"的通用逻辑覆盖所有发行版。
最简形态:Portage 和 Pacman
Gentoo 的 Portage Matcher 是最简单的发行版 Matcher:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
它直接调用 MatchPackageByDistro,没有任何额外处理——不需要上游包映射,不需要 Epoch 处理,不需要 EOL 兜底,不需要 NAK 查询。这是因为 Gentoo 的安全数据组织方式恰好与 Grype 的"按发行版 + 包名搜索"模型完美匹配。Pacman(Arch Linux)Matcher 则使用 MatchPackageByEcosystemPackageName,按包自身的生态名称搜索,同样是最简形态。
这个"最简形态"并不是缺陷——恰恰相反,它体现了良好的架构设计:当使用场景不需要额外逻辑时,框架允许 Matcher 保持简洁。
上游包映射:DPKG(Debian)
Debian Matcher 在发行版匹配的基础上增加了上游源包间接匹配和 EOL CPE 兜底两层逻辑:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
上游包间接匹配的动机是:Debian 安全公告以源包名为索引。当系统安装了 libssl3 时,安全数据库中 openssl 源包的漏洞记录也应该被匹配到 libssl3 上。Syft 在编目阶段会通过 UpstreamPackages() 推导出包的源包信息——Debian 包的元数据中包含 Source 字段。DpkgMatcher 为每个上游包构造一个合成包(synthetic package),用源包名去数据库中搜索,然后将匹配结果通过 ConvertToIndirectMatches() 标记为间接匹配,并关联回原始包。
这里还有一个细致的配置选择——Debian Matcher 使用了 MissingEpochStrategy 来控制当版本号中缺失 Epoch 时的处理方式。Debian 版本号格式为 [epoch:]upstream_version[-debian_revision],Epoch 是一个前置整数,用于在包版本号倒退时(比如上游项目从 2.0 回退到 1.0 的重新打包)保持版本比较的正确性。当一个包或一条漏洞记录的版本号没有显式指定 Epoch 时,应该如何处理?MissingEpochStrategy 提供了"假设为 0"和"剥离 Epoch 后比较"等不同策略。
EOL CPE 兜底的动机则更加现实。当发行版停止维护后(例如 Debian 9 已于 2022 年 EOL),它的安全团队不再发布新的安全公告。但 NVD 等通用漏洞库仍在持续更新。此时通过 CPE 路径可以为 EOL 发行版补充发现那些发行版安全团队不再追踪的新漏洞:
1 | func IsDistroEOL(provider vulnerability.Provider, d *distro.Distro) bool { |
发行版的 EOL 日期嵌入在漏洞数据库中,由 vulnerability.EOLChecker 接口提供。这种设计将"什么发行版已经 EOL"的知识放在数据层(数据库维护者可以随时更新 EOL 日期),而不是硬编码在代码中。
Epoch 和 EUS:RPM(RedHat/CentOS/AlmaLinux)
RPM Matcher 是所有发行版 Matcher 中逻辑最复杂的一个。这不仅因为 RPM 包生态本身的分支繁多(RedHat、CentOS、AlmaLinux、Rocky Linux 等),更因为 RPM 包的版本管理机制(尤其是 Epoch)和 RedHat 的 EUS(Extended Update Support,扩展更新支持)策略引入了额外的复杂度。
Epoch 的歧义处理。 RPM Matcher 的核心复杂性来源于一个问题:同一个包的 Epoch 在不同场景中可能有不同的处理方式。RPM 版本格式是 [epoch:]version-release,例如 0:1.28-419.el8_4.1。
在直接匹配(matchPackage)中,RPM Matcher 会选择"补全缺失的 Epoch 为 0"的策略:
1 | func addEpochIfApplicable(p *pkg.Package) { |
这个策略的合理性在于:直接匹配面对的是具体的 RPM 包,它的 Epoch 是确定可知的——要么在版本字符串中已经存在,要么在 RPM 元数据中显式标注,要么确实就是 0(因为该包从未改变过版本方案)。而 RedHat 的安全数据(RHSA)同样包含完整的版本信息(包括 Epoch),所以双方都有 Epoch 时比较是精确的。
但在上游包间接匹配(matchUpstreamPackages)中,策略完全不同——上游包不加 Epoch。原因在源码注释中有一个精妙的例子:
1 | 比如 perl-Errno 包: |
这就是 RPM Matcher 在直接匹配和间接匹配中使用不同 Epoch 处理策略的根本原因——source RPM 的有无 Epoch 代表了不同的信息完整度,不能假设缺失即零。
EUS 分支的特殊匹配。 当发行版带有 EUS Channel 标记时(例如 RHEL 9.4+eus),RPM Matcher 会切换到 redhatEUSMatches 路径。EUS 是 RedHat 为特定次版本(minor version)提供的扩展安全支持——例如 RHEL 9.4-EUS 只接收针对 9.4 的安全修复,而不会升级到 9.5。
EUS 匹配的独特之处在于它需要合并两个命名空间的查询结果:
1 | // 第一步:查基础发行版(>= 9.0 && < 10)中有哪些漏洞 |
从基础发行版查 disclosure(受影响记录),从基础 + EUS 发行版查 resolution(修复记录),然后进行合并:如果某条 disclosure 在 EUS 中已经有了修复,且修复版本小于等于当前包的版本,则该 disclosure 被移除(包已修复);如果修复版本大于当前包的版本,则 disclosure 保留(包仍受影响)。
更进一步,EUS 修复还做了可达性检查。RHEL 的 EUS 分支是独立的——9.4-EUS 的修复不会被合并到 9.2-EUS 中。因此,当当前系统是 RHEL 9.2-EUS 时,一条标记为 9.4-EUS 的修复是不"可达"的——即使用户受该漏洞影响,也无法通过升级到 EUS 版本来修复。isFixReachableForEUS() 通过解析 RPM 版本 release 字段中的 el 标记来判断修复是否属于当前 EUS 分支。
AlmaLinux 的特殊处理。 RPM Matcher 在入口处还有一个 AlmaLinux 判断:
1 | if p.Distro != nil && shouldUseAlmaLinuxMatching(p.Distro) { |
为什么 AlmaLinux 不能用普通的 RPM 匹配逻辑?因为 AlmaLinux 的安全公告系统(ALSA)的运作方式与 RedHat 不同。AlmaLinux 是 RHEL 的二进制兼容发行版,它的安全修复通常基于 RHEL 的 RHSA,但可能有自己的发布节奏和修复版本号。
matchAlmaLinux 的策略是:
- 以 RHEL 身份查数据库,获取 RHEL 中有哪些 disclosure;
- 以 AlmaLinux 身份查数据库,获取 AlmaLinux 中哪些包被标记为"不受影响"(unaffected);
- 将 RHEL disclosure 中在 AlmaLinux 不受影响的条目移除;
- 对于保留下来的漏洞,用 AlmaLinux 的修复版本信息替换 RHEL 的修复信息(
replaceWithAlmaLinuxFixInfo)。
这种"先查 RHEL、再过滤 AlmaLinux 不受影响"的策略,充分利用了 RHEL 数据覆盖面广和 AlmaLinux 数据精确度高的各自优势。
NAK 与 SecDB 优先:APK(Alpine)
APK Matcher 是所有 Matcher 中逻辑最完备的一个。它在 DPKG 的基础上进一步叠加了两层逻辑:NAK 机制和 SecDB 优先的 CPE 去重。
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
NAK(Negative Acknowledgement,否定确认)是 Alpine SecDB 的一个特殊条目类型。Alpine 安全团队在分析某个漏洞后,如果确认 Alpine 的特定版本的某个包不受该漏洞影响(例如因为编译选项不同、补丁已提前合入等原因),会在 SecDB 中发布一条 NAK 记录。NAK 记录的版本约束是 < 0(一个永远无法满足的约束),以此区别于正常的漏洞记录。
APK Matcher 的 NAK 查询使用了特殊的约束条件:
1 | nakConstraint = search.ByConstraintFunc(func(c version.Constraint) (bool, error) { |
查到 NAK 记录后,它们被转为 OwnershipIgnores 过滤器。这与 MatchPackageByDistro 中"已修复的漏洞"生成 IgnoreFilter 的逻辑类似——都是为了让发行版的权威判断传播到同文件位置上的其他生态包(如 Alpine 包内嵌的 Python wheel),避免重复报告。
SecDB 优先的 CPE 去重是 APK Matcher 另一个精妙的设计。cpeMatchesWithoutSecDBFixes() 函数实现了这样的逻辑:
1 | func (m *Matcher) cpeMatchesWithoutSecDBFixes(provider vulnerability.Provider, p pkg.Package) { |
这段逻辑的核心策略是:
- 如果某条 CVE 在 SecDB 中不存在,说明 NVD 收录了但 Alpine 安全团队还没有(或不需要)处理,此时保留 CPE 匹配但清除其修复版本信息——因为 NVD 不知道 Alpine 版本的修复应在哪个版本完成;
- 如果某条 CVE 在 SecDB 中存在且当前包已修复,说明 Alpine 安全团队已经有了权威判断(该版本的包已被修复),CPE 匹配的结果应当被丢弃;
- 如果某条 CVE 在 SecDB 中存在且当前包仍受影响,CPE 匹配与 SecDB 匹配是一致的,保留 CPE 匹配——后续的
deduplicateMatches会确保只保留 SecDB 中没有出现的 CVE。
这种设计体现了数据源的优先级原则:发行版安全团队的判断优先于 NVD 的通用数据。
特殊生态 Matcher:MSRC 和 Bitnami
MSRC(Microsoft Security Response Center)Matcher 处理的是 Windows 系统的 KB(Knowledge Base)补丁匹配。它的特殊之处在于"包"的定义——KB 补丁不是传统意义上的软件包,而是 Windows 系统的安全更新标识:
1 | func (m *Matcher) PackageTypes() []syftPkg.Type { |
MSRC Matcher 使用 MatchPackageByEcosystemPackageName 而非 MatchPackageByEcosystemAndCPEs,因为 KB 编号是精确的匹配键——KB5001234 要么有对应的漏洞,要么没有,不需要 CPE 兜底。
Bitnami Matcher 同样使用 MatchPackageByEcosystemPackageName。Bitnami 是为云原生应用提供预打包部署的发行商,它的包通过特殊的 PURL(Package URL)结构提供了完整的版本、发行版和架构信息:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
Stock Matcher:兜底的最后一环
Stock Matcher 的 PackageTypes() 返回 nil——它不主动认领任何包类型。在 newMatcherIndex() 中,它被单独提取为 defaultMatcher,只为那些在索引中找不到专属 Matcher 的包类型提供兜底:
1 | func (m *Matcher) Match(store vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) { |
Stock Matcher 的策略与简单语言生态 Matcher 完全相同——先按语言生态搜索,再按 CPE 补充。唯一的区别是:它的 Type() 返回 "stock-matcher",在 Match Details 中标注此匹配是由兜底匹配器完成的。这意味着如果某个 CVE 是通过 Stock Matcher 匹配到的,用户至少可以知道这不是专属 Matcher 的判断——需要更多人工审查。
从差异中看设计原则
回顾 16 个 Matcher 的实现,可以提取出三条贯穿始终的设计原则。
原则一:组合优于继承。 Grype 没有定义一个"基础 Matcher"然后让各生态继承,而是提供了三个正交的基块函数(Language/Distro/CPE),让每个 Matcher 自由组合。DPKG Matcher 组合了 Distro + Upstream + EOL CPE,APK Matcher 在此基础上再叠加 NAK + SecDB 优先 CPE 去重。每个 Matcher 的复杂度恰好等于它所处理生态的实际复杂度,不多也不少。
原则二:数据源的权威性分层。 当同一个漏洞在多个数据源中都有记录时,Grype 有一个隐式的优先级:发行版安全数据库 > 语言生态数据库 > NVD CPE。这种优先级不是硬编码的规则,而是通过 IgnoreFilter 的跨 Matcher 传播和 APK Matcher 的 SecDB 优先去重等机制自然实现的。发行版安全团队的判断之所以优先级最高,是因为他们实际审查了源代码并测试了具体版本——这种判断比 NVD 基于版本号范围的通配判断更可靠。
原则三:信息缺失时的保守策略。 当关键信息缺失时(如 Go 主模块的版本是 (devel),source RPM 没有 Epoch,Maven 坐标不完整),Grype 的策略不是"尽力猜",而是"明确边界"。Go Matcher 直接跳过无意义版本的包;Java Matcher 尝试通过 SHA-1 从外部补全信息;RPM Matcher 在间接匹配中剥离 Epoch。这些策略的共同点是知道什么情况下自己不可靠,并采取对应的降级或补全措施。
小结
本文从"为什么不同生态不能共用一种匹配规则"出发,逐一分析了 Grype 16 个 Matcher 的差异化实现策略:
- 纯语言生态 Matcher(JavaScript、Python、Ruby、Rust、Dotnet、Hex)以生态匹配为主路径、CPE 匹配为辅助路径,策略最为一致;
- Go Matcher 通过预处理过滤掉主模块的伪版本,并为 stdlib 提供了独立的 CPE 匹配开关;
- Java Matcher 能通过 SHA-1 摘要实时查询 Maven 中央仓库来补全不完整的包信息;
- DPKG Matcher 增加了上游源包间接匹配和 EOL 发行版的 CPE 兜底;
- RPM Matcher 在 Epoch 处理上区分了直接匹配(补全 0)和间接匹配(剥离 Epoch)两种策略,并支持 EUS 扩展更新支持的特殊匹配;AlmaLinux 分支则通过"先查 RHEL、再过滤 AlmaLinux 不受影响"的策略协调两个数据源;
- APK Matcher 是策略最完备的发行版 Matcher,在 DPKG 基础上叠加了 NAK 否定确认机制和 SecDB 优先的 CPE 去重逻辑;
- Stock Matcher 作为兜底匹配器,确保没有包因找不到专属 Matcher 而遗漏。
掌握了不同生态的匹配策略差异后,下一篇我们将深入 Grype 中被低估的核心——版本比较系统。分析 Grype 如何处理语义版本、发行版版本、Epoch、预发布版本,以及 fixed-in 和版本约束的判断过程。版本比较看似简单,却是决定"有漏洞"还是"已修复"的计算基础。