🤖
AI审核中

从依赖扫描到可信发布:Java 项目软件供应链安全实战

Java 21分钟 106浏览 0评论

在很多 Java 团队中,软件安全仍停留在三个动作:升级依赖、扫描 CVE、修复高危漏洞。

这些动作当然重要,但它们只能解决软件供应链中的一部分问题。

一个通过测试、没有高危漏洞的 JAR,并不能自动证明:

  • 其中包含的依赖与预期一致;
  • 它确实由受信任的流水线构建;
  • 构建完成后没有被替换;
  • 部署时使用的就是当初通过安全检查的那个制品。

OWASP 对软件供应链威胁的总结中,不仅包括依赖混淆,还包括上游基础设施被入侵、代码签名凭据被盗以及 CI/CD 系统遭到攻击。也就是说,风险并不只存在于代码仓库,而是分布在依赖下载、编译、打包、存储、发布和部署的整个链路中。([OWASP Cheat Sheet Series][1])

因此,真正的软件供应链安全,不是给项目再加一个扫描器,而是把发布过程从“相信流程执行过”,升级为“每一步都能够用证据验证”。


一、为什么 pom.xml 不变,产物依然可能不可信

假设一个 Spring Boot 项目的业务代码和 pom.xml 都没有变化,重新执行一次构建,最终产物仍然可能受到以下因素影响:

环节 可能发生的问题 传统测试为什么难以发现
依赖解析 使用了 SNAPSHOT、版本范围、可变私服制品或恶意传递依赖 依赖能够正常编译,单元测试也可能全部通过
构建环境 JDK、Maven 插件、CI 镜像或构建脚本发生变化 Git 仓库中的业务代码没有对应变更
镜像构建 基础镜像标签指向了新的镜像内容 应用 JAR 本身可能完全相同
制品存储 镜像、JAR 或元数据在构建后被替换 原始构建日志无法证明当前制品未被修改
漏洞情报 某个依赖在发布后才披露新漏洞 构建当天的扫描结果无法代表今天
发布环节 测试通过的是制品 A,实际部署的是制品 B 缺少制品身份与部署对象之间的绑定

传统的依赖扫描通常只能回答:

当前识别到的组件中,是否存在已知漏洞?

但一条可信的发布链路至少还要回答另外四个问题:

  1. 制品中到底包含什么?
  2. 制品是如何构建出来的?
  3. 制品和相关安全报告是否被篡改?
  4. 这些证据是否满足组织的发布规则?

这也是 SBOM、SLSA Provenance、数字签名、Attestation 和策略门禁需要协同使用的原因。


二、先拆开五个经常被混用的概念

SBOM、漏洞报告、VEX、构建来源证明和数字签名解决的是不同问题,不能互相替代。

安全证据 主要回答的问题 不能单独证明什么
SBOM 制品中有哪些组件、版本和依赖关系 组件是否存在漏洞、漏洞是否可利用
漏洞报告 当前漏洞数据库中命中了哪些风险 制品是否来自可信构建环境
VEX 某个漏洞在当前产品中是否真正受影响 制品是否被篡改
SLSA Provenance 谁构建、使用什么流程、输入是什么 完整而精细的组件清单
签名与 Attestation 谁对制品或声明负责,内容是否被修改 制品本身没有漏洞

SLSA 官方文档明确区分了 SBOM 与 Provenance:SBOM 通常描述最终制品中细粒度的组件信息,而 Provenance 更关注构建平台、构建参数和顶层输入。两者可能包含部分相似数据,但处于不同抽象层级;可信的 Provenance 还能够提高 SBOM 本身的可信度。([SLSA][2])

VEX 则进一步回答“这个漏洞是否真的影响当前产品”。例如,依赖中虽然存在某个漏洞函数,但应用没有调用相关路径,或者漏洞功能在编译时已被移除,此时可以通过 VEX 记录 not_affected 结论及其依据,而不是让团队长期面对没有上下文的漏洞告警。([CycloneDX][3])


三、一条可验证的 Java 发布链路应该是什么样

完整链路可以设计为:

