0%

Grype 源码分析 05:Grype 的核心数据模型

之前文章分别分析了 Grype 的整体执行流程、命令行配置系统和软件包发现过程。在 pkg.Provide() 返回 []pkg.Package 之后,runGrype() 会将这批软件包交给 VulnerabilityMatcher 执行漏洞匹配,最终生成一份扫描报告。在这一整条链路中,有三个数据结构贯穿始终:

  • Package:从 Syft 编目结果转换而来的软件包描述;
  • Vulnerability:从漏洞数据库查询出的漏洞记录;
  • Match:前两者结合并附上匹配证据后形成的"一次发现"。

扫描报告中的每一行,本质上就是一个 Match 对象。理解这三个核心类型以及它们之间的关联方式,是理解 Grype 匹配引擎、去重策略、忽略规则和报告输出的基础。这篇文章将从一次具体的扫描结果出发,逐一分析这些数据模型的设计和实现。

从一行扫描结果看数据模型

先用一个具体例子建立直观认识。执行 grype dir:./vulnerable-node-app 后,JSON 输出中的一条匹配记录大致如下:

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
{
"matches": [
{
"vulnerability": {
"id": "GHSA-g4mx-q9vg-27p4",
"severity": "Medium",
"fix": { "state": "fixed", "versions": ["1.0.1"] }
},
"artifact": {
"name": "lodash",
"version": "4.17.20",
"type": "npm",
"purl": "pkg:npm/lodash@4.17.20"
},
"matchDetails": [
{
"type": "exact-direct-match",
"matcher": "javascript-matcher",
"searchedBy": { "language": "javascript", "package": { "name": "lodash", "version": "4.17.20" } },
"found": { "vulnerabilityID": "GHSA-g4mx-q9vg-27p4", "versionConstraint": "< 1.0.1" }
}
]
}
]
}

这一条记录包含了三个层次的信息:

  • 匹配了什么包artifact):lodash 4.17.20,npm 生态;
  • 匹配到什么漏洞vulnerability):GHSA-g4mx-q9vg-27p4,修复版本为 1.0.1;
  • 怎么匹配上的matchDetails):JavaScript Matcher 通过包名和生态系统进行精确直接匹配,漏洞的版本约束为 < 1.0.1

这个结构背后对应的是三个核心 Go 类型。我们先从软件包一侧开始分析,然后进入漏洞一侧,最后看它们如何被组合为 Match。

Package:匹配引擎的输入

从 Syft Package 到 Grype Package

在 Grype 中,Package 定义在 grype/pkg/package.go,它并不是 Syft 的 syftPkg.Package 的原样拷贝,而是一个经过裁剪和重构的专用结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Package struct {
ID ID
Name string // 包名
Version string // 版本号
Locations file.LocationSet // 发现此包的路径集合
Language syftPkg.Language // 所属编程语言生态(JavaScript、Python 等)
Distro *distro.Distro // 来源发行版信息
Licenses []string
Type syftPkg.Type // 包类型(Npm、Rpm、Deb 等)
CPEs []cpe.CPE // 候选 CPE 标识符
PURL string // Package URL
Upstreams []UpstreamPackage
Metadata any // 生态特定的元数据(非 Syft 原样,仅保留匹配需要的关键字段)

Annotations map[string][]string
RelatedPackages map[artifact.RelationshipType][]*Package
}

这里有两个关键设计决策值得关注。

第一个是 Metadata 的裁剪。Syft 的软件包元数据非常丰富——以 RPM 为例,Syft 的 RpmDBEntry 包含数十个字段。但漏洞匹配并不需要全部信息,它只关心少数几个关键维度:epoch、modularity label、source package 名称等。因此 Grype 的 New() 函数在构造 Package 时,会调用 dataFromPkg() 根据 Syft 的元数据类型提取出 Grype 专用的精简结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func dataFromPkg(p syftPkg.Package) (any, []UpstreamPackage) {
var metadata any
var upstreams []UpstreamPackage

switch p.Metadata.(type) {
case syftPkg.GolangModuleEntry, syftPkg.GolangBinaryBuildinfoEntry, syftPkg.GolangSourceEntry:
metadata = golangMetadataFromPkg(p)
case syftPkg.DpkgDBEntry:
upstreams = dpkgDataFromPkg(p)
case syftPkg.RpmArchive, syftPkg.RpmDBEntry:
m, u := rpmDataFromPkg(p)
upstreams = u
if m != nil {
metadata = *m
}
case syftPkg.JavaArchive:
if m := javaDataFromPkgMetadata(p); m != nil {
metadata = *m
}
// ... 其他生态
}
return metadata, upstreams
}

