如何防止 GitHub Actions 依赖项被投毒
你的工作流使用了 actions/checkout@v4。今天,这个标签指向一个经过审核的版本。明天,一个被攻陷的维护者或攻击者可能将标签指向恶意代码。而你的流水线仍会携带 GITHUB_TOKEN 的访问权限去执行它。
第三方 Actions 本质上就是依赖项。一个浮动标签(floating tag)的行为类似于一个未锁定的包依赖。如果引用目标发生变化,即使你的仓库没有任何改动,工作流也可能执行不同的代码。
本文介绍如何防止持续集成(CI)依赖项被投毒:将 Actions 锁定到完整的 commit SHA,验证这些锁定值是否对应你审核过的发布标签,限制允许使用的 Actions 列表,并使用 Dependabot 自动化受控的版本更新。
适用人群:负责加固 GitHub Actions 工作流的平台及 DevSecOps 工程师。
前置条件:
拥有仓库管理员权限
工作流中使用
uses: org/action@ref语法
快速参考:
本文将指导你完成以下操作:
将每个
uses:引用锁定到一个完整的 commit SHA,而非@v4这类浮动标签。验证记录的 SHA 是否匹配你审核批准的那个发布版本。
在组织或仓库层面维护一份允许使用的 Actions白名单。
为 GitHub Actions 的版本更新启用 Dependabot,并配合人工审查。
优先使用官方或经过验证的创建者。如有必要,在内部镜像关键 Actions。
在合并前通过 Pull request 检查来验证锁定是否有效。
目录
为什么浮动标签不可靠
GitHub Actions 允许你用分支名、版本标签或 commit SHA 来引用依赖,但它们的稳定性完全不同:分支内容随时可能变化,版本标签在你审查之后也可能被挪到别的 commit 上,而 commit SHA 则精确指向某一个特定的版本。
下表对比了几种常见的引用方式,并说明可移动的标签为何会带来供应链风险:
| 引用方式 | 风险 |
|---|---|
@main |
workflow 启动时执行的是分支上的最新代码 |
@v4 |
标签可以移动,同一个名字可能执行不同的代码 |
@v4.2.1 |
更具体,但依然是可变的标签 |
@abc1234...(完整 SHA) |
精确指向一个不可变的 commit |
前三种引用方式的问题在于,名称无法永久锁定 GitHub 实际执行的代码。而使用完整 SHA 就能解决这个问题——只有仓库中的引用本身被修改时,workflow 才会发生变化。
核心观点:CI 流水线值得获得和应用代码同等的依赖管理纪律。
将 Action 固定到 Commit SHA
固定(Pin)一个 Action,就是把它引用的分支或版本标签替换成你审查过的那个 commit 的完整 SHA。这样 GitHub 检出的就是那个确切的版本,即使发布者之后移动了原来的标签也不受影响。
第一个示例是错误的,因为两个引用都使用了可变的版本标签。日后 workflow 可能在你的仓库没有任何改动的情况下,运行不同的 Action 代码:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
第二个示例是正确的,因为每个引用都使用了完整的 40 位 commit SHA。注释中保留了便于维护者阅读的版本号,但 GitHub 使用的是 SHA 而非注释:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@4992456781334f2795ae9dd169f5041797d85689 # v4.4.0
SHA 可以在该 Action 的 GitHub Release 页面找到,或者通过以下命令获取:
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
请使用你审核过的确切 Release 标签。在注释中添加人类可读的版本号以提高可维护性。虽然注释有助于审阅者理解,但它并非安全控制的一部分。GitHub 实际执行的是由 SHA 指定的提交。
不要使用截断的 SHA。完整的 40 字符 SHA 能降低意外冲突和审阅歧义的风险。
验证提交 SHA
完整 SHA 可防止合并后标签被移动导致工作流失效,但在批准前仍需验证该 SHA。Release 标签和提交应一起审阅。以下检查旨在比较已审阅标签背后的提交与工作流中记录的提交是否一致:
#!/usr/bin/env bash
set -euo pipefail
repository="actions/checkout"
release_tag="v4.2.2"
expected_sha="11bd71901bbe5b1630ceea73d27597364c9af683"
actual_sha="$({
git ls-remote "https://github.com/${repository}.git" \
"refs/tags/${release_tag}" "refs/tags/${release_tag}^{}"
} | awk -v tag="refs/tags/${release_tag}" '
$2 == tag "^{}" { print $1; found = 1 }
$2 == tag { fallback = $1 }
END { if (!found) print fallback }
')"
if [[ "${actual_sha}" != "${expected_sha}" ]]; then
printf 'SHA mismatch for %s %s\n' "${repository}" "${release_tag}" >&2
printf 'Expected: %s\nActual: %s\n' "${expected_sha}" "${actual_sha}" >&2
exit 1
fi
printf 'Validated %s@%s\n' "${repository}" "${expected_sha}"
这是发布引用验证,并不代表该仓库可信。请阅读 Action 源代码,审查其权限,并记录你批准该依赖的原因。在需要更高保障的环境中,建议内部镜像关键 Action,并通过常规的仓库控制措施验证镜像制品。
为 Actions 启用 Dependabot
Dependabot 是 GitHub 的自动化依赖更新服务。对于 GitHub Actions,它会检查工作流引用中是否有新版本,并发起 Pull Request 进行更新。这为你提供了一个可审查的场景,以便检查和批准 Action 更新,而非手动修改固定版本或使用浮动标签。
创建 .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
当固定 SHA 指向的动作有新版发布时,Dependabot 会发起 Pull Request。处理方式与应用程序依赖项的审查和合并相同。
在建立针对动作变更的审查流程之前,切勿配置 Dependabot 自动合并此类更新。一次更新可能引入新的权限、修改后的脚本或新的传递依赖。
每个更新 Pull Request 都要对比新旧提交,检查动作的发布说明,审查 action.yml、JavaScript 包、Shell 脚本及工作流权限的变化。确认新提交确实属于你打算采用的版本。若该动作涉及部署、发布、凭据或其他高影响操作,务必先在测试仓库或环境中运行工作流。
目的不是拒绝所有更新,而是让信任决策可见且可重复。合并前,审查者应能回答三个问题:代码改了什么、为什么需要新版本、这段代码可以使用哪些权限?
动作白名单
动作白名单是仓库或组织策略,限制工作流只能调用指定的发布者和仓库。它能降低贡献者将未知或未审查的动作引入可信流水线的风险。
组织管理员可在设置 → 动作 → 策略 → 允许指定的动作中配置。选择与工作流匹配的最严格策略,仅添加团队已审查的动作仓库。
白名单模式示例:
actions/checkout@*
actions/setup-node@*
actions/cache@*
docker/*
my-org/*
在此策略下,不符合已批准模式的动作将被拒绝。组织内的仓库默认继承该策略,具体受 GitHub 计划及仓库设置约束。
对于无组织策略的单个仓库,使用设置 → 动作 → 通用 → 允许 GitHub 创建的动作,并选择非 GitHub 动作,仅限已验证的创建者。
白名单限制的是动作身份,不能替代 SHA 固定。即便动作已被许可,在所有工作流中仍应以已审查的完整 SHA 引用。
在 Pull Request 中强制使用 SHA 锁定
在 CI 中运行一个 workflow 检查工具,并添加一个仓库检查:一旦某个 workflow 引入了非 SHA 的引用,检查就失败。注意,检查工具本身也要先用 SHA 锁定,才能放进受保护的 workflow 中使用。请把下面示例中的占位符替换为你所选 actionlint 版本经过验证的完整 commit SHA:
name: actionlint
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: rhysd/actionlint@03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7
with:
args: -color
解析 release tag、审查对应的 commit,然后记下完整的 40 位 SHA,做法和之前处理 actions/checkout 时一样。
你也可以用一个简单的检查脚本,捕捉常见的浮动引用。把它当作一个辅助信号即可,它不是 YAML 解析器,也算不上完整的供应链安全策略:
- name: Reject floating action tags
run: |
if grep -RInE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^#]+@(main|master|v[0-9]+)([[:space:]]|$)' .github/workflows/; then
echo "Floating action refs found. Pin to full SHA."
exit 1
fi
这个检查应在 pull request 和受保护分支上运行。但要注意,pull request 通过了这项检查,仍可能包含恶意 commit,所以还需要结合代码评审、白名单和 SHA 校验流程一起使用。
审查第三方 Action
把社区 action 加入白名单之前:
阅读该 action 的
action.yml和入口脚本。查看 star 历史、维护者声誉,以及未解决的安全 issue。
如果某个 action 很关键但由外部维护,就锁定 SHA 并 fork 到
my-org/action-name。
此外,还要检查 workflow 可获得的权限。一个能读取仓库 secrets 或写入 release 的 action,理应比一个只做文件格式化的 action 受到更严格的审查。把 workflow 的顶层权限设为最低必需值,然后只在必要时给单个 job 追加权限。
如何验证这些措施有效
对每个你批准的 action 运行 SHA 校验脚本,确认所有比对都能通过。
发起一个测试 pull request,把某个 action 改成
@main,确认 CI 检查会失败。在浮动标签后添加尾部注释,并确认解析器或 linter 依然会拒绝它。
确认组织策略能在测试工作流中拦截被禁止的 action。
确认 Dependabot 能发起 action 更新的 pull request,且该变更经过了正常审查。
审查工作流运行权限,确保 action 无法访问它不需要的凭据。
何时会失效
紧急补丁:SHA 固定会拖慢热修复。相比临时使用浮动标签,Dependabot 加上值班审查机制更为安全。
私有仓库中的复合 action:内部 action 的固定方式应与公共 action 保持一致。
可复用工作流:除了固定内部步骤,还需将可复用工作流的 ref 固定为 SHA。
附注标签与镜像:在记录 SHA 之前,先将附注标签解析为其对应的 commit,并通过自身的变更控制流程验证内部镜像。
虚假的安全感:若未阅读代码便进行固定,仍是在固定时刻信任维护者。需将固定策略与白名单、最小权限及代码审查相结合。
结论
本教程介绍了如何通过将 GitHub Actions 固定为已审查的 commit SHA、验证这些引用、对可信 action 实施白名单、启用 Dependabot 更新以及在 pull request 检查中强制执行固定策略,来防止被投毒的 CI 依赖。
这形成了一套分层控制机制:包括经过审查的不可变引用、受限的允许 action 集合、自动化的更新提案,以及能在合并前捕获回归问题的 pull request 检查。