在上一篇文章中,我们分析了 Package、Vulnerability 和 Match 三个核心类型,其中 Vulnerability 对象是在漏洞数据库中查询得到的,查询接口由 vulnerability.Provider 定义。但是,这个 Provider 背后是什么?漏洞数据库从哪里来、如何存储、又是如何被高效查询的?这正是本文要回答的问题。
Grype 的漏洞数据库是一个本地 SQLite 文件,名为 vulnerability.db,它由 Anchore 团队定期构建并通过 CDN 分发。客户端在启动扫描时,会先检查本地数据库的状态,如果需要则自动下载更新,然后以只读方式打开数据库,创建 vulnerabilityProvider 来提供漏洞查询服务。本文将沿着下面这条生命周期线路,逐一展开这些环节的实现细节。
1 | 数据库版本检查 |
数据库的 Schema 版本管理
在深入具体实现之前,我们需要理解 Grype 如何管理数据库版本,因为版本兼容性贯穿了整个数据库生命周期。
Grype v6 数据库采用 SchemaVer(Schema Version)来标识版本。SchemaVer 是一种专门为数据 schema 设计的语义化版本规范,它由三个数字组成:MODEL.REVISION.ADDITION。在 grype/db/v6/db.go 中定义了当前客户端支持的版本:
1 | const ( |
这三个数字的含义各不相同:
- ModelVersion(模型版本):固定为
6,表示数据库 v6 的主 schema 代际。当 Model 号变化时,新旧数据库完全不能互通,客户端必须使用匹配的数据库。 - Revision(修订版本):当引入的变更可能阻止与部分历史数据的交互时递增。例如 v6.1.0 新增了修复可用性信息字段,旧客户端读到这些字段时会忽略它们,不会出错。
- Addition(附加版本):当引入完全向后兼容的变更时递增。例如 v6.0.1 新增了 CISA KEV 表,旧客户端不依赖这张表也能正常工作。
源码中的 changelog 清晰记录了每次版本变更的原因:
1 | // v6 model changelog: |
这种版本管理策略的好处是:客户端可以按能力探测数据库的功能。例如,vulnerabilityDecoratorStore 在初始化时会根据数据库的 schema 版本决定是否启用 KEV、EPSS 和 CWE 查询功能:
1 | func newVulnerabilityDecoratorStore(db *gorm.DB, bs *blobStore, dbVersion schemaver.SchemaVer) *vulnerabilityDecoratorStore { |
如果当前数据库的版本低于某项功能所需的最小版本,对应的查询方法会直接返回空结果(优雅降级),而不是报错。反之,如果是写入操作遇到版本不足,则会返回 ErrDBCapabilityNotSupported 错误——因为构建新的数据库时不应缺少必要的能力。
数据库整体结构:store 和 Reader/Writer 接口
理解了版本管理后,我们来看数据库的内部组织。Grype 将底层 SQLite 的读写操作封装在 store 结构体中,它组合了多个子 Store,每个子 Store 负责一组相关的数据表:
1 | type store struct { |
这些子 Store 并不直接暴露给外部。Grype 通过 Reader 和 Writer 两个接口来隔离读写职责:
1 | type Reader interface { |
值得注意的是,Reader 接口并不包含数据的写入能力。在扫描场景中,Grype 以只读方式打开数据库,调用 NewReader() 创建 store 时 writable 参数为 false。而在构建数据库的场景中,NewWriter() 以可写方式建立连接,并在 Close() 时执行一系列优化操作:删除所有索引(因为分发时索引占用大量空间,客户端打开后会重新创建)、执行 VACUUM 压缩、运行 PRAGMA integrity_check 完整性校验。
在 store 之下,还有一个 Curator 接口。如果将 Reader/Writer 看作是对数据库文件的直接操作,那么 Curator 则是数据库生命周期的管理者——它负责检查本地数据库状态、决定是否需要更新、下载新版本、替换旧版本等等。我们在《Grype 整体执行流程》中看到的 dbCurator 变量,就实现了这个接口。
数据模型:从关系表到 Blob 存储
核心表结构
v6 数据库的数据模型定义在 grype/db/v6/models.go 中,它使用 GORM(Go 语言 ORM 框架)来管理 SQLite 表结构。Models() 函数列出了所有 GORM 管理的模型:
1 | func Models() []any { |
这些模型可以按用途分为几类:
- 元数据(
DBMetadata):记录数据库的构建时间和 SchemaVer 版本号; - 数据来源(
Provider):标识每条漏洞数据的来源,例如nvd、github、ubuntu等 Vunnel provider; - 漏洞核心(
VulnerabilityHandle和VulnerabilityAlias):存储漏洞的 ID、状态、发布时间等索引字段,以及别名关系; - 漏洞装饰(
KnownExploitedVulnerabilityHandle、EpssHandle、CWEHandle):存储 KEV(已知被利用漏洞)、EPSS(漏洞利用预测评分)和 CWE(通用弱点枚举),不直接参与匹配,但用于计算漏洞优先级; - 匹配编排(
AffectedPackageHandle、UnaffectedPackageHandle、AffectedCPEHandle、UnaffectedCPEHandle):关联漏洞和受影响的软件包/CPE,是匹配查询的核心表; - 支撑数据(
Package、OperatingSystem、Cpe):定义软件包、操作系统和 CPE 的具体属性。
Blob 存储:为什么把 JSON 存进数据库
在这些表结构中,有一个关键的设计模式值得深入讨论:Blob 存储。观察 VulnerabilityHandle 的定义:
1 | type VulnerabilityHandle struct { |
注意到 BlobValue 的 gorm tag 是 gorm:"-",意味着它不直接作为该表的列存在。实际上,漏洞的详细内容(描述、引用、别名、严重性评分等)被序列化成 JSON 存入单独的 Blob 表:
1 | type Blob struct { |
而 VulnerabilityBlob 则定义了这段 JSON 的结构:
1 | type VulnerabilityBlob struct { |
为什么要这样设计? 原因有几点:
- 减少 JOIN 和 NULL 列。漏洞的严重性评分、引用链接和别名都是变长列表。如果在关系表中为每个字段建列,要么需要大量 NULL 列,要么需要更多 JOIN。将变长数据放入 JSON 可以保持表结构简洁。
- 去重。多个漏洞可能引用相同的严重性信息或描述。Blob 表通过 xxhash 摘要来判断重复,相同的 JSON 只存一份。
- 查询效率。匹配查询只需要索引字段(漏洞名称、provider、状态),这些保留在
VulnerabilityHandle表中。查询时先走索引找到匹配行,再通过BlobID获取详细的 JSON 内容。
类似的设计也用于 PackageBlob(描述受影响的版本范围)、KnownExploitedVulnerabilityBlob(KEV 详情)等。几乎每个 Handle 表都关联了一个 Blob。
Blob 的读写流程
写入时,blobStore.addBlobable() 将 Go 对象通过 json.Marshal 序列化,计算 xxh64 摘要,再按摘要去重后写入 Blob 表:
1 | func (s *blobStore) addBlobs(blobs ...*Blob) error { |
读取时,blobStore.attachBlobValue() 先收集所有需要填充的 Handle 的 BlobID,再批量查询 Blob 表,最后将 JSON 反序列化到各 Handle 的 BlobValue 字段中。这个过程叫做「附加」,因为它是在原有查询结果之上追加内容,而不是重新查询。
写入优化:内存缓存
在数据库构建过程中,大量数据需要写入,且存在大量重复——多个 Provider 可能引用同一个 CVE,多行数据可能引用同一个 Package。为了加速写入和确保去重,Grype 在 Context 中附加了一个内存缓存:
1 | func NewLowLevelDB(dbFilePath string, empty, writable, debug bool) (*gorm.DB, error) { |
这个 cache 结构按表名和 cacheKey() 维护了 name-to-ID 的映射。以 VulnerabilityHandle 为例,其 BeforeCreate hook 会先查缓存:如果缓存中已有同名的漏洞记录,则直接复用它的 ID,跳过本次创建;AfterCreate hook 则在创建成功后更新缓存。
同样的缓存机制也应用于 Provider、OperatingSystem、Package 和 Cpe 等实体。这让大量数据的批量写入能够安全地在 Go 业务层面去重,而不依赖每次 INSERT 时的数据库约束检查。
数据库的生命周期管理:Curator
理解数据库的内部结构后,我们来看它的生命周期是如何被管理的。这部分的核心是 installation/curator。
配置和目录
Curator 的默认配置决定了所有与数据库存储相关的路径和行为:
1 | func DefaultConfig(id clio.Identification) Config { |
数据库文件最终存储在 <CacheHome>/grype/db/6/vulnerability.db。其中 6 是 ModelVersion,这样不同主版本的数据库可以共存。默认的 MaxAllowedBuiltAge 是 5 天——超过 5 天的数据库被视为"过期",Curator 会在检查时报告错误。
打开数据库:Reader() 和 rehydration
当 Grype 启动扫描时,root.go 中调用的 dbCurator.Reader() 会经历以下步骤:
- 检查状态:调用
Status()检查数据库文件是否存在; - 打开 Reader:以只读方式打开 SQLite 数据库,获取
DBMetadata; - 检查是否需要 rehydration:这是一个关键步骤。所谓 rehydration(补水)是指,当旧客户端下载了数据库后,新客户端打开它时可能需要重建索引。因为数据库在分发时删除了所有索引(节省空间),客户端首次打开时 GORM 的 auto-migrate 会重建索引。但旧客户端和新客户端的模型定义可能不同(新客户端可能有新的索引需求),所以需要重新 hydrate:
1 | func isRehydrationNeeded(fs afero.Fs, dirPath string, currentDBVersion *schemaver.SchemaVer, currentClientVersion schemaver.SchemaVer) (bool, error) { |
简言之:如果 import.json 中记录的 hydration 客户端版本比当前客户端版本旧,就需要重新 hydrate。
检查更新:Update() 流程
在扫描流程中,如果用户允许自动更新(db-auto-update: true),Grype 会在打开数据库前调用 Update()。这个过程分为几个阶段:
第一阶段:检查本地状态。 读取本地数据库的 Description(构建时间和 schema 版本),验证年龄是否超出 MaxAllowedBuiltAge。如果本地数据库不存在或已过期,则将 current 设为 nil——这意味着任何有效的新数据库都会被采用。
第二阶段:查询远端。 distribution.Client.IsUpdateAvailable() 从 Grype 的 CDN 拉取 latest.json 文件,其结构如下:
1 | type LatestDocument struct { |
isUpdateAvailable() 的判断逻辑很直接:如果 latest.json 中的构建时间比本地数据库更新,就返回这个 Archive 作为可用的更新;如果两者的 ModelVersion 不一致,则拒绝更新(因为主版本不兼容);如果远端状态为 deprecated 或 eol,还会通过 bus.Notify 发送警告提示用户升级 Grype。
第三阶段:频率控制。 在检查更新之前,isUpdateCheckAllowed() 会检查上次更新的时间戳(记录在 last_update_check 文件中)。如果距离上次检查不到 UpdateCheckMaxFrequency(默认 2 小时),则跳过本次更新检查。这是一个低通滤波机制,防止频繁启动 Grype 时对 CDN 造成不必要的压力。
第四阶段:下载和激活。 如果需要更新,Curator 会通过 distribution.Client 下载数据库归档文件到临时目录。下载时 Archive 的 Checksum 作为 URL 查询参数传递给 go-getter 库,go-getter 会自动在下载完成后验证文件完整性。
下载完成后进入 activate(),这是一个关键的过渡阶段:
1 | func (c curator) activate(dbDirPath, url string, mon monitor) error { |
hydrate() 不仅创建索引,还会调用 WriteImportMetadata() 生成 import.json 文件,其中包含:
digest:数据库文件的 xxh64 摘要,用于后续完整性校验;source:数据库来源 URL;client_version:当前客户端的 SchemaVer 版本,用于未来的 rehydration 判断。
replaceDB() 则通过 os.Rename 原子性地(同一文件系统内)将新数据库目录替换到最终位置。
完整性校验
每次 Reader() 被调用时,都会经过 isRehydrationNeeded() 和 Status() 的校验。而 validateIntegrity() 在 Status() 中被调用,它会重新计算磁盘上数据库文件的 xxh64 摘要,与 import.json 中记录的 digest 对比。如果不一致,说明数据库文件已被篡改或损坏。
手动导入
除了自动更新,Curator 还支持 Import() 手动导入数据库。用户可以通过 grype db import <path> 导入一个本地 DB 文件或 archive。Import() 会根据输入的类型(URL、.db 文件、archive 文件)分别处理,最终统一走 activate() 流程:
1 | func (c curator) Import(reference string) error { |
漏洞查询:Provider 如何工作
数据库就绪之后,扫描流程将 Reader 包装成 vulnerabilityProvider,提供给 VulnerabilityMatcher 使用。这个 Provider 是漏洞数据库的查询入口,其核心方法是 FindVulnerabilities()。
从 Criteria 到 SearchQuery
Matcher 在匹配时,会传入一组 vulnerability.Criteria 对象,这些 Criteria 定义了"要找什么样的漏洞"。Provider 的第一步是将这些 Criteria 转换成一个可执行的 searchQuery:
1 | type searchQuery struct { |
这个转换过程由 searchQueryBuilder 完成,它通过类型断言(type switch)逐一处理各种 Criteria:
PackageNameCriteria→ 设置pkgSpec.NameEcosystemCriteria→ 设置pkgSpec.Ecosystem(并做生态 normalize,例如JavaScript→npm)CPECriteria→ 设置cpeSpec,同时将 CPE 也设置到pkgSpec.CPE(因为 CPE 匹配会同时走 Package 和 CPE 表)DistroCriteria→ 设置osSpecs,指定要匹配的发行版及其版本IDCriteria→ 设置vulnSpecs,限定漏洞 IDVersionConstraintMatcher→ 提取出来,不进入数据库查询而是在后续过滤阶段使用UnaffectedCriteria→ 设置unaffectedOnly = true,切换到查询unaffected_package_handles表
未能被 builder 消化的 Criteria(如 VersionConstraintMatcher)会作为 remainingCriteria 保留,在数据库查询完成后做二次过滤。
数据库查询过程
FindVulnerabilities() 的核心流程分为三条主线:
第一条主线:按 Package 查询。 fetchAndProcessAffectedPackages() 调用 GetAffectedPackages(),在 affected_package_handles 表上执行一个复杂的 JOIN 查询:
- JOIN
packages表(匹配包名和生态) - JOIN
operating_systems表(匹配发行版) - JOIN
vulnerability_handles表(如果指定了漏洞过滤条件)
查询结果返回 []AffectedPackageHandle,每个 Handle 包含 Package、OperatingSystem 和 Vulnerability 的关联信息。随后,批量预加载 Blob 数据(通过 attachBlobValue 将 JSON 反序列化为 BlobValue)。
第二条主线:按 CPE 查询。 fetchAndProcessCPEs() 走 affected_cpe_handles 表(或 unaffected_cpe_handles 表)。CPE 查询的逻辑与 Package 查询类似,但它不涉及 packages 表的 JOIN,因为 CPE 表中的数据本身就是以 CPE 标识符为键。
第三条主线:Unaffected 查询。 当 unaffectedOnly = true 时,查询切换到 unaffected_package_handles 表。UnaffectedPackageHandle 的结构与 AffectedPackageHandle 完全相同(通过类型定义别名 type UnaffectedPackageHandle packageHandle),只是表名不同。
无论走哪条主线,查询结果最终都会通过 newVulnerabilityFromAffectedPackageHandle() 转换成 vulnerability.Vulnerability 对象。
版本范围过滤
数据库查询返回的结果是"候选"漏洞——它们满足包名、生态和发行版的匹配条件,但还没有经过版本约束的筛选。版本过滤在数据库查询之后进行,由 filterAffectedPackageVersions() 完成。
其核心逻辑在 filterAffectedPackageRanges() 中:遍历 PackageBlob 中的每个 Range,将其中的版本约束字符串解析为 version.Constraint 对象,然后调用 constraintMatcher.MatchesConstraint(constraint) 检查软件包的当前版本是否落入这个约束范围。
1 | func filterAffectedPackageRanges(matcher search.VersionConstraintMatcher, b *PackageBlob) (bool, []string) { |
一个值得注意的细节:如果 Ranges 为空,表示该漏洞对软件包的所有版本都有效(return false, nil),不会因为"没有匹配任何 version range"而被过滤掉。
命名空间兼容性
查询结果的 Vulnerability 对象中有一个 Namespace 字段,它标识了这个漏洞记录的来源语义空间(例如 nvd:cpe、github:language:javascript、redhat:distro:rhel:8)。v6 数据库的 namespace 并非直接存储,而是通过 MimicV5Namespace() 函数模拟出来的:
1 | func MimicV5Namespace(vuln *VulnerabilityHandle, affected *AffectedPackageHandle) string { |
这个设计意味着 v6 数据库在存储时不区分 distro/language/cpe 的命名空间,而是在运行时根据关联的 Package 或 OperatingSystem 信息动态推导。这是为了保持向后兼容——上层的 Matcher 代码不需要因为 v5 到 v6 的升级而修改 namespace 匹配逻辑。
元数据查询和缓存
每个 VulnerabilityHandle 转换为 vulnerability.Vulnerability 时,Provider 还会查询它的元数据(Metadata),包括 CVSS 评分、KEV 信息、EPSS 评分和 CWE 关联。这些信息来自 vulnerabilityDecoratorStore 的三张表:
KnownExploitedVulnerabilityHandle→ CISA KEV(已知被利用漏洞)EpssHandle→ EPSS(漏洞利用预测评分)CWEHandle→ CWE(通用弱点枚举)
为了避免重复查询,Provider 内部维护了一个 metadataCache,以漏洞 ID 为键缓存已经查过的 Metadata:
1 | func (vp vulnerabilityProvider) getCachedMetadata(vuln *VulnerabilityHandle, namespace string, metadataCache map[string]*vulnerability.Metadata) (*vulnerability.Metadata, error) { |
与整体扫描流程的衔接
在之前的整体流程中,我们介绍过扫描的主链路:dbCurator.Reader() 打开数据库,NewVulnerabilityProvider(reader) 创建 Provider,然后将 Provider 传给 VulnerabilityMatcher。本文的分析正好填补了这三个步骤之间的空白:
dbCurator.Reader()→ 经历了状态检查、完整性校验、rehydration 判断,最终返回一个只读的Reader(即底层的 store);NewVulnerabilityProvider(reader)→ 将Reader包装成vulnerabilityProvider,实现了vulnerability.Provider接口;vulnerabilityProvider.FindVulnerabilities()→ 将 Criteria 转换为 SQL 查询,从关系表中找出候选漏洞,再通过版本约束过滤和元数据查询,返回最终的[]vulnerability.Vulnerability。
下图展示了这些组件之间的关系:
1 | dbCurator (Curator 接口) |
小结
Grype v6 漏洞数据库的设计体现了几个关键思路:
-
分层抽象:底层 SQLite 由 GORM 管理,模型定义在
models.go中;中间层是store及其子 Store,对上层暴露 Reader/Writer 接口;最上层是vulnerabilityProvider,实现了vulnerability.Provider接口,与 Matcher 框架对接。 -
Blob + Handle 的混合存储:将频繁查询的字段(ID、名称、状态)留在关系表索引中,而将变长和不常查询的字段(描述、严重性评分、引用)序列化为 JSON 存入 Blob 表,兼顾了查询效率和存储灵活性。
-
SchemaVer 兼容性管理:通过 MODEL.REVISION.ADDITION 三级版本号,客户端能够按能力探测数据库功能,在版本不匹配时采取优雅降级或拒绝写入的策略。
-
安全的生命周期管理:数据库的下载、校验(archive checksum → file xxh64 digest)、hydration(索引重建 + 元数据记录)、激活(rename 替换),每个阶段都有验证步骤,确保不会将损坏或不兼容的数据库安装到生产环境。
掌握了数据库的实现原理之后,下一篇我们将进入 Grype 最核心的匹配引擎——VulnerabilityMatcher,看它如何将软件包和漏洞记录关联起来,生成我们最终看到的扫描结果。