0%

Grype 源码分析 04:从目录和镜像到软件包清单

前两篇文章从 main() 和配置系统开始,建立了 Grype 的顶层调用框架。runGrype() 完成配置加载后,第一个关键步骤就是调用 pkg.Provide(),将用户输入转换成漏洞匹配引擎可以直接使用的 []pkg.Package。这篇文章将分析 Grype 如何把 dir:docker:registry:sbom: 甚至一个 PURL 字符串统一为一个软件包集合。

为什么需要 Provider 链

Grype 接受多种输入形式。用户可以扫描本地目录:

1
grype dir:./my-project

也可以直接扫描一个容器镜像:

1
grype docker:alpine:latest

或者提供一份已经生成的 SBOM(Software Bill of Materials,软件物料清单)文件:

1
grype sbom:result.json

甚至只检查一个特定的软件包:

1
grype pkg:npm/lodash@4.17.20

这些输入本质上完全不同——目录是文件系统上的路径,镜像是 Docker daemon 或 registry 上的分层文件系统,SBOM 文件是 JSON 或 XML 文档,PURL(Package URL,软件包 URL)则是单纯的字符串。如果让 runGrype() 去判断"这是哪种输入",代码会变得冗长且难以扩展。

Grype 的解法是 Provider 链:将每种输入解析成独立的 Provider,然后按固定顺序尝试,直到某一个 Provider 声明 这个输入我可以处理。没有哪个 Provider 需要知道其他 Provider 的存在,新增输入类型只需要在链中增加一个环节。

Provider 链的调度过程

pkg.Provide() 是外部调用的入口函数。但它并不直接执行输入识别,而是先做一些准备工作,再将核心调度委托给内部的 provide()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) {
applyChannel := getDistroChannelApplier(config.Distro.FixChannels)
if config.Distro.Override != nil {
applyChannel(config.Distro.Override)
log.Infof("using distro: %s", config.Distro.Override.String())
}

packages, ctx, s, err := provide(userInput, config, applyChannel)
if err != nil {
return nil, Context{}, nil, err
}
setContextDistro(packages, &ctx)
// ...
packages = removePackagesByOverlap(packages)
return FromPtrs(packages), ctx, s, nil
}

入口函数有三层职责:准备 Distro 信息并应用到各 Provider;在 Provider 返回结果后补充发行版传播和去重;最后将结果从指针切片转为值切片。

而真正的 Provider 调度链位于 provide() 中,按固定顺序执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func provide(userInput string, config ProviderConfig, applyChannel func(d *distro.Distro) bool) ([]*Package, Context, *sbom.SBOM, error) {
packages, ctx, s, err := purlProvider(userInput, config, applyChannel)
if !errors.Is(err, errDoesNotProvide) {
return packages, ctx, s, err
}

packages, ctx, s, err = cpeProvider(userInput, config)
if !errors.Is(err, errDoesNotProvide) {
return packages, ctx, s, err
}

packages, ctx, s, err = syftSBOMProvider(userInput, config, applyChannel)
if !errors.Is(err, errDoesNotProvide) {
return packages, ctx, s, err
}

packages, ctx, s, err = zarfProvider(userInput, config, applyChannel)
if !errors.Is(err, errDoesNotProvide) {
return packages, ctx, s, err
}

// 最终回退到 Syft
return syftProvider(userInput, config, applyChannel)
}

这段代码的逻辑非常清晰:每个 Provider 接收同样的 userInput,如果不认识这个输入就返回 errDoesNotProvide,调度器则继续尝试下一个。一旦某个 Provider 返回的结果不是 errDoesNotProvide(无论成功还是失败),调度链立即终止。最后一个 syftProvider 没有返回 errDoesNotProvide 的检查,它是兜底的,因为 Syft 有能力处理目录、文件和镜像这三类最常见的输入。

可以把这个过程理解为一道流水线:

1
2
3
4
5
6
7
输入字符串

├── purlProvider → "这个字符串像 PURL 吗?"
├── cpeProvider → "这个字符串像 CPE 吗?"
├── syftSBOMProvider → "这个字符串指向 SBOM 文件吗?"
├── zarfProvider → "这个字符串指向 Zarf 包吗?"
└── syftProvider → "交给 Syft 识别 (目录/文件/镜像)"

