纵深防御:Python供应链安全实用指南

Lobsters Hottest 新闻

摘要

一份关于通过分层防御保护Python供应链的实用指南,包括使用Ruff进行代码检查、使用哈希锁定依赖项、使用pip-audit进行漏洞扫描、生成SBOM以及使用OIDC证明的可信发布。

<p>层层设防,不要信任任何单一控制措施。使用带安全规则的Ruff在代码发布前捕获bug。使用uv lock或uv pip compile --generate-hashes将所有依赖项用加密哈希锁定,这样没人能替换你的包。在CI中运行pip-audit,在已知CVE进入生产环境前捕获它们。使用CycloneDX生成SBOM,这样当下一次出现Ultralytics式的入侵时,你可以在几分钟而非几天内回答“我们是否受影响?”</p> <p>如果你在发布包,放弃长期有效的API令牌,改用带有OIDC的可信发布。这会通过Sigstore自动生成证明,将你的包链接回源代码仓库。运行内部镜像的组织可以添加7天延迟,让社区充当你的金丝雀——但前提是你有维护它的基础设施。</p> <p>这里没有什么是完美的。哈希锁定可以防止篡改,但无法拯救你最初安装的恶意包。扫描能发现已知CVE,但会遗漏零日漏洞。证明能证实代码的来源,但不能证明其安全性。这就是为什么你要分层设防——当一个控制失效时,其他控制会补上。从代码检查和锁定开始快速见效,接着添加扫描和SBOM,然后随着你成熟逐步升级到高级内容。</p> <p><a href="https://lobste.rs/s/ghsneu/defense_depth_practical_guide_python">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/04/20 14:44

