0%

Grype 源码分析 09:软件版本比较为什么如此复杂

前面的文章中,我们分析了 Grype 如何加载漏洞数据库、匹配框架如何运转、以及不同生态系统如何实施各自的匹配策略。但在整个漏洞匹配链路中,有一个环节被我们反复提及却一直未被展开讨论——版本比较。Grype 需要为 14 种以上的软件包格式分别提供正确的版本比较逻辑,而每一套逻辑背后都对应着一个生态的历史积累、社区约定和技术规范。

本文将从 Grype 版本比较系统的整体架构出发,深入剖析各类版本比较器的实现原理,并展示它们如何与约束表达式和数据库查询协作,完成从"版本字符串"到"是否受影响"的最终判断。

版本比较系统的整体架构

在进入具体实现之前,我们先建立整个版本比较系统的全局视图。Grype 的版本比较系统位于 grype/version 包,由四个核心抽象组成:

1
2
3
4
5
6
7
Format(版本格式)


Version(版本对象) ◄── Comparator(版本比较器)


Constraint(约束表达式) ◄── Range Expression(范围表达式解析)

Format 是一个枚举类型,标识版本字符串属于哪种软件生态。Grype 定义了 14 种格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const (
UnknownFormat Format = iota
SemanticFormat // npm, NuGet, Hex, Composer, Pub, Swift, Conan 等
ApkFormat // Alpine Linux
DebFormat // Debian / Ubuntu
MavenFormat // Java / Maven
RpmFormat // Red Hat / CentOS / Fedora
PythonFormat // Python PEP 440
KBFormat // Windows KB 补丁
GemFormat // Ruby Gem
PortageFormat // Gentoo
GolangFormat // Go Module
JVMFormat // JVM / JRE / JDK
BitnamiFormat // Bitnami
PacmanFormat // Arch Linux
)

一个包(如 lodash)通过 pkg.VersionFormat(p) 函数映射到对应的 Format——NpmPkg 类型映射到 SemanticFormatApkPkg 映射到 ApkFormat,以此类推。

Version 是版本比较系统的核心对象,它封装了一个版本字符串和它对应的 Format,并对外提供 Compare()Is() 两个方法:

1
2
3
4
5
6
7
8
9
type Version struct {
Raw string
Format Format
comparators map[Format]Comparator
Config ComparisonConfig
}

func (v Version) Compare(other *Version) (int, error) { ... }
func (v *Version) Is(op Operator, other *Version) (bool, error) { ... }

Comparator 是一个接口,定义了 Compare() 方法——负责实际比较两个版本字符串的数值大小:

1
2
3
type Comparator interface {
Compare(*Version) (int, error)
}

Grype 为每种 Format 都实现了一个 Comparator:semanticVersionapkVersiondebVersionrpmVersion 等等。Version.Compare() 的核心逻辑就是:根据自身 Format 获取对应的 Comparator,然后调用 Comparator 的 Compare() 方法。这里有一个重要的容错设计——如果主格式的比较失败(例如解析错误),Grype 会尝试用另一个版本的 Format 的 Comparator 进行回退比较。

Constraint 是版本比较系统的上层抽象,负责表达"版本范围"并回答"某个版本是否满足这个范围"。它的接口定义如下:

1
2
3
4
5
6
type Constraint interface {
fmt.Stringer
Value() string
Format() Format
Satisfied(*Version) (bool, error)
}

约束表达式可以是 >= 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
2
3
4
type rangeUnit struct {
Operator Operator
Version string
}

parseRange() 使用正则表达式 constraintPartPattern 从每段文本中提取操作符(>=<=><=)和版本号:

1
2
3
var constraintPartPattern = regexp.MustCompile(
`\s*(?P<prefix>[^><=a-zA-Z0-9().'"]*)(?P<operator>[><=]*)\s*(?P<version>.+)`,
)

最终,整个约束表达式被解析为一个 simpleRangeExpression,其内部结构是 [][]rangeUnit——外层数组是 OR 关系,内层数组是 AND 关系。这种"或中包含与"(OR-of-ANDs)的结构可以表达大多数实际的漏洞约束场景。

约束的满足性判断