这五个 Provider 各自如何判断输入是否属于自己的领域,是接下来分析的重点。

逐一解析五个 Provider

PURL Provider:直接描述单个软件包

PURL 是一套标准化的软件包标识方案,格式如 pkg:npm/lodash@4.17.20。PURL Provider 的判断逻辑最为简单直接:

1
2
3
4
5
6
7
8
9
10
11
func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) {
if strings.HasPrefix(userInput, singlePurlInputPrefix) {
ctx.Source = &source.Description{
Metadata: PURLLiteralMetadata{
PURL: userInput,
},
}
return strings.NewReader(userInput), ctx, nil
}
return nil, ctx, errDoesNotProvide
}

只要用户输入以 pkg: 开头,PURL Provider 就将这个字符串当作合法的 Syft SBOM 输入,调用 format.Decode() 让 Syft 去解析。这里利用了 Syft 的解码器能够识别 PURL 字符串并将其转换为一个最小化的 SBOM 对象。解析完成后,FromCollection() 再将 Syft Package 转换为 Grype Package。

PURL Provider 还引入了一个重要的模式——Enhancer。它将 PURL 作为 Pure string 传入 Syft,但有些信息 Syft 不会从纯 PURL 字符串中提取(例如上游包信息和发行版信息)。因此 PURL Provider 在转换时附加了 purlEnhancers()

1
2
return FromCollection(s.Artifacts.Packages, s.Relationships,
config.SynthesisConfig, purlEnhancers(applyChannel)...), ctx, s, nil

Enhancer 是一个函数类型:

1
type Enhancer func(out *Package, purl packageurl.PackageURL, pkg syftPkg.Package)

FromPackages() 中,每个 Syft Package 转换完成后,所有 Enhancer 都会依次执行,补充 PURL 中的上游包和 Distro 信息。这个模式使得转换过程对扩展开放——如果未来需要从 PURL 中提取更多数据,只需增加一个新的 Enhancer 即可。

CPE Provider:用标准化名称描述产品

CPE(Common Platform Enumeration,通用平台枚举)是另一种描述软件产品的标准,格式如 cpe:2.3:a:vendor:product:version:*:*:*:*:*:*:*。CPE Provider 的结构与 PURL Provider 几乎对称:

1
2
3
4
5
6
7
8
9
10
11
func getCPEReader(userInput string) (r io.Reader, ctx Context, err error) {
if strings.HasPrefix(userInput, cpeInputPrefix) {
ctx.Source = &source.Description{
Metadata: CPELiteralMetadata{
CPE: userInput,
},
}
return strings.NewReader(userInput), ctx, nil
}
return nil, ctx, errDoesNotProvide
}

同样借助 Syft 的 format.Decode() 将 CPE 字符串解析为最小 SBOM,再转换为 Grype Package。CPE Provider 不需要 Enhancer,因为 CPE 只表达产品标识,不含额外的 Distro 或上游信息。

SBOM Provider:解析已有的物料清单

SBOM Provider 处理的场景是用户已经提前生成了一份 SBOM 文件。它支持三种输入方式:

  • 通过管道重定向:cat sbom.json | grype
  • 使用 sbom: 前缀显式指定:grype sbom:./report.json
  • 直接给出 SBOM 文件路径:grype ./report.json

判断规则的入口在 getSBOMReader()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func getSBOMReader(userInput string) (io.ReadSeeker, string, error) {
switch {
case userInput == "":
r, err := stdinReader()
if err != nil {
return nil, "", err
}
return decodeStdin(r)

case explicitlySpecifyingPurlList(userInput):
// ...
case explicitlySpecifyingCPEList(userInput):
// ...
case explicitlySpecifyingSBOM(userInput):
filepath := strings.TrimPrefix(userInput, "sbom:")
return openFile(filepath)

case isPossibleSBOM(userInput):
return openFile(userInput)

default:
return nil, "", errDoesNotProvide
}
}

