0%

Grype 源码分析 02:整体流程

上一篇文章介绍了 Grype 的基本用法。这篇文章开始分析 Grype 的源码实现,我们先不深入某一种漏洞如何匹配,而是从程序入口开始,跟踪执行一条扫描命令最后是如何获取漏洞结果的。通过分析扫描命令的执行流程,我们将建立 Grype 的整体调用链,为后续分析配置系统、软件包 Provider、漏洞数据库和 Matcher 打下基础。

Grype 的整体调用链

Grype 是一个 CLI(Command-Line Interface,命令行界面)程序,但真正的扫描逻辑并不在 main() 中。main() 负责创建应用,Cobra 负责解析命令,runGrype() 负责组织一次扫描,具体的软件包发现、漏洞匹配和报告输出又分别交给不同模块完成。

一次目录扫描的核心调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
cmd/grype/main.go
└── main()
└── cli.Application(...).Run()

├── cli.create()
│ ├── SetupConfig()
│ ├── clio.New()
│ └── commands.Root()

└── Cobra root command RunE
└── runGrype()

├── format.MakeScanResultWriter()

├── parallel()
│ ├── checkForAppUpdate()
│ ├── grype.LoadVulnerabilityDB()
│ └── pkg.Provide()

├── 创建漏洞可利用性处理器
├── VulnerabilityMatcher.FindMatchesContext()
│ ├── findDBMatches()
│ ├── findVEXMatches()
│ └── 检查 --fail-on

├── models.NewDocument()
└── writer.Write()
└── Presenter.Present()

这条调用链可以分成四层:

层次 主要职责 核心位置
应用入口 组装应用信息并启动 CLI cmd/grype/main.go
命令层 初始化配置、用户界面(UI,User Interface)、日志和 Cobra 命令 cmd/grype/cli
扫描编排层 协调数据库、软件包、Matcher 和报告 cmd/grype/cli/commands/root.go
领域实现层 完成软件包发现、漏洞匹配和结果展示 grype/pkggrype/matchergrype/presenter

先看实际运行日志

在分析每一层之前,先来看实际扫描提取出的关键日志。源码和日志都使用 DB(Database)指代漏洞数据库:

1
2
3
4
5
6
7
8
[0000] DEBUG gathering packages
[0000] DEBUG loading DB
[0001] INFO gathered packages packages=3 time=1.086846115s
[0002] INFO loaded DB status=valid time=2.896080308s
[0002] DEBUG found 5 vulnerabilities package=pkg:npm/lodash@4.17.20
[0002] DEBUG found 2 vulnerabilities package=pkg:npm/minimist@0.0.8
[0002] INFO found 7 vulnerability matches across 3 packages
[0002] INFO found vulnerability matches time=11.131427ms

从这段日志中可以先得到几个结论:

  • gathering packagesloading DB 几乎同时出现,说明软件包收集和数据库加载是并行执行的;
  • 软件包编目一共得到 3 个包;
  • 只有 lodashminimist 命中了漏洞;
  • lodash 产生 5 条 Match,minimist 产生 2 条 Match;
  • 最终一共得到 7 条漏洞 Match。

日志中的耗时与机器、缓存和数据库状态有关,重点是各阶段的先后关系和数据数量。

接下来从 main() 开始,看这些阶段分别对应哪些源码。

从程序入口进入 runGrype

这一层解决的是“谁创建应用、谁接收命令、谁开始扫描”。这里涉及三个连续步骤:main() 创建应用,cli.create() 组装命令,根命令的 RunE 调用 runGrype()

main 函数只负责启动应用

Grype 的程序入口位于 cmd/grype/main.go,核心代码非常短:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
app := cli.Application(
clio.Identification{
Name: applicationName,
Version: version,
BuildDate: buildDate,
GitCommit: gitCommit,
GitDescription: gitDescription,
},
)

app.Run()
}

这里没有输入解析、数据库加载或漏洞匹配。它只做了两件事:

  1. 使用名称、版本、构建时间和 Git 信息创建应用;
  2. 调用 app.Run() 启动应用。

