软件供应链安全:SBOM、工件签名与防护恶意包
1. 概述
一个典型的 Spring Boot 应用,通常只有几千行由你编写的代码。剩下的部分来自数百个第三方库、build 工具、CI runner 和容器基镜像。每个环节都可能成为攻击目标。这就是软件供应链安全问题。
本文围绕三个实际支柱展开:生成SBOM 以了解你正在使用的内容、签名工件以证明工件未被篡改,以及在解析依赖时防护恶意包。读完后,你可以将这些步骤添加到现有的 Maven 项目和 CI pipeline 中。
2. 为什么供应链安全现在备受关注
攻击者意识到,攻击一个热门库的效果远优于逐个攻击应用。只需在一个包中注入恶意代码,他们就能危害所有使用该包的项目。
常见的攻击方式包括:
- Typosquatting:发布名称与真实包极其相似的包(相差一个字符),然后等待开发者输入错误。
- Dependency confusion:在公共 registry 中发布与内部包同名但版本更高的包。配置不当的 build 工具会选择公共版本。
- 账户接管:库维护者的凭证泄露,随后一个“合法”版本的发布包含恶意代码被推送上去。
- Build 系统攻击:源代码没问题,但 CI runner 或 build 脚本被篡改,导致输出的工件被污染。
- 传递依赖漏洞:你没有直接声明该库,但它仍然在 classpath 中。
同时,越来越多的组织和企业客户开始要求软件供应商提交 SBOM 并证明 build 来源。因此,这个主题不再是“安全团队的事”,而已成为日常技术要求。
3. SBOM:了解你在运行什么
**SBOM(Software Bill of Materials)**是一份完整列表,记录软件中的所有组件,包括名称、版本、许可证、哈希和依赖关系。把它想象为食品包装上印的成分表。
两个主流格式是 CycloneDX 和 SPDX。Java 生态对 CycloneDX 的支持相当不错,所以本文使用该格式。
3.1. 使用 Maven 生成 SBOM
在 pom.xml 中添加 CycloneDX 插件:
<properties>
<!-- 替换为你检查过的最新版本,来自 Maven Central -->
<cyclonedx.version>REPLACE_WITH_LATEST</cyclonedx.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>${cyclonedx.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
<configuration>
<outputFormat>json</outputFormat>
<includeTestScope>false</includeTestScope>
</configuration>
</plugin>
</plugins>
</build>
说明:
makeAggregateBom将多模块项目中所有模块的依赖合并到一个文件中。- 绑定到
packagephase,使 SBOM 始终与 JAR 文件一起生成,无需额外手动操作。 includeTestScope=false排除 JUnit、Mockito 等依赖,因为它们在 runtime 不存在。
运行 build:
mvn clean package
插件会生成 target/bom.json 文件。内容片段如下所示:
{
"bomFormat": "CycloneDX",
"components": [
{
"group": "com.fasterxml.jackson.core",
"name": "jackson-databind",
"version": "...",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@...",
"hashes": [ { "alg": "SHA-256", "content": "..." } ],
"licenses": [ ... ]
},
...
]
}
最重要的字段是 purl(Package URL)。这是一个标准标识符,漏洞扫描工具用它来对标 CVE 数据库。
3.2. SBOM 有什么用途
SBOM 本身不能保护任何东西。它的价值在于你如何使用它:
- 扫描漏洞:将
bom.json提供给 CI 中的 SBOM 扫描工具。当发现新 CVE 时,你可以扫描旧 SBOM 而无需重新 build。 - 回答“我们是否受影响?”:当出现重大漏洞时,你可以在所有服务的 SBOM 仓库中按
purl搜索,而不是逐个询问各个团队。 - 检查许可证:发现与公司政策不符的许可证库。
Spring Boot 3.x 的最新版本还有一个 actuator 端点 sbom。当 SBOM 被打包到应用中时(使用 spring-boot-starter-parent 加 CycloneDX 插件),你可以启用该端点:
management.endpoints.web.exposure.include=health,sbom
调用 GET /actuator/sbom 查看正在运行的实例的 SBOM。这在需要确切了解哪个版本在生产环境运行时很有用。
注意: SBOM 列出了完整的库和版本,攻击者可以用它探测漏洞。不要将
sbom端点暴露到互联网。将其限制在内部网络或加上身份验证。
4. 工件签名:证明工件未被篡改
SBOM 说明工件包含什么。数字签名说明工件来自哪里以及签名后未被修改。
4.1. 校验和:必要但不充分
最简单的级别是检查哈希。以下类计算文件的 SHA-256 并与预期值比对:
package dev.nonla.supplychain;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public final class ArtifactVerifier {
private ArtifactVerifier() {
}
public static String sha256(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
public static void verify(Path file, String expectedSha256) throws IOException {
String actual = sha256(file);
boolean matches = MessageDigest.isEqual(
actual.getBytes(StandardCharsets.US_ASCII),
expectedSha256.toLowerCase().getBytes(StandardCharsets.US_ASCII));
if (!matches) {
throw new SecurityException(
"Checksum mismatch for " + file.getFileName() + ": " + actual);
}
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
值得注意的要点:
- 文件按 8 KB 块读取,所以大工件也不会耗尽内存。
HexFormat(Java 17+ 开始支持)将字节转换为十六进制字符串,无需外部库。MessageDigest.isEqual在恒定时间内进行比较。虽然在这里不是特别关键,但这是处理安全相关值时的最佳实践。
用 JUnit 5 和 AssertJ 为主要行为编写测试:
package dev.nonla.supplychain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ArtifactVerifierTest {
private static final String HELLO_SHA256 =
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
@TempDir
Path tempDir;
@Test
void givenUntouchedArtifact_whenVerify_thenPasses() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello");
assertThat(ArtifactVerifier.sha256(jar)).isEqualTo(HELLO_SHA256);
assertThatCode(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.doesNotThrowAnyException();
}
@Test
void givenTamperedArtifact_whenVerify_thenThrows() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello!");
assertThatThrownBy(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("order-service.jar");
}
}
仅增加一个 ! 字符,哈希就完全改变,verify 抛出 SecurityException。
问题在于:如果攻击者能替换工件,他们通常也能替换旁边的 .sha256 文件。校验和只能保证完整性,不能证明来源。 要证明来源,你需要数字签名。
4.2. 使用 Sigstore cosign 签名
传统方法是用 GPG 签名。这可行,但长期密钥管理很繁琐:需要存储、轮换、泄露时撤销。Sigstore 提供了一种称为 keyless signing 的不同方法。签名时,cosign 工具采用 OIDC 身份(例如 CI workflow 的身份),请求一个短期证书,签名,然后将记录写入公共的透明日志。
在 CI 中签名 JAR 文件:
cosign sign-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--yes
.sigstore.json 文件包含签名、证书和已写入透明日志的证明。接收方验证如下:
cosign verify-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--certificate-identity-regexp "^https://github.com/acme/order-service/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
合法时的结果:
Verified OK
关键是两个参数 --certificate-identity* 和 --certificate-oidc-issuer。如果省略它们,你只知道“某人签过”,但不知道签署人是否是你的 pipeline。对于容器镜像,流程类似,使用 cosign sign 和 cosign verify 作用在镜像 digest 上。
4.3. 集成到 pipeline 中
一个最小化的 GitHub Actions job,签名 JAR 和 SBOM:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # 需要此项以便 cosign 获取 OIDC token
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: sigstore/cosign-installer@v3
- run: mvn -B clean package
- run: |
cosign sign-blob target/order-service.jar --bundle order-service.jar.sigstore.json --yes
cosign sign-blob target/bom.json --bundle bom.json.sigstore.json --yes
权限 id-token: write 允许 workflow 获取 OIDC token。你无需在 secrets 中存储任何私钥。同时签名 bom.json 有助于接收方确信 SBOM 确实伴随该工件,而不是从其他地方拼凑的。
5. 从一开始就防护恶意包
签名工件保护你的输出。本节保护输入,即你拉取的依赖。
5.1. 仅通过受控仓库解析
配置 ~/.m2/settings.xml(或 CI 中使用的 settings),使所有请求都经过内部仓库,并在校验和不匹配时立即失败:
<settings>
<mirrors>
<mirror>
<id>internal</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.acme.internal/maven-all/</url>
</mirror>
</mirrors>
<profiles>
<profile>
<id>strict</id>
<repositories>
<repository>
<id>internal</id>
<url>https://repo.acme.internal/maven-all/</url>
<releases>
<checksumPolicy>fail</checksumPolicy>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>strict</activeProfile>
</activeProfiles>
</settings>
mirrorOf=*拦截所有仓库,包括第三方pom.xml自己声明的,都必须经过内部 proxy。checksumPolicy=fail使 build 在校验和不匹配时停止,而不仅仅警告。- 禁用外部 source 的 snapshot,因为 snapshot 的内容可能改变而版本不变。
要对抗 dependency confusion,应配置仓库 proxy,使内部 groupId(如 com.acme.*)仅从内部仓库获取,永远不从公共源获取。Maven Central 发布时需要验证命名空间所有权,风险较低,但你仍不应完全依赖它。
5.2. 用 Maven Enforcer 强制策略
Enforcer 插件将策略转化为 build 失败:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${enforcer.version}</version>
<executions>
<execution>
<id>supply-chain-rules</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>log4j:log4j</exclude>
<exclude>commons-collections:commons-collections:[,3.2.2)</exclude>
</excludes>
</bannedDependencies>
<requireUpperBoundDeps/>
<requireReleaseDeps>
<onlyWhenRelease>false</onlyWhenRelease>
</requireReleaseDeps>
</rules>
</configuration>
</execution>
</executions>
</plugin>
bannedDependencies阻止已停支的库或具有已知漏洞的版本范围。此规则适用于传递依赖。requireUpperBoundDeps在 Maven 默默选择低于其他库要求的版本时报错。requireReleaseDeps禁止依赖中的 SNAPSHOT。
违反时,build 停止并显示类似消息:
[ERROR] Rule 0: org.apache.maven.enforcer.rules.dependency.BannedDependencies failed with message:
com.acme:order-service:jar:1.0.0
log4j:log4j:jar:1.2.17 <--- banned via the exclude/include list
5.3. 小习惯,大效果
- 固定具体版本。不要使用版本范围如
[1.0,)或LATEST,因为今天和明天的 build 可能不同。 - 添加新依赖时进行评审:检查确切名称、groupId、用户数量、发布历史。与热门库名称相似的无名库是 typosquatting 的迹象。
- 自动化更新用 bot 创建 pull request,但仍让 CI 运行完整测试和 SBOM 扫描再合并。
- 减少依赖:classpath 中没有的东西无法攻击你。
6. 常见陷阱与何时不应过度设计
注意: 生成 SBOM 但无人查看效果接近于无。将 SBOM 与自动化扫描和负责人联系起来处理结果。
一些常见错误:
- 签名工件但无人验证。签名仅在 deploy 步骤(或 Kubernetes admission controller)拒绝无有效签名工件时有价值。
- 验证签名但未绑定身份。如第 4.2 节所述,“有签名”不同于“有我的 pipeline 的签名”。
- 从源代码而非最终工件生成 SBOM。如果打包步骤添加或删除库,SBOM 会不准确。在生成工件的同一 build 步骤中生成 SBOM。
- 禁用列表很长且从不更新。团队最终会绕过 Enforcer,策略失效。
何时不应该大量投入:
- 原型、一次性脚本、学习项目。固定版本和维护一个仓库镜像就足够。设置 keyless 签名加 admission 策略的工作量超过收益。
- 团队还没有漏洞处理流程。数百条 CVE 警报一次性涌入而无人分类只会导致“alert fatigue”。从高严重性的 SBOM 和扫描开始,逐步扩展。
- 完全 air-gapped 环境。Keyless 签名依赖外部 OIDC 服务和透明日志。在这种环境中,用自管密钥签名(GPG 或 cosign 带密钥对)更合适。
7. 结论
软件供应链安全围绕三个问题:我在使用什么、这个工件真的是我的吗、我拉进来的东西可信吗。SBOM 回答第一个问题,Sigstore 的数字签名回答第二个,受控仓库加 Maven Enforcer 帮助回答第三个。
你无需一次部署全部。合理的路线图是:固定版本并使用内部镜像,然后在每次 build 中生成 SBOM,接着添加 Enforcer,最后签名工件并在 deploy 时强制验证。每一步都显著降低风险,都可在现有 pipeline 中自动化。