这里值得关注的是最后一个分支 isPossibleSBOM():当用户没有加上 sbom: 前缀,输入也不是 PURL 或 CPE 时,Grype 怎么判断一个文件路径指向的是普通文件还是 SBOM?答案是读取文件头部的 MIME 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func isPossibleSBOM(userInput string) bool {
f, path, err := openFile(userInput)
if err != nil {
return false
}
defer log.CloseAndLogError(f, path)

mType, err := mimetype.DetectReader(f)
if err != nil {
return false
}

return isAncestorOfMimetype(mType, "text/plain")
}

SBOM 文件的格式可以是 JSON(application/json)、XML(application/xml)或纯文本(text/plain),它们在 MIME 类型树上都属于 text/plain 的后代。如果文件不是文本格式(例如是一个二进制 ELF 文件),Grype 就认为这不是 SBOM,返回 errDoesNotProvide 让调度器跳过。

找到 SBOM 文件后,syftSBOMProvider() 调用 format.Decode() 解析 SBOM 内容。这里有一个细节值得注意:对于非 Syft JSON 格式的 SBOM(例如 SPDX),同样使用了 purlEnhancers() 来从 PURL 中补充信息。这是因为 Syft JSON 在序列化时已经保留了完整字段,而其他格式可能丢失了上游包或 Distro 数据。

Zarf Provider:解析 Zarf 包

Zarf 是用于气隙(airgap)环境部署的一种打包格式。Grype 对 Zarf 的支持通过 zarfProvider() 实现,它会识别 zarf 包格式并提取其中的 SBOM 信息,本质上也是一种 SBOM 解析的特化形式。

Syft Provider:面向目录和镜像的通用编目

前面四个 Provider 都无法处理用户输入时,调度器进入最后的 syftProvider()。这正是最常见的情况——扫描目录或容器镜像:

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
func syftProvider(userInput string, config ProviderConfig, applyChannel func(*distro.Distro) bool) ([]*Package, Context, *sbom.SBOM, error) {
src, err := getSource(userInput, config)
if err != nil {
return nil, Context{}, nil, err
}
defer log.CloseAndLogError(src, "syft source")

s, err := syft.CreateSBOM(context.Background(), src, config.SBOMOptions)
if err != nil {
return nil, Context{}, nil, err
}

if s == nil {
return nil, Context{}, nil, errors.New("no SBOM provided")
}

srcDescription := src.Describe()
d, distroDetectionFailed := distroFromSBOM(s, config, applyChannel)
packages := FromCollection(s.Artifacts.Packages, s.Relationships, config.SynthesisConfig)
pkgCtx := Context{
Source: &srcDescription,
Distro: d,
DistroDetectionFailed: distroDetectionFailed,
}

return packages, pkgCtx, s, nil
}

这一小段代码浓缩了 Grype 与 Syft 的完整协作流程,可以拆成三步:创建 Source、编目生成 SBOM、转换软件包。理解这三步之前,需要先理解 Grype 和 Syft 之间到底是什么关系。

Grype 与 Syft 的分工

Grype 和 Syft 是 Anchore 开源生态中的两个独立工具。Syft 负责软件组成分析——给定一个目标,识别其中包含的所有软件包并生成 SBOM。Grype 负责漏洞扫描——获取一份软件包清单和一个漏洞数据库,找出哪些包受哪些漏洞影响。

因此 Grype 并不重新实现软件包编目逻辑(它不需要关心怎么从 package-lock.json 中提取 npm 包、怎么从 APK_INDEX 中解析 Alpine 包),而是直接调用 Syft 作为库。两者的分工如下:

  • Syft:回答"这个目标里有哪些软件包";
  • Grype:回答"这些软件包里有哪些漏洞"。

Grype 调用 Syft 生成 SBOM 后,再将 Syft 的 Package 模型转换为自己的 pkg.Package。这个转换的必要性在于:Syft 的 Package 包含了大量编目细节(文件证据、分类器等),而 Grype 只关心漏洞匹配所需的核心字段——包名、版本、类型、PURL、CPE 和 Distro 信息。

创建 Source:输入类型的关键识别

