0%

Grype 源码分析 03:如何解析命令行和配置

上一篇文章从 main() 开始,跟踪了一次扫描如何进入 runGrype()。在真正加载漏洞数据库(Database,源码和日志中常简写为 DB)和收集软件包之前,还有一个重要步骤:把默认值、配置文件、profile(配置档)、环境变量和命令行参数合并成最终的 options.Grype。这篇文章我们将分析 Grype 是如何解析命令行和配置的。

一个配置值的完整旅程

Grype 是一个 CLI(Command-Line Interface,命令行界面)程序,同一个配置通常有多种写法。例如,下面几种方式都可以设置结果排序策略:

1
2
3
4
5
6
7
8
# 配置文件
grype -c grype.yaml dir:.

# 环境变量
GRYPE_SORT_BY=severity grype dir:.

# 命令行参数
grype dir:. --sort-by risk

如果三种方式同时出现,最终谁生效?配置值如何从一个字符串变成 runGrype() 可以直接使用的对象?这篇文章将围绕 sort-by 的完整生命周期,分析 clio、fangs、Viper 和 Cobra 如何共同完成 Grype 的配置加载。

先来看最终结论。Grype 配置从低到高的优先级如下:

1
2
3
4
5
默认值
< 配置文件顶层值
< 选中的 profile
< 环境变量
< 命令行参数

fangs 源码中还保留了 Viper 内部 Set 和键值配置层,但对于普通 Grype 用户,最重要的就是上面五层。

使用 sort-by 验证优先级

sort-by 可以设置为 packageseverityepssriskkevvulnerability。其中,epss 依据 EPSS(Exploit Prediction Scoring System,漏洞利用预测评分系统)数据排序,kev 则优先展示 KEV(Known Exploited Vulnerabilities,已知被利用漏洞)目录收录的漏洞。

为了观察这些配置来源如何相互覆盖,我们准备下面的 YAML(YAML Ain’t Markup Language,一种便于人阅读的数据序列化格式)配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
sort-by: package
check-for-app-update: false
timestamp: false

db:
auto-update: false

profiles:
ci:
sort-by: severity
fail-on-severity: high
only-fixed: true

