0%

Grype 源码分析 06:漏洞数据库实现原理

在上一篇文章中,我们分析了 Package、Vulnerability 和 Match 三个核心类型,其中 Vulnerability 对象是在漏洞数据库中查询得到的,查询接口由 vulnerability.Provider 定义。但是,这个 Provider 背后是什么?漏洞数据库从哪里来、如何存储、又是如何被高效查询的?这正是本文要回答的问题。

Grype 的漏洞数据库是一个本地 SQLite 文件,名为 vulnerability.db,它由 Anchore 团队定期构建并通过 CDN 分发。客户端在启动扫描时,会先检查本地数据库的状态,如果需要则自动下载更新,然后以只读方式打开数据库,创建 vulnerabilityProvider 来提供漏洞查询服务。本文将沿着下面这条生命周期线路,逐一展开这些环节的实现细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
数据库版本检查
│
▼
下载数据库归档
│
▼
校验摘要和版本
│
▼
安装到缓存目录
│
▼
打开数据库并创建 Provider
│
▼
按包名、生态和版本约束查询

数据库的 Schema 版本管理

在深入具体实现之前,我们需要理解 Grype 如何管理数据库版本,因为版本兼容性贯穿了整个数据库生命周期。

Grype v6 数据库采用 SchemaVer(Schema Version)来标识版本。SchemaVer 是一种专门为数据 schema 设计的语义化版本规范,它由三个数字组成:MODEL.REVISION.ADDITION。在 grype/db/v6/db.go 中定义了当前客户端支持的版本:

1
2
3
4
5
const (
ModelVersion = 6 // 破坏性变更次数
Revision = 1 // 可能影响部分历史数据的变更次数
Addition = 7 // 与所有历史数据兼容的变更次数
)

这三个数字的含义各不相同:

  • ModelVersion(模型版本):固定为 6,表示数据库 v6 的主 schema 代际。当 Model 号变化时,新旧数据库完全不能互通,客户端必须使用匹配的数据库。
  • Revision(修订版本):当引入的变更可能阻止与部分历史数据的交互时递增。例如 v6.1.0 新增了修复可用性信息字段,旧客户端读到这些字段时会忽略它们,不会出错。
  • Addition(附加版本):当引入完全向后兼容的变更时递增。例如 v6.0.1 新增了 CISA KEV 表,旧客户端不依赖这张表也能正常工作。

源码中的 changelog 清晰记录了每次版本变更的原因:

1
2
3
4
5
6
7
8
9
// v6 model changelog:
// 6.0.0: Initial version 🎉
// 6.0.1: Add CISA KEV to VulnerabilityDecorator store
// 6.0.2: Add EPSS to VulnerabilityDecorator store
// 6.0.3: Add channel column to OperatingSystem model
// 6.1.0: Add Fix availability information
// 6.1.1: Add UnaffectedCPE / UnaffectedPackage models
// 6.1.2: Add CWEs
// ...

这种版本管理策略的好处是:客户端可以按能力探测数据库的功能。例如,vulnerabilityDecoratorStore 在初始化时会根据数据库的 schema 版本决定是否启用 KEV、EPSS 和 CWE 查询功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
func newVulnerabilityDecoratorStore(db *gorm.DB, bs *blobStore, dbVersion schemaver.SchemaVer) *vulnerabilityDecoratorStore {
minSupportedKEVClientVersion := schemaver.New(6, 0, 1)
minSupportedEPSSClientVersion := schemaver.New(6, 0, 2)
minSupportedCWEClientVersion := schemaver.New(6, 1, 2)
return &vulnerabilityDecoratorStore{
// ...
vulnerabilityDecoratorCapabilities: vulnerabilityDecoratorCapabilities{
kevEnabled: dbVersion.GreaterOrEqualTo(minSupportedKEVClientVersion),
epssEnabled: dbVersion.GreaterOrEqualTo(minSupportedEPSSClientVersion),
cweEnabled: dbVersion.GreaterOrEqualTo(minSupportedCWEClientVersion),
},
}
}

