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 通过 ReaderWriter 两个接口来隔离读写职责:

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() 创建 storewritable 参数为 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):标识每条漏洞数据的来源,例如 nvdgithububuntu 等 Vunnel provider;
  • 漏洞核心VulnerabilityHandleVulnerabilityAlias):存储漏洞的 ID、状态、发布时间等索引字段,以及别名关系;
  • 漏洞装饰KnownExploitedVulnerabilityHandleEpssHandleCWEHandle):存储 KEV(已知被利用漏洞)、EPSS(漏洞利用预测评分)和 CWE(通用弱点枚举),不直接参与匹配,但用于计算漏洞优先级;
  • 匹配编排AffectedPackageHandleUnaffectedPackageHandleAffectedCPEHandleUnaffectedCPEHandle):关联漏洞和受影响的软件包/CPE,是匹配查询的核心表;
  • 支撑数据PackageOperatingSystemCpe):定义软件包、操作系统和 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 则在创建成功后更新缓存。

同样的缓存机制也应用于 ProviderOperatingSystemPackageCpe 等实体。这让大量数据的批量写入能够安全地在 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。其中 6ModelVersion,这样不同主版本的数据库可以共存。默认的 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 不一致,则拒绝更新(因为主版本不兼容);如果远端状态为 deprecatedeol,还会通过 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,例如 JavaScriptnpm
  • 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 包含 PackageOperatingSystemVulnerability 的关联信息。随后,批量预加载 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:cpegithub:language:javascriptredhat: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,看它如何将软件包和漏洞记录关联起来,生成我们最终看到的扫描结果。