调用 Syft 的第一步,是用 getSource() 将用户输入字符串转换为 Syft 的 source.Source 接口。Source 是 Syft 中对扫描目标的抽象,它统一了目录和镜像两种完全不同的数据来源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func getSource(userInput string, config ProviderConfig) (source.Source, error) {
if config.SBOMOptions.Search.Scope == "" {
return nil, errDoesNotProvide
}

sources := config.Sources
if len(sources) == 0 {
schemeSource, newUserInput := stereoscope.ExtractSchemeSource(userInput, allSourceTags()...)
if schemeSource != "" {
sources = []string{schemeSource}
userInput = newUserInput
}
}

return syft.GetSource(context.Background(), userInput, syft.DefaultGetSourceConfig().
WithSources(sources...).
WithDefaultImagePullSource(config.DefaultImagePullSource).
WithAlias(source.Alias{Name: config.Name}).
WithRegistryOptions(config.RegistryOptions).
WithPlatform(platform).
WithExcludeConfig(source.ExcludeConfig{Paths: config.Exclusions}))
}

这里面有两个输入来源。首先是 --from 参数(对应 config.Sources),它显式告诉 Syft 以什么身份去解释输入:

1
grype python:3.13.2-alpine3.21 --from registry

如果用户没有设置 --from,Grype 会使用 stereoscope(Anchore 的容器镜像分析库)从用户输入中提取 scheme 前缀。常见的 scheme 包括:

scheme 含义 Syft 行为
dir: 本地目录 直接扫描文件系统
file: 本地文件 扫描指定文件
docker: Docker daemon 镜像 从 Docker daemon 拉取
registry: 镜像仓库 从 registry 拉取
podman: Podman 镜像 从 Podman 拉取
image: 通用镜像 按配置顺序尝试各 source
无前缀 可能是目录或文件 按配置顺序尝试各 source

scheme 识别完成后,Grype 将清理后的输入和 source 列表一起交给 syft.GetSource()。Syft 内部维护了一个 Source Provider 注册表(类似 Grype 自己的 Provider 链),按顺序尝试用不同 Provider 去"打开"这个输入,直到某一个成功返回一个可用的 Source 对象。

如果所有 Provider 都失败,Syft 返回错误,Grype 的 Provider 链也因此终止,pkg.Provide() 向上传播这个错误。errDoesNotProvide 的终止条件保证了调度链只在"这个输入不属于我"时继续向前,而一旦出现"我试图处理但失败了"的错误,整个链路就会停下来。

编目生成 SBOM

拿到 Source 后,Syft 调用 CreateSBOM() 执行实际的软件包编目:

1
s, err := syft.CreateSBOM(context.Background(), src, config.SBOMOptions)

CreateSBOM() 会根据 Source 的类型和配置,选择合适的 Cataloger(编目器)集合去遍历文件系统或镜像层。例如,对于 npm 项目,JavaScript cataloger 会解析 package-lock.jsonnode_modules;对于 Alpine 镜像,apk cataloger 会解析 /lib/apk/db/installed。完成编目后,Syft 返回一个 sbom.SBOM 对象,其中的 s.Artifacts.Packages 就是发现的全部软件包。

这个过程中还有一个小细节:config.SBOMOptions 承载了 Grype 的配置对 Syft 编目行为的影响,例如编目范围。Grype 没有直接操作 Syft 的 Cataloger 列表,而是通过配置告诉 Syft 应该怎么做,保持了两层之间的清晰边界。

识别 Linux 发行版

SBOM 生成后,Grype 从 SBOM 的 LinuxDistribution 字段中提取发行版信息:

1
2
3
4
5
6
7
8
9
10
func distroFromSBOM(s *sbom.SBOM, config ProviderConfig, applyChannel func(*distro.Distro) bool) (d *distro.Distro, detectionFailed bool) {
if config.Distro.Override != nil {
d = config.Distro.Override
} else {
d = distro.FromRelease(s.Artifacts.LinuxDistribution, config.Distro.FixChannels)
applyChannel(d)
detectionFailed = s.Artifacts.LinuxDistribution != nil && d == nil
}
return d, detectionFailed
}