这个为持续集成(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
2
grype -vv -c grype.yaml \
dir:./vulnerable-node-app -o json > /dev/null
  • 选择 ci profile:
1
2
grype -vv -c grype.yaml --profile ci \
dir:./vulnerable-node-app -o json > /dev/null
  • 在 profile 之上设置环境变量:
1
2
3
GRYPE_SORT_BY=kev \
grype -vv -c grype.yaml --profile ci \
dir:./vulnerable-node-app -o json > /dev/null
  • 最后再增加命令行参数:
1
2
3
4
GRYPE_SORT_BY=kev \
grype -vv -c grype.yaml --profile ci \
--sort-by epss \
dir:./vulnerable-node-app -o json > /dev/null

-vv 输出的最终配置如下:

1
2
3
4
5
6
CASE         SORT_BY    EXIT_CODE
default risk 0
config package 0
profile severity 2
environment kev 2
flag epss 2

结果与优先级完全一致:

  • risk 来自 Grype 默认值;
  • package 来自配置文件顶层;
  • severity 来自 ci profile,并覆盖顶层的 package
  • 环境变量 GRYPE_SORT_BY=kev 覆盖 profile;
  • 命令行参数 --sort-by epss 具有最高优先级。

profile、环境变量和 flag 三组命令的退出码都是 2,因为 ci profile 还设置了 fail-on-severity: highonly-fixed: true。环境变量和命令行参数只覆盖了 sort-by,没有清除 profile 中的其他配置。

配置对象何时完成加载

从源码角度看,一个配置值会经过下面的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
DefaultGrype()
└── 创建带默认值的 options.Grype


AddFlags()
└── 将字段指针绑定到 Cobra flag


Cobra 解析命令行


clio PreRunE
└── fangs.Load()
├── 查找并合并配置文件
├── 合并 profile
├── 绑定环境变量和 flag
├── Viper Unmarshal
└── 递归执行 PostLoad()


runGrype(ctx, app, opts, userInput)
  • clio 是 anchore 自己封装的上层的应用框架:它以 Cobra 命令为基础,统一管理应用初始化、全局配置、日志和退出码等生命周期。在这篇文章关注的配置流程中,clio 最重要的作用是包装 Cobra 的 PreRunE,确保配置加载发生在命令的 RunE 之前。
  • fangs 是 clio 使用的底层配置库,目标是让 Cobra 与 Viper 的组合更简单、更一致。Cobra 负责解析命令行参数,Viper 负责统一读取配置文件和环境变量,fangs 则通过字段指针和 mapstructure tag,将这些外部值与已经带有默认值的 Go 结构体连接起来,完成配置文件查找、profile 合并、环境变量与 flag 绑定
  • runGrype() 接收到的 opts 已经是最终结果。扫描流程不需要再分别判断某个值来自配置文件还是命令行。

配置对象是如何构造的

Grype 没有为命令行、环境变量和配置文件分别维护三套模型。它们最终都映射到 cmd/grype/cli/options 中的 Go 结构体。

SetupConfig 建立全局配置能力

cmd/grype/cli/cli.go 中的 SetupConfig() 负责初始化 clio:

1
2
3
4
5
6
func SetupConfig(id clio.Identification) *clio.SetupConfig {
return clio.NewSetupConfig(id).
WithGlobalConfigFlag().
WithGlobalLoggingFlags().
WithConfigInRootHelp()
}

其中与配置加载直接相关的是:

  • WithGlobalConfigFlag() 注册全局 -c--config--profile
  • WithGlobalLoggingFlags() 注册 -v-q 等日志参数;
  • NewSetupConfig(id) 使用应用名 grype 创建 fangs 配置,因此环境变量统一以 GRYPE_ 开头;
  • WithConfigInRootHelp() 让根命令的 --help 能够展示完整的应用配置说明。

全局参数注册在 Cobra 的 PersistentFlags() 中,所以 -c--profile 不仅能用于扫描根命令,也能用于 grype configgrype db 等子命令。

DefaultGrype 先建立默认值

根命令在创建时调用:

1
opts := options.DefaultGrype(app.ID())

DefaultGrype() 返回一个已经设置默认值的配置对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
func DefaultGrype(id clio.Identification) *Grype {
return &Grype{
Search: defaultSearch(source.SquashedScope),
FixChannel: DefaultFixChannels(),
DatabaseCommand: DatabaseCommand{DB: DefaultDatabase(id)},
Match: defaultMatchConfig(),
ExternalSources: defaultExternalSources(),
CheckForAppUpdate: true,
SortBy: defaultSortBy(),
Timestamp: true,
Alerts: defaultAlerts(),
}
}

SortBy 为例,defaultSortBy() 从 Presenter 支持的策略中构造允许值,并将默认 Criteria 设置为 risk

1
2
3
4
5
6
7
8
9
10
11
type SortBy struct {
Criteria string `mapstructure:"sort-by"`
AllowableOptions []string `mapstructure:"-"`
}

func defaultSortBy() SortBy {
return SortBy{
Criteria: models.DefaultSortStrategy.String(),
AllowableOptions: strategies,
}
}

AllowableOptions 使用 mapstructure:"-",表示它是内部辅助字段,不从外部配置中加载。

结构体 tag 定义配置路径

options.Grype 是整个扫描命令的配置根对象。下面截取部分字段:

1
2
3
4
5
6
7
8
9
type Grype struct {
Outputs []string `yaml:"output" mapstructure:"output"`
OnlyFixed bool `yaml:"only-fixed" mapstructure:"only-fixed"`
Search search `yaml:"search" mapstructure:"search"`
Match matchConfig `yaml:"match" mapstructure:"match"`
FailOn string `yaml:"fail-on-severity" mapstructure:"fail-on-severity"`
SortBy SortBy `yaml:",inline" mapstructure:",squash"`
DatabaseCommand `yaml:",inline" mapstructure:",squash"`
}

mapstructure tag 决定 fangs 和 Viper 使用的配置路径,yaml tag 则用于输出可读配置。原始结构中也存在 json tag,这里为了突出主线省略了。

普通嵌套字段会形成多级路径。例如:

1
2
3
type search struct {
Scope string `mapstructure:"scope"`
}

它对应三种外部写法:

1
2
3
# YAML 配置
search:
scope: all-layers
1
2
3
4
5
# 环境变量:层级使用下划线连接
GRYPE_SEARCH_SCOPE=all-layers grype dir:.

# 命令行参数
grype dir:. --scope all-layers

SortBy 上的 mapstructure:",squash" 则表示不增加 sort-by 的父级,因此配置写成顶层 sort-by: risk,而不是 sort-by.criteria: risk

AddFlags 使用字段指针注册参数

GrypeSortBy 都实现了 AddFlags()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (o *Grype) AddFlags(flags clio.FlagSet) {
flags.StringArrayVarP(&o.Outputs,
"output", "o",
"report output formatter",
)

flags.StringVarP(&o.FailOn,
"fail-on", "f",
"set the return code to 2 when threshold is met",
)
}

func (o *SortBy) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&o.Criteria,
"sort-by", "",
"sort the match results",
)
}

