供应链安全
这一课你将能做到
- 说清依赖风险的本质:为什么你只看了一页
pom.xml,却信任了 500 个陌生 jar
- 在 CI 里配置 Dependabot + lockfile + 签名验证,阻止已知漏洞和恶意包进入构建
- 画出本站的依赖树,并识别出 3 类高风险依赖(过旧版本、未使用、来自个人维护者)
为什么要学
你的应用不是完全由你写的。pom.xml 里 30 个直接依赖,实际拉下来可能 300 个间接依赖。Log4j2(log4j-core)只是其中之一,但一次远程代码执行漏洞就席卷全球。2023 年 xz 后门事件则证明:依赖不仅可以有漏洞,还可以被主动植入后门。
人话:供应链攻击是「借刀杀人」——我不打你的代码,我打你信任的第三方包。你 import 的那个库如果被攻陷,等于你自己的服务器被攻陷。防御三原则:知道你在依赖什么、只信你验证过的、能回退能审计。
先建立图像
一个 Spring Boot 项目的真实依赖图:
你的代码: myapp.jar
│
├─ spring-boot-starter-web (官方)
│ ├─ spring-web ← 官方可信
│ ├─ tomcat-embed-core ← 注意 CVE 历史
│ └─ jackson-databind ← 多次 RCE 漏洞
│
├─ mybatis-plus (第三方)
│ ├─ mybatis ← 个人维护者
│ └─ jsqlparser ← 更小项目
│
└─ commons-compress (第三方)
└─ xz-java ← xz 后门同款
直接依赖: 30 个
传递依赖: 300+ 个
攻击面: 每个 jar 都是
构建管道的信任链:
源代码 ──→ 依赖解析 ──→ 编译构建 ──→ 镜像构建 ──→ 部署上线
│ │ │ │ │
│ ├ lockfile ├ 签名工具 ├ SBOM ├ 准入校验
│ │ 防漂移 │ 防篡改 │ 可追溯 │ 拒绝未签名的
│ │ │ │ │
└─ git ────┴─ 仓库 ─────┴─ 制品库 ────┴─ 镜像仓库 ──┴─ K8s
跟着做
步骤 1:看依赖树,知道你在信任什么
mvn dependency:tree > deps.txt
# 重点看:
# - 过旧版本(是否有 CVE)
# - 未使用的依赖(代码里没 import)
# - 来自个人或极小项目的传递依赖
也可以用插件可视化:
mvn com.github.ferstl:depgraph-maven-plugin:graph
# 生成 .dot 文件,用 Graphviz 转成 PNG 查看
清理未使用依赖(用 mvn dependency:analyze):
mvn dependency:analyze
# Unused declared dependencies: 可以删掉
# Used undeclared dependencies: 显式声明(避免传递版本变更导致意外)
步骤 2:锁定依赖版本(lockfile)
Maven 不像 npm 有原生 lockfile,用 dependency:resolve 生成固定版本快照:
# 把当前所有解析出的版本锁定到一个文件
mvn dependency:resolve dependency:copy-dependencies \
-DoutputDirectory=lib -DincludeScope=runtime
# 更推荐:在 pom 里显式管理传递依赖版本
<dependencyManagement>
<dependencies>
<!-- 显式声明你关心的传递依赖版本,避免被覆盖 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
</dependencies>
</dependencyManagement>
步骤 3:Dependabot / Renovate 自动提升级 PR
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
# 高危 CVE 自动创建 PR
allow:
- dependency-type: "direct"
commit-message:
prefix: "deps"
规则:Dependabot 提的 PR 不是直接合并,而是:
- 看 CHANGELOG 确认没有破坏性变更
- 跑全量测试
- 扫描升级后的新依赖树是否引入新的 CVE
- 合并并打 tag
步骤 4:CI 中强制依赖扫描(已在本课之前实现,这里补充策略)
# 在 CI 中,顺序很重要:
1. 编译前: gitleaks 扫描(防密钥泄漏)
2. 编译中: mvn dependency-check:check(OWASP 漏洞扫描)
- 配置 failBuildOnCVSS=7
3. 编译后: Trivy 扫描 jar 包(容器镜像扫描)
4. 归档: 生成 SBOM(Trivy/ Syft)
步骤 5:验证下载的依赖(签名校验)
Maven Central 上的包有 PGP 签名。开启严格校验:
<!-- settings.xml -->
<profiles>
<profile>
<id>strict-signatures</id>
<properties>
<maven.artifact.threads>1</maven.artifact.threads>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.simplify4u.plugins</groupId>
<artifactId>pgpverify-maven-plugin</artifactId>
<version>1.18.0</version>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
这能防止「依赖包在传输中被替换」或「仓库被投毒」。但注意:签名只能证明「这个包是谁发布的」,不能证明「这个包没有漏洞」。漏洞扫描仍然是必要的。
步骤 6:构建管道加固
CI 加固清单:
□ 编译和部署用不同身份(编译只读源码,部署只推送制品)
□ CI 密钥(AK、Docker Hub token)走 Vault / KMS,不写在环境变量明文
□ CI 里禁止 `--no-verify` 跳过 git hooks
□ 每次构建产物附带 SBOM + 签名(cosign sign)
□ 生产只部署有签名的制品,拒绝任何手工上传的 jar/镜像
# 用 cosign 给镜像签名(基于 Sigstore 生态)
cosign sign --key env://COSIGN_PRIVATE_KEY coderoadmap/app:1.2.3
# K8s 准入控制器验证签名,未签名的镜像拒绝启动
讲清楚要点
-
依赖数量爆炸是常态:30 个直接依赖可能带 300 个传递依赖,你不是在审核 30 个,是在信任一个巨大的陌生人网络。必须自动化——人工不可能看 300 个库的代码。
-
lockfile 防的是「构建不可重现」:今天 mvn package 拉的是版本 A,明天开发者 B 构建时因为某个传递依赖发了新版本 B,行为变了,排查爆炸。lockfile 或 dependencyManagement 显式锁定每个版本。
-
已知漏洞修得快,未知后门防不了:Dependabot 解决的是前者(公开 CVE)。xz 后门这类未知威胁,要靠「基础镜像最小化」「应用沙箱」「行为监控」兜底——供应链安全不是万能药。
-
签名解决「谁发布的」不解决「有没有 bug」:PGP/cosign 签名防止中间人替换包,但原作者的代码可能有漏洞。签名+扫描+最小化三层都要。
-
构建管道本身也是攻击面:如果 CI 服务器被攻破,攻击者可以在编译时往 jar 里注入恶意代码。CI 加固(最小权限、密钥管理、审计日志、签名验证)是供应链安全的最后一环。
你可能会懵的地方
-
所有依赖升级都要手动审吗?
不需要。低风险(patch 版本、测试覆盖好的)可以自动化合并;中风险(minor 版本)跑测试后合;高风险(major 版本、核心框架如 Spring Boot)人工审。Dependabot 可以按 severity 自动分组。
-
Maven 没有 lockfile 怎么办?
用 dependencyManagement 显式声明所有关键传递依赖版本,或用 versions-maven-plugin 锁定。或者用 Gradle(原生支持 lockfile)。核心原则一样:构建结果可重现。
-
私有仓库(Nexus/Artifactory)怎么防投毒?
配置代理仓库时只缓存,不自动信任;对私有发布包强制签名;定期和上游中央仓库同步校验哈希;内部包和外部包用不同 groupId 前缀区分。
-
SBOM 和 lockfile 什么区别?
lockfile 是构建输入(我要用什么版本),SBOM 是构建输出(实际打进去的是什么)。两者都重要——lockfile 保证可重现,SBOM 用于事后溯源。
-
如果依赖的作者删库跑路(left-pad 事件)怎么办?
私有仓库缓存解决(Nexus/Artifactory 缓存了就不会消失)。生产构建不从公网直接拉,从私有仓库拉。同时评估依赖的「巴士系数」——一个人维护的项目风险极高。
错法对照
| 错法 | 问题 | 正解 |
|---|
pom.xml 里版本写 LATEST 或范围 | 不可重现、随时引入新 CVE | 显式固定版本 |
| 看到 Dependabot PR 直接合 | 可能引入破坏性变更 | 看 CHANGELOG + 跑全量测试 |
| 构建从公网 Maven Central 直接拉 | 网络故障/删库即构建失败 | 私有 Nexus 缓存 |
| 不扫传递依赖 | 只关注 30 个直接依赖,漏掉 300 个 | OWASP Dependency-Check 扫全树 |
| CI 用 root / 主账号 AK | CI 被攻破=全账号沦陷 | 最小权限角色+KMS/Vault |
| 没有 SBOM | 出漏洞时不知道哪个版本受影响 | 每次发版生成并归档 |
卡点急救
-
Dependency-Check 报告误报太多:对确认的误报加 suppression XML(写明 CVE、原因、复查日期),不要简单设成 failBuildOnCVSS=10 回避问题。维护好 suppression 列表是工程纪律。
-
升级 Spring Boot 大版本后一堆兼容问题:不要一次升级多个 major 版本。Spring Boot 有迁移指南和 spring-boot-properties-migrator。先在测试环境跑全量集成测试,确认 actuator、security 配置都正常再推生产。
-
私有仓库缓存的包和上游不一致:可能是缓存时上游还没被篡改,之后被篡改了——但你的缓存是干净的;也可能是缓存本身被污染。定期用 mvn dependency:purge-local-repository 清本地,再和上游哈希比对。对关键包启用 PGP 签名验证。
面试常问
-
什么是供应链攻击?举个例子?
攻击者不直接攻击目标系统,而是攻击其依赖的第三方组件(开源库、构建工具、CI/CD 管道)。例子:Log4Shell(log4j2 的 JNDI 解析漏洞)、xz 后门(压缩库维护者身份被窃取后植入后门)、SolarWinds(构建管道被入侵,在编译时注入恶意代码)。防御需要覆盖依赖选择、构建加固、签名验证、SBOM 溯源全链路。
-
如何防止恶意依赖包进入生产?
① 来源控制:只从可信仓库(Maven Central)拉取,私有 Nexus 缓存;② 版本锁定:lockfile/dependencyManagement 固定版本;③ 签名验证:PGP/cosign 验证包未被篡改;④ 漏洞扫描:CI 中 OWASP Dependency-Check + Trivy 卡高危;⑤ 准入控制:K8s 只运行签过名的镜像;⑥ SBOM 归档:出事后快速定位受影响版本。
-
Log4Shell 的教训是什么?
① 日志框架做 lookup(JNDI/LDAP)是过度设计,一个日志库不应能触发网络请求;② 间接依赖同样是攻击面;③ 修复窗口极短但定位窗口极长——没有 SBOM 的团队手动排查数周;④ 安全修复要快:先升级再验证,不要等完美方案;⑤ 纵深防御:即使应用有漏洞,WAF 规则、网络隔离、最小权限也能降低损害。
自测三问
- 你的 Spring Boot 项目实际有多少个传递依赖?怎么查看?
- lockfile 和 SBOM 分别解决什么问题?
- 如果 CI 服务器被攻破,攻击者能在供应链的哪个环节作恶?怎么防?
小结一句话
供应链安全的铁律:知道你在依赖什么(SBOM)、只信验证过的(签名+扫描)、构建可重现(lockfile)——Log4Shell 不会是你遇到的最后一次依赖危机。
延伸
- SLSA 框架 — Google 发起的供应链安全框架,四级成熟度模型
- 下一课:检测与 SIEM——从「防住攻击」到「发现攻击」,日志、告警、关联规则与自动化响应。