深入 syft 的 ELF 二进制包目录器:从 elf-testdata 测试夹具看 .note.package 解析与 SBOM 包级依赖关系构建
【免费下载链接】syftCLI tool and library for generating a Software Bill of Materials from container images and filesystems项目地址: https://gitcode.com/GitHub_Trending/sy/syft
导读
本文以 syft 仓库中的 elf-testdata 测试夹具文档 为主线,讲解 syft 如何从 ELF 可执行文件与共享库的.note.package段中提取包元数据(名称、版本、许可证、PURL、CPE 等),并据此在 SBOM 中建立"包到包"(package-to-package)而非"包到文件"(package-to-file)的依赖关系。读完本文,你将掌握 syft ELF 二进制包目录器的底层原理、该测试夹具的构建与验证方法,以及一个真实仓库中"逻辑包聚合 + 依赖关系表达"的完整设计思路。
一、背景:syft 的二进制包目录器与.note.package段
syft 是一个从容器镜像与文件系统生成软件物料清单(SBOM)的 CLI 工具与库。对于 ELF 格式的可执行文件与共享库,syft 提供了一套专门的目录器(cataloger),其核心实现位于 elf_package_cataloger.go,注册名为elf-binary-package-cataloger(见 Name() 方法)。
该目录器的工作思路是:读取 ELF 文件中的.note.package段。该段通常由打包工具(如 RPM、dpkg、systemd 生态下的构建工具链)注入,携带一段 JSON 格式的包元数据,其规范背景可追溯到 systemd 的 ELF_PACKAGE_METADATA 设计。源码注释中明确指出(见 elf_package_cataloger.go):当前实现只针对.note.package段中单一的 JSON 数据形态,未来需要泛化以支持非 JSON 数据(例如 Fedora 会在 JSON 载荷前附加一个 ELF section header 前缀——这也正是源码中getELFNotes对"段头前缀 + JSON"双格式兼容处理的由来,见 elf_package_cataloger.go)。
为了持续验证这套解析与关系建模逻辑,仓库在 testdata/elf-testdata 目录下维护了一组专门构造的 ELF 测试夹具,配套的 README 文档正是本文的主体素材。
二、elf-testdata 测试夹具的目录结构与构建方式
2.1 三个测试场景
elf-testdata目录下包含三个独立的构造场景:
syft/pkg/cataloger/binary/testdata/elf-testdata/ ├── elfbinwithnestedlib/ # 嵌套共享库场景:库位于 bin/lib 子目录 ├── elfbinwithsisterlib/ # "姊妹库"场景:elfsrc1 与 elfsrc2 两个源码目录 ├── elfbinwithcorrupt/ # 损坏/异常场景 ├── Dockerfile # 一键构建镜像的 Dockerfile └── README.mdREADME 明确说明该夹具的用途:"This image illustrates a few examples of how ELF executables can be assembled and illustrated in an SBOM.",即演示 ELF 可执行文件如何在 SBOM 中被装配与表达。
2.2 用 objcopy 注入包元数据:夹具的核心构造技巧
每个场景的源码目录中都包含makefile、*.cpp与*.h文件。以 elfbinwithnestedlib/elfsrc/makefile 为例,其核心构造逻辑分两步:
- 编译共享库:使用
g++ -std=c++17 -Wall -Wextra -pedantic编译hello_world.cpp为位置无关目标文件,再链接为libhello_world.so; - 注入元数据:通过
objcopy将一段 JSON 字符串写入.note.package段:
echo '{"type": "testfixture","license":"MIT","commit":"5534c38d0ffef9a3f83154f0b7a7fb6ab0ab6dbb","sourceRepo":"https://github.com/someone/somewhere.git","vendor": "syft","system": "syftsys","name": "libhello_world.so","version": "0.01","purl": "pkg:generic/syftsys/syfttestfixture@0.01","cpe": "cpe:/o:syft:syftsys_testfixture_syfttestfixture:0.01"}' | objcopy --add-section .note.package=/dev/stdin --set-section-flags .note.package=noload,readonly $@关键点:--add-section .note.package=/dev/stdin从标准输入读取 JSON 并作为新段写入;--set-section-flags .note.package=noload,readonly将该段标记为不加载(noload)、只读(readonly),这保证注入的元数据段不会在程序加载时占用内存,与真实发行版的做法一致。可执行文件本身也会注入一段内容几乎相同、但name为syfttestfixture的 JSON(见同一 makefile 中$(BIN_DIR)/$(EXECUTABLE)目标)。
这正对应了目录器的数据模型:elfBinaryPackageNotes(见 elf_package_cataloger.go)中的字段与上述 JSON 一一对应:
| JSON 字段 | Go 结构体字段 | 说明 |
|---|---|---|
name | Name | 包名 |
version | Version | 版本号 |
license | License | 许可证(如MIT) |
purl | PURL | 包 URL 标识 |
cpe | CPE | CPE 标识 |
type/vendor/system/sourceRepo/commit | pkg.ELFBinaryPackageNoteJSONPayload(内嵌) | 类型、厂商、系统、源码仓库、提交哈希 |
osCpe | CorrectOSCPE | 与 systemd ELF 包元数据规范对齐的 OS CPE(大小写修正版) |
2.3 多阶段 Docker 构建:模拟真实 RPM 系统环境
Dockerfile 采用两阶段构建:
- 第一阶段(base):基于
rockylinux:8,安装make automake gcc gcc-c++ kernel-devel等编译工具链,随后将三个场景源码 COPY 到/usr/local/bin/elftests/下并逐个make,并通过ENV LD_LIBRARY_PATH=/usr/local/bin/elftests/elfbinwithnestedlib/bin/lib保证运行时能找到嵌套库; - 第二阶段:基于
busybox:1.36.1-musl精简镜像,仅保留编译产物与运行依赖——/usr/local/bin/elftests、RPM 数据库/var/lib/rpm、libstdc++.so.6与libc.so.6,并执行ln -s /usr/lib64 /lib64创建符号链接,专门用于验证 syft 能正确处理符号链接路径(注释写明 "prove we can operate over symlinks")。
RPM 数据库与 glibc/libstdc++ 的保留并非偶然:README 的"期望状态"部分明确提出,除二进制与共享库之间的依赖外,还希望表达二进制与由共享库带来的 RPM 包传递依赖(如glibc、libstdc++)之间的关系,这一点在下方第 4 节的关系图中会再次体现。
三、案例详解:elfbinwithsisterlib 与"同名库"的聚合
3.1 场景构成
README 的 Example 1(elf-testdata/elfbinwithsisterlib)构建了两个二进制:
elfsrc1/编译出elfwithparallellibbin1与共享库libhello_world.so;elfsrc2/编译出elfwithparallellibbin2与共享库libhello_world2.so。
三个共享库中,两个同名(libhello_world.so)、一个不同名(libhello_world2.so),且位于不同目录(nested/bin/lib/与sister/lib/)。README 特别指出:尽管位置不同,它们的.note.package段内容输出一致,因此它们应当被聚合成同一个逻辑包。
3.2 用 objdump 验证注入结果
README 提供了用objdump验证元数据段内容的标准手段:
objdump -s -j .note.package /usr/local/bin/elftests/elfbinwithnestedlib/bin/lib/libhello_world.so输出显示该段为elf64-littleaarch64格式(此处以 aarch64 文件为例),十六进制内容可还原为 JSON 明文:{"type": "testfixture","license":"MIT","commit":"5534c38d0ffef9a3f83154f0b7a7fb6ab0ab6dbb","sourceRepo":"https://github.com/someone/somewhere.git","vendor": "syft","system": "syftsys","name": "libhello_world.so","version": "0.01","purl": "pkg:generic/syftsys/syfttestfixture@0.01","cpe": "cpe:/o:syft:syftsys_testfixture_syfttestfixture:0.01"}。
这条验证命令是测试夹具设计的关键一环:它确保"段内容可被外部工具读取、且字段与目录器解析逻辑对齐",使夹具不仅能驱动 syft 测试,也能被人工审计。
3.3 产物清单与期望关系
README 给出的最终产物清单如下:
Binaries(可执行文件)
/usr/local/bin/elftests/elfbinwithnestedlib/bin/elfbinwithnestedlib /usr/local/bin/elftests/elfbinwithsisterlib/bin/elfwithparallellibbin2 /usr/local/bin/elftests/elfbinwithsisterlib/bin/elfwithparallellibbin1Libraries(共享库)
/usr/local/bin/elftests/elfbinwithnestedlib/bin/lib/libhello_world.so /usr/local/bin/elftests/elfbinwithsisterlib/lib/libhello_world.so /usr/local/bin/elftests/elfbinwithsisterlib/lib/libhello_world2.soBinaries related to Libraries(期望的二进制→库关系)
elfbinwithnestedlib -> libhello_world.so elfwithparallellibbin2 -> libhello_world.so elfwithparallellibbin1 -> libhello_world2.so注意最后一行是 README 原文的期望关系(elfwithparallellibbin1 -> libhello_world2.so),结合后文源码测试可以发现,实际目录器是按"动态库导入 + 同名聚合"来统一建模的,具体见第 5 节。
四、实际状态与期望状态:两张关系图的背后含义
README 的核心诉求在"Desired State"一节中表达得非常明确:
We want to drop the package to file relationships and instead do package to package.
即:放弃"包到文件"关系,改为"包到包"关系。期望的单条关系是:
ElfPackage libhello_world.so -> ElfPackage syfttestfixture(共享库)同时还要表达二进制与 RPM 包传递依赖之间的关系。
4.1 实际状态(Actual State)
README 用 mermaid 图描述了未经聚合时的实际状态——每个二进制/库节点指向其直接导入的共享库,最终收敛到系统库libc.so.6与libstdc++.so.6:
这张图展示了实际扫描时发现的完整导入网络:3 个二进制、3 个共享库(其中libhello_world.so出现于两个不同位置)、2 个系统库,共 11 个节点与 12 条 import 边。
4.2 期望状态(Desired Relationships)
期望的关系图将分散的文件聚合为两个逻辑包,再表达包间依赖:
该图传达三条核心设计意图:
- 文件→逻辑包:3 个可执行文件聚合为
syfttestfixture包(因为它们的.note.package中name均为syfttestfixture);3 个共享库聚合为libhello_world.so包; - 包间依赖:
libhello_world.so包是syfttestfixture应用包的依赖(dependency-of); - RPM 传递依赖:
glibc同时是应用包与库包的依赖,libstdc++是应用包的依赖——这正是 Dockerfile 中保留/var/lib/rpm与系统库的原因,用于让测试夹具能同时驱动 RPM 目录器产生这些传递依赖节点。
五、源码印证:目录器如何实现"同名聚合"与"逻辑包"
5.1 两遍扫描与按键聚合
elf_package_cataloger.go 的Catalog方法采用两遍扫描策略:
- 第一遍:通过
resolver.FilesByMIMEType按 MIME 类型(mimetype.ExecutableMIMETypeSet)筛选出所有可执行文件,逐个解析.note.package段;解析成功的文件按elfPackageKey(Name/Version/PURL/CPE 四元组,见 elf_package_cataloger.go)聚合到notesByLocation映射中; - 第二遍:对每个 key 下的所有 note,将它们的 Location 合并进同一个
file.NewLocationSet(),并为每个 note 标注pkg.PrimaryEvidenceAnnotation证据注解,最后基于第一个 note 生成一个逻辑包。
源码注释(elf_package_cataloger.go)明确指出这样设计的原因:可能存在多个 ELF 二进制具有相同的 name 与 version,它们集合起来才共同代表一个逻辑包——这与 README 中"三个位置的同名库应聚合成一个包"的诉求完全对应。
5.2 包对象的组装:newELFPackage 与 PURL 生成
elf_package.go 中的newELFPackage将解析出的元数据组装为pkg.Package:
Name、Version取自 note;Licenses由metadata.License构建(测试中可看到MIT被解析为 SPDX 表达式MIT、类型为declared);PURL由elfPackageURL动态生成;Type由元数据中的type字段映射:rpm→pkg.RpmPkg、deb→pkg.DebPkg、apk→pkg.ApkPkg、alpm→pkg.AlpmPkg,其余回落为pkg.BinaryPkg(见 elf_package.go);- 元数据主体存入
Metadata(pkg.ELFBinaryPackageNoteJSONPayload)。
elfPackageURL(见 elf_package.go)还会根据type与 OS 信息构造带distro限定符的 PURL:OS 信息优先取元数据中的os/osVersion字段,缺失时回退解析osCpe(见osNameAndVersionFromMetadata,elf_package.go)。以本夹具为例,purl字段被直接注入为pkg:generic/syftsys/syfttestfixture@0.01。
5.3 测试用例中的期望输出
elf_package_cataloger_test.go 的Test_ELFPackageCataloger中,名为 "go case" 的用例直接使用elf-testdata夹具,其期望输出精确验证了聚合逻辑:
- 包 1:
libhello_world.so@0.01,PURL 为pkg:generic/syftsys/libhello_world.so@0.01,LocationSet 包含 3 个路径(nested 的bin/lib/libhello_world.so、sister 的lib/libhello_world.so、sister 的lib/libhello_world2.so)——即三个不同路径的同名/同元数据库被聚合为一个包; - 包 2:
syfttestfixture@0.01,LocationSet 包含 3 个可执行文件路径,且每个 Location 都带PrimaryEvidenceAnnotation——三个二进制聚合为一个应用逻辑包。
同一测试文件中的 fedora / debian / wolfi 用例则展示了生产环境的真实形态:coreutils的 note 元数据(type: rpm、Architecture、OSCPE)被映射为pkg:RpmPkg与带?distro=限定符的 PURL。
5.4 依赖关系的落点:交给最终关系任务
值得注意的设计细节:elfPackageCataloger.Catalog的返回值中relationships 恒为 nil。源码注释(elf_package_cataloger.go)解释了原因:二进制目录器会记录每个二进制导入的动态库,但包到包、包到文件的关系不在目录器内直接生成,而是由扫描管线末端的专门任务(见 syft/internal/task/relationship_tasks.go,以及 elf_package_cataloger.go 的注释)基于"每个二进制导入的动态库集合"统一创建。这保证了关系建模的全局一致性——README 中"Actual State"导入网络图所呈现的多对多关系,正是这一最终任务依据ldd式动态库导入信息(含libc.so.6、libstdc++.so.6这类系统库)绘制出来的。
5.5 健壮性保障:elfutil 的节大小上限
目录器解析 ELF 时并非直接使用 Go 标准库debug/elf,而是通过 syft/internal/elfutil/elfutil.go 提供的受控 ELF 打开器。该包专门防御"压缩节声明了不合理的解压后大小"导致的 Go 内存爆炸问题(maxDeclaredSectionSize为 128MB/节,见 elfutil.go):对.note.package这类 syft 实际会读取的节做解压大小上限约束,超限时拒绝展开并返回ErrDeclaredSizeExceeded,宁可跳过该文件也不让整个扫描 OOM。这为.note.package解析链路提供了关键的安全边界。
六、如何复现与验证
6.1 构建测试镜像
在 elf-testdata 目录 下执行:
docker build -t syft/elf-testdata .构建完成后,镜像内/usr/local/bin/elftests/即包含三个场景的完整产物(可执行文件、共享库、RPM 数据库与系统库)。
6.2 用 syft 扫描并核对 SBOM
# 扫描镜像 syft scan syft/elf-testdata -o syft-json # 扫描本地目录(等价) syft scan /usr/local/bin/elftests -o syft-json对照测试期望(elf_package_cataloger_test.go)检查输出中的libhello_world.so@0.01与syfttestfixture@0.01两个包,以及它们与glibc、libstdc++之间的依赖关系。
6.3 用 objdump 独立校验注入段
objdump -s -j .note.package /usr/local/bin/elftests/elfbinwithnestedlib/bin/lib/libhello_world.so比对十六进制输出与 makefile 中注入的 JSON,即可确认夹具元数据与目录器解析假设的一致性。
6.4 运行目录器测试
# 仅运行 elf-testdata 相关用例(默认使用已入库的 fixture) go test -v ./syft/pkg/cataloger/binary -run Test_ELFPackageCataloger关于该目录器测试夹具的通用管理方式(make list/make download/make add-snippet,以及 snippets 与完整二进制的取舍),可进一步参考 binary 目录器 README。
七、总结
elf-testdata测试夹具是理解 syft ELF 二进制包建模的最佳入口。它以极小的构造成本(g++ + objcopy 注入 JSON)复现了真实发行版中.note.package段的形态,并围绕三个精心设计的场景——同名共享库、多位置聚合、嵌套库、符号链接、RPM 传递依赖——把 syft 目录器最复杂的行为边界(同名逻辑包聚合、包到包关系替换包到文件关系)具象化。配合 elf_package_cataloger.go 的两遍扫描聚合逻辑、elf_package.go 的 PURL/类型映射,以及 elf_package_cataloger_test.go 的期望输出,读者可以完整还原 syft 从"ELF 文件的元数据段"到"SBOM 中逻辑包与依赖关系"的整条数据链路。
【免费下载链接】syftCLI tool and library for generating a Software Bill of Materials from container images and filesystems项目地址: https://gitcode.com/GitHub_Trending/sy/syft
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考