如果当前数据库的版本低于某项功能所需的最小版本,对应的查询方法会直接返回空结果(优雅降级),而不是报错。反之,如果是写入操作遇到版本不足,则会返回 ErrDBCapabilityNotSupported 错误——因为构建新的数据库时不应缺少必要的能力。

数据库整体结构:store 和 Reader/Writer 接口

理解了版本管理后,我们来看数据库的内部组织。Grype 将底层 SQLite 的读写操作封装在 store 结构体中,它组合了多个子 Store,每个子 Store 负责一组相关的数据表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type store struct {
*dbMetadataStore
*providerStore
*vulnerabilityStore
*operatingSystemStore
*affectedPackageStore
*unaffectedPackageStore
*affectedCPEStore
*unaffectedCPEStore
*vulnerabilityDecoratorStore
blobStore *blobStore
db *gorm.DB
config Config
}

这些子 Store 并不直接暴露给外部。Grype 通过 Reader 和 Writer 两个接口来隔离读写职责:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Reader interface {
DBMetadataStoreReader
ProviderStoreReader
VulnerabilityStoreReader
VulnerabilityDecoratorStoreReader
OperatingSystemStoreReader
AffectedPackageStoreReader
UnaffectedPackageStoreReader
AffectedCPEStoreReader
UnaffectedCPEStoreReader
io.Closer
}

type Writer interface {
DBMetadataStoreWriter
ProviderStoreWriter
VulnerabilityStoreWriter
// ... 其他写入接口
io.Closer
}

值得注意的是,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Models() []any {
return []any{
&Blob{},
&DBMetadata{},
&Provider{},
&VulnerabilityHandle{},
&VulnerabilityAlias{},
&AffectedPackageHandle{},
&UnaffectedPackageHandle{},
&OperatingSystem{},
&Package{},
&AffectedCPEHandle{},
&UnaffectedCPEHandle{},
&Cpe{},
&KnownExploitedVulnerabilityHandle{},
&EpssHandle{},
&CWEHandle{},
// ...
}
}

