上一篇文章从 main() 开始,跟踪了一次扫描如何进入 runGrype()。在真正加载漏洞数据库(Database,源码和日志中常简写为 DB)和收集软件包之前,还有一个重要步骤:把默认值、配置文件、profile(配置档)、环境变量和命令行参数合并成最终的 options.Grype。这篇文章我们将分析 Grype 是如何解析命令行和配置的。
一个配置值的完整旅程
Grype 是一个 CLI(Command-Line Interface,命令行界面)程序,同一个配置通常有多种写法。例如,下面几种方式都可以设置结果排序策略:
1 | # 配置文件 |
如果三种方式同时出现,最终谁生效?配置值如何从一个字符串变成 runGrype() 可以直接使用的对象?这篇文章将围绕 sort-by 的完整生命周期,分析 clio、fangs、Viper 和 Cobra 如何共同完成 Grype 的配置加载。
先来看最终结论。Grype 配置从低到高的优先级如下:
1 | 默认值 |
fangs 源码中还保留了 Viper 内部 Set 和键值配置层,但对于普通 Grype 用户,最重要的就是上面五层。
使用 sort-by 验证优先级
sort-by 可以设置为 package、severity、epss、risk、kev 或 vulnerability。其中,epss 依据 EPSS(Exploit Prediction Scoring System,漏洞利用预测评分系统)数据排序,kev 则优先展示 KEV(Known Exploited Vulnerabilities,已知被利用漏洞)目录收录的漏洞。
为了观察这些配置来源如何相互覆盖,我们准备下面的 YAML(YAML Ain’t Markup Language,一种便于人阅读的数据序列化格式)配置文件:
1 | sort-by: package |
这个为持续集成(Continuous Integration,CI)准备的 ci profile 将排序方式改为 severity,同时只保留已有修复版本的漏洞,并对 High 及以上漏洞启用门禁。
接下来依次运行五组命令。命令统一选择 JSON(JavaScript Object Notation,JavaScript 对象表示法)输出,并将扫描报告重定向到 /dev/null,只通过 -vv 日志观察最终配置。
- 只使用默认值:
1 | grype -vv dir:./vulnerable-node-app -o json > /dev/null |
- 加载配置文件:
1 | grype -vv -c grype.yaml \ |
- 选择
ciprofile:
1 | grype -vv -c grype.yaml --profile ci \ |
- 在 profile 之上设置环境变量:
1 | GRYPE_SORT_BY=kev \ |
- 最后再增加命令行参数:
1 | GRYPE_SORT_BY=kev \ |
-vv 输出的最终配置如下:
1 | CASE SORT_BY EXIT_CODE |
结果与优先级完全一致:
risk来自 Grype 默认值;package来自配置文件顶层;severity来自ciprofile,并覆盖顶层的package;- 环境变量
GRYPE_SORT_BY=kev覆盖 profile; - 命令行参数
--sort-by epss具有最高优先级。
profile、环境变量和 flag 三组命令的退出码都是 2,因为 ci profile 还设置了 fail-on-severity: high 和 only-fixed: true。环境变量和命令行参数只覆盖了 sort-by,没有清除 profile 中的其他配置。
配置对象何时完成加载
从源码角度看,一个配置值会经过下面的过程:
1 | DefaultGrype() |
- clio 是 anchore 自己封装的上层的应用框架:它以 Cobra 命令为基础,统一管理应用初始化、全局配置、日志和退出码等生命周期。在这篇文章关注的配置流程中,clio 最重要的作用是包装 Cobra 的
PreRunE,确保配置加载发生在命令的RunE之前。 - fangs 是 clio 使用的底层配置库,目标是让 Cobra 与 Viper 的组合更简单、更一致。Cobra 负责解析命令行参数,Viper 负责统一读取配置文件和环境变量,fangs 则通过字段指针和
mapstructuretag,将这些外部值与已经带有默认值的 Go 结构体连接起来,完成配置文件查找、profile 合并、环境变量与 flag 绑定 runGrype()接收到的opts已经是最终结果。扫描流程不需要再分别判断某个值来自配置文件还是命令行。
配置对象是如何构造的
Grype 没有为命令行、环境变量和配置文件分别维护三套模型。它们最终都映射到 cmd/grype/cli/options 中的 Go 结构体。
SetupConfig 建立全局配置能力
cmd/grype/cli/cli.go 中的 SetupConfig() 负责初始化 clio:
1 | func SetupConfig(id clio.Identification) *clio.SetupConfig { |
其中与配置加载直接相关的是:
WithGlobalConfigFlag()注册全局-c、--config和--profile;WithGlobalLoggingFlags()注册-v、-q等日志参数;NewSetupConfig(id)使用应用名grype创建 fangs 配置,因此环境变量统一以GRYPE_开头;WithConfigInRootHelp()让根命令的--help能够展示完整的应用配置说明。
全局参数注册在 Cobra 的 PersistentFlags() 中,所以 -c 和 --profile 不仅能用于扫描根命令,也能用于 grype config、grype db 等子命令。
DefaultGrype 先建立默认值
根命令在创建时调用:
1 | opts := options.DefaultGrype(app.ID()) |
DefaultGrype() 返回一个已经设置默认值的配置对象:
1 | func DefaultGrype(id clio.Identification) *Grype { |
以 SortBy 为例,defaultSortBy() 从 Presenter 支持的策略中构造允许值,并将默认 Criteria 设置为 risk:
1 | type SortBy struct { |
AllowableOptions 使用 mapstructure:"-",表示它是内部辅助字段,不从外部配置中加载。
结构体 tag 定义配置路径
options.Grype 是整个扫描命令的配置根对象。下面截取部分字段:
1 | type Grype struct { |
mapstructure tag 决定 fangs 和 Viper 使用的配置路径,yaml tag 则用于输出可读配置。原始结构中也存在 json tag,这里为了突出主线省略了。
普通嵌套字段会形成多级路径。例如:
1 | type search struct { |
它对应三种外部写法:
1 | # YAML 配置 |
1 | # 环境变量:层级使用下划线连接 |
SortBy 上的 mapstructure:",squash" 则表示不增加 sort-by 的父级,因此配置写成顶层 sort-by: risk,而不是 sort-by.criteria: risk。
AddFlags 使用字段指针注册参数
Grype 和 SortBy 都实现了 AddFlags():
1 | func (o *Grype) AddFlags(flags clio.FlagSet) { |
这里最重要的是传入字段指针,例如 &o.Criteria。fangs 会记录 这个 flag 对应哪个配置字段,后面才能让 Viper 将命令行参数、环境变量和配置文件合并到同一位置。
fangs 的 FlagSet 不要求调用者再次传入默认值,而是直接读取字段当前值:
1 | func (f *pflagSet) StringVarP( |
因此默认值只在 DefaultGrype() 中定义一次,Cobra flag 的默认值会自动与配置对象保持一致。
fangs 还会递归遍历嵌套结构体,所以根对象调用一次 AddFlags 后,SortBy.AddFlags()、数据库选项和其他子配置的 AddFlags() 也会被执行。
外部配置如何合并
配置对象和 flag 注册完成后,Cobra 开始执行命令。clio 将配置加载包装在 PreRunE 中,因此它一定发生在实际 RunE 之前。
clio 在 PreRunE 中调用 fangs.Load
clio 的 setupCommand() 会替换命令原有的 PreRunE:
1 | *fn = func(cmd *cobra.Command, args []string) error { |
a.Setup() 最终调用:
1 | fangs.Load( |
这意味着执行顺序是:
1 | Cobra 解析 flag |
如果配置加载或校验失败,RunE 不会执行,数据库和软件包收集也不会开始。
配置文件查找顺序
没有显式指定 -c 时,fangs 会按下面的顺序查找 Grype 配置:
1 | ./.grype.yaml |
可以直接查看当前环境中的搜索位置:
1 | grype config locations |
如果使用 -c grype.yaml 显式指定文件,就不再搜索这些默认位置。Grype 支持多次使用 -c,fangs 会按照文件出现顺序合并;同一个标量键由靠前的文件优先,列表和 map 则会按规则合并。
也可以通过 GRYPE_CONFIG 指定配置文件,但命令行 -c 的优先级更高:
1 | GRYPE_CONFIG=grype.yaml grype dir:. |
profile 先覆盖配置文件顶层值
fangs 读取配置文件后,会根据 --profile 找到 profiles 下的对应节点,再将 profile 内容覆盖到顶层配置。
对于前面的配置:
1 | sort-by: package |
选择 profile:
1 | grype -c grype.yaml --profile ci config --load |
实际加载后的关键字段为:
1 | only-fixed: true |
profile 适合保存一组有共同含义的配置。例如开发者本地可以使用完整报告,CI profile 则只对已有修复的 High 及以上漏洞执行门禁。
profile 属于配置文件合并阶段,因此它仍会被更高优先级的环境变量或命令行参数覆盖。这就是前面 GRYPE_SORT_BY=kev 和 --sort-by epss 能继续改变结果的原因。
环境变量如何映射嵌套字段
fangs 基于应用名设置环境变量前缀,并将配置路径中的点号和连字符替换为下划线:
1 | sort-by → GRYPE_SORT_BY |
因此,大部分配置项不需要手写环境变量绑定。只要结构体存在 mapstructure 路径,Viper 就可以按照统一规则生成对应名称。
对于 registry 凭据等需要兼容 Syft 或特殊处理的字段,Grype 也会在相应 PostLoad() 中读取专用环境变量。普通配置则遵循统一映射规则。
flag 为什么具有最高优先级
在 configureViper() 中,fangs 通过字段指针找到已经注册的 pflag,并调用 BindPFlag():
1 | if flag, ok := flags[ptr]; ok { |
Viper 合并时,显式设置的 flag 优先于环境变量和配置文件。需要注意,“显式设置”与“flag 自己带有默认值”不同:用户没有传入 --sort-by 时,配置文件仍然能够将默认 risk 覆盖为 package;只有真正传入 --sort-by epss 时,flag 才成为最高优先级值。
PostLoad 如何校验和转换配置
Viper 将外部数据反序列化到 Go 结构体后,配置还不能立即进入扫描流程。fangs 会递归查找实现 PostLoader 接口的对象并调用 PostLoad()。
根配置负责跨字段处理
options.Grype.PostLoad() 处理根对象上的逻辑:
1 | func (o *Grype) PostLoad() error { |
这里做了两件事:
- 将
--from docker,registry这样的逗号分隔值展开为列表; - 将
fail-on-severity转换成内部 Severity,并拒绝未知值。
子配置负责自己的约束
嵌套的 SortBy、search、Database、registry 和 matcher config 也分别实现了 PostLoad()。
以 SortBy 为例:
1 | func (o *SortBy) PostLoad() error { |
实际传入非法值:
1 | grype -c grype.yaml --sort-by invalid dir:. |
程序输出:
1 | ERROR invalid application config: invalid sort-by criteria: "invalid" |
日志中没有 loading DB 或 gathering packages,说明错误发生在 PreRunE 配置阶段,runGrype() 根本没有开始执行。
这种结构让每个子配置只关心自己的合法性。例如:
search.PostLoad()校验squashed、all-layers等 scope;Database.PostLoad()展开缓存目录中的~;- RPM(RPM Package Manager)和 dpkg(Debian 系的软件包管理器)配置会校验缺失 epoch 时采用的版本比较策略;
- registry config 检查认证方式和相关环境变量。
FieldDescriber 生成配置说明
options.Grype 还实现了 FieldDescriber:
1 | func (o *Grype) DescribeFields( |
这里同样使用字段指针,将说明文字与具体字段关联。grype config 输出中的注释、环境变量名称和允许值,就是由字段描述、flag usage 和结构体路径共同生成的。
查看带默认值的完整配置模板:
1 | grype config |
加载并校验当前配置后再显示:
1 | grype -c grype.yaml --profile ci config --load |
--load 很重要:不加它时,config 主要展示配置模板;加上它才会读取配置文件、环境变量和 profile,并执行 PostLoad() 校验。
最终配置如何影响扫描
配置加载完成后,Cobra 执行根命令的 RunE:
1 | return runGrype(cmd.Context(), app, opts, userInput) |
从这里开始,opts 不再是“配置文件对象”或“命令行对象”,而是所有来源合并后的扫描配置。
不同配置被分发给不同模块
runGrype() 会将 options.Grype 拆分给各个模块:
| 配置 | 下游模块 | 作用 |
|---|---|---|
Outputs、File、Pretty |
format.MakeScanResultWriter() |
创建报告 Writer |
Search、Registry、Exclusions |
getProviderConfig() |
配置 Syft 和输入 Provider |
DB |
LoadVulnerabilityDB() |
配置数据库下载、缓存和校验 |
Match |
getMatcherConfig() |
创建各软件生态 Matcher |
Ignore、VexDocuments |
VulnerabilityMatcher |
过滤或调整 Match |
FailOn |
VulnerabilityMatcher.FailSeverity |
执行严重级别门禁 |
SortBy.Criteria |
models.NewDocument() |
决定报告排序 |
因此配置系统并不执行扫描逻辑,它的作用是为后面的领域模块准备类型明确且经过校验的参数。
debug 日志展示的是最终配置
clio 在 fangs.Load() 和全部 PostLoad() 完成后调用 logConfiguration()。因此 -vv 日志中的:
1 | config: |
展示的是最终生效值,而不是某一个配置文件的原始内容。排查“为什么某个选项没有生效”时,-vv 是最直接的方法。
回到文章开头的五组实验,现在可以将每一行结果与源码步骤对应起来:
| 结果 | 来源 | 对应实现 |
|---|---|---|
risk |
DefaultGrype() |
默认配置对象 |
package |
grype.yaml |
fangs 读取配置文件 |
severity |
profiles.ci |
mergeProfiles() 覆盖顶层值 |
kev |
GRYPE_SORT_BY |
Viper 自动环境变量映射 |
epss |
--sort-by |
BindPFlag() 提供最高优先级 |
小结
这篇文章分析了 Grype 配置从创建到消费的完整过程。DefaultGrype() 先创建默认对象,fangs 通过字段指针递归注册 Cobra flag;命令执行时,clio 在 PreRunE 中调用 fangs.Load(),将配置文件、profile、环境变量和 flag 合并,再递归执行各级 PostLoad()。
对于普通用户,配置优先级可以记为:命令行参数高于环境变量,环境变量高于 profile,profile 高于配置文件顶层值,配置文件又高于默认值。非法配置会在 runGrype() 启动之前被拒绝,而通过校验的最终 options.Grype 会被分发给 Provider、数据库、Matcher 和 Presenter。
下一篇将继续沿着 runGrype() 向下分析 pkg.Provide(),看看 dir:、docker:、registry: 和 sbom: 等不同输入如何被识别,以及 Syft 如何将目录和镜像转换成 Grype 可以匹配的 Package 集合。