当一个约束需要判断某个版本是否满足它时,执行的是两级逻辑运算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (c *simpleRangeExpression) satisfiedWithConfig(format Format, version *Version, cfg ComparisonConfig) (bool, error) {
oneSatisfied := false
for i, andOperand := range c.Units {
allSatisfied := true
for j, andUnit := range andOperand {
// 比较本版本和约束中的版本
result, err := version.Compare(constraintVersion)
...
if !unit.Satisfied(result) {
allSatisfied = false
}
}
oneSatisfied = oneSatisfied || allSatisfied
}
return oneSatisfied, nil
}

外层循环遍历 OR 组——只要有一个 OR 组整体满足就返回 true。内层循环遍历 AND 组——必须所有 AND 单元都满足才判定为该 OR 组满足。每个 rangeUnit.Satisfied() 方法根据比较结果判断该单元是否满足:

1
2
3
4
5
6
7
8
9
func (c *rangeUnit) Satisfied(comparison int) bool {
switch c.Operator {
case EQ: return comparison == 0
case GT: return comparison > 0
case GTE: return comparison >= 0
case LT: return comparison < 0
case LTE: return comparison <= 0
}
}

至此,我们得到了约束解析到判断的完整链条:文本约束 → 扫描为 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type semanticVersion struct {
obj *hashiVer.Version
}

func newSemanticVersion(raw string, strict bool) (semanticVersion, error) {
clean := semverPrereleaseNormalizer.Replace(raw)
// ...
if strict {
verObj, err = hashiVer.NewSemver(clean)
} else {
clean = trimLeadingV(clean)
verObj, err = hashiVer.NewVersion(clean)
}
// ...
}

这里有几个值得注意的设计决策。

非严格模式是默认选择。 newSemanticVersion()strict 参数控制是否使用严格的 semver 解析。在 strict 模式下,只有完全符合 MAJOR.MINOR.PATCH-prerelease+build 格式的版本才能被接受。但 Grype 的实际调用中,strict 默认为 false——这是因为现实中的版本号往往不完全符合 semver 规范,例如 1.0.0a1.0(缺少 PATCH 段)、v1.0.0(带 v 前缀)等。非严格模式给 hashicorp/go-version 更大的包容度,允许解析部分版本和不规范版本。

预发布后缀规范化。 semverPrereleaseNormalizer 处理了 Ruby gem 社区中的一个常见变体:

1
2
3
var semverPrereleaseNormalizer = strings.NewReplacer(
".alpha", "-alpha", ".beta", "-beta", ".rc", "-rc",
)

一些 gem 包使用 .alpha.beta.rc 而不是 semver 标准的 -alpha-beta-rc。通过简单的字符串替换,Grype 在不丢失信息的前提下统一了这些变体。

v 前缀去除。 trimLeadingV() 是一个全局共用的工具函数,如果版本号以 vV 开头且后跟数字,则去除此前缀。这意味着 v1.5.01.5.0 被视为同一个版本。

语义化版本的 Compare 方法极为直接——纯粹委托给底层库:

1
2
3
4
5
6
7
func (v semanticVersion) Compare(other *Version) (int, error) {
o, err := newSemanticVersion(other.Raw, false)
if err != nil {
return 0, err
}
return v.obj.Compare(o.obj), nil
}

底层库的 semver 比较规则遵循标准的优先级规律:MAJOR > MINOR > PATCH;有预发布标识的版本小于无预发布标识的正式版本;预发布标识按字典序比较。

APK 和 DEB:Linux 发行版的版本体系

Alpine Linux 的 APK 包管理器和 Debian 的 dpkg 分别使用各自的版本格式,它们都委托给专门的外部 Go 库来实现比较。

APK 版本

APK 版本比较委托给 github.com/knqyf263/go-apk-version 库:

1
2
3
4
5
6
7
8
9
10
11
type apkVersion struct {
obj apk.Version
}

func newApkVersion(raw string) (apkVersion, error) {
ver, err := apk.NewVersion(trimLeadingV(raw))
if err != nil {
return apkVersion{}, invalidFormatError(ApkFormat, raw, err)
}
return apkVersion{obj: ver}, nil
}

APK 的版本格式遵循 Alpine 的 pkgver 约定,形式为 {digit}{.digit}...{_alpha}{_suf}。例如 1.2.3-r420210101-r0。比较逻辑是典型的逐段比较:数字段按数值比较,字母段按字符串比较,版本后缀 _alpha_beta_pre_rc_p 有预设的先后顺序。

