过去一年,开源供应链几乎成了攻击者的游乐场。Axios 在 npm 上被劫持,正常版本里混进了远程访问木马;LiteLLM 的 PyPI 包同样遭篡改——这些都不是理论风险,而是已经发生的事。攻击路径往往指向同一个薄弱环节:CI/CD 流水线的执行权限。谁能触发 workflow?谁能往流水线里注入步骤?谁能拿到签名密钥?这些问题如果没想清楚,项目再火也只是给攻击者递了一把更锋利的刀。
攻击者怎么利用你的 CI/CD
开源项目的 CI/CD 天然比私有项目更"开放"——任何人都能提交 PR、fork 仓库、触发部分 workflow。这种开放性正是攻击面:
- 恶意 PR 触发 workflow:攻击者提交一个看似正常的 PR,workflow 运行时执行了 PR 中注入的脚本,窃取仓库 secrets。
- Fork 仓库的 pull_request_target 陷阱:
pull_request_target事件允许来自 fork 的 PR 访问主仓库 secrets,如果 workflow 代码本身被 PR 修改并合并执行,密钥就泄露了。 - 标签/发布流程劫持:攻击者通过社会工程或权限漏洞推送恶意 tag,触发 release workflow,在发布产物中植入后门。
Axios 和 LiteLLM 事件的共同点:攻击者拿到了发布流程的控制权,然后用这个权限把恶意代码塞进了用户信任的包里。
GitHub Actions 里最危险的三个关键字
先看一段典型的"危险 workflow":
# ⚠️ 危险示例——不要直接用
name: CI
on:
pull_request_target: # 允许 fork 的 PR 触发,且能访问 secrets
push:
tags: ['v*']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # checkout PR 代码
- run: ./run-tests.sh # 执行 PR 中可能被修改的脚本
- run: echo "${{ secrets.RELEASE_TOKEN }}" | curl ... # 泄露密钥
三个问题叠加在一起:
pull_request_target:让 fork PR 能跑 workflow,同时赋予 secrets 访问权。- checkout PR head SHA:把 fork 的代码拉到能访问 secrets 的环境里。
- 执行 PR 中的脚本或引用 PR 修改的 action:攻击者控制了执行内容。
单独看每一步都有合理用途,组合起来就是密钥泄露的完整路径。
锁住流水线:分层防御策略
第一层:限制谁能触发
最直接的办法——把敏感 workflow 的触发条件收紧:
name: Release
on:
push:
tags: ['v*']
# 只允许特定分支的 tag 触发
workflow_dispatch:
# 手动触发,但加上权限限制
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
# 关键:限制执行者身份
if: github.actor == 'maintainer-handle' || github.actor == 'another-maintainer'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and publish
run: |
npm run build
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if 条件直接过滤执行者。不是 maintainer?workflow 跳过。这比事后审计快得多。
第二层:用环境保护规则做门禁
GitHub Environments 不只是部署目标,它是一套审批机制:
name: Publish to PyPI
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi-release # 关键行
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build package
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# secrets 在 environment 层面配置,需要审批才能访问
在 GitHub 仓库设置里配置 pypi-release 环境:
- Required reviewers:指定 1-2 个 maintainer 必须手动审批,workflow 才能继续。
- Wait timer:加一个 5 分钟等待窗口,给自动化攻击增加时间成本。
- Branch protection:只允许
main分支的 tag 触发这个环境。
这样即使攻击者推了一个恶意 tag,workflow 也会停在审批环节,maintainer 看到异常可以直接拒绝。
第三层:PR workflow 的安全写法
来自 fork 的 PR 仍然需要跑测试,但绝不能让 PR 代码接触 secrets:
name: PR Tests
on:
pull_request: # 注意:用 pull_request,不是 pull_request_target
permissions:
contents: read # 只给读权限,不给写权限
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 默认 checkout base 分支代码,不 checkout PR head
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -e ".[test]"
- name: Run tests
run: pytest tests/
# 这里不使用任何 secrets
关键选择:
- 用
pull_request而不是pull_request_target——前者不授予 secrets 访问权。 permissions显式声明为contents: read——最小权限原则。- 不 checkout PR 的 SHA,不执行 PR 中的自定义脚本。
如果确实需要 fork PR 访问某些 secrets(比如需要 API key 跑集成测试),用 pull_request_target 但绝不 checkout PR head SHA,也绝不 run PR 中可能被修改的文件。把测试逻辑完全放在 base 分支的代码里。
密钥管理:别把钥匙放在门旁边
CI/CD 里的 secrets 是最终目标。几个实操原则:
# 检查你的仓库 secrets 列表——定期审计
gh secret list -R your-org/your-project
# 删除不再使用的 secrets
gh secret delete DEPRECATED_KEY -R your-org/your-project
# 用 environment-level secrets 替代 repository-level secrets
gh secret set NPM_TOKEN --env npm-release -R your-org/your-project
- Repository secrets 对所有 workflow 可见——风险最高。
- Environment secrets 只在指定环境的 job 里可用——配合审批机制,安全性大幅提升。
- OIDC token(
id-token: write)比固定密钥更安全:PyPI、npm、AWS 都支持 OIDC 联合认证,不需要在 GitHub 里存任何长期密钥。
OIDC 发布到 PyPI 的配置:
- name: Publish to PyPI via OIDC
uses: pypa/gh-action-pypi-publish@release/v1
with:
# 不需要 PyPI API token,OIDC 自动获取短期凭证
permissions:
id-token: write # 必须声明
去 PyPI 项目设置里配置 Trusted Publisher,把 GitHub repository 和 environment 绑定好。之后发布完全不需要存储任何 token。
自检清单
把下面这些项逐条过一遍,你的项目 CI/CD 安全水位就会明显提升:
| 检查项 | 状态 |
|---|---|
所有 release/publish workflow 是否限制了触发者身份(if 条件或 environment 审批) |
☐ |
PR workflow 是否用 pull_request 而非 pull_request_target |
☐ |
使用 pull_request_target 时是否绝不 checkout PR head SHA |
☐ |
每个 workflow 是否显式声明了最小 permissions |
☐ |
| Repository-level secrets 是否已迁移到 environment-level | ☐ |
| 发布流程是否已迁移到 OIDC 联合认证(PyPI/npm/AWS) | ☐ |
是否定期用 gh secret list 审计并清理废弃密钥 |
☐ |
| Fork PR 是否禁止执行 PR 中自定义脚本或 action | ☐ |
开源项目的 CI/CD 安全不是一次性配置,而是持续维护。每次加新 workflow、新 secret、新发布目标时,问自己一个问题:如果攻击者控制了这个触发点,他们最多能拿到什么? 答案如果包含发布密钥或用户信任的产物,就需要加一层门禁。