以 RPM 为例,Grype 只保留了 epoch 和 modularity label,因为 epoch 直接影响版本比较结果,而 modularity label 决定了一个包属于哪个模块流。至于 Java,Grype 只保留 POM 的 artifactIDgroupID,以及 Manifest 中的 Name 字段,这些是 Maven 坐标匹配所需的全部信息。

第二个设计决策是 Upstreams 与间接匹配。以 Debian 为例,系统上安装的可能是 libssl3,但漏洞数据库中记录的却是 openssl——因为 Debian 的漏洞公告使用 source package 名称。dpkgDataFromPkg() 正是为了解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
func dpkgDataFromPkg(p syftPkg.Package) (upstreams []UpstreamPackage) {
switch value := p.Metadata.(type) {
case syftPkg.DpkgDBEntry:
if value.Source != "" {
upstreams = append(upstreams, UpstreamPackage{
Name: value.Source,
Version: value.SourceVersion,
})
}
// ...
}
return upstreams
}

在匹配阶段,Matcher 不仅会用当前包名搜索漏洞,还会为每个 UpstreamPackage 生成一个"虚拟"的搜索目标,用上游包名和版本去查询漏洞数据库。这解释了为什么一个 npm 包可能产生多条匹配——不是因为它本身有那么多漏洞,而是匹配引擎从不同维度(当前包名、上游包名、CPE)分别进行了搜索。

Package 的辅助信息

除了核心的标识字段,Package 还有两组重要的辅助信息。

Locations 记录了 Syft 在文件系统的哪些位置发现了这个包。比如在扫描一个 Node.js 项目时,lodash 的 Location 就是 node_modules/lodash/package.json。Locations 不仅用于报告展示,还用于 overlap 去重逻辑:当 OS 包(如 deb)和语言包(如 npm)共享同一个文件路径时,Grype 会判断语言包是否被 OS 包"包含",从而决定是否过滤掉其中一个。

CPEs(Common Platform Enumerators,通用平台枚举标识符)是 NVD(National Vulnerability Database,美国国家漏洞数据库)等通用漏洞库用于标识软件的标准格式,形如 cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:*:*:*。在匹配阶段,CPE 提供了另一种搜索路径——即使某个漏洞记录不是按 npm 包名索引的,它仍然可以通过 CPE 匹配到对应的软件包。

Vulnerability:从数据库到匹配对象

漏洞记录的结构

Vulnerability 定义在 grype/vulnerability/vulnerability.go,它代表从漏洞数据库查询出的一条记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Vulnerability struct {
Reference
Status string
PackageName string
Constraint version.Constraint
PackageQualifiers []qualifier.Qualifier
CPEs []cpe.CPE
Fix Fix
Advisories []Advisory
RelatedVulnerabilities []Reference
Metadata *Metadata
Unaffected bool
}

这个结构中,有几个字段是理解匹配逻辑的关键。

Constraint 是版本约束,它并不固定为某一种格式。对于 npm 包,约束可能是 >= 1.0.0, < 1.0.1(受影响的版本范围);对于 Debian 包,约束可能是 <= 1.1.1n-1+deb11u1(指修复前的版本)。Constraint 由 grype/version 包负责解析和判断,具体的比较规则因生态而异——这部分将在后续的版本比较专题文章中详细展开。

PackageQualifiers(软件包限定词)是 v6 数据库引入的一个重要能力。传统匹配只关心"包名是否一致"和"版本是否在受影响范围内",但有些漏洞只在特定条件下才影响目标包。例如:

  • 某个 RPM 漏洞只在特定 modularity stream 下存在;
  • 某个 Java 漏洞只影响特定架构的构建;
  • 某个 Go 漏洞的符号(symbol)未被目标二进制引用。

Qualifier 接口只有一个方法:

1
2
3
type Qualifier interface {
Satisfied(p pkg.Package) (bool, error)
}