flowchart LR
    A["Git 提交与版本标签"] --> B["受控 CI 构建"]
    B --> C["生成 JAR"]
    B --> D["生成 CycloneDX SBOM"]
    B --> E["生成构建 Provenance"]

    C --> F["构建 OCI 镜像"]
    D --> G["漏洞与许可证扫描"]
    F --> H["按镜像 Digest 签名"]
    D --> I["作为 Attestation 绑定到镜像"]

    E --> J["验证构建身份与输入"]
    G --> K{"策略门禁"}
    H --> K
    I --> K
    J --> K

    K -- "通过" --> L["进入制品库"]
    K -- "拒绝" --> M["阻断发布并创建修复任务"]

    L --> N["部署前再次验签"]
    N --> O["允许部署"]

这里最重要的原则是:

所有安全证据都必须绑定到不可变的制品摘要,而不是只绑定 latestrelease 或版本标签。

标签更适合人类阅读,摘要才是制品的真实身份。

例如:

registry.example.com/order-service@sha256:8a5f...

签名、SBOM、漏洞报告、构建来源和部署策略都围绕同一个摘要建立关联,才能避免“扫描的是一个镜像,部署的却是另一个镜像”。


四、在 Maven 构建阶段生成 CycloneDX SBOM

CycloneDX Maven Plugin 可以生成包含项目直接依赖和传递依赖的 SBOM。对于 Maven 多模块项目,makeAggregateBom 会在构建根目录生成聚合后的组件清单。

本文使用 cyclonedx-maven-plugin 2.9.3。该版本属于 2.9.x 系列,支持 CycloneDX 1.6 的 XML 和 JSON 格式。([GitHub][4])

在父项目的 pom.xml 中加入:

<build>
    <plugins>
        <plugin>
            <groupId>org.cyclonedx</groupId>
            <artifactId>cyclonedx-maven-plugin</artifactId>
            <version>2.9.3</version>

            <executions>
                <execution>
                    <id>generate-sbom</id>
                    <phase>verify</phase>
                    <goals>
                        <goal>makeAggregateBom</goal>
                    </goals>
                </execution>
            </executions>

            <configuration>
                <projectType>application</projectType>
                <schemaVersion>1.6</schemaVersion>

                <includeBomSerialNumber>true</includeBomSerialNumber>

                <includeCompileScope>true</includeCompileScope>
                <includeProvidedScope>false</includeProvidedScope>
                <includeRuntimeScope>true</includeRuntimeScope>
                <includeSystemScope>false</includeSystemScope>
                <includeTestScope>false</includeTestScope>

                <includeLicenseText>false</includeLicenseText>

                <outputFormat>json</outputFormat>
                <outputName>bom</outputName>
                <outputDirectory>
                    ${project.build.directory}
                </outputDirectory>
            </configuration>
        </plugin>
    </plugins>
</build>

执行构建:

mvn -B -ntp clean verify

构建完成后检查文件:

test -s target/bom.json

此时 target/bom.json 就是当前构建对应的 CycloneDX SBOM。

为什么绑定到 verify 阶段

将 SBOM 生成绑定到 verify,意味着它不是开发人员偶尔执行的辅助命令,而是正式构建的一部分。

只要插件执行失败、SBOM 没有生成或输出格式不符合要求,流水线就不能继续进入发布阶段。

依赖 Scope 不能直接照抄默认值

CycloneDX Maven Plugin 默认会包含 compileprovidedruntimesystem,排除 test。但团队应根据交付方式调整,而不是机械使用默认配置。([GitHub][5])

对于 Spring Boot 可执行 JAR:

  • compileruntime 通常应包含;
  • test 不应进入生产 SBOM;
  • provided 通常不在最终可执行制品中;
  • system 最好从依赖模型中逐步消除。

如果项目以 WAR 形式部署到外部 Tomcat,那么 Servlet 容器及其依赖属于运行平台的一部分。此时可以将应用 SBOM 与平台 SBOM 分开管理,而不是把所有内容混成一张无法区分责任边界的清单。


五、SBOM 生成以后,还要进行风险扫描

SBOM 只是组件清单,不等于漏洞报告。

可以使用 Trivy 读取 CycloneDX JSON,并对其中的漏洞与许可证信息进行扫描:

trivy sbom \
    --scanners vuln,license \
    --format json \
    --output target/sbom-scan.json \
    target/bom.json

Trivy 的 sbom 子命令能够读取 CycloneDX 和 SPDX 等格式。默认执行漏洞扫描,通过 --scanners vuln,license 可以同时启用许可证检查。当前 Trivy 的 SBOM 输入仅支持 CycloneDX JSON,不支持 CycloneDX XML。([Trivy][6])

