返回 2026-05-26
🔒 安全

GitHub Actions在Python包中的安全实践GitHub Actions security in Python packages

nesbitt.io·2026-05-25

文章未完整提供内容,仅提及致谢Dr. Zizmor,推测可能讨论如何在Python包中使用GitHub Actions时保障安全性,如权限控制、依赖扫描等关键措施。

Andrew Nesbitt

这是我在2026年美国长滩PyCon大会上的演讲文字稿。幻灯片(PDF)、脚本和数据集可在github.com/andrew/pycon获取。

PyPI列出的约86.4万个包中,约有38.7万个在GitHub上声明了仓库URL,合并多仓库后对应34.3万个独立仓库。其中15.2万仓库包含.github/workflows/内容。实际上开源Python领域只有一个CI系统:Travis CI曾是默认方案,占同类群体的11%,但自2023年起已停止提供免费开源构建服务,其余所有CI工具占比均低于2%。

在这些仓库中,约5.6万个工作流程文件引用了pypa/gh-action-pypi-pypi发布动作——即标记提交、启动运行器、构建wheel包并通过twine上传至PyPI。其中约22%已迁移到可信发布模式:PyPI接受工作流生成的短期OIDC令牌而非存储的API密钥,PyPI端的发布者配置需指定仓库名、工作流文件及可选部署环境。其余约4.4万个仓库仍在使用PYPI_API_TOKEN作为仓库机密。

我认为可信发布是近年来Python打包领域的重大改进之一,这意味着工作流的身份本身成为凭证,PyPI对发布的信任建立在Actions运行的完整性基础上。PEP 740认证、Sigstore签名和SLSA溯源机制都将制品绑定到来源的工作流和提交,能显示构建位置但无法保证上传前工作流是否被篡改。由于签名是最后一步,攻击者可触及任何前置步骤,这也是我上个月在《GitHub Actions是最薄弱环节》中重点论述的观点。

以Actions作为包管理器

工作流中的uses语句实质上是依赖声明:从他人仓库拉取代码并在你的运行器上以任务权限执行,功能类似pip install,只是@后的git ref是可变指针而非不可变版本号,且源仓库控制者可随时修改它。

git tag -f v41 <new-sha>
git push -f origin v41

上述两条命令执行后,所有@v41版本的工作流下次运行时都会自动执行新提交,包括上周成功构建的重跑。没有记录昨日接受SHA值的锁文件,也不存在--require-hashes等效机制;劫持的标签除非有人强制推送修复否则将持续生效,连PEP 592撤回机制也没有。复合动作会在运行时解析内部uses语句,因此即使你固定了SHA值,其内部仍可能通过helper@main等未锁定版本引入依赖,这些变化不会直接体现在你的工作流文件中。更详细的论证见12月博文,而近期实际事件已累计达十起,其中六起最终导致恶意wheel包出现在PyPI。

这六次 PyPI 上传中有五次发生在今年 3 月至 5 月。闪电行是一个有用的反例,因为该工作流本身并无问题;它通过其他途径泄露了一个长期有效的 API 令牌,而仅依靠可信发布机制本可阻止此类事件。elementary-data 和 mistralai chain 则属于相反情况:攻击者最终持有真实工作流生成的有效 OIDC 令牌,因此即使配置了可信发布也无济于事。LiteLLM、Telnyx 和 Ultralytics 的情况介于两者之间——其存储的令牌因 Actions 配置错误被攻击者获取。

值得详细分析的是 Ultralytics 案例,因为它在一次事件中叠加了三种失效模式。一个 pull_request_target 工作流以缓存写入权限范围检出并运行了来自分叉 PR 的代码,且分支名称被插值到 shell 步骤中,这使攻击者得以污染 GitHub Actions 缓存条目。随后合法发布工作流恢复了该缓存,构建了一个包含加密货币矿工的 wheel 文件,并使用存储的令牌将其上传至 PyPI(版本号 8.3.41 和 8.3.42)。由于第一阶段已从 runner 窃取该令牌,后续又直接上传了两个恶意版本,全程未触发 CI。该模板注入漏洞已于 2024 年 8 月被报告并修复,但在安全公告发布十天后又因回归引入。

方法

将这些轶事转化为可研究对象的关键是 zizmor——William Woodruff 为 Actions 工作流开发的静态分析工具。它能读取 .github/workflows/ 目录,并以命名审计形式输出含严重性和置信度的发现结果,本地或 CI 环境下仅需几秒即可完成扫描。