versionbuildDategitCommitgitDescription 默认都是 NotProvided,正式发布时通过构建参数注入。这也是直接执行 go build ./cmd/grype 后,grype version 可能显示 [not provided] 的原因。

cli.create 组装整个命令行应用

cli.Application() 内部调用 create()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Application(id clio.Identification) clio.Application {
app, _ := create(id)
return app
}

func create(id clio.Identification) (clio.Application, *cobra.Command) {
clioCfg := SetupConfig(id)
app := clio.New(*clioCfg)
rootCmd := commands.Root(app)

rootCmd.AddCommand(
commands.DB(app),
commands.Completion(app),
commands.Explain(app),
clio.VersionCommand(id, syftVersion, dbVersion),
clio.ConfigCommand(app, nil),
)

return app, rootCmd
}

create() 先通过 SetupConfig() 创建 clio 配置,再创建应用和根命令,最后注册 dbcompletionexplainversionconfig 等子命令。clio 是 Anchore 多个命令行项目共用的应用框架,Cobra 则负责命令、参数和子命令解析。两者的分工可以简单理解为:

1
2
3
4
5
6
7
8
9
10
Cobra
└── 命令树、参数、RunE

clio
├── 配置加载
├── 日志
├── UI
├── 事件总线
├── 应用生命周期
└── 退出码映射

Grype 在交互式终端中可以显示动态进度,在重定向输出、quiet 模式或不适合启用 UI 时,则使用无动态界面的实现。

SetupConfig() 还负责将 clio 创建的事件总线、日志和脱敏存储设置到 Grype、Syft、stereoscope 等内部包中。这样后续的软件包编目和镜像读取可以共用同一套日志与 UI。

根命令将控制权交给 runGrype

根命令定义在 cmd/grype/cli/commands/root.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Root(app clio.Application) *cobra.Command {
opts := options.DefaultGrype(app.ID())

return app.SetupRootCommand(&cobra.Command{
Use: fmt.Sprintf("%s [IMAGE]", app.ID().Name),
Args: validateRootArgs,
RunE: func(cmd *cobra.Command, args []string) error {
userInput := ""
if len(args) > 0 {
userInput = args[0]
}
return runGrype(cmd.Context(), app, opts, userInput)
},
}, opts)
}

options.DefaultGrype() 创建默认扫描配置,app.SetupRootCommand() 将配置字段和命令行参数绑定起来。Cobra 完成参数解析和校验后执行 RunE,并将用户输入交给 runGrype()

因此,从入口到扫描函数的核心路径可以简化为:

1
2
3
4
5
6
main()
→ cli.Application()
→ create()
→ commands.Root()
→ Cobra.RunE
→ runGrype()

runGrype 如何组织一次扫描

runGrype() 是整条调用链的编排中心。它本身并不实现 npm 版本比较或 Alpine 漏洞查询,而是准备各个模块需要的对象,并规定执行顺序。为了看清它的层次,可以把这个函数拆成三个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
准备阶段
├── 创建输出 Writer
├── 整理 ignore 规则
├── 加载漏洞数据库
└── 收集软件包

匹配阶段
├── 创建 VEX Processor
├── 创建 VulnerabilityMatcher
├── 执行数据库 Matcher
├── 应用 ignore / VEX
└── 检查严重级别阈值

输出阶段
├── 构建 Document
├── 按策略排序
└── Presenter 输出报告

准备阶段

输出 Writer 在扫描前创建

runGrype() 进入后首先调用:

1
2
3
4
5
6
7
8
9
writer, err := format.MakeScanResultWriter(
opts.Outputs,
opts.File,
format.PresentationConfig{
TemplateFilePath: opts.OutputTemplateFile,
ShowSuppressed: opts.ShowSuppressed,
Pretty: opts.Pretty,
},
)

这一步只确定结果将以 table、JSON(JavaScript Object Notation,JavaScript 对象表示法)、SARIF(Static Analysis Results Interchange Format,静态分析结果交换格式)或其他格式输出,以及输出到标准输出还是文件。Writer 必须在耗时扫描开始前创建,因为输出格式或文件路径错误应该尽早返回,而不是完成扫描后才告诉用户无法写入报告。