将高危漏洞变成流水线门禁

初始阶段可以先阻断“存在修复版本的高危和严重漏洞”:

trivy sbom \
    --scanners vuln \
    --severity HIGH,CRITICAL \
    --ignore-unfixed \
    --exit-code 1 \
    target/bom.json

这里几个参数的含义是:

  • --severity HIGH,CRITICAL:只将高危和严重漏洞纳入当前门禁;
  • --ignore-unfixed:仅展示已经存在修复方案的漏洞;
  • --exit-code 1:命中规则时返回非零退出码,让 CI 失败。

Trivy 默认即使发现安全问题也会返回退出码 0,因此只有显式设置 --exit-code,扫描结果才真正具有阻断能力。([Trivy][7])

--ignore-unfixed 不应成为永久忽略风险的理由。对于互联网入口、身份认证、支付等关键系统,即使严重漏洞暂时没有修复版本,也应进入人工评估、补偿控制或正式例外审批流程。

对许可证风险设置独立门禁

许可证扫描可以单独执行:

trivy sbom \
    --scanners license \
    --severity UNKNOWN,HIGH,CRITICAL \
    --exit-code 1 \
    target/bom.json

Trivy 会将许可证分类映射为不同风险等级,例如:

分类 默认风险等级
Forbidden CRITICAL
Restricted HIGH
Reciprocal MEDIUM
Notice LOW
Permissive LOW
Unknown UNKNOWN

这套分类只是通用起点,不应直接代替公司的法务政策。企业应根据产品是否商业闭源、是否对外分发、是否提供 SaaS 服务等实际情况,定制自己的许可证允许、审核与禁止规则。([Trivy][8])


六、为什么还要再扫描最终镜像

通过 Maven 插件生成的 SBOM 主要反映 Java 项目的依赖模型,但最终运行的容器还可能包含:

  • Linux 系统包;
  • Shell、证书和本地工具;
  • Java Runtime;
  • Agent 与监控组件;
  • Dockerfile 额外下载的文件;
  • 多阶段构建复制进来的其他二进制文件。

因此,Java SBOM 扫描和容器镜像扫描不应二选一。

推荐同时执行:

trivy image \
    --scanners vuln,license \
    --severity HIGH,CRITICAL \
    --exit-code 1 \
    "$IMAGE"

两次扫描解决的问题不同:

检查对象 主要价值
Maven 生成的 SBOM 保留稳定、可归档、可比较的 Java 组件清单
最终 OCI 镜像 检查真正进入运行环境的系统包和镜像层
两者差异 发现声明依赖与最终制品之间的不一致

Trivy 官方也提醒,读取其他工具生成的 SBOM 时,部分检测精度可能受到自定义属性缺失的影响。因此,不能只依赖一份外部 SBOM,还应对最终制品进行交叉扫描。([Trivy][6])


七、使用 Cosign 为镜像签名,并绑定 SBOM

漏洞扫描回答的是“有没有已知问题”,数字签名回答的是另一个问题:

当前准备部署的镜像,是否就是受信任流水线发布的那个镜像?

下面先使用本地密钥演示。生产环境中不应把私钥提交到 Git 仓库。

生成密钥:

cosign generate-key-pair

指定不可变镜像摘要:

IMAGE="registry.example.com/order-service@sha256:8a5f..."

为镜像签名:

cosign sign \
    --yes \
    --key cosign.key \
    "$IMAGE"

将 CycloneDX SBOM 作为 Attestation 绑定到镜像:

cosign attest \
    --yes \
    --key cosign.key \
    --type cyclonedx \
    --predicate target/bom.json \
    "$IMAGE"

Cosign 当前支持 cyclonedxspdxjsonslsaprovenanceopenvex 等多种 Predicate 类型,也可以使用自定义 Predicate。([GitHub][9])

部署前验证镜像签名:

cosign verify \
    --key cosign.pub \
    "$IMAGE"

验证 CycloneDX Attestation:

cosign verify-attestation \
    --key cosign.pub \
    --type cyclonedx \
    "$IMAGE"

Cosign 不仅可以签名 OCI 镜像,还支持通过 sign-blobattest-blob 对本地文件进行签名或证明。因此,即使团队暂时没有容器化,也可以对 JAR、SBOM 和发布清单建立相同的验证机制。([Sigstore][10])

验签成功不等于策略通过

