之前文章分别分析了 Grype 的整体执行流程、命令行配置系统和软件包发现过程。在 pkg.Provide() 返回 []pkg.Package 之后,runGrype() 会将这批软件包交给 VulnerabilityMatcher 执行漏洞匹配,最终生成一份扫描报告。在这一整条链路中,有三个数据结构贯穿始终:
- Package:从 Syft 编目结果转换而来的软件包描述;
- Vulnerability:从漏洞数据库查询出的漏洞记录;
- Match:前两者结合并附上匹配证据后形成的"一次发现"。
扫描报告中的每一行,本质上就是一个 Match 对象。理解这三个核心类型以及它们之间的关联方式,是理解 Grype 匹配引擎、去重策略、忽略规则和报告输出的基础。这篇文章将从一次具体的扫描结果出发,逐一分析这些数据模型的设计和实现。
从一行扫描结果看数据模型
先用一个具体例子建立直观认识。执行 grype dir:./vulnerable-node-app 后,JSON 输出中的一条匹配记录大致如下:
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 | type Package struct { |
这里有两个关键设计决策值得关注。
第一个是 Metadata 的裁剪。Syft 的软件包元数据非常丰富——以 RPM 为例,Syft 的 RpmDBEntry 包含数十个字段。但漏洞匹配并不需要全部信息,它只关心少数几个关键维度:epoch、modularity label、source package 名称等。因此 Grype 的 New() 函数在构造 Package 时,会调用 dataFromPkg() 根据 Syft 的元数据类型提取出 Grype 专用的精简结构:
1 | func dataFromPkg(p syftPkg.Package) (any, []UpstreamPackage) { |
以 RPM 为例,Grype 只保留了 epoch 和 modularity label,因为 epoch 直接影响版本比较结果,而 modularity label 决定了一个包属于哪个模块流。至于 Java,Grype 只保留 POM 的 artifactID 和 groupID,以及 Manifest 中的 Name 字段,这些是 Maven 坐标匹配所需的全部信息。
第二个设计决策是 Upstreams 与间接匹配。以 Debian 为例,系统上安装的可能是 libssl3,但漏洞数据库中记录的却是 openssl——因为 Debian 的漏洞公告使用 source package 名称。dpkgDataFromPkg() 正是为了解决这个问题:
1 | func dpkgDataFromPkg(p syftPkg.Package) (upstreams []UpstreamPackage) { |
在匹配阶段,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 | type Vulnerability struct { |
这个结构中,有几个字段是理解匹配逻辑的关键。
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 | type Qualifier interface { |
这种设计将 qualify/disqualify(鉴定/否定)逻辑从匹配框架中解耦出来。数据库可以携带平台特定的判定条件,而 Matcher 无需知道这些条件的内部实现,只需调用 Satisfied() 即可。目前 Grype 内置了四种 qualifier:
rpmmodularity:检查 RPM 包是否属于受影响的 modularity stream;platformcpe:验证 CPE 的平台匹配(操作系统和架构);architecture:验证二进制包的架构是否匹配;rootio:处理io.root.*前缀的 Java 反向移植包的鉴定逻辑。
Fix 描述了漏洞的修复状态:
1 | type Fix struct { |
State 有四种取值:fixed(已有修复)、not-fixed(尚未修复)、wont-fix(不会修复)、unknown(未知)。--only-fixed 和 --only-notfixed 就是根据这个字段过滤匹配结果的。修复状态直接决定了漏洞的可操作性——一个 not-fixed 的 High 漏洞比一个 fixed 的 Critical 漏洞更值得关注。
Metadata:漏洞的上下文信息
每个 Vulnerability 都关联一个 Metadata,它包含了漏洞的严重程度、评分和描述信息:
1 | type Metadata struct { |
其中三个字段直接决定了漏洞的优先级排序(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 | type Match struct { |
这种设计的优势在于:
- 一个包可以从多个角度匹配同一条漏洞记录:比如 lodash 既可以通过 npm 生态匹配到 GHSA 公告,也可以通过 CPE 匹配到 CVE 记录。两条 Match 共享同一个 Package 和同一个漏洞 ID,但 Details 不同。
- 同一漏洞记录可以属于多个 Match:比如一个 CVE 同时影响 npm 包和 Debian 包,每个受影响的包都是一条独立的 Match。
- 去重和合并有明确的粒度:Match 是最小的可去重单位,两条 Match 是否"相同"由 Fingerprint 决定,而不是简单地比较漏洞 ID。
Match Details:记录匹配路径
Details 记录了匹配是如何发生的,它是 Match 中最容易被忽略但最重要的部分:
1 | type Detail struct { |
匹配类型(Type)目前有三种:
| 匹配类型 | 含义 | 示例 |
|---|---|---|
exact-direct-match |
直接精确匹配:搜索时使用的包名与软件包名一致 | lodash → lodash |
exact-indirect-match |
间接精确匹配:搜索时使用的包名来自 UpstreamPackage | libssl3 → openssl |
cpe-match |
CPE 匹配:通过 CPE 标识符匹配 | cpe:2.3:a:lodash:* → lodash |
每种 Match Detail 的 SearchedBy 和 Found 字段携带了各自所需的上下文信息。以 CPE 匹配为例,SearchedBy 包含当时使用的 CPE 字符串和命名空间,而 Found 包含匹配到的漏洞 ID 和版本约束。这些信息在 JSON 报告中都是可见的,让用户能够追溯每一条匹配结果是怎么来的。
MatcherType 则标识了哪个 Matcher 完成了匹配(javascript-matcher、dpkg-matcher、stock-matcher 等)。同一个漏洞 ID 可能被不同 Matcher 各自匹配一次——例如 stock-matcher 通过 CPE 路径匹配到 CVE-XXXX,而 javascript-matcher 通过 npm 生态的精确匹配也命中了同一个 CVE 的别名(GHSA 公告)。
Fingerprint 与 Matches 集合
当同一个包通过不同路径(npm 生态 + CPE)匹配到同一条漏洞记录时,Grype 需要将这两条初步匹配合并为一个最终的 Match——否则报告里会出现重复行。这就是 Fingerprint 的作用:
1 | type Fingerprint struct { |
Fingerprint 的本质是"漏洞 ID + 命名空间 + 软件包 ID + 修复版本"的组合哈希。两条 Match 如果 Fingerprint 相同,说明它们是对同一个包的同一个漏洞的不同发现路径,应该被合并:
- 两条 Match 的
Details会合并为一个列表,保留所有不重复的匹配路径; RelatedVulnerabilities会合并,保留所有关联的漏洞引用(如 GHSA 对应的 CVE);- CPE 列表也会合并去重。
整个合并逻辑在 Match.Merge() 中实现。而 Matches 集合(定义在 grype/match/matches.go)是这个去重机制的容器:
1 | type Matches struct { |
Matches 同时维护了三个索引:
- byFingerprint:完整指纹 → Match,用于去重和最终输出;
- byCoreFingerprint:核心指纹 → 完整指纹集合,用于合并时的查找。核心指纹与完整指纹的区别在于前者不包含修复版本信息——这意味着同一个漏洞的不同修复版本记录会被识别为"同一个漏洞匹配"并根据规则(直接匹配优先于间接匹配)进行合并或替换;
- byPackage:包 ID → 完整指纹集合,用于按包查询匹配结果。
Add() 方法在插入新 Match 时的合并策略分为三种情况:
- 情况 A:完整指纹已存在 → 调用
Merge()合并 Details 和相关漏洞引用; - 情况 B:核心指纹已存在但完整指纹不同 → 优先保留直接匹配(
exact-direct-match)替换间接匹配(exact-indirect-match),否则合并; - 情况 C:指纹完全不存在 → 作为新匹配直接插入。
这套索引机制解决了三个实际问题:同一个包的同一个漏洞从不同路径匹配到应该如何合并;多个包匹配到同一个漏洞后如何在"按包查看"和"按漏洞查看"之间切换;以及扫描报告的排序和分组。
数据流:从 Package 到 Document
理解三个核心数据结构之后,再回头看它们在整个扫描流程中的位置和转换关系。
阶段一:生产 Package
1 | pkg.Provide(userInput) |
在这一阶段,Syft 完成文件系统的扫描和软件包编目,Grype 通过 pkg.New() 将 Syft 的结果转换为自己的 Package 结构,只保留漏洞匹配需要的关键字段。
阶段二:匹配并生成 Match
1 | VulnerabilityMatcher.FindMatchesContext(pkgs) |
这一阶段是漏洞匹配的核心。每个 Package 被分派给对应的 Matcher,Matcher 将搜索参数(包名、版本、生态、CPE 等)构造为 Criteria 传入 VulnerabilityProvider.FindVulnerabilities(),数据库返回匹配的 Vulnerability 列表。内部的 result.Set 将这些原始匹配整理为 Match 并完成指纹去重,然后经过忽略规则和 VEX 处理器过滤后,得到最终的 Matches 集合和 IgnoredMatch 列表。
阶段三:构建 Document
1 | models.NewDocument(packages, context, matches, ignoredMatches, ...) |
在报告阶段,NewDocument() 遍历 Matches 集合中的每条 Match,通过 pkg.ByID() 从原始软件包列表中回查完整的 Package 对象,再将内部模型转换为面向输出的 models.Match、models.Vulnerability 和 models.Package。之所以要回查原始 Package 列表,是因为 Matches 中存储的 Package 信息是匹配时使用的"搜索版本"(可能是 UpstreamPackage 变换后的),而报告需要展示"实际发现"的包信息。
Context:贯穿全流程的环境信息
在整个数据流中,还有一个容易被忽略但贯穿始终的结构——Context:
1 | type Context struct { |
Context 不是数据模型的核心,而是数据模型的"背景"。它的三个字段分别回答了三个问题:扫描的源是什么(目录路径还是镜像引用)?目标系统的发行版是什么(这在匹配 OS 包漏洞时至关重要)?发行版识别是否失败了(如果是,需要在报告中告警)?
Context 由 pkg.Provide() 返回,与 []Package 一起传入 VulnerabilityMatcher,最终进入 Document 的 Source 和 Distro 字段。它的生命周期与 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 的实现原理,分析数据是如何从上游源抓取、整理、封装为可供查询的归档文件的。