我使用了 ecosyste.ms 索引的每个关联 GitHub 仓库的 PyPI 包列表,浅克隆每个仓库后,在 workflows 目录上执行 `zizmor --format=json`,同时单独提取所有 uses: 行生成 actions 清单,两种输出均存入 SQLite 数据库。扫描时间为 2026 年 5 月 9-11 日,zizmor 版本固定为 1.24.1。约 20% 的仓库 URL 克隆失败(404、私有化或重定向失效),但这些包仍可通过 pip 正常安装,尽管其源代码已不可公开访问,这一现象或许值得另文探讨。

zizmor 仅解析 YAML 文件,无法判断仓库设置 UI 中“工作流权限”是否默认切换为只读,也无法识别受审读者保护的密钥环境变量,或分支保护机制能否阻止注入触发的推送。以下数据应理解为统计 YAML 允许模式的数量,这是当前可被利用情况的上限。

发现

GitHub 安全公告库中目前共有 49 条标记为 ecosystem:actions 的公告。按 zizmor 审计名称分类并统计受影响 PyPI 仓库后得到如下结果:

公告列统计的是每类审计作为已记录泄露根源的出现频率,这与单个发现的危险性不同。单独的“过度权限”发现通常无害,单独的“模板注入”也往往如此,因此下文多数内容需结合多种审计类型理解而非简单排序。例如,这两种组合正是攻击者从你的仓库发布新版本的方式。

当工作流没有权限(block)时,会触发 excessive-permissions 错误,此时 job 的 GITHUB_TOKEN 将继承仓库默认权限。对于 2023 年 2 月前创建的仓库,默认权限包含 contents: write 和 actions: write,这意味着若其他方式导致某步骤被入侵,则可推送提交并触发其他工作流。约三分之二的仓库(10.2 万个)处于此状态,而在文件顶部添加 permissions: {} 并为每个 job 明确授予权限可解决该问题。