DEB 版本与 Epoch 问题

DEB 版本的复杂性在于 Epoch(纪元号)。Debian 的完整版本格式是 [epoch:]upstream_version[-debian_revision],其中 epoch 是一个整数,后跟冒号。当包的上游改变了版本编号方式时(例如从日期格式切换到语义版本),Epoch 用于确保正确的升级顺序——epoch 更大的版本永远是更新的版本,即使 upstream_version 看起来更小。

1
2
3
4
5
type debVersion struct {
obj deb.Version
epoch *int
raw string
}

DEB 版本比较同样委托给 github.com/knqyf263/go-deb-version。但 Grype 额外手动提取了 epoch 字段:

1
2
3
4
5
6
7
8
9
10
11
12
func extractDebEpoch(raw string) *int {
before, _, ok := strings.Cut(raw, ":")
if !ok {
return nil
}
epochStr := before
epoch, err := strconv.Atoi(epochStr)
if err != nil {
return nil
}
return &epoch
}

之所以需要手动提取 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
2
3
4
const (
MissingEpochStrategyZero MissingEpochStrategy = "zero" // 缺失 epoch 视为 0(默认)
MissingEpochStrategyAuto MissingEpochStrategy = "auto" // 缺失 epoch 匹配约束中的 epoch
)

MissingEpochStrategyZero 是最保守的策略——缺失 epoch 一律视为 0。这在 epoch 确实为 0 的场景下是正确的,但可能导致上述误报。

MissingEpochStrategyAuto 的策略是:如果软件包版本缺少 epoch,但漏洞约束中有 epoch,则临时为软件包版本注入约束中的 epoch 值再进行比较:

1
2
3
4
5
6
7
8
if cfg.MissingEpochStrategy == MissingEpochStrategyAuto {
if v.epoch == nil && o.epoch != nil {
versionWithEpoch := strconv.Itoa(*o.epoch) + ":" + v.raw
vWithEpoch, err := deb.NewVersion(versionWithEpoch)
// ...
return normalizeComparison(vWithEpoch.Compare(o.obj)), nil
}
}

这个设计的精妙之处在于:它假设"缺失 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 是 epoch
  • 1.28 是 version
  • 419.el8_4.1 是 release

Grype 将这三部分分别存储在 rpmVersion 结构体中:

1
2
3
4
5
type rpmVersion struct {
epoch *int
version string
release string
}

epoch 的解析通过 splitEpochFromVersion() 完成——在冒号处分割,冒号前是 epoch,冒号后是剩余的版本号。关键设计在于:Grype 用 nil 而非 0 表示"epoch 缺失"。这个决策有深刻的含义:

1
2
3
4
5
6
7
func splitEpochFromVersion(rawVersion string) (*int, string, error) {
fields := strings.SplitN(rawVersion, ":", 2)
if len(fields) == 1 {
return nil, rawVersion, nil // epoch 缺失 → nil 而非 0
}
// ...
}

为什么是 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
2
3
4
5
6
7
8
9
10
11
func addEpochIfApplicable(p *pkg.Package) {
meta, ok := p.Metadata.(pkg.RpmMetadata)
switch {
case strings.Contains(ver, ":"):
return // 已经有 epoch
case ok && meta.Epoch != nil:
p.Version = fmt.Sprintf("%d:%s", *meta.Epoch, ver) // 从元数据补全
default:
p.Version = "0:" + ver // 显式补为 0
}
}

在这个场景下,版本比较使用 MissingEpochStrategyZero,缺失 epoch 被视为 0,因为 epoch 信息的来源是可信的。

场景二:上游包匹配(source RPM 匹配)。 当通过 source RPM 查找上游包(例如 perl 的上游 sourceRPM)时,epoch 的问题变得复杂。source RPM 文件名通常不包含 epoch 信息——但上游包可能确实有一个非零 epoch。

考虑 RPM Matcher 源码中的注释给出的真实案例:

1
2
3
4
5
6
7
8
包名:       perl-Errno
版本: 0:1.28-419.el8_4.1
sourceRPM: perl-5.26.3-419.el8_4.1.src.rpm