这里最重要的是传入字段指针,例如 &o.Criteria。fangs 会记录 这个 flag 对应哪个配置字段,后面才能让 Viper 将命令行参数、环境变量和配置文件合并到同一位置。

fangs 的 FlagSet 不要求调用者再次传入默认值,而是直接读取字段当前值:

1
2
3
4
5
6
func (f *pflagSet) StringVarP(
p *string,
name, shorthand, usage string,
) {
f.flagSet.StringVarP(p, name, shorthand, *p, usage)
}

因此默认值只在 DefaultGrype() 中定义一次,Cobra flag 的默认值会自动与配置对象保持一致。

fangs 还会递归遍历嵌套结构体,所以根对象调用一次 AddFlags 后,SortBy.AddFlags()、数据库选项和其他子配置的 AddFlags() 也会被执行。

外部配置如何合并

配置对象和 flag 注册完成后,Cobra 开始执行命令。clio 将配置加载包装在 PreRunE 中,因此它一定发生在实际 RunE 之前。

clio 在 PreRunE 中调用 fangs.Load

clio 的 setupCommand() 会替换命令原有的 PreRunE

1
2
3
4
5
6
7
8
9
10
*fn = func(cmd *cobra.Command, args []string) error {
err := a.Setup(cfgs...)(cmd, args)
if err != nil {
return err
}
if original != nil {
return original(cmd, args)
}
return nil
}

a.Setup() 最终调用:

1
2
3
4
5
fangs.Load(
a.setupConfig.FangsConfig,
cmd,
allConfigs...,
)

这意味着执行顺序是:

1
2
3
4
5
6
7
8
9
10
11
Cobra 解析 flag


PreRunE
└── fangs.Load()
├── 合并外部配置
└── 执行 PostLoad()


RunE
└── runGrype()

如果配置加载或校验失败,RunE 不会执行,数据库和软件包收集也不会开始。

配置文件查找顺序

没有显式指定 -c 时,fangs 会按下面的顺序查找 Grype 配置:

1
2
3
4
5
./.grype.yaml
./.grype/config.yaml
~/.grype.yaml
~/.config/grype/config.yaml
/etc/xdg/grype/config.yaml

可以直接查看当前环境中的搜索位置:

1
grype config locations

如果使用 -c grype.yaml 显式指定文件,就不再搜索这些默认位置。Grype 支持多次使用 -c,fangs 会按照文件出现顺序合并;同一个标量键由靠前的文件优先,列表和 map 则会按规则合并。

也可以通过 GRYPE_CONFIG 指定配置文件,但命令行 -c 的优先级更高:

1
2
GRYPE_CONFIG=grype.yaml grype dir:.
grype -c grype.yaml dir:.

profile 先覆盖配置文件顶层值

fangs 读取配置文件后,会根据 --profile 找到 profiles 下的对应节点,再将 profile 内容覆盖到顶层配置。

对于前面的配置:

1
2
3
4
5
6
7
sort-by: package

profiles:
ci:
sort-by: severity
fail-on-severity: high
only-fixed: true

选择 profile:

1
grype -c grype.yaml --profile ci config --load

实际加载后的关键字段为:

1
2
3
only-fixed: true
fail-on-severity: 'high'
sort-by: 'severity'

profile 适合保存一组有共同含义的配置。例如开发者本地可以使用完整报告,CI profile 则只对已有修复的 High 及以上漏洞执行门禁。

profile 属于配置文件合并阶段,因此它仍会被更高优先级的环境变量或命令行参数覆盖。这就是前面 GRYPE_SORT_BY=kev--sort-by epss 能继续改变结果的原因。

环境变量如何映射嵌套字段

fangs 基于应用名设置环境变量前缀,并将配置路径中的点号和连字符替换为下划线:

1
2
3
4
sort-by                         → GRYPE_SORT_BY
search.scope → GRYPE_SEARCH_SCOPE
db.auto-update → GRYPE_DB_AUTO_UPDATE
match.javascript.using-cpes → GRYPE_MATCH_JAVASCRIPT_USING_CPES

因此,大部分配置项不需要手写环境变量绑定。只要结构体存在 mapstructure 路径,Viper 就可以按照统一规则生成对应名称。