约 8.6 万个仓库存在 unpinned-uses 问题,占使用第三方 action 仓库的 91%。该类别下的四个安全公告对应已知的四种标签劫持事件:tj-actions、reviewdog、Trivy 和 xygeni。第二次 Trivy 事件曝光一个月后,仍有 403 个 PyPI 包通过标签引用 aquasecurity/trivy-action;CVE-2025-30066 发布一年后,仍有 336 个仓库通过移动 ref 引用 tj-actions/changed-files。修复方案是在 @ 后附加 40 字符的 commit SHA,Dependabot 和 Renovate 会自动更新。zizmor --fix=all 配合 GH_TOKEN 可将仓库所有标签直接重写为当前 SHA。尽管锁定 actions/* 本身作用有限,因为 GitHub 组织一旦被入侵,其 runner 镜像的执行环境即失效。

模板注入占全部 49 项安全公告中的 27 项(占比最高),约 2.1 万个 PyPI 仓库受影响。攻击模式是将 ${{ }} 表达式与受控数据结合,在 run: 块中插值执行。由于展开发生在 shell 解析脚本前,PR 标题、分支名或 issue 内容均可作为 shell 源。elementary-data 案例中,issue_comment 触发器将 github.event.comment.body 传入 bash,两天前创建的账户通过闭合 echo 并追加 curl | bash 实现攻击。因未设置 permissions: block,GITHUB_TOKEN 默认具有写入权限,攻击后十分钟便在 PyPI 上出现恶意版本 0.23.3,窃取 SSH 密钥和云凭据。

该仓库当时已存在于我的数据集中,且 zizmor 三周前已通过三次独立审计标记出具体问题行,任一次修复本可阻断攻击链。从 2.1 万仓库中筛选出实际含攻击者控制数据的插值表达式后剩余 1,396 个,再过滤 issues/issue_comment 等始终涉及 secrets 的触发器后剩 99 个。去重共享 monorepo 并检查 job 级权限后,发现其中 10 个 job 同时存在写入令牌和存储的 PyPI 凭证。这 10 个案例正通过协调披露处理,此处不具名。通用解决方案是通过 env: 传递值并引用 shell 变量,既保留数据又避免预解析展开。

use-trusted-publishing 存在于约 44,000 个仓库中,占 gh-action-pypi-publish 用户的约 78%。由于没有人会为存储长期有效的令牌(尽管该令牌是攻击者认为其他审计有价值的原因)提交 CVE,因此没有 GHSA 安全公告。仍在使用存储令牌的最大包包括:月下载量 8.96 亿的六个包、fsspec(6.16 亿)、pyasn1(4.3 亿)、tomli(3.77 亿)、greenlet(3.37 亿)和 sqlalchemy(3.35 亿)。需要注意的是,PyPI 的受信任发布尚不支持可复用工作流,因此通过 Speakeasy 等共享发布工作流发布的包有合理理由继续使用存储令牌。zizmor 也完全遗漏了这些调用方,因为 twine 上传位于不同仓库,mistralai 就是其中之一,这解释了为何它未出现在 44,000 个仓库中(尽管存储了令牌),也是其最终出现在事件表中的部分原因。

缓存投毒漏洞影响约 15,000 个仓库,其中特权作业从低权限作业可写入的缓存命名空间恢复数据,Ultralytics 是已发布的两个安全公告之一。2026 年 5 月 mistralai 和 guardrails-ai 的入侵在入口端呈现相同模式:pull_request_target 作业运行分叉代码,分叉代码污染缓存,发布工作流恢复缓存后,缓存代码从内存中提取了 OIDC 令牌。受信任发布未能阻止此事件,因为工作流拥有合法的 id-token: write 权限,且攻击者已在其中执行。1,348 个 PyPI 仓库同时存在 dangerous-triggers 和缓存投毒问题,属于该链条影响的范围,所有案例的修复方案均为避免在构建或发布工件的工作流中恢复缓存。

dangerous-triggers 是六种威胁中最小的,涉及约 7,000 个仓库和八个安全公告。它在 pull_request_target 和 workflow_run 上触发(两者均在基础仓库上下文中运行并可用密钥),常见错误是随后检出 PR 头部分支并运行其测试。spotbugs PAT 窃取事件引发的 reviewdog 和 tj-actions 连锁反应四个月前即采用此模式,Ultralytics 和 Trivy 的入口点也遵循此路径。大多数工作流应改用 plain pull_request(分叉 PR 仅获得读取受限令牌且无密钥),确需 pull_request_target 的情况绝不应检出 PR 头部或恢复缓存。

archived-uses 未列入上述表格(因无相关安全公告),但捕获约 3,600 个仓库依赖至少一个被维护者在 GitHub 归档的动作。actions/create-release 尤为突出:近 2,000 个仓库使用(98.7% 未固定版本),GitHub 于 2021 年 3 月自行归档。几周前我在《Weekend at Bernie’s》中写过这种模式——无人维护但仍安装的依赖,actions/create-release 正是数据集中最典型的“Bernies”之一。

Who Python CI depends on

在审计结果的基础上,提取每一行使用情况可以生成一份清单,列出 Python CI 实际依赖的所有项。pypa/gh-action-pypi-publish 出现在约 56,000 个仓库中,其中 84% 未固定版本;codecov/codecov-action 出现在 21,000 个仓库中,92% 未固定;astral-sh/setup-uv 出现在 17,000 个仓库中,86% 未固定;然后是 softprops/action-gh-release、pre-commit/action、Docker 相关操作以及 pypa/cibuildwheel。在前几个名称之后,所有者逐渐变为个人(softprops、拥有 16 个独立操作的 peter-evans、dtolnay、ncipollo、JamesIves),每个账户都是单一实体,其密钥一旦泄露将影响数千个下游项目。

zizmor 的审计仅停留在工作流 YAML 层面,而某个操作在运行时的具体行为是另一回事。因此,作为单操作案例研究,我进一步分析了 pypa/cibuildwheel——它存在于约 2,700 个发布工作流中,并已用 zizmor 自身扫描出一个低风险问题。它的 action.yml 本质上是围绕 `python -m cibuildwheel` 的一层轻量级组合包装器,而对应的 Python 代码运行时通过 HTTPS 从七个不同的上游主机获取 CPython、PyPy、GraalPy、virtualenv、Node.js、nuget 和 python-build-standalone,且未使用哈希校验。这种传递性依赖树并未出现在任何 action.yml 中,所有调用该工作流的流程都会直接继承这些依赖,却无从察觉。其他流行的组合操作要么对内部依赖使用 SHA 固定,要么仅调用 actions/*,仅有少数长尾反例会在组合定义内通过标签引入第三方操作。

筛选出与 pypa/gh-action-pypi-publish 在同一任务中运行的第三方操作后,画面再次缩小。

若上述任一标签遭劫持,将在数百或数千个包中与 PyPI 凭证并行执行。列表顶端的 Astral 是一家拥有安全团队的公司,但其余部分均为个人维护者,即使未固定比例相同,风险画像也截然不同。列表中唯一的例外是 step-security/harden-runner,涉及 144 个发布作业,仅 2.4% 未固定,安全性显著优于其他条目——这基本说明:已运行 Actions 安全工具的人更倾向于固定依赖。

GitHub 路线图

Python 打包领域耗费十五年构建了 Actions 所缺乏的控制机制。requirements.txt 配合 `--require-hashes`、uv.lock 或 PEP 751 锁文件意味着昨日解析的结果将成为明日安装的版本,无论上游标签如何变动。PEP 592 允许维护者撤回特定版本,使解析器停止选择它;而在 Actions 中,被劫持的标签需手动强制推送才能失效,tj-actions 的恶意标签曾持续数小时未被处理。

GitHub 于 3 月发布的 2026 年安全路线图宣布了对直接及传递性操作 SHA 的工作流依赖锁定功能,可在组织级别彻底禁止 pull_request_target,限制仓库写入权限以分离秘密访问权限,并为托管 Runner 配置出站防火墙。这些功能均无明确发布日期,文档更像意图声明而非详细计划,但锁定功能相当于“锁文件”,距离 pip 支持 `--require-hashes` 已过去十三年。路线图未提及市场对恶意软件的检测、操作撤回机制、依赖操作的 CVE 警报,或默认强制执行措施——这些功能 PyPI 均已实现。

加固发布工作流

如果你维护一个通过 Actions 发布 Python 包的仓库,最佳投入产出比的改进是迁移到受信任发布(trusted publishing)并部署环境,其中环境需设置必需审核人或分支限制,且 PyPI 上的受信任发布者需绑定到环境名称。

jobs:
  pypi-publish:
    environment: release
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
      - uses: pypa/gh-action-pypi-publish@release/v1

OIDC 本身会移除长期有效的凭证,但阻止 elementary-data 这类基础数据篡改的关键在于环境绑定:当注入的 actions: write 步骤触发真实发布流程时,由于工作流文件名和仓库声明仍匹配,流程仍能获取有效 OIDC 令牌。2024年4月的 intercom-client 事件虽发生在 npm 而非 PyPI,但其机制与注册表无关——攻击者推送标签后,未配置环境的工作流运行、生成有效 OIDC 令牌,随后删除工作流运行导致审计记录丢失。MistralAI 事件则是第三种变体:即使无环境配置,发布作业内已执行的代码也能从 runner 内存中直接铸造 OIDC 令牌,这种漏洞无法通过环境绑定解决,因此保持发布作业精简且不恢复缓存具有独立意义。

在工作流文件中顶部设置 permissions: {} 并按作业明确授权权限,可取消默认继承的写入权限。第三方操作码需固定为 40 字符 SHA(zizmor --fix=all 可全仓扫描),Dependabot 或 Renovate 负责更新这些固定值。run: 块内引用的 ${{ github.event.* }} 应通过 env: 传递。发布作业仅包含 actions/checkout、actions/download-artifact 和 pypa/gh-action-pypi-publish,而 wheel 构建在独立作业完成并通过工件传递。任何第三方操作码劫持都不会与发布凭证在同一进程中运行。

将 zizmor 集成到 CI 只需四行代码:

name: zizmor
on: [push, pull_request]
permissions: {}
jobs:
  zizmor:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@v4
      - uses: zizmorcore/zizmor-action@v0

检测结果会显示在 PR 和安全选项卡中,且默认失败。作为零风险的证明,requests、pytest、stamina、flask、django 和 boto3 当前在主分支上均通过扫描,其发布工作流可作为合理模板参考。

我定期重新扫描 PyPI 关键集以观察公开事件是否影响行为模式。

第一次间隔期覆盖了 Trivy 和 elementary-data 事件,整体发现数量下降约 15%,apispec、awscli 和 babel 完全归零;第二次间隔期无重大新闻事件,数据持平或微升。维护者通常只关注自己订阅中的漏洞,随后注意力转移——这正是预期现象。将检查嵌入 CI 作为阻塞步骤的优势在于无需人工记忆,每次变更都会自动执行。

感谢 William Woodruff 开发和维护 zizmor,没有他的贡献本分析不可能实现,他在整合过程中解答了大量问题。zizmor 本质上是个人成果;若它帮你避免了事故,或你认为开源 CI 平台最可靠的防护不应依赖志愿者业余时间,请考虑通过 GitHub Sponsors、thanks.dev 或 ko-fi 支持他。

引用事件:

  • spotbugs -> reviewdog -> tj-actions 链,2024年11月-2025年3月
  • Ultralytics缓存投毒与PyPI分析,2024年12月
  • tj-actions/changed-files CVE-2025-30066,2025年3月
  • 美国网络安全局(CISA)关于tj-actions/reviewdog的安全通告
  • Trivy动作遭入侵及第二轮标签劫持事件,2026年2-3月
  • LiteLLM与Telnyx通过Trivy链及Datadog的TeamPCP分析报告,2026年3月
  • elementary-data评论注入攻击,2026年4月
  • npm上的lightning凭证窃取器与intercom-client OIDC绕过漏洞,2026年4月
  • mistralai/guardrails-ai遭入侵及CSA Mini Shai-Hulud研究笔记,2026年5月
  • 需要完整排版与评论请前往来源站点阅读。