随后,--only-fixed--only-notfixed--ignore-states 等选项会被转换成 IgnoreRule。例如 --only-fixed 的实现并不是让 Matcher 只查询有修复的漏洞,而是增加规则,将 not-fixedwont-fixunknown 结果移入 ignored matches。

三个准备任务并行执行

准备阶段最关键的一段代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
err = parallel(
func() error {
checkForAppUpdate(app.ID(), opts)
return nil
},
func() error {
vp, status, err = grype.LoadVulnerabilityDB(...)
return validateDBLoad(err, status)
},
func() error {
packages, pkgContext, s, err = pkg.Provide(
userInput,
getProviderConfig(opts),
)
return err
},
)

parallel()cmd/grype/cli/commands/util.go 中为每个函数启动一个 goroutine,再通过 sync.WaitGroup 等待全部任务完成。三个任务分别是:

  1. 检查 Grype 是否存在新版本;
  2. 加载并检查漏洞数据库;
  3. 根据输入生成软件包集合。

这三个任务之间没有数据依赖,适合并行执行。前面的真实日志正好验证了这一点:

1
2
[0000] DEBUG gathering packages
[0000] DEBUG loading DB

软件包收集在 1.09 秒左右完成,数据库加载在 2.90 秒左右完成。因为后续 Matcher 同时依赖数据库和软件包,所以 parallel() 必须等两个任务都结束后才能继续。

pkg.Provide 将输入转换成软件包

pkg.Provide() 的目标是把各种输入统一成下面三个结果:

1
2
3
packages   []pkg.Package
pkgContext pkg.Context
s *sbom.SBOM

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
2
3
4
5
6
7
8
9
pkg.Provide(userInput)
├── purlProvider
├── cpeProvider
├── syftSBOMProvider
├── zarfProvider
└── syftProvider
├── syft.GetSource()
├── syft.CreateSBOM()
└── pkg.FromCollection()

本文扫描的是 dir: 目录,所以最终进入 syftProvider()。实际 debug 日志显示 JavaScript lock cataloger 发现了 3 个包:根项目本身、lodashminimist

LoadVulnerabilityDB 返回 vulnerability.Provider

另一个并行任务调用 grype.LoadVulnerabilityDB()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func LoadVulnerabilityDB(...) (
vulnerability.Provider,
*vulnerability.ProviderStatus,
error,
) {
client, err := distribution.NewClient(...)
curator, err := installation.NewCurator(...)

if update {
_, err = curator.Update()
}

status := curator.Status()
reader, err := curator.Reader()

return v6.NewVulnerabilityProvider(reader), &status, nil
}

这里返回的 vulnerability.Provider 隐藏了数据库的具体存储实现。后面的 Matcher 不需要知道漏洞数据来自 SQLite 表、文件还是其他实现,只需要通过 Provider 查询候选漏洞和元数据。

本文运行时加载的数据库状态如下:

1
2
3
schema: v6.1.7
built: 2026-07-15T06:58:38Z
status: valid

到这里,准备阶段已经得到两个最重要的输入:

1
2
3
4
5
6
packages []pkg.Package
+
vulnerability.Provider


可以开始漏洞匹配

匹配阶段

创建 VulnerabilityMatcher

数据库和软件包准备完成后,runGrype() 创建 VEX Processor 和 VulnerabilityMatcher

1
2
3
4
5
6
7
8
9
10
11
vulnMatcher := grype.VulnerabilityMatcher{
VulnerabilityProvider: vp,
IgnoreRules: opts.Ignore,
NormalizeByCVE: opts.ByCVE,
FailSeverity: opts.FailOnSeverity(),
Matchers: getMatchers(opts),
VexProcessor: vexProcessor,
Alerts: grype.AlertsConfig{
EnableEOLDistroWarnings: opts.Alerts.EnableEOLDistroWarnings,
},
}

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
2
3
4
5
6
7
8
type Matcher interface {
PackageTypes() []syftPkg.Type
Type() MatcherType
Match(
vp vulnerability.Provider,
p pkg.Package,
) ([]Match, []IgnoreFilter, error)
}

匹配阶段中的 VEX(Vulnerability Exploitability eXchange,漏洞可利用性交换)用于描述某条漏洞是否真正影响一个产品。

