题记与说明
- 日期:2026-11-01
- 形式:主题研究(检索 5 个来源并精读,网络可用)
- 前缀:knowledge-x8tjinj_-2
- 主题:GitHub Actions 安全实践——权限最小化、OIDC、Fork PR、Secrets 与依赖钉扎
来源清单(本次实际精读)
- GitHub Docs — Security hardening for GitHub Actions(官方加固总纲)
https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
- GitHub Docs — Events that trigger workflows(
pull_request/pull_request_target/workflow_run权限语义)
https://docs.github.com/en/actions/reference/events-that-trigger-workflows
- GitHub Docs — About security hardening with OpenID Connect(OIDC 官方说明)
https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect
- GitHub Security Lab — Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests(作者 jarlob,2021-08-03 发布;示例代码 2026-06-17 更新)※ 本文为全文精读
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
- StepSecurity — Harden-Runner detection: tj-actions/changed-files action is compromised(2025-03 供应链事件复盘,CVE-2025-30066)
https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
提取方式说明:来源 4 通过 webReader 读到全文原文;来源 1/2/3/5 通过 WebFetch 对页面内容做定向提取(其中标注为引述的英文句子为提取结果中的原句)。GitHub 文档 URL 近期有过迁移(本次有两条旧 URL 返回 404 后改用现行地址)。
一、核心问题
GitHub Actions 把不可信输入(外部 fork 的 PR、第三方 Action、依赖包、PR 标题等事件上下文)和高权限资产(仓库写权限的 GITHUB_TOKEN、secrets、自托管 runner、云部署凭证)放进了同一个执行面。核心问题是:如何系统性收紧这条信任边界,使得"一次恶意 PR"或"一次第三方 Action 被投毒"不再等于仓库沦陷或 secrets 泄漏。五个子问题恰好对应主题的五个关键词:
GITHUB_TOKEN默认给多少权限、怎么收窄?- 云部署要不要把 AK/SK 长期凭证存成 secret?
- fork PR 的代码能不能跑?在哪个权限级别跑?
- secrets 怎么存、怎么避免被日志和第三方 action 拿走?
uses:引用的第三方 Action,版本引用是否可信、可否被事后篡改?
二、主要主张
2.1 权限最小化:默认拒绝、按 job 提升
- 官方将"给 token 最小权限"列为第一原则:*"you should grant the
GITHUB_TOKENthe least required access"*(automatic-token-authentication 文档),并建议 *"It's good security practice to set the default permission for theGITHUB_TOKENto read access only for repository contents."* permissions键可以在 workflow 级和 job 级分别声明(示例:workflow 级contents: read,job 级再加issues: write),即"先全局降到最低,再对需要的 job 单独开"。- 一个容易被忽略的洞:*"An action can access the
GITHUB_TOKENthrough thegithub.tokencontext even if the workflow does not explicitly pass theGITHUB_TOKENto the action."* —— 即便你没把 token 传给某个 action,它也能自己读到,所以收窄 token 权限比"不传 token"更根本。 - 超出
GITHUB_TOKEN能力的需求走 GitHub App installation token 或 PAT,而不是放大 token。
2.2 触发器即权限边界:pull_request vs pull_request_target vs workflow_run
官方 events 文档的原文(本次提取):
- *"With the exception of
GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repo"* —— fork PR 天然拿不到 secrets; - *"The
GITHUB_TOKENhas read-only permissions in pull requests from forked repositories."* —— fork PR 的 token 只读; pull_request_target则相反:*"This event runs in the context of the default branch of the base repository, rather than in the context of the merge commit, as thepull_requestevent does."* —— 用的是 base 仓库的代码、默认分支的 checkout,且带写权限和 secrets;官方警告该触发器配不可信代码会有 *"cache poisoning and granting unintended access to write privileges or secrets"* 的风险;workflow_run是"接力棒":*"is able to access secrets and write tokens, even if the previous workflow was not."*
pwn request(Security Lab 核心结论):*Combining pull_request_target workflow trigger with an explicit checkout of an untrusted PR is a dangerous practice that may lead to repository compromise.* 攻击路径很朴素——fork PR 提交恶意 build 脚本/新增 npm 依赖(preinstall/postinstall 钩子)/新测试用例,一旦特权 workflow 显式 checkout PR head(ref: ${{ github.event.pull_request.head.sha }}),不可信代码就在带 secrets 与写 token 的上下文里执行了。官方加固文档直接定性:*"These vulnerabilities can be exploited to take over a repository."*,并规定 *"Workflows that use these triggers must not explicitly check out untrusted code, including from pull request forks"*。
安全的两段式模式(Security Lab 给出的标准解,示例代码已更新到 2026-06 版且所有 action 钉 SHA):无特权的 pull_request workflow 跑构建并把 PR 号等结果存为 artifact 后退出;带特权的 workflow_run workflow 下载 artifact、校验(PR 号按整数解析校验)后再执行需要权限的动作。文中明确提示:artifact 本身也是不可信输入——"Artifacts resulting from untrusted PR data are themselves untrusted",绝不能在特权上下文里运行不可信 PR 构建出的二进制。
2.3 OIDC:用短期身份令牌替代长期云凭证
官方 OIDC 文档的机制:云侧先配置对特定 GitHub 仓库/工作流的信任(trust relationship);每个 job 运行时 GitHub 的 OIDC provider *"auto-generates an OIDC token"*(RS256 签名的 JWT,每个 job 唯一);workflow 步骤取 token 交给云厂商验签,云厂商校验 claims 后 *"provides a short-lived cloud access token that is available only for the duration of the job"*。关键主张:
- token *"is only valid for a single job, and then automatically expires"* —— 没有"轮换"问题,因为根本没有长期凭证可偷;
- 关键 claims:
iss=https://token.actions.githubusercontent.com;aud为受众;sub形如repo:octo-org/octo-repo:environment:prod,另有environment、ref、sha、job_workflow_ref等可用于收紧信任条件; - workflow 需要显式声明
permissions: id-token: write才能取 token(这也是最小权限原则的延续); - 2026 年的新变化:2026-07-15 之后创建的仓库(GHES 除外)默认使用包含 owner/repo ID 的不可变 subject 格式,防止改名后的 sub 混淆;
- 已知限制:官方加固文档注明 *"Support for custom claims for OIDC is unavailable in AWS."*;Dependabot 的 OIDC 支持仅限少数制品库(AWS CodeArtifact / Azure DevOps Artifacts / JFrog Artifactory)。
2.4 Secrets 管理:写权限即读权限
- 最刺眼的一条:*"Any user with write access to your repository has read access to all secrets configured in your repository."* —— secrets 的边界就是"有仓库写权限的人"的边界,所以 secret 数量本身要最小化。
- 结构化数据会击穿日志脱敏(redaction 靠精确匹配):*"Never use structured data as a secret"*(JSON/XML/YAML 整体做 secret 时,其中的值不会单独被脱敏),要拆成单个 secret;派生值(私钥签出的 JWT、base64/URL 编码后的值)也必须重新注册为 secret 才会被脱敏。
- 脱敏只在"本 job 用过该 secret"时生效;一旦日志里出现未脱敏的 secret:删日志 + 轮换。
- 临时值可用
::add-mask::VALUE动态脱敏;环境 secrets 可加 required reviewers(job 拿不到批准就不给 secret)。 - 脚本注入防御:
${{ github.event.pull_request.title }}这类不可信上下文绝不能直接插进run:脚本文本,应先放进中间环境变量再引用(*"doesn't interact with the script generation process"*),或改用 action 传参。
2.5 依赖钉扎:tag 可被追溯改写,SHA 不可
- 官方建议钉 full-length commit SHA,理由是成本不对称:*"This helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision"*;同时要核实 SHA 来自原仓库而非 fork;只有在信任作者时才允许用 tag(tag *"can be moved or deleted"*)。
- 2025-03 的 tj-actions/changed-files 事件(CVE-2025-30066 / GHSA-mw4p-6x4p-x5m5)把这条建议变成了实证:攻击者拿到维护者 bot 账号(@tj-actions-bot)的 PAT 后,把 v1 到 v45.0.7 的所有历史 tag 追溯改指到同一个恶意 commit(
0e58ed8671d6b60d0890c21b07f8835ace038e67)。恶意脚本从 Gist 下载memdump.py,读/proc定位Runner.Worker进程内存,grep 含"isSecret":true的片段,base64 两次后直接 echo 进构建日志——公开仓库里谁都能读到。StepSecurity 的估计是约 23,000 个仓库受影响。正因 tag 可追溯改写,"钉大版本 tag"在这次事件里形同虚设,只有钉 SHA 的用户天然免疫。 - 官方加固文档的新趋势:actions lockfile 成为更强方案——记录包括传递依赖在内的精确 commit 并在运行时校验来源身份,*"supersedes SHA pinning for repositories that adopt it."*
- 钉 SHA 的代价(官方自己承认的权衡):*"Dependabot ... only creates alerts for vulnerable actions that use semantic versioning and will not create alerts for actions pinned to SHA values."* —— 钉 SHA 会失去漏洞告警可见性,需要靠 Dependabot version updates(支持更新 SHA 引用并带同位注释)或 lockfile 补位。
三、方法(技术路线与论证方式)
- 规范层:GitHub 官方文档采用"威胁清单 + 加固清单"结构——先枚举信任边界(fork 代码、第三方 action、自托管 runner、持有写权限者),再对每条边界给默认隔离机制(fork 只读 token/无 secrets)与显式逃生门(
pull_request_target、workflow_run、environment reviewers),并配套检测设施(CodeQL Actions 扫描、OpenSSF Scorecards、依赖图、审计日志事件如org.update_actions_secret)。 - 攻击研究层:Security Lab 从真实上报的漏洞中归纳反模式,用 CORS/SOP 类比讲清
pull_request(默认隔离)与pull_request_target(显式放宽)的关系;给出"漏洞/非漏洞"判别式——PR 内容被当作被动数据(diff、grep)是安全的,一旦影响构建执行流程(npm install、make)即危险。 - 实证层:StepSecurity 用运行时 egress/行为监控在生产事件中取证(异常出口
gist.githubusercontent.com触发告警),并复现利用(github-actions-goat 测试仓);事件时间线(2025-03-14 15:00 UTC 多版本被改 → 03-15 GitHub 下架/清理 → 03-18 关联 reviewdog org 多个 action 被发现失陷)提供了供应链攻击的完整样本。 - 三角互证:官方规范(应然)+ Security Lab 漏洞模式(攻击可行性)+ 真实事件(现实发生概率)共同支撑同一套建议。
四、数据与证据(含关键原文引述)
| # | 证据 | 出处 |
|---|---|---|
| 1 | fork PR 默认隔离:*"The GITHUB_TOKEN has read-only permissions in pull requests from forked repositories."*;*"With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repo"* | GitHub Docs: Events that trigger workflows |
| 2 | pwn request 定性:*"Combining pull_request_target workflow trigger with an explicit checkout of an untrusted PR is a dangerous practice that may lead to repository compromise."*;*"These vulnerabilities can be exploited to take over a repository."*;修复建议含 label 门控仅作临时方案(*"prone to a race condition in which the attacker may push new changes after the workflow was approved ... but has not started yet"*) | GitHub Security Lab: Preventing pwn requests |
| 3 | token 落盘坑:actions/checkout 的 persist-credentials 默认为 true,*"it makes it even worse ... any running code can simply read the stored repository token from the disk"* | GitHub Security Lab: Preventing pwn requests |
| 4 | 修复后遗留 PR 坑:*"All PRs that were opened before a fix was made to the vulnerable workflow will use the version of the workflow as it existed at the time the PR was opened."* → 需关闭或 rebase 旧 PR | GitHub Security Lab: Preventing pwn requests |
| 5 | secrets 边界:*"Any user with write access to your repository has read access to all secrets configured in your repository."*;结构化数据/派生值脱敏失效两条规则 | GitHub Docs: Security hardening / Secure use reference |
| 6 | SHA 钉扎理由:*"they would need to generate a SHA-1 collision"*;lockfile *"supersedes SHA pinning"*;Dependabot 告警对 SHA 钉扎不生效 | GitHub Docs: Security hardening for GitHub Actions |
| 7 | tj-actions 事件量化:约 23,000 仓库受影响;v1–v45.0.7 全部 tag 追溯改指恶意 commit 0e58ed86...;payload 从 Runner.Worker 进程内存 grep "isSecret":true 后双 base64 打进日志;入口为 @tj-actions-bot 的 PAT 失陷;检测靠 egress 异常(gist.githubusercontent.com);*"There is no evidence that the leaked secrets were exfiltrated to any remote network destination"* | StepSecurity 复盘 + GHSA-mw4p-6x4p-x5m5 |
| 8 | OIDC 短期性:*"is only valid for a single job, and then automatically expires"*;permissions: id-token: write;2026-07-15 后新仓库用含 owner/repo ID 的不可变 sub 格式(GHES 除外);AWS 不支持自定义 claims | GitHub Docs: About security hardening with OpenID Connect |
| 9 | 自托管 runner:*"can be persistently compromised by untrusted code in a workflow"*,公共仓库*"should essentially never be used"*;JIT runner *"perform at most one job before being automatically removed"* | GitHub Docs: Security hardening(runner 章节) |
证据充分度评估:官方规范与攻击模式互相印证,且有一条真实大规模事件作实证,主张的可信度高。不足:除 tj-actions 外缺少"遵循这些实践能降低多少风险"的量化研究;Security Lab 文章发布于 2021 年(文中锁文件等新机制未覆盖,需以现行文档为准)。
五、结论
GitHub Actions 安全本质是信任边界管理,可压缩成一套最小实践集:
- 权限:workflow 级先写
permissions: contents: read,job 级按需提升;默认只读要成为肌肉记忆。 - Fork PR:不可信代码只允许在
pull_request(无 secrets、只读 token)下跑;需要写权限/secret 的动作放到pull_request+workflow_run两段式,artifact 按不可信数据校验;pull_request_target仅限"不 checkout PR 内容"的场景,且永不显式 checkout fork head。 - OIDC:云部署一律用 OIDC 短期 token 替代长期 AK/SK secret;信任条件写到
sub/aud级别。 - Secrets:能不存就不存;绝不整体存 JSON/YAML;派生值也要注册;泄漏即删日志+轮换;高敏 secret 用 environment + required reviewers 门控。
- 依赖钉扎:第三方 action 钉 full-length commit SHA(或采用 actions lockfile);配合 CODEOWNERS 保护
.github/workflows/、开启 Dependabot version updates 弥补 SHA 钉扎下的告警盲区;公共仓库基本不用自托管 runner。 - 检测:CodeQL Actions 扫描 + OpenSSF Scorecards + 审计日志 + 运行时 egress 监控(tj-actions 正是被 egress 异常抓到的)。
反直觉要点合集:persist-credentials 默认 true(token 落盘);修复 workflow 后旧 PR 仍跑旧版 workflow(需 close/rebase);"safe to test" label 门控有竞态;pull_request_target 默认 checkout 的是 base 分支——常见"失效但不构成漏洞"的假 CI;Dependabot 安全告警看不见 SHA 钉扎的 action。
六、局限
- 文档性来源为主:GitHub Docs 是规范而非度量研究,没有"采纳率/风险下降幅度"的定量证据;事件证据以个案(tj-actions)为主。
- 时效分层:Security Lab pwn requests 原文 2021 年发布(示例 2026-06 更新过),未涵盖 lockfile 等新机制;OIDC 不可变 subject 规则(2026-07-15 起生效)非常新,生态工具(第三方 SSO/云厂商模板)适配情况未逐一验证。
- 来源立场:StepSecurity 复盘出自安全厂商,检测叙事天然推销其 Harden-Runner 产品;事件根因(PAT 如何失陷)官方亦未查明(*"The exact method of PAT compromise is unknown"*)。
- 本次阅读方式:来源 1/2/3/5 经 WebFetch 定向提取(小模型对页面作答),仅来源 4 为全文精读;引述句以提取返回的原文为准,个别长段落的上下文完整性弱于逐字通读。
- 未覆盖面:reusable workflow 的调用方权限、artifact attestation/签名验证、GitHub App token 替代方案、Enterprise 策略(org 级默认 GITHUB_TOKEN 设置、允许的 action 白名单)本次仅带过未展开。
七、对我的启发
- 对我自己仓库的立即行动:给所有 workflow 顶部加
permissions: contents: read,需要写权限的 job 单独声明;uses:全部换成 full-length commit SHA(可借 Dependabot version updates 保持更新,弥补告警盲区)。 - 模板即防线:把 Security Lab 的
pull_request+workflow_run两段式沉淀成自己的可复用 workflow 模板(含 artifact 校验、persist-credentials: false、action 全部钉 SHA 的版本),新项目直接继承,而不是每次重写时重新踩坑。 - 把 OIDC 当默认而非优化:任何涉及云厂商部署(AWS/Azure/GCP)的项目,第一选择是 OIDC trust policy,secret 只留真正不可联邦化的凭据;这直接消灭了"日志泄漏 secret"这一整类事故。
- 审计视角迁移:review 别人/生成的 workflow 时,先看三件事——
on:里有没有pull_request_target、run:里有没有插值${{ github.event.* }}、uses:是不是 tag 引用;这三处命中就是高危信号(CodeQL/Scorecards 的关注点也是如此)。 - 写自动化流水线的通用原则:可追溯引用(SHA/lockfile)+ 短期凭证(OIDC)+ 权限默认拒绝(permissions 键)+ 不可信输入隔离(触发器边界)——这套组合不依赖具体平台,迁移到其他 CI(GitLab CI、Gitea Actions)时对应物基本都存在。
*笔记由精读员·#2 于 2026-11-01 生成;仅写入本文件,未改动其他条目笔记、matrix.csv、backlog 或 nightly-log.md。*