漏洞记录:
ID: CVE-2020-10543
Package Name: perl(注意:是 source 包名,不是 perl-Errno)
Version constraint: < 4:5.26.3-419.el8

如果按 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (v rpmVersion) compare(v2 rpmVersion) int {
// 只有双方都有显式 epoch 时才比较 epoch
if epochIsPresent(v.epoch) && epochIsPresent(v2.epoch) {
epochResult := compareEpochs(*v.epoch, *v2.epoch)
if epochResult != 0 {
return epochResult
}
}
// 否则跳过 epoch,直接比较 version 段和 release 段
ret := compareRpmVersions(v.version, v2.version)
if ret != 0 {
return ret
}
return compareRpmVersions(v.release, v2.release)
}

这个设计虽然不符合 Red Hat 官方 RPM 规范,但对于漏洞扫描的场景来说是更实用的选择——它能避免因数据源中 epoch 信息不一致而导致的错误匹配。

MissingEpochStrategyAuto 被激活时(在 RPM Matcher 的上游包搜索路径中使用),处理逻辑更精细:

1
2
3
4
5
6
7
if cfg.MissingEpochStrategy == MissingEpochStrategyAuto {
if !epochIsPresent(v.epoch) && epochIsPresent(v2.epoch) {
vWithEpoch := v
vWithEpoch.epoch = v2.epoch // 使用约束中的 epoch
return vWithEpoch.compare(v2)
}
}

这相当于说:“如果软件包版本缺少 epoch,我猜它和约束是同一个 epoch 体系”。这个假设在上游包匹配场景中通常是合理的。

RPM 的逐段比较算法

epoch 之后,RPM 的 version 和 release 段使用逐段比较算法。compareRpmVersions() 的实现参考了 RPM 源码中的 C 函数 rpmvercmp

  1. 按字母数字分段:将版本字符串按"字母"和"数字"的边界切分为多个 segment。例如 1.28 分段为 ["1", "28"]419.el8_4.1 分段为 ["419", "el", "8", "4", "1"]

  2. 逐个 segment 比较:数字 segment 按数值比较(去除前导零),字母 segment 按字典序比较。

  3. 波浪号(tilde)是"最小":RPM 的波浪号 ~ 表示"小于任何值"。例如 1.0~alpha 小于 1.0。这是因为 RPM 规范中 ~ 用于表示 pre-release 版本。

  4. 剩余 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
2
3
4
5
6
7
func newGolangVersion(v string) (golangVersion, error) {
if v == "(devel)" {
return golangVersion{}, ErrUnsupportedVersion
}
fixedUp := strings.TrimPrefix(v, "go")
// ...
}

挑战二是 +incompatible+dirty 这类非标准分隔的构建元数据。 Go 模块的 +incompatible 标记表示一个 major 版本 > 1 的模块没有使用 Go modules。但有时会出现 +incompatible+dirty 这样的连续加号。按 semver 规范,构建元数据由 . 分隔,而非 +

1
2
3
4
before, after, found := strings.Cut(fixedUp, "+")
if found {
fixedUp = before + "+" + strings.ReplaceAll(after, "+", ".")
}

修复后,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type pep440Version struct {
public goPepVersion.Version // 公开发布部分
full goPepVersion.Version // 完整版本(含本地段)
}

func (v pep440Version) Compare(other *Version) (int, error) {
o, err := newPep440Version(other.Raw)
// ...
result := v.public.Compare(o.public)
if result != 0 {
return result, nil
}
// 公开发布部分相等时,检查本地版本标签
if o.full.Local() == "" {
return 0, nil // 约束无本地段→视为相等
}
return v.full.Compare(o.full), nil
}

这里的逻辑是:只有当约束中包含本地版本标签时,才比较本地段;否则,如果公开发布部分相等,就认为版本相等。 这是因为在漏洞匹配的场景中,含有本地版本标签的包(例如 1.0.0+internal.patch.1)通常与其公开发布版本(1.0.0)具有相同的安全性特征——本地补丁一般不改变包的漏洞状态。

Ruby Gem:自实现的精细比较

Ruby Gem 的版本比较是 Grype 自行实现的,不使用外部库。它的复杂性来自两个特殊处理:

一是架构后缀剥离。 Gem 的版本号可能包含平台/架构信息,例如 1.0.0-x86_64-linuxcleanArchFromVersion() 函数在比较前剥离这些后缀,避免它们干扰版本比较:

1
2
3
4
5
6
7
8
9
10
11
func cleanArchFromVersion(raw string) string {
platforms := []string{"x86", "universal", "arm", "aarch64", "java", "dalvik", "x64", "powerpc", "sparc", "mswin"}
dash := "-"
for _, p := range platforms {
vals := strings.SplitN(raw, dash+p, 2)
if len(vals) == 2 {
return vals[0]
}
}
return raw
}

二是混合 segment 比较。 Gem 的版本由字母和数字交替组成(如 1.0.0.alpha1),Grype 将这些 segment 解析为 []any(整数或字符串),然后逐段比较:

  • 数字 segment 按数值比较
  • 字母 segment 按字典序比较
  • 数字 segment "大于"字母 segment(即 1.a < 1.0

三是预发布版本中的中间零处理。 在预发布版本中(包含字母字符的版本),数字 segment 中的零会被裁剪——例如 1.0.0.alpha1.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
2
3
4
var (
preJep223VersionPattern = regexp.MustCompile(`^1\.(?P<major>\d+)(\.(?P<minor>\d+)([_-](update)?(_)?(?P<patch>\d+))?...)?`)
nonCompliantSemverIsh = regexp.MustCompile(`^(?P<major>\d+)(\.(?P<minor>\d+)(\.(?P<patch>\d+))?([_-](update)?(_)?(?P<update>\d+))?...)?`)
)

转换的核心思路是:将 update 后跟的数字映射为 semver 的 PATCH 段(如果 PATCH 不存在或为 0),将 -b 后的数字映射为构建元数据。例如:

  • 1.8.0_302-b088.0.302+8
  • 8.0-update302-b088.0.302+8
  • 11.0.16.111.0.16.1(无需转换)

转换后统一使用 hashicorp/go-version 进行 semver 风格的比较。

模糊比较:当一切皆为未知时的最后防线

并非所有软件包都能归入上述已知格式。Grype 提供了 FuzzyVersionFuzzyConstraint 作为处理未知格式(UnknownFormat)的降级方案。

FuzzyVersion 的策略是"先尝试 semver,不成就用模糊比较":

1
2
3
4
5
6
7
func (v fuzzyVersion) Compare(other *Version) (int, error) {
semver := newFuzzySemver(other.Raw)
if semver != nil && v.semVer != nil && v.semVer.obj != nil && semver.obj != nil {
return v.semVer.obj.Compare(semver.obj), nil // 都能解析为 semver → 用 semver
}
return fuzzyVersionComparison(v.raw, other.Raw), nil // 否则用模糊比较
}

fuzzyVersionComparison() 是一个改编自 Facebook nvdtools 的通用版本比较函数,它的核心思想是 “将版本字符串拆分为数字段和字母段,然后逐段比较”——这本质上是一个最朴素的版本比较模型:

  • 按字符类型(数字 vs 标点)将字符串分段
  • 数字段按数字的值比较(用零填充使位数相等)
  • 字母段按字符串字典序比较
  • 如果包含 p9p15 这类"patch number"格式,将数字部分提取出来单独比较

它不需要任何生态知识,代价是可能出现不准确的比较——例如 "2000" vs "11.7" 的比较结果会是不合理的。但作为最后的兜底策略,它确保 Grype 不会因为无法解析版本而直接崩溃。

FuzzyConstraint 的匹配策略也遵循类似的"双轨制":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (f fuzzyConstraint) Satisfied(verObj *Version) (bool, error) {
// 如果版本有已知格式,先尝试用正确的约束类型重试
if verObj.Format != UnknownFormat {
newConstraint, err := GetConstraint(f.RawPhrase, verObj.Format)
if err == nil {
satisfied, err := newConstraint.Satisfied(verObj)
if err == nil {
return satisfied, nil
}
}
}
// 回退到 semver 约束检查
if f.SemanticConstraint != nil {
if semver, err := newSemanticVersion(version, true); err == nil {
return f.SemanticConstraint.Check(semver.obj), nil
}
}
// 最后手段:模糊约束检查
return f.Constraints.satisfied(UnknownFormat, verObj)
}

这个三级回退策略非常务实:有格式就用格式专用解析 → 尝试 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
2
3
4
5
6
func (v kbVersion) compare(other kbVersion) int {
if reflect.DeepEqual(v, other) {
return 0
}
return 1 // 不相等就返回 1,不做大小判断
}

这是因为 KB 补丁号(如 KB5001234)本身就是标识符而非顺序版本号——你不需要判断 KB5001234 是否"大于"KB5001233,你只需要知道当前系统是否安装了某个特定的 KB 补丁。

从数据库到匹配:版本比较的调用全景

有了所有版本比较器的知识,我们可以回顾版本比较在整个漏洞匹配链路中的完整位置。

数据库加载阶段

漏洞数据库(v6 architecture)在内存中构建 Vulnerability 对象时,会调用 getVersionConstraint() 将数据库中的 affected range 转换为 Constraint 对象:

1
2
3
4
5
6
7
8
9
10
11
func getVersionConstraint(affectedRanges []Range) (version.Constraint, error) {
var constraints []string
for _, r := range affectedRanges {
if r.Version.Constraint != "" {
constraints = append(constraints, r.Version.Constraint)
}
}
versionFormat := version.ParseFormat(ty)
constraint, err := version.GetConstraint(strings.Join(constraints, "||"), versionFormat)
return constraint, nil
}

关键点在于:多个 affected range 被 || 拼接成一个组合约束,然后 version.GetConstraint() 根据漏洞的生态系统类型创建对应格式的 Constraint——可能是 genericConstraintkbConstraint,也可能是 fuzzyConstraint

匹配执行阶段

在匹配阶段,matchPackageByLanguagematchPackageByDistro 通过 OnlyVulnerableVersions() 创建版本条件:

1
2
3
4
5
6
7
8
func OnlyVulnerableVersions(v *version.Version) vulnerability.Criteria {
if v == nil || v.Raw == "" {
return search.ByFunc(func(_ vulnerability.Vulnerability) (bool, string, error) {
return true, "", nil // 无版本→匹配所有
})
}
return search.ByVersion(*v)
}

这个 Criteria 最终触发 VersionCriteria.MatchesConstraint(),调用 constraint.Satisfied(&v.Version),这才是版本比较真正发生的地方。

优化策略:Provider 层面的提前过滤

在 v6 Provider 中,filterAffectedPackageRanges() 函数提供了数据库查询层面的提前过滤——在构建完整的 Vulnerability 对象之前,先检查版本约束是否满足:

1
2
3
4
5
6
7
8
9
10
11
12
func filterAffectedPackageRanges(matcher search.VersionConstraintMatcher, b *PackageBlob) (bool, []string) {
for _, r := range b.Ranges {
format := version.ParseFormat(v.Type)
constraint, err := version.GetConstraint(v.Constraint, format)
matches, err := matcher.MatchesConstraint(constraint)
if matches {
continue // 这个 range 满足,保留
}
unmatchedConstraints = append(unmatchedConstraints, v.Constraint)
}
return len(b.Ranges) == len(unmatchedConstraints), unmatchedConstraints
}

当所有 ranges 都不满足时,这个 Blob 根本不会被转换为 Vulnerability 对象——从而避免了不必要的内存分配和后处理。

Epoch 策略的传递路径

最后一个值得关注的细节是 Epoch 策略如何从 Matcher 配置一路传递到版本比较逻辑。路径是这样的:

  1. Matcher 配置中设置 MissingEpochStrategy
  2. Matcher 创建 version.NewWithConfig() 将策略封装进 Version 对象
  3. simpleRangeExpression.satisfied() 提取 Version 中的 Config 并调用 compareWithConfig()
  4. compareWithConfig() 通过类型断言判断 Comparator 是否支持配置化比较(目前只有 RPM 和 DEB),并调用其 CompareWithConfig() 方法

这条路径确保了 epoch 策略的设置在正确的时间点、对正确的格式生效。

总结

版本比较是漏洞匹配中最容易被低估的部分。在 Grype 中,它不是一个简单的字符串比较或数字大小判断,而是一套包含 14 种格式、三级分类策略、两类 epoch 处理策略和多层回退机制的子系统。理解了版本比较系统后,我们就完成了从"命令行入口"到"扫描结果"的完整技术链条。下一篇将转向漏洞匹配的后续处理——Grype 如何对匹配结果进行风险排序和优先级评估。