按软件包类型选择 Matcher

FindMatchesContext() 先执行数据库匹配,再应用 VEX,最后检查 --fail-on

1
2
3
4
5
6
7
8
remainingMatches, ignoredMatches, err = m.findDBMatches(...)
remainingMatches, ignoredMatches, err = m.findVEXMatches(...)

if m.FailSeverity != nil &&
hasSeverityAtOrAbove(..., *remainingMatches) {
return remainingMatches, ignoredMatches,
grypeerr.ErrAboveSeverityThreshold
}

数据库匹配的核心在 searchDBForMatches()。它先使用 newMatcherIndex() 建立 软件包类型 → Matcher 的索引,然后逐个处理软件包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for _, p := range packages {
matchAgainst, ok := matcherIndex[p.Type]
if !ok {
matchAgainst = []match.Matcher{defaultMatcher}
}

for _, theMatcher := range matchAgainst {
matches, ignorers, err := callMatcherSafely(
theMatcher,
m.VulnerabilityProvider,
p,
)
// 收集并过滤 matches
}
}

本文的两个依赖都是 npm 包,所以由 JavaScript Matcher 处理。实际结果为:

1
2
3
pkg:npm/lodash@4.17.20    → 5 条 Match
pkg:npm/minimist@0.0.8 → 2 条 Match
根项目 → 0 条 Match

因此日志中会出现 across 3 packages,但最终报告只有 2 个受影响的软件包。3 是被编目的包数,2 是产生 Match 的包数,7 才是最终的 Match 数量。

ignore、VEX 和 fail-on 的执行顺序

数据库 Matcher 返回结果后,VulnerabilityMatcher 还会进行几步处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
原始 Matcher 结果


数据库排除表和硬编码规则


用户 ignore 规则


可选的 --by-cve 标准化


VEX 处理


--fail-on 严重级别检查

这说明 --fail-on 检查的是过滤后的 remainingMatches,而不是所有原始 Match。与此同时,即使命中严重级别阈值,FindMatchesContext() 仍然会返回已经找到的结果和专门的 ErrAboveSeverityThreshold 错误。runGrype() 对这个错误做了特殊处理:保留错误但继续构建报告。这样 Grype 既能输出完整扫描结果,又能在最后返回门禁失败。

输出阶段

NewDocument 将领域对象转换成报告模型

漏洞匹配完成后,runGrype() 调用 models.NewDocument()

1
2
3
4
5
6
7
8
9
10
11
12
13
model, err := models.NewDocument(
app.ID(),
packages,
pkgContext,
*remainingMatches,
ignoredMatches,
vp,
opts,
dbInfo(status, vp),
models.SortStrategy(opts.SortBy.Criteria),
opts.Timestamp,
distroAlertData,
)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
func GetPresenter(format Format, ...) presenter.Presenter {
switch format {
case JSONFormat:
return json.NewPresenter(...)
case TableFormat:
return table.NewPresenter(...)
case SarifFormat:
return sarif.NewPresenter(...)
case CycloneDXJSON:
return cyclonedx.NewJSONPresenter(...)
case TemplateFormat:
return template.NewPresenter(...)
}
}

Presenter 将同一个 Document 编码成 table、JSON、SARIF、CycloneDX 或自定义模板。由此可以看到,输出格式不会改变漏洞匹配过程,它只改变最后一步如何表达结果。

小结

这篇文章从 main() 开始,跟踪了一次目录扫描如何经过 clio 和 Cobra 进入 runGrype(),再依次完成软件包发现、数据库加载、漏洞匹配、Document 构建和 Presenter 输出。

Grype 的整体结构可以概括为:入口层负责启动,命令层负责参数和生命周期,runGrype() 负责流程编排,领域模块负责具体能力。数据库加载和软件包收集被并行执行,Matcher 在二者准备完成后按软件包类型运行,最后再统一处理 ignore、VEX、严重级别门禁和报告输出。

理解这条主干后,后续分析就有了坐标。下一篇将继续进入命令行和配置系统,分析命令行参数、环境变量、配置文件和默认值如何合并成 options.Grype,以及这些配置如何影响本文看到的每一个扫描阶段。