软件供应链安全: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 将多模块项目中所有模块的依赖合并到一个文件中。
  • 绑定到 package phase,使 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 中自动化。

securitysupply-chainsbommavendevsecops