首页 / 资讯动态 / Python requirements.txt锁定版本防止供应链投毒

Python requirements.txt锁定版本防止供应链投毒

一个依赖包版本号前的波浪号(~)或插入符(^),看似无害,实则可能是一扇向攻击者敞开的后门。很多开发者习惯在 requirements.txt 里写 flask>=2.0 或 requests~=2.25,觉得这样能自动获取最新补丁,省心省力。但在网络安全领域,这种“省心”恰恰是供应链投毒攻击最喜欢的猎物。攻击者不需要攻破你的服务器,也不需要破解你的密码,只需要向一个你信任的公共仓库上传一个版本号高一点、名字一模一样的恶意包,你的项目就会在下次构建时乖乖把它下载下来。这不是理论推演,过去几年 PyPI 上多次出现恶意包冒充知名库的事件,攻击手法从窃取环境变量到植入后门不一而足。而最有效的第一道防线,其实就藏在你一直忽视的那个等号上。

为什么版本浮动是供应链投毒的温床

要理解锁版本的紧迫性,得先看清攻击者的成本结构。在 Python 生态里,发布一个包几乎没有门槛。攻击者可以扫描 requirements.txt 里那些使用非精确版本的依赖,然后计算出一个比现有版本略高、但仍在范围约束内的版本号。比如你写的是 requests>=2.28.0,攻击者就可以上传 requests 2.28.1,甚至 2.28.0.post1,这些版本在 pip 的解析逻辑里都是合法的升级对象。一旦得手,恶意代码就会在你的构建流水线、开发环境甚至生产服务器上执行。更隐蔽的做法是“长期潜伏型投毒”,攻击者先上传一个完全正常的高版本包,等下载量上去、被大量项目锁定后,再在更小的版本号里注入恶意载荷,利用部分用户仍使用浮动范围的特点进行精准打击。这类攻击之所以难以防范,是因为它不依赖任何零日漏洞,它利用的是依赖管理机制本身的信任假设。

精确锁定版本的三个核心动作

第一,把所有依赖写成精确版本。打开你的 requirements.txt,把每一个 flask>=2.0 改成 flask==2.0.3,把 requests~=2.25 改成 requests==2.25.1。这一步没有任何技术难度,纯粹是习惯问题。但要注意,直接依赖锁了还不够,依赖的依赖同样危险。一个经典的攻击路径是“间接依赖劫持”:你直接依赖的 A 库使用了浮动版本依赖 B 库,攻击者通过污染 B 库的高版本就能间接控制你的应用。这就是为什么 pip freeze 生成的完整依赖树远比手写的几行直接依赖更安全。

第二,生成并验证哈希值。版本号可以伪造,但文件内容的加密哈希无法伪造。pip 支持在 requirements.txt 里为每个包指定 --hash,格式如下:

flask==2.0.3 \
    --hash=sha256:a0245f2e5b9c5e6d8c8f7d9e0b3c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 \
    --hash=sha256:b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2

生成这个文件最简单的方式是用 pip-compile 配合 hash 模式,或者用 pip hash 命令手动计算已下载包的哈希值。当 requirements.txt 里包含哈希时,pip 会在安装前校验下载内容是否与哈希匹配,任何篡改都会直接导致安装失败。这是目前成本最低、效果最直接的防篡改手段。需要注意的是,哈希锁定必须覆盖整个依赖树,如果只锁了直接依赖而没锁间接依赖,攻击者仍然可以通过污染深层依赖绕过校验。

pip-tools 与 pip freeze 的实战对比

很多团队习惯在虚拟环境里跑完 pip install 之后用 pip freeze > requirements.txt 生成锁定文件。这个做法比手写浮动版本安全得多,但它有一个致命缺陷:pip freeze 会把当前环境里安装的所有包全部导出,包括那些你根本没主动依赖、只是被其他包顺带安装进来的库。久而久之,文件里会堆积大量僵尸依赖,没人知道哪些是真正需要的,哪些可以删。更麻烦的是,pip freeze 不区分直接依赖和间接依赖,当需要升级某个包时,你无法快速判断改动的影响范围。

pip-tools 解决了这个问题。它的工作流分两层:你维护一个 requirements.in,里面只写直接依赖,版本可以写精确也可以写范围;然后运行 pip-compile 生成 requirements.txt,这个文件包含完整的依赖树且全部精确锁定版本和哈希。当你需要升级某个包时,修改 requirements.in 然后重新编译,pip-compile 会自动解析最新的兼容版本并更新锁定文件。这套流程既保留了人工维护的清晰度,又实现了自动化的深度锁定。对于生产环境,强烈建议在 requirements.in 里也使用精确版本,把“是否升级”这个决策权完全掌握在自己手里,而不是交给 pip 的解析器。

锁定文件的版本控制与持续验证