verify-attestation 成功,主要说明:

  • Attestation 的签名有效;
  • Attestation 与目标镜像建立了绑定;
  • 签名者符合当前信任配置。

但生产门禁还应检查 Attestation 内部内容,例如:

  • Predicate 类型是否为 CycloneDX;
  • SBOM 是否属于当前产品;
  • 构建器身份是否在允许列表中;
  • 源代码提交是否与发布版本一致;
  • 是否包含禁止组件;
  • 扫描时间和漏洞数据库是否满足新鲜度要求。

Cosign 支持使用 CUE 或 Rego 策略验证 In-toto Attestation,而不是只检查“有没有一份签名声明”。([Sigstore][11])


八、生产环境不要把签名私钥放进流水线变量

本地密钥适合演示,却不适合长期生产使用。

更稳妥的方式包括:

  • 通过 OIDC 使用短生命周期的无密钥签名;
  • 将私钥保存到云 KMS、Vault 或硬件安全模块;
  • 限制只有发布流水线能够发起签名;
  • 将构建任务与签名任务隔离;
  • 对签名身份和 OIDC Issuer 设置明确校验规则。

Sigstore 支持使用 OIDC 身份和临时密钥完成容器签名。SLSA 对高可信构建的要求也强调,签名秘密必须存放在安全管理系统中,只能由构建服务身份访问,不能暴露给用户自定义的构建步骤。([Sigstore][12])

换句话说,下面这种做法并不安全:

variables:
  COSIGN_PRIVATE_KEY: "完整私钥内容"

一旦普通构建脚本、第三方插件或恶意依赖能够读取该变量,攻击者就可能给恶意制品签上“合法签名”。

正确的设计应该是:

flowchart LR
    A["普通构建任务"] --> B["生成并推送镜像"]
    B --> C["返回镜像 Digest"]
    C --> D["隔离的发布任务"]
    D --> E["OIDC / KMS 获取签名能力"]
    E --> F["签名指定 Digest"]
    F --> G["销毁临时凭据"]

签名权限不应属于“任何能够运行构建脚本的人”,而应属于受控的发布身份。


九、如何设计不会拖垮团队的发布策略

供应链门禁最常见的失败方式,是第一天就试图阻断所有漏洞和所有许可证问题。

遗留项目通常存在大量历史告警。如果直接启用最严格策略,最终往往只有两种结果:

  1. 流水线长期无法发布;
  2. 团队添加一个全局跳过参数,安全门禁名存实亡。

更现实的方式是先建立基线,再阻断新增风险。

推荐的策略分层

风险 建议动作
SBOM 未生成 直接阻断
镜像未使用 Digest 直接阻断
签名不存在或验签失败 直接阻断
Attestation 类型或签名身份不符合要求 直接阻断
新增存在修复版本的 CRITICAL 漏洞 直接阻断
新增存在修复版本的 HIGH 漏洞 默认阻断
无修复版本的严重漏洞 人工评估或限时例外
VEX 明确为 not_affected 验证依据后允许
Forbidden 许可证 直接阻断
Restricted 或 Unknown 许可证 法务或安全审核
MEDIUM、LOW 历史漏洞 记录债务,按计划治理

可以将组织策略抽象为以下配置模型:

artifact:
  requireDigest: true
  requireSignature: true
  requireCycloneDxAttestation: true

vulnerability:
  deny:
    - severity: CRITICAL
      vexStatus: affected
    - severity: HIGH
      fixAvailable: true

  exception:
    ownerRequired: true
    reasonRequired: true
    expiresWithinDays: 14

license:
  denyClassifications:
    - Forbidden

  reviewClassifications:
    - Restricted
    - Unknown

provenance:
  allowedBuilders:
    - ci://java-release

这份配置的重点不是具体语法,而是将“安全人员的口头判断”转换为机器可以执行的规则。


十、例外机制必须有负责人和过期时间

现实项目不可能做到所有漏洞立即修复,因此安全门禁必须允许例外。但例外不能只是 .trivyignore 中的一行 CVE 编号。

一条完整的例外记录至少应包含:

字段 示例
风险编号 CVE-20XX-XXXXX
受影响组件 groupId:artifactId:version
负责人 具体团队或个人
允许原因 不可达代码、无修复版本、升级会破坏兼容性
补偿控制 WAF、权限隔离、关闭功能、流量限制
证据 调用链分析、测试报告、VEX
生效时间 例外批准时间
到期时间 最长允许保留多久
复查条件 新版本发布、利用代码公开、架构变化