这种设计将 qualify/disqualify(鉴定/否定)逻辑从匹配框架中解耦出来。数据库可以携带平台特定的判定条件,而 Matcher 无需知道这些条件的内部实现,只需调用 Satisfied() 即可。目前 Grype 内置了四种 qualifier:

  • rpmmodularity:检查 RPM 包是否属于受影响的 modularity stream;
  • platformcpe:验证 CPE 的平台匹配(操作系统和架构);
  • architecture:验证二进制包的架构是否匹配;
  • rootio:处理 io.root.* 前缀的 Java 反向移植包的鉴定逻辑。

Fix 描述了漏洞的修复状态:

1
2
3
4
5
type Fix struct {
Versions []string
State FixState
Available []FixAvailable
}

State 有四种取值:fixed(已有修复)、not-fixed(尚未修复)、wont-fix(不会修复)、unknown(未知)。--only-fixed--only-notfixed 就是根据这个字段过滤匹配结果的。修复状态直接决定了漏洞的可操作性——一个 not-fixed 的 High 漏洞比一个 fixed 的 Critical 漏洞更值得关注。

Metadata:漏洞的上下文信息

每个 Vulnerability 都关联一个 Metadata,它包含了漏洞的严重程度、评分和描述信息:

1
2
3
4
5
6
7
8
9
10
11
type Metadata struct {
ID string
DataSource string
Severity string
URLs []string
Description string
Cvss []Cvss
KnownExploited []KnownExploited
EPSS []EPSS
CWEs []CWE
}

其中三个字段直接决定了漏洞的优先级排序(Risk Score):

  • CVSS(Common Vulnerability Scoring System,通用漏洞评分系统):量化漏洞严重性的评分标准,BaseScore 范围 0-10;
  • EPSS(Exploit Prediction Scoring System,漏洞利用预测评分系统):表示漏洞在未来 30 天内被利用的概率,范围 0-1;
  • KEV(Known Exploited Vulnerabilities,已知被利用漏洞):由 CISA 维护的已被野外利用的漏洞目录,同时标记载体是否涉及已知勒索软件活动。

Metadata 的 RiskScore() 方法综合了 threat(威胁)、severity(严重性)和 KEV 修正因子,计算出一个 0-100 的风险分值。这一计算的详细逻辑将在风险排序专题中展开,这里先记住一点:Metadata 并不只是一个数据容器,它自身承载了优先级判断的业务逻辑。

Match:连接 Package 和 Vulnerability

为什么是 Match 而不是独立的包或漏洞

现在我们已经分析了 Package 和 Vulnerability。一个自然的疑问是:扫描结果为什么不是"列出所有软件包,然后在每个包下列出漏洞",或者反过来"列出所有漏洞,然后在每个漏洞下列出受影响的包"?

实际上,Grype 的答案是 Match。一次漏洞匹配的本质是"某个软件包的某个版本,因为某种原因,被判定受某个漏洞影响"。这三个维度(包、漏洞、原因)缺一不可:

1
2
3
4
5
type Match struct {
Vulnerability vulnerability.Vulnerability // 漏洞详情
Package pkg.Package // 被匹配的软件包
Details Details // 匹配证据
}

这种设计的优势在于:

  • 一个包可以从多个角度匹配同一条漏洞记录:比如 lodash 既可以通过 npm 生态匹配到 GHSA 公告,也可以通过 CPE 匹配到 CVE 记录。两条 Match 共享同一个 Package 和同一个漏洞 ID,但 Details 不同。
  • 同一漏洞记录可以属于多个 Match:比如一个 CVE 同时影响 npm 包和 Debian 包,每个受影响的包都是一条独立的 Match。
  • 去重和合并有明确的粒度:Match 是最小的可去重单位,两条 Match 是否"相同"由 Fingerprint 决定,而不是简单地比较漏洞 ID。

Match Details:记录匹配路径

Details 记录了匹配是如何发生的,它是 Match 中最容易被忽略但最重要的部分:

1
2
3
4
5
6
7
type Detail struct {
Type Type // 匹配类型
SearchedBy any // 搜索条件
Found any // 匹配到的目标属性
Matcher MatcherType // 哪个 Matcher 完成了匹配
Confidence float64 // 匹配置信度(预留)
}

匹配类型(Type)目前有三种:

匹配类型 含义 示例
exact-direct-match 直接精确匹配:搜索时使用的包名与软件包名一致 lodash → lodash
exact-indirect-match 间接精确匹配:搜索时使用的包名来自 UpstreamPackage libssl3 → openssl
cpe-match CPE 匹配:通过 CPE 标识符匹配 cpe:2.3:a:lodash:* → lodash

