前面的文章中,我们分析了 Grype 如何加载漏洞数据库、匹配框架如何运转、以及不同生态系统如何实施各自的匹配策略。但在整个漏洞匹配链路中,有一个环节被我们反复提及却一直未被展开讨论——版本比较。Grype 需要为 14 种以上的软件包格式分别提供正确的版本比较逻辑,而每一套逻辑背后都对应着一个生态的历史积累、社区约定和技术规范。
本文将从 Grype 版本比较系统的整体架构出发,深入剖析各类版本比较器的实现原理,并展示它们如何与约束表达式和数据库查询协作,完成从"版本字符串"到"是否受影响"的最终判断。
版本比较系统的整体架构
在进入具体实现之前,我们先建立整个版本比较系统的全局视图。Grype 的版本比较系统位于 grype/version 包,由四个核心抽象组成:
1 | Format(版本格式) |
Format 是一个枚举类型,标识版本字符串属于哪种软件生态。Grype 定义了 14 种格式:
1 | const ( |
一个包(如 lodash)通过 pkg.VersionFormat(p) 函数映射到对应的 Format——NpmPkg 类型映射到 SemanticFormat,ApkPkg 映射到 ApkFormat,以此类推。
Version 是版本比较系统的核心对象,它封装了一个版本字符串和它对应的 Format,并对外提供 Compare() 和 Is() 两个方法:
1 | type Version struct { |
Comparator 是一个接口,定义了 Compare() 方法——负责实际比较两个版本字符串的数值大小:
1 | type Comparator interface { |
Grype 为每种 Format 都实现了一个 Comparator:semanticVersion、apkVersion、debVersion、rpmVersion 等等。Version.Compare() 的核心逻辑就是:根据自身 Format 获取对应的 Comparator,然后调用 Comparator 的 Compare() 方法。这里有一个重要的容错设计——如果主格式的比较失败(例如解析错误),Grype 会尝试用另一个版本的 Format 的 Comparator 进行回退比较。
Constraint 是版本比较系统的上层抽象,负责表达"版本范围"并回答"某个版本是否满足这个范围"。它的接口定义如下:
1 | type Constraint interface { |
约束表达式可以是 >= 1.0, < 2.0 这样的简单范围,也可以是 >= 1.0, < 2.0 || = 2.0.1 这样多个范围的 OR 组合。Grype 通过 version.GetConstraint(constStr, format) 工厂函数,根据不同的 Format 创建对应的 Constraint 实例。
这四个抽象之间的协作关系可以这样理解:Constraint 告诉你"漏洞影响哪些版本"(例如 < 4.17.21),Version 告诉你"软件包的当前版本"(例如 4.17.20),Comparator 负责比较两个版本的数值大小,三者配合完成最终判断:软件的当前版本是否落在漏洞影响的版本范围内。
约束表达式:从字符串到结构化范围
在深入各种版本比较器之前,我们需要先理解约束表达式是如何被解析的。漏洞数据库中存储的约束是一段文本,例如:
>= 1.0, < 2.0:声明版本范围在 1.0 以上但不到 2.0< 3.2.1:版本低于 3.2.1 才受影响>= 1.0, < 2.0 || >= 3.0, < 4.0:版本在 1.x 和 3.x 两个区间之一
这些文本需要被解析为 Grype 可以逐个计算的比较单元。解析工作由 range_expression.go 中的 parseRangeExpression() 和 range.go 中的 parseRange() 两个函数完成。
两阶段解析:从文本到 OR-of-AND 结构
约束表达式的解析分为两个阶段。
第一阶段是词法扫描(scanExpression()),使用 Go 标准库的 text/scanner 将原始字符串拆分为"或组"(OR groups)和"与组"(AND groups)。具体来说:
- 逗号
,分隔的是 AND 关系——所有条件必须同时满足。例如>= 1.0, < 2.0表示"版本 >= 1.0 且 < 2.0"。 - 双竖线
||分隔的是 OR 关系——任一条件满足即可。例如< 1.0 || > 2.0表示"版本 < 1.0 或 > 2.0"。
这样的设计直接对应了漏洞约束中常见的手写形式:一个漏洞影响范围通常由若干 OR 子句组成,每个子句内部是 AND 连接的多个边界条件。
第二阶段是单元解析(parseRange()),将每个版本-操作符对解析为 rangeUnit:
1 | type rangeUnit struct { |
parseRange() 使用正则表达式 constraintPartPattern 从每段文本中提取操作符(>=、<=、>、<、=)和版本号:
1 | var constraintPartPattern = regexp.MustCompile( |
最终,整个约束表达式被解析为一个 simpleRangeExpression,其内部结构是 [][]rangeUnit——外层数组是 OR 关系,内层数组是 AND 关系。这种"或中包含与"(OR-of-ANDs)的结构可以表达大多数实际的漏洞约束场景。
约束的满足性判断
当一个约束需要判断某个版本是否满足它时,执行的是两级逻辑运算:
1 | func (c *simpleRangeExpression) satisfiedWithConfig(format Format, version *Version, cfg ComparisonConfig) (bool, error) { |
外层循环遍历 OR 组——只要有一个 OR 组整体满足就返回 true。内层循环遍历 AND 组——必须所有 AND 单元都满足才判定为该 OR 组满足。每个 rangeUnit.Satisfied() 方法根据比较结果判断该单元是否满足:
1 | func (c *rangeUnit) Satisfied(comparison int) bool { |
至此,我们得到了约束解析到判断的完整链条:文本约束 → 扫描为 OR/AND 组 → 解析为 rangeUnit → 通过 Comparator 比较版本 → 逻辑运算得到最终布尔结果。
有了这个全局视图,我们就可以深入各个版本比较器的具体实现了。
版本比较的三类策略
Grype 中的 14 种版本格式,虽然各有独特的比较逻辑,但从实现方式上可以归纳为三类:
- 委托外部分库类:Semantic、APK、DEB、Maven、Python、Bitnami——这些版本格式将比较工作委托给成熟的第三方库,Grype 自身只负责适配和数据清洗;
- 自主分段比较类:RPM、Pacman、Gem、Portage——这些格式自行实现了分段提取与逐段比较的逻辑;
- 降级与简化类:Golang(伪版本处理)、JVM(JEP 223 转换)、KB(相等性比较)、Fuzzy(通用模糊比较)——面对不规范或非标准版本号,采用转换、规范化或降级策略。
下面我们按"从简单到复杂"的顺序逐一分析。
语义化版本:最广泛使用的比较基础
语义化版本(Semantic Versioning,semver)是 Grype 中使用频率最高的版本格式——npm、NuGet、Hex、Composer、Pub、Swift、Conan 等多个生态都归入此格式。Grype 通过 github.com/anchore/go-version(hashicorp 版 go-version 的 fork)库来处理语义化版本比较:
1 | type semanticVersion struct { |
这里有几个值得注意的设计决策。
非严格模式是默认选择。 newSemanticVersion() 的 strict 参数控制是否使用严格的 semver 解析。在 strict 模式下,只有完全符合 MAJOR.MINOR.PATCH-prerelease+build 格式的版本才能被接受。但 Grype 的实际调用中,strict 默认为 false——这是因为现实中的版本号往往不完全符合 semver 规范,例如 1.0.0a、1.0(缺少 PATCH 段)、v1.0.0(带 v 前缀)等。非严格模式给 hashicorp/go-version 更大的包容度,允许解析部分版本和不规范版本。
预发布后缀规范化。 semverPrereleaseNormalizer 处理了 Ruby gem 社区中的一个常见变体:
1 | var semverPrereleaseNormalizer = strings.NewReplacer( |
一些 gem 包使用 .alpha、.beta、.rc 而不是 semver 标准的 -alpha、-beta、-rc。通过简单的字符串替换,Grype 在不丢失信息的前提下统一了这些变体。
v 前缀去除。 trimLeadingV() 是一个全局共用的工具函数,如果版本号以 v 或 V 开头且后跟数字,则去除此前缀。这意味着 v1.5.0 和 1.5.0 被视为同一个版本。
语义化版本的 Compare 方法极为直接——纯粹委托给底层库:
1 | func (v semanticVersion) Compare(other *Version) (int, error) { |
底层库的 semver 比较规则遵循标准的优先级规律:MAJOR > MINOR > PATCH;有预发布标识的版本小于无预发布标识的正式版本;预发布标识按字典序比较。
APK 和 DEB:Linux 发行版的版本体系
Alpine Linux 的 APK 包管理器和 Debian 的 dpkg 分别使用各自的版本格式,它们都委托给专门的外部 Go 库来实现比较。
APK 版本
APK 版本比较委托给 github.com/knqyf263/go-apk-version 库:
1 | type apkVersion struct { |
APK 的版本格式遵循 Alpine 的 pkgver 约定,形式为 {digit}{.digit}...{_alpha}{_suf}。例如 1.2.3-r4 或 20210101-r0。比较逻辑是典型的逐段比较:数字段按数值比较,字母段按字符串比较,版本后缀 _alpha、_beta、_pre、_rc、_p 有预设的先后顺序。
DEB 版本与 Epoch 问题
DEB 版本的复杂性在于 Epoch(纪元号)。Debian 的完整版本格式是 [epoch:]upstream_version[-debian_revision],其中 epoch 是一个整数,后跟冒号。当包的上游改变了版本编号方式时(例如从日期格式切换到语义版本),Epoch 用于确保正确的升级顺序——epoch 更大的版本永远是更新的版本,即使 upstream_version 看起来更小。
1 | type debVersion struct { |
DEB 版本比较同样委托给 github.com/knqyf263/go-deb-version。但 Grype 额外手动提取了 epoch 字段:
1 | func extractDebEpoch(raw string) *int { |
之所以需要手动提取 epoch,是因为 Grype 引入了 MissingEpochStrategy(缺失 epoch 处理策略)——一个专门针对 DEB 和 RPM 版本的版本比较配置机制。这个机制解决了一个在实际漏洞扫描中频繁出现的问题:当软件包的版本字符串没有包含 epoch,但漏洞约束中包含 epoch 时,应该如何比较?
想象这样一个场景:Debian 系统中安装了一个名为 libssl 的包,版本号为 1.1.1n-0+deb10u3(没有 epoch 前缀);数据库中记录的漏洞约束是 >= 1:1.1.1d-0+deb10u2(epoch 为 1,意味着这是一次上游版本体系的重大变更)。如果简单地将缺失的 epoch 视为 0,那么 0:1.1.1n 将远小于 1:1.1.1d,导致扫描器报告一个实际上不受影响的漏洞。这正是 MissingEpochStrategyAuto 要解决的问题。
Grype 定义了两种 epoch 处理策略:
1 | const ( |
MissingEpochStrategyZero 是最保守的策略——缺失 epoch 一律视为 0。这在 epoch 确实为 0 的场景下是正确的,但可能导致上述误报。
MissingEpochStrategyAuto 的策略是:如果软件包版本缺少 epoch,但漏洞约束中有 epoch,则临时为软件包版本注入约束中的 epoch 值再进行比较:
1 | if cfg.MissingEpochStrategy == MissingEpochStrategyAuto { |
这个设计的精妙之处在于:它假设"缺失 epoch 的版本实际上属于该 epoch 的版本谱系"。这个假设在 Debian 官方仓库的上下文中通常是安全的——同一发行版的同一个包,名称固定,不会跨越 epoch 边界产生混乱。
debVersion 通过 CompareWithConfig() 方法来支持这种配置化的比较。同时它保留了标准的 Compare() 方法作为回退——当没有配置时,默认使用 go-deb-version 库的原生行为。
RPM 版本:最复杂的比较场景
RPM 版本比较是整个 grype/version 包中最复杂的部分,它面临的挑战远超其他格式。要理解为什么,需要先了解 RPM 的版本结构和漏洞匹配中的特殊场景。
RPM 的版本结构
RPM 的完整版本格式是 [epoch:]version-release。例如 2:1.28-419.el8_4.1 中:
2是 epoch1.28是 version419.el8_4.1是 release
Grype 将这三部分分别存储在 rpmVersion 结构体中:
1 | type rpmVersion struct { |
epoch 的解析通过 splitEpochFromVersion() 完成——在冒号处分割,冒号前是 epoch,冒号后是剩余的版本号。关键设计在于:Grype 用 nil 而非 0 表示"epoch 缺失"。这个决策有深刻的含义:
1 | func splitEpochFromVersion(rawVersion string) (*int, string, error) { |
为什么是 nil 而不是 0?因为 Red Hat 的官方规范说"epoch 缺失时应视为 0",但实际漏洞数据中的 epoch 经常不一致——上游源 RPM 文件名可能剥离了 epoch,漏洞数据库中的约束可能包含 epoch。如果简单地用 0 替代缺失的 epoch,可能产生错误的比较结果。用 nil 让缺失状态"有迹可循",这样比较逻辑本身可以根据上下文灵活决策。
RPM 的两套比较逻辑:直接匹配 vs 上游包匹配
RPM Matcher 中有两种不同场景的版本比较,它们对 epoch 的处理方式截然不同——这正是 epoch 问题的核心所在。
场景一:直接匹配(二进制包匹配)。 当用二进制 RPM 包名直接匹配漏洞数据库中的同名记录时,epoch 信息通常是可靠的——RPM 数据库中存储了完整的 epoch,Red Hat 的安全公告也准确记录了 epoch。在这种情况下,缺失的 epoch 可以安全地假设为 0,Grype 甚至会在搜索前主动补全 epoch:
1 | func addEpochIfApplicable(p *pkg.Package) { |
在这个场景下,版本比较使用 MissingEpochStrategyZero,缺失 epoch 被视为 0,因为 epoch 信息的来源是可信的。
场景二:上游包匹配(source RPM 匹配)。 当通过 source RPM 查找上游包(例如 perl 的上游 sourceRPM)时,epoch 的问题变得复杂。source RPM 文件名通常不包含 epoch 信息——但上游包可能确实有一个非零 epoch。
考虑 RPM Matcher 源码中的注释给出的真实案例:
1 | 包名: perl-Errno |
如果按 Red Hat 规范,缺失 epoch 视为 0,则比较是:0:5.26.3-419.el8 < 4:5.26.3-419.el8 = true——判断为受影响。但实际上,perl-Errno 的 source RPM 来自同一发行版的稳定仓库,它很可能就是 4:5.26.3-419.el8,而不是 0:5.26.3-419.el8。真正的比较应该是:5.26.3-419.el8 == 5.26.3-419.el8 = true——版本相同,不受影响。
这正是 RPM 比较器默认行为的设计哲学——当只有一方有 epoch 时,忽略 epoch 直接比较 version 和 release 段:
1 | func (v rpmVersion) compare(v2 rpmVersion) int { |
这个设计虽然不符合 Red Hat 官方 RPM 规范,但对于漏洞扫描的场景来说是更实用的选择——它能避免因数据源中 epoch 信息不一致而导致的错误匹配。
当 MissingEpochStrategyAuto 被激活时(在 RPM Matcher 的上游包搜索路径中使用),处理逻辑更精细:
1 | if cfg.MissingEpochStrategy == MissingEpochStrategyAuto { |
这相当于说:“如果软件包版本缺少 epoch,我猜它和约束是同一个 epoch 体系”。这个假设在上游包匹配场景中通常是合理的。
RPM 的逐段比较算法
epoch 之后,RPM 的 version 和 release 段使用逐段比较算法。compareRpmVersions() 的实现参考了 RPM 源码中的 C 函数 rpmvercmp:
-
按字母数字分段:将版本字符串按"字母"和"数字"的边界切分为多个 segment。例如
1.28分段为["1", "28"];419.el8_4.1分段为["419", "el", "8", "4", "1"]。 -
逐个 segment 比较:数字 segment 按数值比较(去除前导零),字母 segment 按字典序比较。
-
波浪号(tilde)是"最小":RPM 的波浪号
~表示"小于任何值"。例如1.0~alpha小于1.0。这是因为 RPM 规范中~用于表示 pre-release 版本。 -
剩余 segment 处理:如果一方有更多 segment,检查剩余 segment 是否为"非零"内容——全部为 0 的 segment 序列不影响比较结果。
这个算法的威力在于它建立在字母/数字交替分段的基础上,能正确处理如 419.el8 vs 419.el8_4 这类混合了字母数字的版本号——它不会被 _4 所迷惑,而是正确地将它拆为独立的 segment 再比较。
Go 伪版本:当版本号本身就是代码
Go Module 的版本号在稳定发布版中遵循 semver(v1.2.3),但 Go 生态中还有一种独特的版本形式:伪版本(pseudo-version)。伪版本的格式是 v0.0.0-YYYYMMDDHHMMSS-commithash,例如 v0.0.0-20230101000000-abcdef123456。它们用于引用没有标签的 commit,或者未发布的开发版本。
Go 版本的比较器面临两个挑战:
挑战一是 Go 标准库的版本前缀。 Syft 报告的 Go 标准库版本格式为 go1.24.1,这不是合法的 semver。newGolangVersion() 通过去除 go 前缀来修复:
1 | func newGolangVersion(v string) (golangVersion, error) { |
挑战二是 +incompatible+dirty 这类非标准分隔的构建元数据。 Go 模块的 +incompatible 标记表示一个 major 版本 > 1 的模块没有使用 Go modules。但有时会出现 +incompatible+dirty 这样的连续加号。按 semver 规范,构建元数据由 . 分隔,而非 +:
1 | before, after, found := strings.Cut(fixedUp, "+") |
修复后,Golang 版本使用 hashicorp/go-version 的严格 semver 模式进行解析和比较。这意味着伪版本 v0.0.0-20230101... 天然小于任何稳定版本 v1.0.0——因为 MAJOR 段为 0 且包含预发布信息。
(devel) 是一个特殊值,表示开发中的版本。Grype 将其标记为 ErrUnsupportedVersion,在比较时直接返回错误。这也是为什么在 Go Matcher 中,主模块的 (devel) 版本会被直接跳过——没有任何版本约束能对此做出有意义的判断。
Python PEP 440:公开发布与本地构建
Python 的版本规范 PEP 440 定义了一个独特的概念:版本由"公开发布标识"和可选的"本地版本标签"组成(格式为 public[+local])。公开发布部分遵循 N[.N]+[{a|b|rc}N][.postN][.devN] 模式;本地版本标签(以 + 为前缀)用于表示内部构建或补丁。
PEP 440 的比较器委托给 github.com/aquasecurity/go-pep440-version 库,但 Grype 在此基础上增加了一层次要逻辑:
1 | type pep440Version struct { |
这里的逻辑是:只有当约束中包含本地版本标签时,才比较本地段;否则,如果公开发布部分相等,就认为版本相等。 这是因为在漏洞匹配的场景中,含有本地版本标签的包(例如 1.0.0+internal.patch.1)通常与其公开发布版本(1.0.0)具有相同的安全性特征——本地补丁一般不改变包的漏洞状态。
Ruby Gem:自实现的精细比较
Ruby Gem 的版本比较是 Grype 自行实现的,不使用外部库。它的复杂性来自两个特殊处理:
一是架构后缀剥离。 Gem 的版本号可能包含平台/架构信息,例如 1.0.0-x86_64-linux。cleanArchFromVersion() 函数在比较前剥离这些后缀,避免它们干扰版本比较:
1 | func cleanArchFromVersion(raw string) string { |
二是混合 segment 比较。 Gem 的版本由字母和数字交替组成(如 1.0.0.alpha1),Grype 将这些 segment 解析为 []any(整数或字符串),然后逐段比较:
- 数字 segment 按数值比较
- 字母 segment 按字典序比较
- 数字 segment "大于"字母 segment(即
1.a < 1.0)
三是预发布版本中的中间零处理。 在预发布版本中(包含字母字符的版本),数字 segment 中的零会被裁剪——例如 1.0.0.alpha 和 1.alpha 被视为等价。这是 RubyGems 规范中关于 is_prerelease? 的实现细节,用于正确处理类似 1.0.0.rc1 vs 1.0.0 的比较。
JVM 版本:JEP 223 前后的两套体系
JVM 的版本号在 Java 9(JEP 223)引入了彻底的变革,使得版本比较必须同时处理两种格式:
JEP 223 之前的旧格式: 1.{major}.{minor}_{update}-b{build},例如 1.8.0_302-b08。
JEP 223 之后的新格式: {major}.{minor}.{patch},例如 11.0.16.1,但实践中经常是非标准的,如 8.0-update302-b08。
Grype 的 JVM 版本比较器通过两套正则表达式和转换逻辑,将所有变体统一为 semver 再比较:
1 | var ( |
转换的核心思路是:将 update 后跟的数字映射为 semver 的 PATCH 段(如果 PATCH 不存在或为 0),将 -b 后的数字映射为构建元数据。例如:
1.8.0_302-b08→8.0.302+88.0-update302-b08→8.0.302+811.0.16.1→11.0.16.1(无需转换)
转换后统一使用 hashicorp/go-version 进行 semver 风格的比较。
模糊比较:当一切皆为未知时的最后防线
并非所有软件包都能归入上述已知格式。Grype 提供了 FuzzyVersion 和 FuzzyConstraint 作为处理未知格式(UnknownFormat)的降级方案。
FuzzyVersion 的策略是"先尝试 semver,不成就用模糊比较":
1 | func (v fuzzyVersion) Compare(other *Version) (int, error) { |
fuzzyVersionComparison() 是一个改编自 Facebook nvdtools 的通用版本比较函数,它的核心思想是 “将版本字符串拆分为数字段和字母段,然后逐段比较”——这本质上是一个最朴素的版本比较模型:
- 按字符类型(数字 vs 标点)将字符串分段
- 数字段按数字的值比较(用零填充使位数相等)
- 字母段按字符串字典序比较
- 如果包含
p9、p15这类"patch number"格式,将数字部分提取出来单独比较
它不需要任何生态知识,代价是可能出现不准确的比较——例如 "2000" vs "11.7" 的比较结果会是不合理的。但作为最后的兜底策略,它确保 Grype 不会因为无法解析版本而直接崩溃。
FuzzyConstraint 的匹配策略也遵循类似的"双轨制":
1 | func (f fuzzyConstraint) Satisfied(verObj *Version) (bool, error) { |
这个三级回退策略非常务实:有格式就用格式专用解析 → 尝试 semver 约束 → 最后用模糊比较兜底。
特殊版本格式概览
除了上述主要格式,Grype 还实现了几个特殊的版本比较器,它们各自服务于特定的匹配场景。
Maven 版本(mavenVersion)委托给 github.com/masahiro331/go-mvn-version 库,Maven 的版本顺序规则与 semver 有微妙差别——例如 1.0-sp(Service Pack)被视为比 1.0 更高的版本。Grype 额外处理了 Java 运行时限定符:.jre11、.jdk17 等后缀在比较前被剥离,因为它们表示运行时版本而非包自身的版本。
Portage 版本(portageVersion)对应 Gentoo Linux 的 ebuild 版本体系。它使用一个复杂的正则表达式 (\d+)((\.\d+)*)([a-z]?)((_(pre|p|beta|alpha|rc)\d*)*)(-r(\d+))? 来捕获所有版本组件。版本后缀 _alpha、_beta、_pre、_rc、_p 有预设的优先级,-r 表示修订号。
Pacman 版本(pacmanVersion)对应 Arch Linux 的包管理器。其结构与 RPM 极其相似([epoch:]version-release),但由于两者的版本比较算法可能在未来独立演进,Grype 有意保留了各自独立的实现。
Bitnami 版本(bitnamiVersion)的特殊之处在于它的"回退解析"策略:首先尝试 Bitnami 专用的 go-version 解析器,如果失败则使用 hashicorp/go-version 的非严格解析作为回退。这是因为 Bitnami 包的版本号通常来自上游(可能是任意格式),但修订号被剥离——Bitnami 修订号只改变打包方式,不改变上游源码,因此不应影响漏洞匹配。
KB 版本(kbVersion)是 Windows KB 安全补丁的版本格式。它是最简单的版本比较器——只做相等性比较:
1 | func (v kbVersion) compare(other kbVersion) int { |
这是因为 KB 补丁号(如 KB5001234)本身就是标识符而非顺序版本号——你不需要判断 KB5001234 是否"大于"KB5001233,你只需要知道当前系统是否安装了某个特定的 KB 补丁。
从数据库到匹配:版本比较的调用全景
有了所有版本比较器的知识,我们可以回顾版本比较在整个漏洞匹配链路中的完整位置。
数据库加载阶段
漏洞数据库(v6 architecture)在内存中构建 Vulnerability 对象时,会调用 getVersionConstraint() 将数据库中的 affected range 转换为 Constraint 对象:
1 | func getVersionConstraint(affectedRanges []Range) (version.Constraint, error) { |
关键点在于:多个 affected range 被 || 拼接成一个组合约束,然后 version.GetConstraint() 根据漏洞的生态系统类型创建对应格式的 Constraint——可能是 genericConstraint、kbConstraint,也可能是 fuzzyConstraint。
匹配执行阶段
在匹配阶段,matchPackageByLanguage 和 matchPackageByDistro 通过 OnlyVulnerableVersions() 创建版本条件:
1 | func OnlyVulnerableVersions(v *version.Version) vulnerability.Criteria { |
这个 Criteria 最终触发 VersionCriteria.MatchesConstraint(),调用 constraint.Satisfied(&v.Version),这才是版本比较真正发生的地方。
优化策略:Provider 层面的提前过滤
在 v6 Provider 中,filterAffectedPackageRanges() 函数提供了数据库查询层面的提前过滤——在构建完整的 Vulnerability 对象之前,先检查版本约束是否满足:
1 | func filterAffectedPackageRanges(matcher search.VersionConstraintMatcher, b *PackageBlob) (bool, []string) { |
当所有 ranges 都不满足时,这个 Blob 根本不会被转换为 Vulnerability 对象——从而避免了不必要的内存分配和后处理。
Epoch 策略的传递路径
最后一个值得关注的细节是 Epoch 策略如何从 Matcher 配置一路传递到版本比较逻辑。路径是这样的:
- Matcher 配置中设置
MissingEpochStrategy - Matcher 创建
version.NewWithConfig()将策略封装进 Version 对象 simpleRangeExpression.satisfied()提取 Version 中的 Config 并调用compareWithConfig()compareWithConfig()通过类型断言判断 Comparator 是否支持配置化比较(目前只有 RPM 和 DEB),并调用其CompareWithConfig()方法
这条路径确保了 epoch 策略的设置在正确的时间点、对正确的格式生效。
总结
版本比较是漏洞匹配中最容易被低估的部分。在 Grype 中,它不是一个简单的字符串比较或数字大小判断,而是一套包含 14 种格式、三级分类策略、两类 epoch 处理策略和多层回退机制的子系统。理解了版本比较系统后,我们就完成了从"命令行入口"到"扫描结果"的完整技术链条。下一篇将转向漏洞匹配的后续处理——Grype 如何对匹配结果进行风险排序和优先级评估。