2. 依赖生态与供应链模型
现代软件项目很少从零构建。一个 Java 服务可能直接声明 30 个依赖,但最终打包进制品库的 jar 包往往超过 200 个;一个前端工程在 package.json 里写下的 20 个包,安装后 node_modules 里可能躺着上千个包。多出来的部分,就是传递性依赖。供应链风险的绝大部分,恰恰藏在这些"我没有直接引入、但实际运行在我的系统里"的组件中。
要治理依赖风险,第一步不是扫描漏洞,而是把"我到底用了什么"这件事说清楚。这需要三个相互配合的概念:SBOM 回答"有哪些组件",依赖图谱回答"它们怎么连在一起",传递性依赖分析回答"哪些是真正进入运行时的"。
SBOM:软件物料清单的实质
SBOM(Software Bill of Materials)是一份机器可读的组件清单,描述一个软件制品中包含的所有第三方组件及其版本、来源、许可证和依赖关系。它类似于食品包装上的配料表:不是食品本身,但让你知道里面有什么。
当前主流格式有三种:SPDX(Linux 基金会主导,ISO/IEC 5962 标准)、CycloneDX(OWASP 主导,面向安全场景,原生支持漏洞与 VEX 信息)、以及各生态自带的锁文件(package-lock.json、pom.xml、go.sum)。SPDX 与 CycloneDX 是交换格式,锁文件是生成 SBOM 的原始输入。
一份最小可用的 CycloneDX 组件条目大致长这样:
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"components": [
{
"type": "library",
"name": "log4j-core",
"version": "2.14.1",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"licenses": [{ "license": { "id": "Apache-2.0" } }]
}
]
}
其中 purl(Package URL)是关键字段,它用统一语法 pkg:类型/命名空间/名称@版本 唯一标识一个组件,是跨生态比对漏洞库的基础。没有 purl 或版本号缺失的 SBOM,在漏洞匹配阶段基本不可用。
依赖图谱:把清单变成网络
SBOM 是一张平铺的清单,依赖图谱则把它还原成有向图。图中每个节点是一个组件(坐标 + 版本),每条有向边表示"前者依赖后者"。根节点是你的应用本身。
依赖图谱的价值在于揭示路径。当某个底层库爆出漏洞时,你需要的不是"我用了它",而是"我是通过哪条链路用上它的"。例如:
my-app@1.0.0
├─ spring-boot-starter-web@2.6.0
│ └─ spring-boot-starter-json@2.6.0
│ └─ jackson-databind@2.13.0 ← 传递依赖
└─ internal-common@3.2.1
└─ jackson-databind@2.9.10 ← 另一条路径,版本更旧
同一个 jackson-databind 出现在两条路径上,且版本不同。这就是依赖图谱要暴露的核心问题:版本收敛。Maven 的"最近优先"、npm 的"扁平化 + 嵌套"、Go 的"最小版本选择(MVS)"会给出不同的最终解析结果,理解你所处生态的解析规则,才能预测实际进入运行时的版本。
直接依赖与间接依赖的识别
识别方法因生态而异,但思路一致:从声明文件出发,沿解析结果向下遍历。
- Maven:
mvn dependency:tree -Dverbose输出完整依赖树,-Dincludes=groupId:artifactId可过滤特定组件;mvn dependency:list给出扁平化后的最终版本列表。 - npm:
npm ls --all展示嵌套结构,npm ls <包名>直接定位某包的所有引入路径;package-lock.json中的packages字段记录了每个包的resolved与dev标记。 - Go:
go mod graph输出模块依赖边列表,go mod why <模块>回答"为什么这个模块会被引入"。 - Python:
pipdeptree生成依赖树,pip show <包>的Required-by字段给出反向依赖。
识别时要区分三类依赖,它们的风险权重完全不同:直接依赖(你主动声明,可控)、传递依赖(被动引入,需追溯)、开发依赖(devDependencies / test scope,通常不进生产制品,但会污染构建环境)。把开发依赖误算进生产 SBOM,会造成大量误报;反之漏掉运行时反射加载的组件,则会漏报。
典型供应链攻击案例
理解模型之后,看几个真实案例,能更直观地感受"传递依赖"为何是攻击面。
Log4Shell(CVE-2021-44228):漏洞位于 log4j-core。绝大多数受影响系统并没有直接声明 log4j,而是通过 Spring Boot、Kafka、Elasticsearch 等框架间接引入。攻击者只需向应用发送一个包含 ${jndi:ldap://...} 的字符串(如写入 User-Agent 头),即可触发远程类加载。这个案例说明:传递依赖的漏洞,其触发点可能在你完全没写过的代码路径上。
event-stream 事件(2018):npm 包 event-stream 的原作者将维护权转交给陌生人,新维护者加入 flatmap-stream 依赖,其中嵌入了针对 Copay 钱包的窃取代码。攻击者没有攻击原包,而是攻击了依赖链上的一个新增节点。这揭示了传递依赖的另一个风险维度:不仅是漏洞,还有恶意代码注入。
SolarWinds Orion(2020):攻击者入侵构建环境,在官方签名的更新包中植入后门。这是"构建链"层面的攻击,SBOM 无法直接防御,但能帮助下游用户在事件曝光后快速判断"我是否使用了受影响版本"——这正是 SBOM 的核心价值:把事后排查从数周缩短到数小时。
从模型到可执行动作
把上述概念落到工程实践,最小闭环是:CI 构建时生成 CycloneDX SBOM 并归档;用依赖图谱工具(如 Dependency-Track、Grype、Trivy)持续比对漏洞库;对传递依赖设置版本收敛策略(Maven 的 dependencyManagement、npm 的 overrides、Go 的 replace);对新增的传递依赖节点做人工复核。SBOM 不是一次性交付物,而是随每次构建更新的活文档——只有保持更新,它才能在下一个 Log4Shell 到来时真正派上用场。