这些模型可以按用途分为几类:

  • 元数据(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
2
3
4
5
6
7
8
9
10
11
type VulnerabilityHandle struct {
ID ID
Name string // 索引字段
Status VulnerabilityStatus // 索引字段
PublishedDate *time.Time // 索引字段
ModifiedDate *time.Time // 索引字段
ProviderID string // 外键
Provider *Provider
BlobID ID // 外键 -> Blob 表
BlobValue *VulnerabilityBlob `gorm:"-"` // 运行时填充,不存入表
}

注意到 BlobValue 的 gorm tag 是 gorm:"-",意味着它不直接作为该表的列存在。实际上,漏洞的详细内容(描述、引用、别名、严重性评分等)被序列化成 JSON 存入单独的 Blob 表:

1
2
3
4
type Blob struct {
ID ID
Value string // JSON 字符串
}

而 VulnerabilityBlob 则定义了这段 JSON 的结构:

1
2
3
4
5
6
7
8
type VulnerabilityBlob struct {
ID string `json:"id"`
Assigners []string `json:"assigner,omitempty"`
Description string `json:"description,omitempty"`
References []Reference `json:"refs,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Severities []Severity `json:"severities,omitempty"`
}

为什么要这样设计? 原因有几点:

  1. 减少 JOIN 和 NULL 列。漏洞的严重性评分、引用链接和别名都是变长列表。如果在关系表中为每个字段建列,要么需要大量 NULL 列,要么需要更多 JOIN。将变长数据放入 JSON 可以保持表结构简洁。
  2. 去重。多个漏洞可能引用相同的严重性信息或描述。Blob 表通过 xxhash 摘要来判断重复,相同的 JSON 只存一份。
  3. 查询效率。匹配查询只需要索引字段(漏洞名称、provider、状态),这些保留在 VulnerabilityHandle 表中。查询时先走索引找到匹配行,再通过 BlobID 获取详细的 JSON 内容。

类似的设计也用于 PackageBlob(描述受影响的版本范围)、KnownExploitedVulnerabilityBlob(KEV 详情)等。几乎每个 Handle 表都关联了一个 Blob。

Blob 的读写流程

写入时,blobStore.addBlobable() 将 Go 对象通过 json.Marshal 序列化,计算 xxh64 摘要,再按摘要去重后写入 Blob 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (s *blobStore) addBlobs(blobs ...*Blob) error {
for i := range blobs {
v := blobs[i]
digest := v.computeDigest() // xxh64 摘要

if id, ok := s.idsByDigest[digest]; ok && id != 0 {
v.ID = id // 已存在,复用 ID
continue
}

if err := s.db.Create(v).Error; err != nil {
return fmt.Errorf("failed to create blob: %w", err)
}
s.idsByDigest[digest] = v.ID
}
return nil
}

读取时,blobStore.attachBlobValue() 先收集所有需要填充的 Handle 的 BlobID,再批量查询 Blob 表,最后将 JSON 反序列化到各 Handle 的 BlobValue 字段中。这个过程叫做「附加」,因为它是在原有查询结果之上追加内容,而不是重新查询。

写入优化:内存缓存

在数据库构建过程中,大量数据需要写入,且存在大量重复——多个 Provider 可能引用同一个 CVE,多行数据可能引用同一个 Package。为了加速写入和确保去重,Grype 在 Context 中附加了一个内存缓存:

1
2
3
4
5
6
7
func NewLowLevelDB(dbFilePath string, empty, writable, debug bool) (*gorm.DB, error) {
// ...
if empty {
dbObj = dbObj.WithContext(withCacheContext(context.Background(), newCache()))
}
return dbObj, err
}

这个 cache 结构按表名和 cacheKey() 维护了 name-to-ID 的映射。以 VulnerabilityHandle 为例,其 BeforeCreate hook 会先查缓存:如果缓存中已有同名的漏洞记录,则直接复用它的 ID,跳过本次创建;AfterCreate hook 则在创建成功后更新缓存。

同样的缓存机制也应用于 Provider、OperatingSystem、Package 和 Cpe 等实体。这让大量数据的批量写入能够安全地在 Go 业务层面去重,而不依赖每次 INSERT 时的数据库约束检查。

数据库的生命周期管理:Curator

理解数据库的内部结构后,我们来看它的生命周期是如何被管理的。这部分的核心是 installation/curator。

配置和目录

Curator 的默认配置决定了所有与数据库存储相关的路径和行为:

1
2
3
4
5
6
7
8
9
func DefaultConfig(id clio.Identification) Config {
return Config{
DBRootDir: filepath.Join(xdg.CacheHome, id.Name, "db"),
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: time.Hour * 24 * 5, // 5 天
UpdateCheckMaxFrequency: 2 * time.Hour, // 2 小时
}
}

数据库文件最终存储在 <CacheHome>/grype/db/6/vulnerability.db。其中 6 是 ModelVersion,这样不同主版本的数据库可以共存。默认的 MaxAllowedBuiltAge 是 5 天——超过 5 天的数据库被视为"过期",Curator 会在检查时报告错误。

打开数据库:Reader() 和 rehydration

当 Grype 启动扫描时,root.go 中调用的 dbCurator.Reader() 会经历以下步骤:

  1. 检查状态:调用 Status() 检查数据库文件是否存在;
  2. 打开 Reader:以只读方式打开 SQLite 数据库,获取 DBMetadata;
  3. 检查是否需要 rehydration:这是一个关键步骤。所谓 rehydration(补水)是指,当旧客户端下载了数据库后,新客户端打开它时可能需要重建索引。因为数据库在分发时删除了所有索引(节省空间),客户端首次打开时 GORM 的 auto-migrate 会重建索引。但旧客户端和新客户端的模型定义可能不同(新客户端可能有新的索引需求),所以需要重新 hydrate:
1
2
3
4
5
6
7
8
9
func isRehydrationNeeded(fs afero.Fs, dirPath string, currentDBVersion *schemaver.SchemaVer, currentClientVersion schemaver.SchemaVer) (bool, error) {
importMetadata, err := db.ReadImportMetadata(fs, dirPath)
// ...
clientHydrationVersion, err := schemaver.Parse(importMetadata.ClientVersion)
hydratedWithOldClient := clientHydrationVersion.LessThanOrEqualTo(*currentDBVersion)
haveNewerClient := clientHydrationVersion.LessThan(currentClientVersion)
doRehydrate := hydratedWithOldClient && haveNewerClient
return doRehydrate, nil
}

简言之:如果 import.json 中记录的 hydration 客户端版本比当前客户端版本旧,就需要重新 hydrate。

检查更新:Update() 流程

在扫描流程中,如果用户允许自动更新(db-auto-update: true),Grype 会在打开数据库前调用 Update()。这个过程分为几个阶段:

第一阶段:检查本地状态。 读取本地数据库的 Description(构建时间和 schema 版本),验证年龄是否超出 MaxAllowedBuiltAge。如果本地数据库不存在或已过期,则将 current 设为 nil——这意味着任何有效的新数据库都会被采用。

第二阶段:查询远端。 distribution.Client.IsUpdateAvailable() 从 Grype 的 CDN 拉取 latest.json 文件,其结构如下:

1
2
3
4
5
6
7
8
9
10
type LatestDocument struct {
Status Status `json:"status"` // active / deprecated / eol
Archive `json:",inline"`
}

type Archive struct {
Description db.Description `json:",inline"`
Path string `json:"path"` // 归档文件的相对路径
Checksum string `json:"checksum"` // 归档的校验和
}

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
2
3
4
5
6
7
func (c curator) activate(dbDirPath, url string, mon monitor) error {
// 1. hydrate: 打开数据库让 GORM auto-migrate,然后计算 DB 文件的 xxh64 摘要
if err := c.hydrate(dbDirPath, url, mon); err != nil { ... }

// 2. replaceDB: 删除旧数据库,将新目录 rename 到最终位置
return c.replaceDB(dbDirPath)
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
func (c curator) Import(reference string) error {
if isURL(reference) {
// 下载
tempDir, err = c.client.Download(reference, c.config.DBRootDir, ...)
} else if strings.HasSuffix(reference, ".db") {
// 复制 DB 文件到临时目录
file.CopyFile(afero.NewOsFs(), reference, filepath.Join(tempDir, db.VulnerabilityDBFileName))
} else {
// 解压 archive
unarchive(reference, tempDir)
}
// 统一激活
return c.activate(tempDir, url, mon)
}

漏洞查询:Provider 如何工作

数据库就绪之后,扫描流程将 Reader 包装成 vulnerabilityProvider,提供给 VulnerabilityMatcher 使用。这个 Provider 是漏洞数据库的查询入口,其核心方法是 FindVulnerabilities()。

从 Criteria 到 SearchQuery

Matcher 在匹配时,会传入一组 vulnerability.Criteria 对象,这些 Criteria 定义了"要找什么样的漏洞"。Provider 的第一步是将这些 Criteria 转换成一个可执行的 searchQuery:

1
2
3
4
5
6
7
8
9
type searchQuery struct {
pkgSpec *PackageSpecifier // 软件包查询条件
cpeSpec *cpe.Attributes // CPE 查询条件
osSpecs OSSpecifiers // 操作系统过滤条件
vulnSpecs VulnerabilitySpecifiers // 漏洞过滤条件
pkgType syftPkg.Type
versionMatcher search.VersionConstraintMatcher // 版本约束匹配器
unaffectedOnly bool // 是否查询不受影响的记录
}

这个转换过程由 searchQueryBuilder 完成,它通过类型断言(type switch)逐一处理各种 Criteria:

  • PackageNameCriteria → 设置 pkgSpec.Name
  • EcosystemCriteria → 设置 pkgSpec.Ecosystem(并做生态 normalize,例如 JavaScript → npm)
  • CPECriteria → 设置 cpeSpec,同时将 CPE 也设置到 pkgSpec.CPE(因为 CPE 匹配会同时走 Package 和 CPE 表)
  • DistroCriteria → 设置 osSpecs,指定要匹配的发行版及其版本
  • IDCriteria → 设置 vulnSpecs,限定漏洞 ID
  • VersionConstraintMatcher → 提取出来,不进入数据库查询而是在后续过滤阶段使用
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func filterAffectedPackageRanges(matcher search.VersionConstraintMatcher, b *PackageBlob) (bool, []string) {
if len(b.Ranges) == 0 {
// 没有 ranges 意味着隐式地对所有版本易受攻击
return false, nil
}
var unmatchedConstraints []string
for _, r := range b.Ranges {
format := version.ParseFormat(r.Version.Type)
constraint, err := version.GetConstraint(r.Version.Constraint, format)
matches, err := matcher.MatchesConstraint(constraint)
if matches {
continue
}
unmatchedConstraints = append(unmatchedConstraints, r.Version.Constraint)
}
return len(b.Ranges) == len(unmatchedConstraints), unmatchedConstraints
}

一个值得注意的细节:如果 Ranges 为空,表示该漏洞对软件包的所有版本都有效(return false, nil),不会因为"没有匹配任何 version range"而被过滤掉。

命名空间兼容性

查询结果的 Vulnerability 对象中有一个 Namespace 字段,它标识了这个漏洞记录的来源语义空间(例如 nvd:cpe、github:language:javascript、redhat:distro:rhel:8)。v6 数据库的 namespace 并非直接存储,而是通过 MimicV5Namespace() 函数模拟出来的:

1
2
3
4
5
6
7
8
9
10
11
func MimicV5Namespace(vuln *VulnerabilityHandle, affected *AffectedPackageHandle) string {
if affected.OperatingSystem != nil {
// 发行版包 → "provider:distro:family:version"
return fmt.Sprintf("%s:distro:%s:%s", pr, family, ver)
}
if affected.Package != nil {
// 语言生态包 → "provider:language:ecosystem"
return mimicV5GithubNamespace(vuln.Provider.ID, affected.Package.Ecosystem)
}
return fmt.Sprintf("%s:cpe", vuln.Provider.ID)
}

这个设计意味着 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
2
3
4
5
6
7
8
func (vp vulnerabilityProvider) getCachedMetadata(vuln *VulnerabilityHandle, namespace string, metadataCache map[string]*vulnerability.Metadata) (*vulnerability.Metadata, error) {
if metadata, ok := metadataCache[vuln.Name]; ok {
return metadata, nil
}
metadata, err := vp.getVulnerabilityMetadata(vuln, namespace)
metadataCache[vuln.Name] = metadata
return metadata, nil
}

与整体扫描流程的衔接

在之前的整体流程中,我们介绍过扫描的主链路: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dbCurator (Curator 接口)
│
├── distribution.Client ─── CDN (latest.json + archive)
│
└── Reader() → Reader 接口
│
▼
vulnerabilityProvider
│
├── FindVulnerabilities()
│ │
│ ├── searchQuery (Criteria → SQL)
│ ├── affectedPackageStore (DB JOIN 查询)
│ ├── affectedCPEStore (CPE 查询)
│ ├── version filter (版本约束过滤)
│ └── vulnerabilityDecoratorStore (KEV/EPSS/CWE)
│
└── VulnerabilityMetadata()

小结

Grype v6 漏洞数据库的设计体现了几个关键思路:

  1. 分层抽象:底层 SQLite 由 GORM 管理,模型定义在 models.go 中;中间层是 store 及其子 Store,对上层暴露 Reader/Writer 接口;最上层是 vulnerabilityProvider,实现了 vulnerability.Provider 接口,与 Matcher 框架对接。

  2. Blob + Handle 的混合存储:将频繁查询的字段(ID、名称、状态)留在关系表索引中,而将变长和不常查询的字段(描述、严重性评分、引用)序列化为 JSON 存入 Blob 表,兼顾了查询效率和存储灵活性。

  3. SchemaVer 兼容性管理:通过 MODEL.REVISION.ADDITION 三级版本号,客户端能够按能力探测数据库功能,在版本不匹配时采取优雅降级或拒绝写入的策略。

  4. 安全的生命周期管理:数据库的下载、校验(archive checksum → file xxh64 digest)、hydration(索引重建 + 元数据记录)、激活(rename 替换),每个阶段都有验证步骤,确保不会将损坏或不兼容的数据库安装到生产环境。

掌握了数据库的实现原理之后,下一篇我们将进入 Grype 最核心的匹配引擎——VulnerabilityMatcher,看它如何将软件包和漏洞记录关联起来,生成我们最终看到的扫描结果。