每种 Match Detail 的 SearchedByFound 字段携带了各自所需的上下文信息。以 CPE 匹配为例,SearchedBy 包含当时使用的 CPE 字符串和命名空间,而 Found 包含匹配到的漏洞 ID 和版本约束。这些信息在 JSON 报告中都是可见的,让用户能够追溯每一条匹配结果是怎么来的。

MatcherType 则标识了哪个 Matcher 完成了匹配(javascript-matcherdpkg-matcherstock-matcher 等)。同一个漏洞 ID 可能被不同 Matcher 各自匹配一次——例如 stock-matcher 通过 CPE 路径匹配到 CVE-XXXX,而 javascript-matcher 通过 npm 生态的精确匹配也命中了同一个 CVE 的别名(GHSA 公告)。

Fingerprint 与 Matches 集合

当同一个包通过不同路径(npm 生态 + CPE)匹配到同一条漏洞记录时,Grype 需要将这两条初步匹配合并为一个最终的 Match——否则报告里会出现重复行。这就是 Fingerprint 的作用:

1
2
3
4
5
6
7
8
9
10
type Fingerprint struct {
coreFingerprint
vulnerabilityFixes string
}

type coreFingerprint struct {
vulnerabilityID string
vulnerabilityNamespace string
packageID pkg.ID
}

Fingerprint 的本质是"漏洞 ID + 命名空间 + 软件包 ID + 修复版本"的组合哈希。两条 Match 如果 Fingerprint 相同,说明它们是对同一个包的同一个漏洞的不同发现路径,应该被合并:

  • 两条 Match 的 Details 会合并为一个列表,保留所有不重复的匹配路径;
  • RelatedVulnerabilities 会合并,保留所有关联的漏洞引用(如 GHSA 对应的 CVE);
  • CPE 列表也会合并去重。

整个合并逻辑在 Match.Merge() 中实现。而 Matches 集合(定义在 grype/match/matches.go)是这个去重机制的容器:

1
2
3
4
5
type Matches struct {
byFingerprint map[Fingerprint]Match
byCoreFingerprint map[coreFingerprint]map[Fingerprint]struct{}
byPackage map[pkg.ID]map[Fingerprint]struct{}
}

Matches 同时维护了三个索引:

  • byFingerprint:完整指纹 → Match,用于去重和最终输出;
  • byCoreFingerprint:核心指纹 → 完整指纹集合,用于合并时的查找。核心指纹与完整指纹的区别在于前者不包含修复版本信息——这意味着同一个漏洞的不同修复版本记录会被识别为"同一个漏洞匹配"并根据规则(直接匹配优先于间接匹配)进行合并或替换;
  • byPackage:包 ID → 完整指纹集合,用于按包查询匹配结果。

Add() 方法在插入新 Match 时的合并策略分为三种情况:

  • 情况 A:完整指纹已存在 → 调用 Merge() 合并 Details 和相关漏洞引用;
  • 情况 B:核心指纹已存在但完整指纹不同 → 优先保留直接匹配(exact-direct-match)替换间接匹配(exact-indirect-match),否则合并;
  • 情况 C:指纹完全不存在 → 作为新匹配直接插入。

这套索引机制解决了三个实际问题:同一个包的同一个漏洞从不同路径匹配到应该如何合并;多个包匹配到同一个漏洞后如何在"按包查看"和"按漏洞查看"之间切换;以及扫描报告的排序和分组。

数据流:从 Package 到 Document

理解三个核心数据结构之后,再回头看它们在整个扫描流程中的位置和转换关系。

阶段一:生产 Package

1
2
3
4
5
6
7
8
9
pkg.Provide(userInput)

└── Syft Source → Catalog → syftPkg.Package

└── New() → grype/pkg.Package (裁剪 Metadata + 提取 Upstreams)

└── FromPackages() → 建立 RelatedPackages + 去重

└── []pkg.Package

在这一阶段,Syft 完成文件系统的扫描和软件包编目,Grype 通过 pkg.New() 将 Syft 的结果转换为自己的 Package 结构,只保留漏洞匹配需要的关键字段。

阶段二:匹配并生成 Match

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VulnerabilityMatcher.FindMatchesContext(pkgs)

