上一篇文章介绍了 Grype 的基本用法。这篇文章开始分析 Grype 的源码实现,我们先不深入某一种漏洞如何匹配,而是从程序入口开始,跟踪执行一条扫描命令最后是如何获取漏洞结果的。通过分析扫描命令的执行流程,我们将建立 Grype 的整体调用链,为后续分析配置系统、软件包 Provider、漏洞数据库和 Matcher 打下基础。
Grype 的整体调用链
Grype 是一个 CLI(Command-Line Interface,命令行界面)程序,但真正的扫描逻辑并不在 main() 中。main() 负责创建应用,Cobra 负责解析命令,runGrype() 负责组织一次扫描,具体的软件包发现、漏洞匹配和报告输出又分别交给不同模块完成。
一次目录扫描的核心调用链如下:
1 | cmd/grype/main.go |
这条调用链可以分成四层:
| 层次 | 主要职责 | 核心位置 |
|---|---|---|
| 应用入口 | 组装应用信息并启动 CLI | cmd/grype/main.go |
| 命令层 | 初始化配置、用户界面(UI,User Interface)、日志和 Cobra 命令 | cmd/grype/cli |
| 扫描编排层 | 协调数据库、软件包、Matcher 和报告 | cmd/grype/cli/commands/root.go |
| 领域实现层 | 完成软件包发现、漏洞匹配和结果展示 | grype/pkg、grype/matcher、grype/presenter |
先看实际运行日志
在分析每一层之前,先来看实际扫描提取出的关键日志。源码和日志都使用 DB(Database)指代漏洞数据库:
1 | [0000] DEBUG gathering packages |
从这段日志中可以先得到几个结论:
gathering packages和loading DB几乎同时出现,说明软件包收集和数据库加载是并行执行的;- 软件包编目一共得到 3 个包;
- 只有
lodash和minimist命中了漏洞; lodash产生 5 条 Match,minimist产生 2 条 Match;- 最终一共得到 7 条漏洞 Match。
日志中的耗时与机器、缓存和数据库状态有关,重点是各阶段的先后关系和数据数量。
接下来从 main() 开始,看这些阶段分别对应哪些源码。
从程序入口进入 runGrype
这一层解决的是“谁创建应用、谁接收命令、谁开始扫描”。这里涉及三个连续步骤:main() 创建应用,cli.create() 组装命令,根命令的 RunE 调用 runGrype()。
main 函数只负责启动应用
Grype 的程序入口位于 cmd/grype/main.go,核心代码非常短:
1 | func main() { |
这里没有输入解析、数据库加载或漏洞匹配。它只做了两件事:
- 使用名称、版本、构建时间和 Git 信息创建应用;
- 调用
app.Run()启动应用。
version、buildDate、gitCommit 和 gitDescription 默认都是 NotProvided,正式发布时通过构建参数注入。这也是直接执行 go build ./cmd/grype 后,grype version 可能显示 [not provided] 的原因。
cli.create 组装整个命令行应用
cli.Application() 内部调用 create():
1 | func Application(id clio.Identification) clio.Application { |
create() 先通过 SetupConfig() 创建 clio 配置,再创建应用和根命令,最后注册 db、completion、explain、version、config 等子命令。clio 是 Anchore 多个命令行项目共用的应用框架,Cobra 则负责命令、参数和子命令解析。两者的分工可以简单理解为:
1 | Cobra |
Grype 在交互式终端中可以显示动态进度,在重定向输出、quiet 模式或不适合启用 UI 时,则使用无动态界面的实现。
SetupConfig() 还负责将 clio 创建的事件总线、日志和脱敏存储设置到 Grype、Syft、stereoscope 等内部包中。这样后续的软件包编目和镜像读取可以共用同一套日志与 UI。
根命令将控制权交给 runGrype
根命令定义在 cmd/grype/cli/commands/root.go:
1 | func Root(app clio.Application) *cobra.Command { |
options.DefaultGrype() 创建默认扫描配置,app.SetupRootCommand() 将配置字段和命令行参数绑定起来。Cobra 完成参数解析和校验后执行 RunE,并将用户输入交给 runGrype()。
因此,从入口到扫描函数的核心路径可以简化为:
1 | main() |
runGrype 如何组织一次扫描
runGrype() 是整条调用链的编排中心。它本身并不实现 npm 版本比较或 Alpine 漏洞查询,而是准备各个模块需要的对象,并规定执行顺序。为了看清它的层次,可以把这个函数拆成三个阶段:
1 | 准备阶段 |
准备阶段
输出 Writer 在扫描前创建
runGrype() 进入后首先调用:
1 | writer, err := format.MakeScanResultWriter( |
这一步只确定结果将以 table、JSON(JavaScript Object Notation,JavaScript 对象表示法)、SARIF(Static Analysis Results Interchange Format,静态分析结果交换格式)或其他格式输出,以及输出到标准输出还是文件。Writer 必须在耗时扫描开始前创建,因为输出格式或文件路径错误应该尽早返回,而不是完成扫描后才告诉用户无法写入报告。
随后,--only-fixed、--only-notfixed、--ignore-states 等选项会被转换成 IgnoreRule。例如 --only-fixed 的实现并不是让 Matcher 只查询有修复的漏洞,而是增加规则,将 not-fixed、wont-fix 和 unknown 结果移入 ignored matches。
三个准备任务并行执行
准备阶段最关键的一段代码是:
1 | err = parallel( |
parallel() 在 cmd/grype/cli/commands/util.go 中为每个函数启动一个 goroutine,再通过 sync.WaitGroup 等待全部任务完成。三个任务分别是:
- 检查 Grype 是否存在新版本;
- 加载并检查漏洞数据库;
- 根据输入生成软件包集合。
这三个任务之间没有数据依赖,适合并行执行。前面的真实日志正好验证了这一点:
1 | [0000] DEBUG gathering packages |
软件包收集在 1.09 秒左右完成,数据库加载在 2.90 秒左右完成。因为后续 Matcher 同时依赖数据库和软件包,所以 parallel() 必须等两个任务都结束后才能继续。
pkg.Provide 将输入转换成软件包
pkg.Provide() 的目标是把各种输入统一成下面三个结果:
1 | packages []pkg.Package |
SBOM(Software Bill of Materials,软件物料清单)记录软件包含的组件及其版本等信息。这里即使输入是目录,Syft 也会先创建一份内存中的 SBOM,再由 Grype 将 Syft Package 转换成自己的 pkg.Package。
Provider 会按固定顺序尝试不同的输入解析器:先识别 PURL(Package URL,软件包 URL)和 CPE(Common Platform Enumeration,通用平台枚举),再尝试 SBOM 和 Zarf 包;如果都不匹配,最后交给 Syft 识别目录、文件和镜像:
1 | pkg.Provide(userInput) |
本文扫描的是 dir: 目录,所以最终进入 syftProvider()。实际 debug 日志显示 JavaScript lock cataloger 发现了 3 个包:根项目本身、lodash 和 minimist。
LoadVulnerabilityDB 返回 vulnerability.Provider
另一个并行任务调用 grype.LoadVulnerabilityDB():
1 | func LoadVulnerabilityDB(...) ( |
这里返回的 vulnerability.Provider 隐藏了数据库的具体存储实现。后面的 Matcher 不需要知道漏洞数据来自 SQLite 表、文件还是其他实现,只需要通过 Provider 查询候选漏洞和元数据。
本文运行时加载的数据库状态如下:
1 | schema: v6.1.7 |
到这里,准备阶段已经得到两个最重要的输入:
1 | packages []pkg.Package |
匹配阶段
创建 VulnerabilityMatcher
数据库和软件包准备完成后,runGrype() 创建 VEX Processor 和 VulnerabilityMatcher:
1 | vulnMatcher := grype.VulnerabilityMatcher{ |
VulnerabilityMatcher 不是某一种软件包的 Matcher,而是所有 Matcher 的上层调度器。它持有:
- 漏洞数据库 Provider;
- 不同软件生态的 Matcher 集合;
- ignore 规则;
- VEX Processor;
NormalizeByCVE决定是否将漏洞编号统一为 CVE(Common Vulnerabilities and Exposures,通用漏洞披露);FailSeverity保存严重级别门禁;- 是否启用 EOL(End of Life,生命周期结束)发行版告警。
getMatchers() 调用 matcher.NewDefaultMatchers(),创建 dpkg、rpm、JavaScript、Go、Python、Java、apk 等默认 Matcher。每个 Matcher 都实现相同接口:
1 | type Matcher interface { |
匹配阶段中的 VEX(Vulnerability Exploitability eXchange,漏洞可利用性交换)用于描述某条漏洞是否真正影响一个产品。
按软件包类型选择 Matcher
FindMatchesContext() 先执行数据库匹配,再应用 VEX,最后检查 --fail-on:
1 | remainingMatches, ignoredMatches, err = m.findDBMatches(...) |
数据库匹配的核心在 searchDBForMatches()。它先使用 newMatcherIndex() 建立 软件包类型 → Matcher 的索引,然后逐个处理软件包:
1 | for _, p := range packages { |
本文的两个依赖都是 npm 包,所以由 JavaScript Matcher 处理。实际结果为:
1 | pkg:npm/lodash@4.17.20 → 5 条 Match |
因此日志中会出现 across 3 packages,但最终报告只有 2 个受影响的软件包。3 是被编目的包数,2 是产生 Match 的包数,7 才是最终的 Match 数量。
ignore、VEX 和 fail-on 的执行顺序
数据库 Matcher 返回结果后,VulnerabilityMatcher 还会进行几步处理:
1 | 原始 Matcher 结果 |
这说明 --fail-on 检查的是过滤后的 remainingMatches,而不是所有原始 Match。与此同时,即使命中严重级别阈值,FindMatchesContext() 仍然会返回已经找到的结果和专门的 ErrAboveSeverityThreshold 错误。runGrype() 对这个错误做了特殊处理:保留错误但继续构建报告。这样 Grype 既能输出完整扫描结果,又能在最后返回门禁失败。
输出阶段
NewDocument 将领域对象转换成报告模型
漏洞匹配完成后,runGrype() 调用 models.NewDocument():
1 | model, err := models.NewDocument( |
Matcher 阶段的对象主要服务于漏洞判断,而 Presenter 需要更完整、更稳定的展示模型。为了生成最终报告,NewDocument() 还会补充 CVSS(Common Vulnerability Scoring System,通用漏洞评分系统)、EPSS(Exploit Prediction Scoring System,漏洞利用预测评分系统)和 KEV(Known Exploited Vulnerabilities,已知被利用漏洞)等风险数据。它会完成以下工作:
- 将每个 Match 与完整 Package 重新关联;
- 查询漏洞 Metadata,补充 CVSS、EPSS、KEV 和 Risk;
- 将 ignored matches 转换成报告结构;
- 填充 source、distro、配置和数据库信息;
- 按
--sort-by指定的策略排序。
这也解释了为什么漏洞匹配完成后还需要单独构建 Document:Match 只回答 哪个包命中了哪条漏洞,报告还需要回答“严重性如何、风险多大、使用了哪个数据库、应该如何排序”。
Writer 根据格式选择 Presenter
最后,writer.Write() 根据用户指定的输出格式选择 Presenter:
1 | func GetPresenter(format Format, ...) presenter.Presenter { |
Presenter 将同一个 Document 编码成 table、JSON、SARIF、CycloneDX 或自定义模板。由此可以看到,输出格式不会改变漏洞匹配过程,它只改变最后一步如何表达结果。
小结
这篇文章从 main() 开始,跟踪了一次目录扫描如何经过 clio 和 Cobra 进入 runGrype(),再依次完成软件包发现、数据库加载、漏洞匹配、Document 构建和 Presenter 输出。
Grype 的整体结构可以概括为:入口层负责启动,命令层负责参数和生命周期,runGrype() 负责流程编排,领域模块负责具体能力。数据库加载和软件包收集被并行执行,Matcher 在二者准备完成后按软件包类型运行,最后再统一处理 ignore、VEX、严重级别门禁和报告输出。
理解这条主干后,后续分析就有了坐标。下一篇将继续进入命令行和配置系统,分析命令行参数、环境变量、配置文件和默认值如何合并成 options.Grype,以及这些配置如何影响本文看到的每一个扫描阶段。