把 requirements.txt 锁好只是第一步,把它放进 Git 仓库并建立配套的验证机制才能形成闭环。每次依赖变更都应该在 Pull Request 里清晰展示改动了哪些包、版本号从多少变成多少、哈希值是什么。Code Review 时重点关注那些来历不明的版本跃升,尤其是补丁版本号异常跳动的情况——一个成熟稳定的库很少会在补丁版本里引入大量代码变更。如果发现某个包从 1.2.3 跳到 1.2.4 但哈希值对应的文件大小变化巨大,这就是一个值得深挖的危险信号。

更进一步的防护是在 CI 流水线里加入依赖审计步骤。pip-audit 可以直接扫描已安装的包是否存在已知漏洞,safety 命令行工具也能做类似的事情。这些工具会对照公开的漏洞数据库,告诉你当前锁定的版本是否包含 CVE。但要注意,审计工具只能发现已知漏洞,对零日投毒无能为力,所以它不能替代版本锁定,只能作为补充。另一个值得投入的实践是定期运行 pip install --require-hashes -r requirements.txt 来验证锁定文件的完整性,确保所有包都有对应的哈希值,没有遗漏。

私有镜像与代理的纵深防御

即使你把版本和哈希都锁死了,你的构建环境仍然需要从 PyPI 下载包。如果攻击者控制了网络链路,或者 PyPI 本身遭受入侵,理论上仍存在风险。在关键业务场景下,搭建私有 PyPI 镜像或使用缓存代理是值得考虑的纵深措施。Devpi 和 Nexus Repository 都支持 PyPI 代理模式,它们会在第一次下载时从公共仓库拉取包并缓存到本地,后续安装直接从内网获取。这样即使公共仓库出现恶意版本,只要你的缓存里没有,就不会被自动拉入。更重要的是,私有镜像可以配置上传白名单,彻底阻断从公共仓库自动拉取新版本的能力,把“引入新包”变成一个需要人工审批的明确操作。

对于资源有限的小团队,至少要做到“首次下载验证”这一步:在将一个新包或新版本加入锁定文件之前,先在隔离环境里检查它的源码、作者信息、发布历史。PyPI 上每个包的发布记录都是公开的,如果一个包昨天才注册、今天就发布了十几个版本,或者它的作者账号是刚创建的、没有任何其他维护记录,这些都是明显的红旗。查看包的 GitHub 仓库,看 Issue 和 PR 的活跃度、维护者的响应风格,这些软信息往往比版本号更能反映一个包是否可信。

处理依赖更新的安全节奏

锁版本不等于永远不更新。完全不更新会导致安全补丁无法应用,同样危险。关键在于建立可控的更新节奏。建议把依赖更新分成两类处理:安全更新和功能更新。对于安全更新,关注你依赖的核心库的发布动态,一旦有修复 CVE 的新版本发布,尽快在测试环境验证兼容性后更新锁定文件。对于功能更新,可以按固定周期(比如每两周或每月)集中处理一批,避免频繁改动带来的不稳定风险。

更新时不要一次性升级所有包,而是逐个处理,每次只改一个依赖,跑完完整测试后再处理下一个。这样一旦出现问题,你能立刻定位是哪个包的版本变更导致的。很多团队图省事直接改 requirements.in 然后全量重新编译,结果面对几十个版本变化的 diff 无从下手排查。依赖管理本质上是风险管理,速度从来不是第一优先级,可控性才是。

多环境下的锁定策略

开发环境、测试环境、生产环境对依赖锁定的严格程度可以不同,但底线必须清晰。生产环境必须使用带哈希的精确版本锁定文件,这一点没有商量余地。开发环境可以适当放宽,允许使用不带哈希的精确版本,方便本地调试时灵活切换。但有一条红线不能碰:任何环境的构建产物都不应该来自一个包含浮动版本范围的依赖文件。如果你用 Docker 构建镜像,确保 Dockerfile 里 COPY 进去的是锁定后的 requirements.txt,并且在 RUN pip install 时加上 --require-hashes 和 --no-deps 参数,前者强制校验哈希,后者禁止 pip 自动安装未在文件里声明的依赖,双重保险杜绝任何意外引入。

从一次构建到整个生命周期的安全闭环

供应链安全不是某个点的防护,而是一条链。requirements.txt 锁定版本解决的是“引入”环节的风险,但包在安装后的运行行为同样需要监控。结合运行时依赖扫描工具,持续检测已安装包是否与锁定文件一致,可以在第一时间发现异常。如果生产环境里某个包的版本号与锁定文件不符,这要么是构建流程出了漏洞,要么是服务器已经被入侵。把依赖锁定与运行时校验打通,你就能在攻击者利用恶意包执行有害操作之前截断链条。

Python 生态的开放性是把双刃剑,它带来了极致的开发效率,也带来了供应链攻击的天然土壤。requirements.txt 里的那个等号,可能是你整个安全体系里成本最低、效果最立竿见影的投入。它不需要额外购买任何服务,不需要学习任何新工具,只需要改变一个书写习惯,就能让绝大多数基于版本混淆的投毒攻击直接失效。下次再写依赖文件的时候,把手从波浪号上移开,放到等号上。这个微小的动作,可能就是阻止下一次安全事故的那道闸门。