├── 为每个 Package 选择 Matcher
│ │
│ └── Matcher.Match(vulnProvider, pkg)
│ │
│ ├── 按包名 + 生态 → VulnerabilityProvider.FindVulnerabilities()
│ ├── 按 CPE → VulnerabilityProvider.FindVulnerabilities()
│ └── Result.Set.ToMatches() → []match.Match

├── ApplyExplicitIgnoreRules() → 去除硬编码排除项
├── ApplyIgnoreFilters() → 应用 Matcher 返回的忽略规则
├── Matches.Add() → Fingerprint 去重与合并
├── applyIgnoreRules() → 应用用户提供的 ignore 规则
└── findVEXMatches() → 应用 VEX 文档

└── *match.Matches, []match.IgnoredMatch

这一阶段是漏洞匹配的核心。每个 Package 被分派给对应的 Matcher,Matcher 将搜索参数(包名、版本、生态、CPE 等)构造为 Criteria 传入 VulnerabilityProvider.FindVulnerabilities(),数据库返回匹配的 Vulnerability 列表。内部的 result.Set 将这些原始匹配整理为 Match 并完成指纹去重,然后经过忽略规则和 VEX 处理器过滤后,得到最终的 Matches 集合和 IgnoredMatch 列表。

阶段三:构建 Document

1
2
3
4
5
6
7
8
9
10
11
12
13
models.NewDocument(packages, context, matches, ignoredMatches, ...)

├── matches.Sorted() → 遍历每条 Match
│ │
│ └── newMatch(m, p, metadataProvider)
│ │
│ ├── 通过 pkg.ByID() 回查完整 Package 信息
│ ├── 填充 RelatedVulnerabilities 的 Metadata
│ ├── 构造 MatchDetails (含 FixDetails)
│ └── models.Match (Vulnerability + Artifact + MatchDetails)

├── SortMatches() → 按策略排序
└── Document{Matches, IgnoredMatches, Source, Distro, Descriptor}

在报告阶段,NewDocument() 遍历 Matches 集合中的每条 Match,通过 pkg.ByID() 从原始软件包列表中回查完整的 Package 对象,再将内部模型转换为面向输出的 models.Matchmodels.Vulnerabilitymodels.Package。之所以要回查原始 Package 列表,是因为 Matches 中存储的 Package 信息是匹配时使用的"搜索版本"(可能是 UpstreamPackage 变换后的),而报告需要展示"实际发现"的包信息。

Context:贯穿全流程的环境信息

在整个数据流中,还有一个容易被忽略但贯穿始终的结构——Context

1
2
3
4
5
type Context struct {
Source *source.Description
Distro *distro.Distro
DistroDetectionFailed bool
}

Context 不是数据模型的核心,而是数据模型的"背景"。它的三个字段分别回答了三个问题:扫描的源是什么(目录路径还是镜像引用)?目标系统的发行版是什么(这在匹配 OS 包漏洞时至关重要)?发行版识别是否失败了(如果是,需要在报告中告警)?

Contextpkg.Provide() 返回,与 []Package 一起传入 VulnerabilityMatcher,最终进入 DocumentSourceDistro 字段。它的生命周期与 Package 列表相同——从 Provider 产生到报告输出,始终作为环境元数据伴随主数据流。

小结

Grype 的核心数据模型由三个对象组成:

  • Package 是匹配的输入,从 Syft 编目结果裁剪而来,包含包名、版本、生态、CPE 和 Upstream 信息。它不是 Syft 结果的简单拷贝,而是专门为漏洞匹配优化的精简视图。

  • Vulnerability 是匹配的目标,来自漏洞数据库的查询结果。它包含版本约束、Qualifier、修复信息和 Metadata。Constraint 决定着版本比较的规则,Qualifier 提供了平台特定的鉴定条件,Metadata 则承载了 CVSS、EPSS 和 KEV 等风险数据。

  • Match 是前两者的"相遇"记录,包含匹配到的漏洞、被匹配的包、以及匹配是如何发生的(Details)。一套 Fingerprint 机制确保同一个包的同一个漏洞不会因多路径匹配而产生重复行,而合理的合并策略则确保直接匹配优于间接匹配。

理解这三个对象及其流转关系,就为接下来分析数据库如何提供 Vulnerability、Matcher 如何产生 Match、以及风险排序如何介入报告输出打下了基础。下一篇将进入 Grype 漏洞数据库 v6 的实现原理,分析数据是如何从上游源抓取、整理、封装为可供查询的归档文件的。