# 深度防御:Python供应链安全实用指南 来源:https://bernat.tech/posts/securing-python-supply-chain/ TLDR:分层防御,不要信任任何单一控制措施。使用 Ruff 的安全规则在代码发布前捕获错误。使用 `uv lock` 或 `uv pip compile --generate-hashes` 锁定所有依赖项的加密哈希,防止他人替换包。在 CI 中运行 `pip-audit` (https://github.com/pypa/pip-audit) 在生产环境前捕获已知 CVE。生成 CycloneDX 格式的 SBOM,以便下次出现类似 Ultralytics 的入侵事件时,你可以在几分钟(而不是几天)内回答“我们受影响了吗?”如果你在发布包,请放弃长期有效的 API 令牌,改用基于 OIDC 的受信任发布。这会通过 Sigstore 自动生成凭证,将你的包与源代码仓库关联起来。运行内部镜像的组织可以添加 7 天的延迟,让社区充当你的“金丝雀”——但前提是你有足够的基础设施来维护它。这里没有完美的方案。哈希锁定可以防止篡改,但无法挽救你第一天就安装的恶意包。扫描可以发现已知 CVE,但会遗漏零日漏洞。凭证可以证明代码来自哪里,但不能证明其安全性。这就是为什么你要分层部署——当一层控制失效时,另一层会接住它。从 linting 和锁定开始获得快速收益,接着添加扫描和 SBOM,然后随着成熟度提升再升级到高级措施。 我维护着多个 PyPA 项目(virtualenv、tox、pipx、platformdirs、filelock),并从事企业级包托管基础设施工作。多年来,我亲眼目睹了针对 Python 包的供应链攻击变得越来越恶劣——无论是作为开源维护者向 PyPI 发布包,还是作为企业消费者管理数千个依赖项。本文涵盖了保护 Python 供应链的实用方法。如需了解涵盖所有生态系统的更广泛威胁模型,CNCF 软件供应链安全白皮书 (https://tag-security.cncf.io/community/working-groups/supply-chain-security/supply-chain-security-paper-v2/Software_Supply_Chain_Practices_whitepaper_v2.pdf) 是一个极好的入门读物。这里我们将专注于 Python 特定的防御措施——编写安全代码、管理依赖项、扫描漏洞以及验证包的 authenticity。 ## 为什么这很重要 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#why-this-matters) 我们面对的数据规模:截至 2026 年 3 月,PyPI 托管着超过 743,000 个包,而且这个数字每天都在增长。你的普通 Python 项目通常会引入数十个传递依赖——那些你从未明确选择但你的依赖项需要它们,所以你不得不依赖的包。关键在于:安全补丁总是滞后于漏洞发现,有时滞后数周甚至数月。 从开发者到应用程序的流程: ```mermaid flowchart TB Dev[开发者编写代码] --> Build[构建包] Build --> Upload[上传到 PyPI] Upload --> PyPI[PyPI] PyPI --> Install[你的 pip install] Install --> App[你的应用] Attacker[攻击者] -.->|入侵| Dev Attacker -.->|恶意包| PyPI Attacker -.->|域名抢注| PyPI style Attacker fill:#dc2626,stroke:#b91c1c,color:#fff style App fill:#50b432,stroke:#3d8a26,color:#fff linkStyle 5 stroke:#dc2626 linkStyle 6 stroke:#dc2626 linkStyle 7 stroke:#dc2626 ``` 注意到所有这些红色箭头了吗?每个都代表一个潜在的攻击向量。真实事件证明了为什么这很重要。 ### 真实攻击,真实影响 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#real-attacks-real-impact) **ctx 和 PHPass 账号劫持 (https://www.crowdstrike.com/en-us/blog/how-crowdstrike-detects-poisoned-python-packages-ctx-phpass/)(2022 年 5 月)**:攻击者通过重新注册 `ctx` 包维护者的过期邮箱域名,入侵了该包(自 2014 年以来未更新)。他们推送了一个恶意更新,将 AWS 凭证和其他敏感环境变量泄露到攻击者控制的服务器。报告指出,在检测到之前大约 10 天内,每天约有 2,000 次下载,可能暴露了许多 AWS 账户。此后,PyPI 实施了域名复活预防措施 (https://blog.pypi.org/posts/2025-08-18-preventing-domain-resurrections/)——检测过期域名并取消验证相关邮箱地址,以缓解此类攻击。 **Ultralytics 入侵事件 (https://blog.pypi.org/posts/2024-12-11-ultralytics-attack-analysis/)(2024 年 12 月)**:广泛使用的 YOLO 计算机视觉包(截至 2024 年 12 月报告每月约 8000 万次下载)因 GitHub Actions 脚本注入攻击被入侵。攻击者窃取了 PyPI 上传令牌,并向四个版本注入了加密货币挖矿程序。成千上万的开发者在运行 `pip install ultralytics` 时不知情地安装了恶意软件。 **PyPI 钓鱼活动 (https://blog.pypi.org/posts/2025-07-28-pypi-phishing-attack/)(2025 年 7 月)**:发布包且元数据中包含邮箱的维护者收到了来自 `[email protected]`(注意小写的 `j`)的钓鱼邮件。攻击使用了一个代理凭证窃取器,将窃取的凭证传递给真实的 PyPI,使受害者认为他们正常登录了。PyPI 随后对基于 TOTP 的登录实施了登录验证 (https://blog.pypi.org/posts/2025-11-14-login-verification/),要求从未识别设备登录时进行验证。 **GhostAction 攻击 (https://blog.pypi.org/posts/2025-09-16-github-actions-token-exfiltration/)(2025 年 9 月)**:威胁行为者向超过 570 个仓库的 GitHub Actions 工作流注入了代码,窃取了超过 3,300 个机密,包括 PyPI 令牌、npm 令牌和 AWS 访问密钥。PyPI 作废了所有被窃的令牌 (https://www.bleepingcomputer.com/news/security/pypi-invalidates-tokens-stolen-in-ghostaction-supply-chain-attack/),并推动所有人迁移到受信任发布者 (https://bernat.tech/posts/securing-python-supply-chain/#the-new-way-trusted-publishing)。 **Shai-Hulud 蠕虫活动 (https://blog.pypi.org/posts/2025-11-26-pypi-and-shai-hulud/)(2025 年 11 月)**:一种跨生态系统的蠕虫,主要针对 npm,但也影响到了 PyPI,因为单体仓库设置会同时存储两个注册表的凭证。攻击者入侵了 npm 账户,并从 GitHub 仓库机密中泄露了长期有效的 PyPI 令牌。PyPI 主动撤销了暴露的令牌,并建议使用 zizmor (https://docs.zizmor.sh/) 审计 GitHub Actions 工作流。 这些不是理论上的攻击。它们发生在拥有数百万用户的真实项目上。如果你在 PyPI 上发现恶意包,可以通过 PyPI 的安全报告系统 (https://pypi.org/security/) 报告。 ### 隐藏的依赖问题 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#the-hidden-dependency-problem) 当你安装 Flask 时,你得到的不仅仅是 Flask。以下是完整的依赖树: ```bash # 安装 Flask uv pip install flask # 显示完整的依赖树 uv pip tree # 输出: # flask v3.1.0 # ├── blinker v1.9.0 # ├── click v8.1.8 # ├── itsdangerous v2.2.0 # ├── jinja2 v3.1.5 # │ └── markupsafe v3.0.2 # └── werkzeug v3.1.3 # └── markupsafe v3.0.2 ``` 看到了吗?你请求了一个包(Flask),却得到了七个。看看最底部的 MarkupSafe——它是由 Jinja2 和 Werkzeug 共同引入的传递依赖。你从未显式安装过它。你可能甚至不知道它做什么。但如果它有漏洞,你的应用程序就会变得脆弱。每个项目平均有 50 多个传递依赖,你的攻击面比你 requirements 文件中看到的大得多。 现在让我们构建你的防御策略,从你自己的代码开始。 ## 首先保护你自己的代码 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#secure-your-own-code-first) 供应链攻击不仅来自外部依赖——你自己的代码也可能创建入口点。一个硬编码在源代码中的 PyPI 令牌,一旦推送到仓库,就会给攻击者一切所需,入侵你的账户并在你的名下发布恶意包。 除了机密之外,常见的安全漏洞隐藏在日常看起来完全正常的代码模式中——而人在时间压力下会忽略这些。用 linter 自动捕获它们是第一层防御。 ### 永久的秘密 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#the-forever-secret) 泄露的凭证是许多供应链入侵的起点。暴露的 PyPI 令牌让攻击者可以发布你包的带后门版本。暴露的数据库 URL 让他们可以泄露数据。然而,这种模式令人沮丧地常见: ```python # 不好的做法:代码中的秘密在 git 历史中永存 SECRET_KEY = "hunter2" DATABASE_URL = "postgres://admin:password123@prod-db:5432/app" # 好的做法:使用环境变量 import os SECRET_KEY = os.environ["SECRET_KEY"] DATABASE_URL = os.environ["DATABASE_URL"] ``` Git 永远不会忘记。当你提交一个秘密时,它会永远存在于仓库的历史中。在后续提交中删除它也无济于事——任何有仓库访问权限(或有旧克隆)的人都可以提取那些凭证。攻击者经常在 git 历史中搜索秘密,泄露的 PyPI 令牌或云凭证往往是供应链入侵的第一步。 ### 损坏的加密 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#broken-cryptography) 另一个常见漏洞: ```python # 不好的做法:MD5 和 SHA1 已损坏 import hashlib digest = hashlib.md5(payload).hexdigest() # 好的做法:使用 SHA256 或更好的算法 digest = hashlib.sha256(payload).hexdigest() ``` MD5 碰撞首次演示于 2004 年,尽管其弱点更早便已知晓。SHA1 实际碰撞于 2017 年演示。NIST 早在 2011 年就弃用了两者用于数字签名。“已损坏”意味着攻击者可以生成碰撞——不同的输入产生相同的哈希值。这会导致证书伪造、下载篡改或完整性检查绕过。不要为了安全目的使用它们。 ### 挂起的连接 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#the-hanging-connection) 这个问题很微妙但危险: ```python # 不好的做法:在缓慢/恶意服务器上无限挂起 import requests response = requests.get("https://api.example.com/data") # 好的做法:始终设置超时 response = requests.get("https://api.example.com/data", timeout=30) ``` 没有超时,一个缓慢或恶意的服务器可以让你的进程无限挂起。攻击者控制了你应用程序通信的服务器,可以让每个请求挂起,耗尽你的线程池,造成拒绝服务。因为你忘记了一个参数,整个应用程序就会瘫痪。 ### 用 Ruff 捕获这些问题 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#catch-these-with-ruff) Ruff (https://docs.astral.sh/ruff/) 是一个速度极快的 Python linter,包含来自 Bandit (https://bandit.readthedocs.io/en/latest/) 的全面安全规则。你可以在 Ruff 安全规则文档 (https://docs.astral.sh/ruff/rules/#flake8-bandit-s) 中了解更多。在 `pyproject.toml` 中配置它: ```toml # 从错误、pyflakes 和安全规则开始 [tool.ruff] line-length = 120 lint.select = ["E", "F", "S"] ``` 仅安全规则(`"S"`)就提供了显著价值——它们是 Bandit 检查,用于捕获硬编码的机密、弱加密和不安全的反序列化。当你的代码库干净后,扩展到所有规则: ```toml # 进取目标:启用所有规则并有选择地忽略 [tool.ruff] line-length = 120 lint.select = ["ALL"] lint.ignore = [ "COM812", # 与格式化器冲突 "CPY", # 无版权 "D", # pydocstyle:后续为公共 API 启用(如果发布库) "ISC001", # 与格式化器冲突 ] ``` Ruff 运行时间不到一秒,因此你可以在 IDE 中边输入边运行,并在每次提交前运行。上述三个漏洞都会被自动捕获: - **S105** (https://docs.astral.sh/ruff/rules/hardcoded-password-string/) - 硬编码的机密 - **S324** (https://docs.astral.sh/ruff/rules/hashlib-insecure-hash-function/) - 弱加密 - **S113** (https://docs.astral.sh/ruff/rules/request-without-timeout/) - 缺少超时 - **S301** (https://docs.astral.sh/ruff/rules/suspicious-pickle-usage/) - pickle 和其他不安全的反序列化 - **S608** (https://docs.astral.sh/ruff/rules/hardcoded-sql-expression/) - 通过字符串格式化的 SQL 注入 - **S307** (https://docs.astral.sh/ruff/rules/suspicious-eval-usage/) - 使用 eval() 处理不受信任的输入 每个链接的规则页面都包含对危险模式原因的详细解释、易受攻击代码的示例以及如何修复——如果你想知道风险而不仅仅是忽略警告,值得一读。 例如,这种危险模式会立即被标记: ```python # 被标记:S301 - pickle.loads() 可以执行任意代码 import pickle data = pickle.loads(untrusted_input) # 改为使用 json.loads() # 被标记:S608 - SQL 注入漏洞 cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'") ``` 将 Ruff 添加到你的编辑器和 CI 流水线——它会拯救你健忘的自己。 ## 管理你的依赖项 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#manage-your-dependencies) 现在让我们谈谈管理你未编写的代码——你的依赖项。这才是供应链攻击实际发生的地方。OpenSSF 安全供应链消费框架 (S2C2F) (https://github.com/ossf/s2c2f) 提供了一个结构化的成熟度模型,指导组织应如何消费开源软件。 ### 谨慎选择依赖项 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#choose-dependencies-carefully) 在添加依赖项之前,考虑你是否真的需要它。每个依赖项都会扩大你的攻击面——更少的依赖项意味着更少的被入侵机会。当你确实添加一个时,使用 OpenSSF Scorecard (https://securityscorecards.dev/) 评估发布者的安全态势,该工具根据分支保护、签名发布、依赖项更新工具和漏洞披露等实践对项目进行评分。低分并不意味着“不要使用它”,但它告诉你你对一个安全卫生有限的项目投入了多少信任。 ### 未固定的依赖问题 链接到标题 (https://bernat.tech/posts/securing-python-supply-chain/#the-unpinned-dependency-problem) 一个比你想象中更常发生的场景:你在 requirements 文件中写了 `flask>=2.0`。今天,当你运行 `pip install` 时,你会得到 Flask 3.1.0,一切正常。明天,攻击者发布了一个被入侵的 Flask 3.1.1。你下一次 `pip install` 会悄悄下载恶意版本,因为它满足你的 `>=2.0` 约束。你刚在没更改一行代码的情况下安装了恶意软件。 从不安全到安全的演进: ```mermaid graph LR A[未固定: flask>=2.0] -->|获得任意版本| B[危险] C[版本固定: flask==3.1.1] -->|获得确切版本| D[更好] E[哈希固定: flask==3.1.1--hash=sha256:...] -->|验证内容| F[安全] style B fill:#dc2626,stroke:#b91c1c,color:#fff style D fill:#f59e0b,stroke:#d97706,color:#fff style F fill:#50b432,stroke:#3d8a26,color:#fff ``` **未固定**(`flask>=2.0`)是最危险的——你会得到任何最新版本,可能已被入侵。你的构建不可重现,也无法检测篡改。 **版本固定**(`flask==3.1.1`)更好——你得到你测试过的确切版本。但没有完整性检查。如果攻击者入侵了维护者的账户,并为相同版本发布了新的带后门构件(例如,之前未上传过的针对某个平台的 wheel),你会安装它。

相似文章

构建我梦寐以求的部署工具

Lobsters Hottest

作者详细介绍了一款名为'Deptool'的自定义Python部署与配置管理工具的开发过程。该工具旨在比Ansible等现有方案更快、更可预测,源于对数字主权和更优工具的追求。