发行版识别是漏洞匹配的前置条件——不同发行版对同一个软件包使用不同的版本命名和补丁策略,漏洞数据库也是按发行版组织的。Syft 从 /etc/os-release 等文件中读取发行版信息,Grype 将其转换为内部的 distro.Distro 对象,并应用发行版特定的 fix channel。

需要注意,DistroDetectionFailed 是一个重要信号:如果 Syft 在文件系统中找到了 os-release 文件(说明这确实是一个 Linux 系统),却无法识别发行版类型(例如是一个未被收录的小众发行版),Grype 会将这个状态标记出来供下游告警使用。

Syft Package 到 Grype Package 的模型转换

SBOM 中的 Syft Package 不能直接用于漏洞匹配,Grype 需要将其转换为自己的 pkg.Package。这个转换在 FromCollection()FromPackages() 中完成。

Grype Package 的结构

先来看转换的目标——Grype 的 Package 结构体只保留漏洞匹配关心的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Package struct {
ID ID
Name string
Version string
Locations file.LocationSet
Language syftPkg.Language
Distro *distro.Distro
Licenses []string
Type syftPkg.Type
CPEs []cpe.CPE
PURL string
Upstreams []UpstreamPackage
Metadata any
Annotations map[string][]string
RelatedPackages map[artifact.RelationshipType][]*Package
}

与 Syft 原生的 Package 相比,Grype 版本省略了文件证据、分类器元数据、依赖关系图等编目过程才需要的信息,但增加了用于漏洞匹配的关键内容:

  • Upstreams:记录上游源码包信息。Debian 系的二进制包可能来自不同于包名的源码包(例如 libssl1.1 的上游是 openssl),漏洞数据是按源码包名组织的。如果没有这个字段,Matcher 就无法将二进制包关联到正确的漏洞记录;
  • Metadata:根据包类型提取的元数据,只保留匹配相关的部分。例如 RPM 包的 epoch 和 modularity label(影响版本比较),Java 包的 POM group ID 和 artifact ID(影响 CPE 匹配),但不会保留 Syft 中完整的 RpmDBEntry 结构;
  • RelatedPackages:记录包之间的关系(OwnershipByFileOverlap、Contains 等),用于后续的去重和关联匹配;
  • Annotations:携带 Provider 附加的键值对信息,支持同一包来自多个来源时聚合。

从 Syft Package 构造 Grype Package

New() 函数完成单个包的转换:

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
func New(p syftPkg.Package, enhancers ...Enhancer) Package {
metadata, upstreams := dataFromPkg(p)

out := Package{
ID: grypeID(p),
Name: p.Name,
Version: p.Version,
Locations: p.Locations,
Licenses: licenses,
Language: p.Language,
Type: p.Type,
CPEs: p.CPEs,
PURL: p.PURL,
Upstreams: upstreams,
Metadata: metadata,
}

if len(enhancers) > 0 {
purl, _ := packageurl.FromString(p.PURL)
for _, e := range enhancers {
e(&out, purl, p)
}
}

return out
}

大部分字段是直接映射的——名称、版本、类型、PURL 不需要转换。真正的转换工作发生在 dataFromPkg() 中,这个函数根据 Syft Package 的 Metadata 类型,提取 Grype 关心的结构化数据。

以 Debian 包为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
func dpkgDataFromPkg(p syftPkg.Package) (upstreams []UpstreamPackage) {
switch value := p.Metadata.(type) {
case syftPkg.DpkgDBEntry:
if value.Source != "" {
upstreams = append(upstreams, UpstreamPackage{
Name: value.Source,
Version: value.SourceVersion,
})
}
// ...
}
return upstreams
}

Debian 系的二进制包在 DpkgDBEntry 中记录了 Source 字段——一个如 openssl 的源码包名。Grype 将其提取为 Upstream,后面的 Matcher 就可以按源码包名去漏洞数据库中查询。