对于 registry 凭据等需要兼容 Syft 或特殊处理的字段,Grype 也会在相应 PostLoad() 中读取专用环境变量。普通配置则遵循统一映射规则。

flag 为什么具有最高优先级

configureViper() 中,fangs 通过字段指针找到已经注册的 pflag,并调用 BindPFlag()

1
2
3
4
if flag, ok := flags[ptr]; ok {
err := viper.BindPFlag(path, flag)
return
}

Viper 合并时,显式设置的 flag 优先于环境变量和配置文件。需要注意,“显式设置”与“flag 自己带有默认值”不同:用户没有传入 --sort-by 时,配置文件仍然能够将默认 risk 覆盖为 package;只有真正传入 --sort-by epss 时,flag 才成为最高优先级值。

PostLoad 如何校验和转换配置

Viper 将外部数据反序列化到 Go 结构体后,配置还不能立即进入扫描流程。fangs 会递归查找实现 PostLoader 接口的对象并调用 PostLoad()

根配置负责跨字段处理

options.Grype.PostLoad() 处理根对象上的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (o *Grype) PostLoad() error {
o.From = flatten(o.From)

if o.FailOn != "" {
failOnSeverity := *o.FailOnSeverity()
if failOnSeverity == vulnerability.UnknownSeverity {
return fmt.Errorf(
"bad --fail-on severity value '%s'",
o.FailOn,
)
}
}
return nil
}

这里做了两件事:

  • --from docker,registry 这样的逗号分隔值展开为列表;
  • fail-on-severity 转换成内部 Severity,并拒绝未知值。

子配置负责自己的约束

嵌套的 SortBysearchDatabaseregistry 和 matcher config 也分别实现了 PostLoad()

SortBy 为例:

1
2
3
4
5
6
7
8
9
10
11
func (o *SortBy) PostLoad() error {
allowed := strset.New(o.AllowableOptions...)
if !allowed.Has(strings.ToLower(o.Criteria)) {
return fmt.Errorf(
"invalid sort-by criteria: %q (allowable: %s)",
o.Criteria,
strings.Join(o.AllowableOptions, ", "),
)
}
return nil
}

实际传入非法值:

1
grype -c grype.yaml --sort-by invalid dir:.

程序输出:

1
2
3
4
ERROR invalid application config: invalid sort-by criteria: "invalid"
(allowable: package, severity, epss, risk, kev, vulnerability)

exit_code=1

日志中没有 loading DBgathering packages,说明错误发生在 PreRunE 配置阶段,runGrype() 根本没有开始执行。

这种结构让每个子配置只关心自己的合法性。例如:

  • search.PostLoad() 校验 squashedall-layers 等 scope;
  • Database.PostLoad() 展开缓存目录中的 ~
  • RPM(RPM Package Manager)和 dpkg(Debian 系的软件包管理器)配置会校验缺失 epoch 时采用的版本比较策略;
  • registry config 检查认证方式和相关环境变量。

FieldDescriber 生成配置说明

options.Grype 还实现了 FieldDescriber

1
2
3
4
5
6
7
8
9
10
11
12
13
func (o *Grype) DescribeFields(
descriptions clio.FieldDescriptionSet,
) {
descriptions.Add(
&o.CheckForAppUpdate,
"enable/disable checking for application updates",
)
descriptions.Add(
&o.Exclusions,
"a list of globs to exclude from scanning",
)
// ...
}

这里同样使用字段指针,将说明文字与具体字段关联。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 拆分给各个模块:

配置 下游模块 作用
OutputsFilePretty format.MakeScanResultWriter() 创建报告 Writer
SearchRegistryExclusions getProviderConfig() 配置 Syft 和输入 Provider
DB LoadVulnerabilityDB() 配置数据库下载、缓存和校验
Match getMatcherConfig() 创建各软件生态 Matcher
IgnoreVexDocuments VulnerabilityMatcher 过滤或调整 Match
FailOn VulnerabilityMatcher.FailSeverity 执行严重级别门禁
SortBy.Criteria models.NewDocument() 决定报告排序

因此配置系统并不执行扫描逻辑,它的作用是为后面的领域模块准备类型明确且经过校验的参数。

debug 日志展示的是最终配置

clio 在 fangs.Load() 和全部 PostLoad() 完成后调用 logConfiguration()。因此 -vv 日志中的:

1
2
3
4
5
config:
...
only-fixed: true
fail-on-severity: high
sort-by: epss

展示的是最终生效值,而不是某一个配置文件的原始内容。排查“为什么某个选项没有生效”时,-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 集合。