一个没有到期时间的例外,本质上就是永久忽略。

对于被判定为 not_affected 的漏洞,也应保留判断依据。因为代码调用关系、编译参数和业务功能都会变化,今天不可利用的漏洞,不代表未来仍然不可利用。

CycloneDX VEX 的价值就在于将漏洞在特定产品上下文中的受影响状态结构化记录下来,帮助团队减少噪声,同时保留可审计的风险判断。([CycloneDX][3])


十一、五个常见但危险的实施误区

1. 只在开发电脑上生成 SBOM

开发环境生成的 SBOM不能代表正式制品。

SBOM 应由正式 CI 流水线在构建过程中自动生成,并与具体提交、构建任务和制品摘要绑定。

2. 生成了 SBOM,却没有保存历史版本

如果每次发布都覆盖同一个 bom.json,当某个新漏洞披露时,团队仍然无法快速回答:

哪些历史版本和生产实例包含这个组件?

正确做法是按版本或制品摘要归档:

order-service/
└── sha256-8a5f.../
    ├── bom.cdx.json
    ├── vulnerability-report.json
    ├── provenance.json
    └── release-metadata.json

3. 只给镜像标签签名

发布策略应围绕 Digest 建立,而不是依赖可变标签。

order-service:1.2.0 适合作为业务版本,真正用于验签和部署的对象应是:

order-service@sha256:8a5f...

4. 认为“有签名”就一定安全

签名只能证明某个受信任身份签过当前内容,并且内容在签名后没有被修改。

如果可信流水线本身构建了恶意代码,签名依然可能完全有效。因此必须同时验证:

  • 签名身份;
  • 构建来源;
  • 源代码提交;
  • 构建平台;
  • SBOM;
  • 漏洞和许可证策略。

5. 只扫描一次,不持续重新评估

漏洞情报会不断更新。一个发布时没有高危漏洞的依赖,可能在运行几个月后出现新的 CVE。

因此,SBOM 不应只服务于发布门禁,还应进入持续监控体系:

flowchart LR
    A["历史 SBOM 仓库"] --> B["定时获取最新漏洞情报"]
    B --> C["重新匹配所有生产版本"]
    C --> D{"发现新增暴露?"}
    D -- "否" --> E["继续监控"]
    D -- "是" --> F["定位受影响服务与版本"]
    F --> G["生成升级或缓解任务"]
    G --> H["更新 VEX 与处置状态"]

这时,SBOM 才真正从一份发布附件,变成了生产风险资产。


十二、适合中小团队的渐进式落地路径

不需要第一天就搭建完整的软件供应链安全平台,可以按照四个阶段逐步推进。

阶段 主要工作 发布是否阻断
第一阶段:建立可见性 Maven 自动生成 SBOM,归档漏洞报告 暂不阻断
第二阶段:控制新增风险 阻断新增可修复的高危漏洞和禁止许可证 部分阻断
第三阶段:建立制品身份 镜像按 Digest 签名,绑定 SBOM Attestation 验签失败阻断
第四阶段:验证构建过程 引入 Provenance、可信 Builder 和部署准入策略 全链路验证

第一阶段的目标不是马上消灭所有风险,而是先回答:

  • 生产环境到底运行了哪些组件?
  • 哪些服务使用了同一个高风险依赖?
  • 某次发布与上一个版本相比新增了什么?
  • 漏洞披露后,哪些历史版本受到影响?

只有资产可见,后续的治理才有基础。


十三、结语

软件供应链安全不是某个扫描工具的功能,也不是在 CI 中增加一条命令。

它是一套围绕制品建立证据链的工程方法:

  • 用 SBOM 说明制品包含什么;
  • 用漏洞报告和 VEX 说明风险状态;
  • 用 Provenance 说明制品如何构建;
  • 用签名和 Attestation 证明证据未被修改;
  • 用策略门禁决定制品是否允许发布;
  • 用持续重扫应对发布后出现的新风险。

当这套链路真正落地后,团队的发布依据就不再是:

这次流水线显示绿色,应该没问题。

而是:

这个制品来自允许的代码提交,由可信构建器生成;它的组件清单、漏洞状态和构建来源都已签名,并且全部通过了当前发布策略。

从“相信一次构建”,走向“验证每一个制品”,才是软件供应链安全真正开始发挥作用的地方。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论