给 GitHub Actions 增加自动标签、欢迎评论或审查机器人时,经常会遇到一个权限问题:普通 pull_request 工作流拿不到某些仓库密钥或写权限,于是有人把触发器换成 pull_request_target。这会让工作流运行在目标仓库的特权上下文中;如果随后检出并执行贡献者提交的代码,恶意改动就可能借此读取密钥、修改仓库或污染缓存。
安全配置的关键不是记住某个触发器“安全”或“不安全”,而是把不可信代码执行和高权限操作拆开。本文从事件差异、最小权限、危险组合、可用模板和验证步骤讲清楚这条边界。
先区分事件上下文和代码来源
GitHub Actions 的触发器决定工作流可见的事件上下文、令牌权限和可访问的密钥。对于外部贡献者发起的 Pull Request,常见选择是 pull_request 与 pull_request_target,两者不能只按“能否评论 PR”来选。
| 触发器 | 默认检出的代码 | 面向 fork PR 的典型权限 | 适合的工作 |
|---|---|---|---|
pull_request |
PR 合并后的测试合并提交 | 通常受限,fork 场景不会获得仓库密钥 | 构建、测试、静态检查 |
pull_request_target |
基础分支上的工作流上下文 | 目标仓库权限,可能可写并可访问密钥 | 读取 PR 元数据、打标签、发评论 |
workflow_run |
后续工作流由前一工作流完成事件触发 | 后续工作流可能有更高权限 | 对已完成任务做受控后处理 |
pull_request 执行的代码可能包含贡献者提交的测试、构建脚本和依赖配置,因此要把运行环境视为不可信输入。它的权限限制是安全设计的一部分,不能为了让测试方便而随意补入长期密钥。
pull_request_target 的工作流文件取自目标仓库的默认分支,并以目标仓库上下文运行。它可以读取 PR 元数据,适合给 PR 加标签或留言;但正因它可能拿到写令牌和仓库密钥,不能把 fork 的分支检出后执行其中的代码。
workflow_run 也不是自动安全的“第二阶段”。它可以在高权限上下文运行,但上游工作流产生的制品、缓存和文件都可能受到不可信代码影响。后续工作流应只读取经过校验的简单数据,不能把上游产物当成可信脚本运行。
为测试工作流明确设定只读令牌
先在仓库设置中把 GitHub Actions 的默认 GITHUB_TOKEN 权限设为只读,再在每个工作流或 Job 中声明确切权限。这样新加工作流时不会意外继承宽泛写权限。
以下工作流只运行 CI 检查,不需要写仓库:
|
|
这里的 contents: read 只允许读取仓库内容。persist-credentials: false 会阻止 checkout 把令牌持久化到本地 Git 配置中,适合测试任务不需要后续推送的场景。示例中的 Action 版本便于阅读;正式仓库可按组织策略使用完整提交 SHA 固定依赖版本,并定期更新。
如果 Action 不需要访问 GitHub API,可以进一步省略 Job 级 permissions,但应先确认仓库默认令牌权限确实已收紧。只在顶层设只读权限,再为确实需要写入的独立 Job 单独加权,比给整个工作流授予 contents: write 更容易审计。
把密钥从外部 PR 测试中移走
测试通常不应依赖生产部署密钥。可以把工作流拆为两个目的不同的流程:外部 PR 只执行不带密钥的测试;合并到受保护分支后,再启动部署工作流并通过 Environment 审批访问部署凭据。
|
|
如果云服务支持 OIDC,优先使用短期身份凭证,而不是在仓库保存长期云密钥。id-token: write 只允许申请 OIDC 身份令牌,并不等同于仓库内容写权限;云端仍需把信任条件限定到预期组织、仓库、分支或 Environment。按实际云平台文档配置受众和主体条件,不要只检查工作流一侧。
需要给 PR 打标签时使用 pull_request_target
元数据型自动化可以使用 pull_request_target,前提是 Job 只操作事件中的编号、标题、标签等元数据,不检出、不执行 PR 内容,也不把未受信任字段拼进 Shell 命令。
|
|
令牌权限收窄到标签操作所需的 pull-requests: write 和 issues: write。不同 API 操作实际所需权限可能不同,若调用失败,应根据 GitHub API 文档确认缺少的权限再逐项添加,不要直接改成所有权限。
将表达式结果放进环境变量后再作为带引号的参数传入,避免 PR 标题、分支名等文本被拼进 Shell 程序。不要写成下面这种形式:
|
|
PR 标题是贡献者可以控制的输入;直接插入脚本可能造成命令注入。即使当前只输出标题,也应按不可信文本处理。需要展示时通过安全转义后的 API 数据或专门 Action 处理,并限制传递给脚本的值。
pull_request_target 中不要出现的检出步骤
不要把下面的配置和高权限触发器放在一起:
|
|
这段配置检出了外部提交,并在有仓库权限的工作流中执行其依赖安装和测试脚本。恶意依赖脚本可以读取环境变量、窃取可见凭据,或对仓库发起写操作。把令牌隐藏在另一个步骤也不能解决问题:同一 Job 中的步骤共享运行环境,恶意程序可能从进程环境或文件中取到凭据。
persist-credentials: false 可以减少 checkout 凭据留在 Git 配置中的风险,但它不会把特权工作流变成安全沙箱,也不会撤销通过环境变量提供给进程的权限。不要用它为 pull_request_target 检出并执行不可信代码背书。
确实需要读取 PR 文件时拆成两段
有些自动化需要分析 PR 的补丁或构建结果,同时还要在检查完成后向 PR 发布结果。应先让无密钥、只读的工作流处理不可信代码,再让独立的受限后处理流程消费经过定义的结果。
一个较安全的设计原则是:第一段在 pull_request 下运行测试,只上传结构简单、尺寸受限的结果数据;第二段仅在可信条件成立时读取该数据,并通过 API 发布状态。第二段不执行上游上传的脚本、二进制文件或未经验证的序列化对象。
示意如下:
|
|
该示例展示数据流分离,并非可以不经审查直接套用的双工作流权限升级方案。Artifact 名称、PR 与运行 ID 的关联、制品来源、文件大小和 JSON 解析都必须验证。绝不能因为某个文件名叫 result.json 就信任它;来自 PR 工作流的上传内容由贡献者代码影响。
如果后处理工作流需要 workflow_run,应严格限定 workflows 与 types,确认触发运行对应目标仓库的预期工作流,并校验关联的 PR 编号、提交 SHA 和制品来源。最小化后处理 Job 的权限,避免 checkout 上游 PR,也不要在同一 Job 中运行来自制品的代码。对高风险写入动作,可以改由维护者手动触发,或让维护者审批 Environment。
如果不能可靠验证制品来源和内容,就不要自动授予写权限。保留只读 CI 检查,并由维护者在检查通过后手动发布评论或执行合并,是可接受的降级路径。
为写入操作单独设置 Job 和审批
不要让所有 CI 步骤都共享写令牌。测试、构建、发布评论、部署可以分成不同 Job;只有需要写权限的那一个 Job 声明相应权限,并且依赖前置检查。
|
|
needs 表示前置 Job 成功后才会继续,但它本身不是信任校验。条件表达式要和触发器、分支保护策略一起检查;不能让来自 fork 的工作流通过可控输入伪造“可信分支”的判断。
对生产部署,在 GitHub 仓库配置 production Environment,并设置必需审查者、允许部署的分支和环境密钥。审批通过后,部署 Job 才能访问相应环境密钥。注意:Environment 审批保护的是环境访问,不会隔离同一 Job 中运行的不可信代码,因此部署 Job 不能检出 fork PR 的代码。
审查第三方 Action 和缓存
每个 uses: 引用的 Action 都会在运行器上执行。它可以访问传给它的输入、环境变量和令牌上下文;即使 YAML 没显式传入 GITHUB_TOKEN,Action 仍可能从 GitHub 上下文获取令牌。因此要检查 Action 源码和版本,并为令牌设置最小权限。
对关键工作流,将第三方 Action 固定到完整提交 SHA,并从 Action 所属仓库核对该 SHA。标签如 @v4 容易阅读,但标签可能被移动;SHA 固定提高可复现性,代价是需要自动化或维护流程跟进安全更新。仓库若采用 GitHub 支持的工作流依赖锁文件,则按该机制审查和更新依赖。
缓存也可能成为跨信任边界的数据通道。不要让有写权限的特权工作流读取由不可信 PR 工作流生成的缓存内容并直接执行;也不要让不可信工作流覆盖受信任分支后续会使用的关键缓存。缓存键应包含锁文件或可信提交标识,命中缓存后仍需校验内容,不要把缓存当成可信构建产物。
在使用 pull_request_target 或 workflow_run 时尤其要注意共享缓存。GitHub 官方文档指出,这些特权触发器可能与基础分支共享缓存;如果不可信代码能够污染后续特权流程使用的缓存,风险会跨越工作流边界。
在仓库里检查实际令牌权限
配置合并后,不要只靠 YAML 代码审查推断实际权限。可以在测试分支触发工作流,查看运行页面显示的权限,也可以运行一个只输出权限范围的检查步骤;不要打印令牌本身。
例如,使用 GitHub CLI 检查工作流身份时只输出必要元数据,并确认环境没有记录敏感值:
|
|
这个请求用于验证读取能力,不会显示令牌。若要确认某个写入操作是否可行,应在测试仓库验证对应 API,并观察最小权限下的响应。不要为了让验证步骤通过就长期扩大生产仓库的权限。
还可以用 GitHub Actions 的 CodeQL 分析或 OpenSSF Scorecard 检查工作流中的危险模式、令牌权限和 Action 固定情况。静态分析能提示风险,但不能替代人工确认工作流是否执行了不可信代码、是否消费了不可信制品。
验收清单
合并工作流改动前,逐条核对:
- fork PR 的测试是否使用
pull_request,并且没有生产密钥? GITHUB_TOKEN默认权限是否只读,Job 权限是否单独收窄?pull_request_target是否只处理元数据,没有 checkout 或执行 PR 代码?- PR 标题、分支名、提交信息是否通过环境变量或安全 API 参数传递,而非插入 Shell 源码?
workflow_run是否校验上游工作流、运行 ID、PR 和提交 SHA?- 后处理 Job 是否只读取经过限制和校验的数据,没有运行上传文件?
- 第三方 Action 是否审查源码并固定版本或提交 SHA?
- 自托管 Runner 是否可能运行来自不可信分支的工作流?
- 缓存是否跨越了不可信工作流与特权工作流的边界?
- 写权限和环境密钥是否只开放给确实需要的 Job?
保护工作流文件本身
工作流定义位于 .github/workflows/。如果外部贡献者可以修改这些文件,而维护者只看到测试通过标记就自动合并,权限设计仍可能被 PR 改动绕过。对工作流目录设置 CODEOWNERS,要求维护者审查触发器、权限、Action 来源、表达式和 Runner 选择。
|
|
将示例中的团队名换成实际拥有审查能力的账号或团队。确认分支保护规则启用了 Code Owner 审查,并且拥有绕过权限的人数受到控制;仅添加 CODEOWNERS 文件而没有启用对应保护规则,并不会自动要求批准。
同样要审查 action.yml、可复用工作流、复合 Action 和构建脚本。工作流可能表面上只运行 npm test,但仓库的 package.json 可以把测试命令映射到任意脚本。安全判断要追踪完整执行链,而不是只看 YAML 中是否出现明显的 curl 或 bash 命令。
将事件元数据限制在预期仓库和分支
自动化若只面向默认分支上的 PR,可以明确列出基础分支,并检查事件上下文来自预期仓库。分支过滤可以减少误触发,但它不是权限隔离:外部 PR 仍然是不可信代码,不能因此在特权工作流中执行。
|
|
事件类型也应尽可能精简。只需要在新 PR 建立时加标签,就不必对每次提交同步、关闭和评论事件重复触发高权限流程。减少触发次数能降低误操作机会,也让审计记录更容易解释。
使用 github.event.pull_request.head.repo.full_name 判断来源仓库时,要考虑同仓库分支和 fork PR 的区别。字段可能为空,例如源分支被删除后;工作流应在缺少必要字段时安全失败,而不是回退到宽泛权限。任何来自事件 JSON 的字符串都应作为数据传递,不可拼接为 Shell 语句或文件路径。
如果工作流只允许针对某个仓库执行,还应确认事件中的 base.repo.full_name 与当前仓库匹配。对于可复用工作流,检查调用方传入的参数和权限声明,不要假设调用方一定是可信仓库。必要时把允许的仓库列表写在受保护的默认分支配置中,并由维护者审核变更。
在发布评论或标签前,可先验证 PR 编号为正整数、仓库名称符合 owner/repo 格式、标签名称属于固定集合。校验失败时停止工作流并记录不含敏感信息的原因。固定集合尤其适用于标签名和命令选项,可以避免意外把事件字符串解释为 CLI 参数。
检查运行历史与权限变更
安全审查不应只发生在首次配置时。定期检查 Actions 运行记录,特别关注新增的写权限、Secrets 使用、第三方 Action 升级、pull_request_target 触发器以及 Runner 标签变化。对意外的工作流运行,先暂停相关写入流程,再确认事件、提交 SHA、调用的 Action 和日志输出。
当日志中出现疑似凭据时,删除或限制日志访问并立即轮换对应凭据。自动脱敏不是绝对保证:编码、拼接、截断后的秘密可能不会匹配原始值。不要把真实令牌复制到调试命令、PR 评论或工单中;可以在临时测试仓库使用无权限的占位值复现数据流问题。
如果团队依赖多个维护者共同审查高权限流程,可以把工作流变更列入安全审查清单,记录变更目的、令牌权限为何必要、哪些输入不可信、是否会检出 PR 代码,以及如何回滚。发生权限误配时,先撤销不必要的写权限或禁用相关工作流,再检查令牌和环境密钥是否需要轮换。
常见失败表现与处理
fork PR 的工作流读不到 Secret
这是 GitHub 对不可信外部贡献者的保护机制。不要为了让测试通过而把 Secret 写进工作流文件,也不要将同一个测试工作流改成 pull_request_target 并执行 PR 内容。把凭据依赖的步骤移到受保护分支的后续流程,或为测试提供不含敏感数据的替代配置。
给 PR 加标签时收到 403
检查工作流的 permissions 是否包含操作所需的 pull-requests: write 或 issues: write,并确认仓库或组织没有进一步限制 Actions 的令牌权限。若组织禁止工作流创建或审批 PR,也应遵守该策略。只增加 API 明确要求的权限,不要用全权限令牌作为排错手段。
Actions 推送后没有触发下一个工作流
使用 GITHUB_TOKEN 执行的大多数仓库写操作不会启动新的工作流运行,这是为了避免递归触发。若设计依赖自动连锁流程,应重新审视事件模型;必要时通过明确授权的 GitHub App 或受控人工步骤实现,并评估递归、权限和审计风险。
不要简单替换成个人访问令牌来绕过限制。PAT 通常权限范围更广、生命周期更长,也绑定个人账户;如果确实需要额外权限,GitHub App 的安装令牌通常更适合按仓库和权限范围进行限制。
组织策略让写权限配置无效
仓库工作流的 permissions 不能覆盖更高层级的组织策略。检查组织 Actions 设置、仓库策略和分支保护规则,确认实际可用的权限范围。安全控制应由有权限的管理员在正确层级调整,不能在工作流中通过更换触发器来规避。
自托管 Runner 上运行外部 PR
自托管 Runner 通常不是每个 Job 都销毁重建的干净虚拟机,工作区、缓存、网络连通性和机器上的其他凭据可能留存。公开仓库尤其不应让不可信 PR 在可接触内部网络或长期密钥的自托管 Runner 上执行。
优先使用 GitHub 托管的临时 Runner。若必须自托管,至少隔离运行器组、限制可使用它的仓库和工作流,移除无关凭据与内部网络访问,并在任务后清理环境。仅在脚本末尾删除工作区不能保证系统状态已经可信。
按工作目的选触发器
| 目标 | 建议触发器 | 令牌范围 | 需要避免的动作 |
|---|---|---|---|
| 测试外部 PR | pull_request |
只读内容 | 注入生产密钥、持久化 Git 凭据 |
| 给 PR 打标签或评论 | pull_request_target |
仅所需评论/标签权限 | checkout PR head、运行 PR 脚本 |
| 部署受信任分支 | push 到受保护分支 |
只读内容及所需 OIDC 权限 | 在部署 Job 运行未审查 PR 代码 |
| 汇总前序工作流结果 | 有严格校验的 workflow_run |
最小写权限 | 直接执行上游制品或信任共享缓存 |
如果一项自动化同时要运行贡献者代码和写入目标仓库,把它拆成两个信任阶段。第一阶段只做不可信代码所需的测试;第二阶段只在受控条件下完成元数据写入或部署。检查阶段之间传递的内容应尽可能小、可验证,并且按不可信数据解析。