RPM 包的转换则更复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func rpmDataFromPkg(p syftPkg.Package) (metadata *RpmMetadata, upstreams []UpstreamPackage) {
switch m := p.Metadata.(type) {
case syftPkg.RpmDBEntry:
if m.SourceRpm != "" {
upstreams = handleSourceRPM(p.Name, m.SourceRpm)
}
if m.Epoch != nil || m.ModularityLabel != nil {
metadata = &RpmMetadata{
Epoch: m.Epoch,
ModularityLabel: m.ModularityLabel,
}
}
}
return metadata, upstreams
}

RPM 的 SourceRpm 字段(例如 openssl-1.1.1k-6.el8_5.src.rpm)需要正则解析才能提取出上游包名和版本,同时 epoch 和 modularity label 是版本比较的必要参数。

CPE 的补充生成

转换过程中还有一个对漏洞匹配至关重要的处理——当 Syft Package 没有 CPE 时,Grype 可以选择自动生成:

1
2
3
4
5
if len(p.CPEs) == 0 {
if config.GenerateMissingCPEs {
p.CPEs = cpes.Generate(p)
}
}

这是由于某些 SBOM 格式(比如 SPDX)可能不包含 CPE 信息,但 CPE 是漏洞数据库的重要查询维度。Grype 通过 Syft 的 cpes.Generate() 根据包名、版本和厂商信息推导出可能的 CPE,确保后面的 CPE 匹配路径不会因为缺少数据而漏报。

包关系的重建

FromPackages() 不仅转换单个包,还会利用 Syft 输出的 artifact relationships 来建立包与包之间的关联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for _, r := range relationships {
if !retainRelationshipType(r.Type) {
continue
}
fromPkg, fromOK := pkgByID[grypeID(r.From)]
toPkg, toOK := pkgByID[grypeID(r.To)]
if !fromOK || !toOK {
continue
}

if invertRelationship(r.Type) {
fromPkg, toPkg = toPkg, fromPkg
}
fromPkg.RelatedPackages[r.Type] = append(fromPkg.RelatedPackages[r.Type], toPkg)
}

Grype 只保留了两种关系类型:Contains(A 包含 B)和 OwnershipByFileOverlap(A 的文件覆盖了 B 的文件)。前者用于表达容器层之间的包含关系和多层镜像扫描;后者是去重的关键依据——当同一个文件同时被 deb 包和 python 包声明时,Grype 需要决定保留哪一个。

特别地,当 SBOM 中缺少 OwnershipByFileOverlap 关系时(某些 SBOM 格式不记录这个关系),Grype 会通过 FileOwner 接口来自动重建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if !hasOverlapRelationships && len(ownedLocations) > 0 {
for _, p := range pkgs {
for _, loc := range p.Locations.ToUnorderedSlice() {
if contained, ok := ownedLocations[loc.RealPath]; ok {
for _, ownerPackage := range contained {
if ownerPackage == p {
continue
}
p.RelatedPackages[artifact.OwnershipByFileOverlapRelationship] =
append(p.RelatedPackages[artifact.OwnershipByFileOverlapRelationship], ownerPackage)
}
}
}
}
}

这段逻辑的思路是:如果某些包声称自己"拥有"某些文件(通过 FileOwner 接口),而其他包的位置也正好在这组文件里,那么它们之间存在文件重叠关系。通过在内存中重建这个关系,Grype 保证了去重逻辑在不完整的 SBOM 中也能正常工作。

发行版信息的传播

Provider 返回软件包后,Provide() 还有一项重要工作:确保每个包都关联了正确的 Linux 发行版信息。

从软件包推断发行版

如果 Context 层面还没有 Distro 信息(例如当输入是一个不含 os-release 的 SBOM 时),Grype 会尝试从软件包自身推断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func setContextDistro(packages []*Package, ctx *Context) {
if ctx.Distro != nil {
return
}
var singleDistro *distro.Distro
for _, p := range packages {
if p.Distro == nil {
continue
}
if singleDistro == nil {
singleDistro = p.Distro
continue
}
if singleDistro.Type != p.Distro.Type ||
singleDistro.Version != p.Distro.Version ||
singleDistro.Codename != p.Distro.Codename {
return
}
}
if singleDistro != nil {
ctx.Distro = singleDistro
}
}

这个函数的策略是:如果所有包都声明了同一个发行版,就将它提升为 Context 级别的 Distro。但如果存在两个不同发行版的包(例如一个混合了 Alpine 和 Debian 软件包的容器),则放弃设置。

将发行版写回每个包

反过来,如果 Context 已经有了 Distro 但某些包缺少这个字段,Grype 会反过来将 Context 的 Distro 写回包上:

1
2
3
4
5
6
7
if ctx.Distro != nil {
for i := range packages {
if packages[i].Distro == nil {
packages[i].Distro = ctx.Distro
}
}
}

这个双向传播保证了:无论发行版信息来自 os-release 还是某个包的 PURL,最终每个包都能获得一致的 Distro 引用,后续 Matcher 无需关心信息来源。

重叠软件包的去重

最后一个关键步骤是去除因文件重叠而冗余的软件包。这个问题的根源是:在容器镜像中,同一个文件可能同时被操作系统包管理器和一个语言包管理器声明。例如,/usr/lib/python3.9/site-packages/... 下的文件既属于 python3.9 deb 包,也被 Python 包管理器识别为 pip 包。

removePackagesByOverlap() 从 RelatedPackages 中读取 OwnershipByFileOverlap 关系并执行去重:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func removePackagesByOverlap(pkgs []*Package) []*Package {
return slices.DeleteFunc(pkgs, func(p *Package) bool {
if p.RelatedPackages == nil {
return false
}
overlapping := p.RelatedPackages[artifact.OwnershipByFileOverlapRelationship]
for _, from := range overlapping {
if excludePackage(p, from) {
return true
}
}
return false
})
}

哪些包应该被移除?excludePackage() 实现了判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func excludePackage(p *Package, parent *Package) bool {
// 版本不一致,两者都保留
if !strings.HasPrefix(parent.Version, p.Version) &&
!strings.HasPrefix(p.Version, parent.Version) {
return false
}

// 父包是 OS 包,子包不是,且该发行版漏洞数据完整——移除子包
if distroFeedIsComprehensive(parent.Distro) && isOSPackage(parent) && !isOSPackage(p) {
return true
}

// 无论如何,移除二进制包
if p.Type != syftPkg.BinaryPkg {
return false
}
return true
}

去重的策略分三层:

  1. 版本一致性检查:如果两个重叠包的版本前缀不匹配(例如 deb 包版本是 3.9.2-1 而 Python 包版本是 3.9.2),说明它们可能代表不同的版本,两者都保留;
  2. OS 包优先:当发行版的漏洞 Feed 足够全面(即提供了包括 not-fixed 在内的完整漏洞状态),OS 包更适合用于匹配,因为发行版维护者会追踪每个包的补丁状态。这时子包(语言包)会被移除,因为它的漏洞信息已经由 OS 包的表覆盖;
  3. 移除二进制包:不论发行版 Feed 是否全面,纯二进制包总是被移除——它们不携带上游信息,无法有效匹配漏洞。

这里的"全面发行版"列表 comprehensiveDistros 是从漏洞数据库中提取的——只有那些漏洞记录中包含 wont-fixnot-fixed 状态的发行版才被认为"全面"。目前包括 Debian、Red Hat、Ubuntu、Arch Linux、Azure、Mariner 等。

回到 runGrype:素材准备完毕

至此,pkg.Provide() 返回了三个结果:

  • []pkg.Package:漏洞匹配引擎的输入;
  • pkg.Context:源信息和发行版信息,供报告生成使用;
  • *sbom.SBOM:完整的 Syft SBOM 对象,供下游需要完整物料清单的场景使用。

runGrype() 中,pkg.Provide()LoadVulnerabilityDB()parallel() 中并发执行。当两者都返回后,扫描流程具备了漏洞匹配的两个必要条件:软件包集合和漏洞数据库。

小结

这篇文章沿着 pkg.Provide() 的 Provider 调度链,分析了 Grype 如何将 PURL、CPE、SBOM 文件、容器镜像和本地目录这五种不同的输入,统一为一个软件包集合。理解这个链路后,下一篇将进入 Grype 的核心数据模型,分析 Package、Vulnerability、Match 和 Context 之间的关系,以及一次漏洞匹配为什么可以产生多条 Match。