摘要:持续集成平台上的“workflow 创建型 secret 外泄”,指的是攻击者或误操作者通过新建或改写工作流定义文件,使流水线在受控上下文里主动读取仓库 Secret 并输出到日志、制品或外部端点。这类风险的关键特征在于:它绕过了“对既有工作流文件的修改保护”,因为攻击面不是修改,而是创建。本文以 GitHub Actions 与 GitLab CI 为对照样本,逐层拆解默认分支保护(branch protection / protected branch)在事件模型上的盲区,论证 push_restrictions 与 blocks_creations 的组合为何构成当前唯一能同时封堵“推送创建”与“分支创建”两条入口的配置,并给出可复现的策略设计、验证脚本与迁移路径。
目录
一、问题的提出:当“保护修改”遇上“允许创建”
在绝大多数团队的认知里,“分支保护”几乎等同于“工作流安全”。只要默认分支被保护、要求 PR 评审、禁止强制推送,似乎就能防止有人偷偷改一版 .github/workflows/*.yml 把 Secret 打到日志里。这个直觉在“修改型”攻击面前是成立的,但在“创建型”攻击面前会整体失效。原因并不复杂:分支保护规则通常以“对已存在分支的引用更新”为作用对象,而新建一个分支、再在新分支上放置一个全新的工作流文件,在事件层面是一次 create 而非 update。
笔者把这类风险统称为“workflow 创建型 secret 外泄”(workflow-creation secret exfiltration)。它的定义可以更精确一些:攻击者在不具备目标分支写权限的前提下,通过创建新分支或新引用,使平台在受信任的触发条件下执行一段由攻击者完全控制的工作流定义,从而读取仓库级或环境级 Secret 并外带。这里的关键词是“创建”与“受信任触发条件”的组合——单独看都不致命,组合起来就绕过了大部分默认防护。
为什么默认分支保护不够?笔者认为可以从三个层面回答。第一是对象错配:保护规则保护的是“分支引用”,而攻击利用的是“引用创建”。第二是触发面外溢:很多平台对 pull_request、push、workflow_dispatch 等事件的信任边界定义不同,创建型路径往往落在“半信任”区间。第三是默认值惯性:平台为了降低上手门槛,默认允许具备写权限的成员创建分支,而“写权限”在很多组织里是宽口径授予的。
需要强调的是,本文讨论的不是“平台漏洞”,而是“配置语义与威胁模型之间的错位”。这类错位在工程上更常见、更难被扫描器发现,也更依赖对平台事件模型的准确理解。后文将沿着“事件模型 → 配置语义 → 组合封堵 → 验证与迁移”这条主线展开,所有结论都尽量落到可验证的配置项与可复现的验证步骤上。
二、事件模型拆解:默认分支保护到底保护了什么
2.1 分支保护的作用对象是“引用更新”
以 GitHub 的 branch protection rules 为例,其核心能力包括:要求拉取请求评审、要求状态检查通过、限制强制推送、限制删除、要求线性历史、要求签名提交等。这些规则的共同点是,它们作用于“对受保护分支引用的更新操作”。换言之,规则判断的输入是“目标引用 + 操作类型 + 操作者身份”,而“新建一个不受保护的分支”并不在这个判断范围内。
GitLab 的 protected branch 语义类似:它控制谁能 push、谁能 merge、谁能 force push 到被保护的分支模式。注意这里的措辞是“到被保护的分支”,新建一个不匹配保护模式的分支,天然不在约束内。本文评述:这不是设计缺陷,而是保护模型的自然边界——保护规则是“按引用匹配”的,而攻击者只需要创建一个不匹配的引用即可。把边界误当成全覆盖,才是真正的风险来源。
为了更直观,笔者把常见保护能力与创建型路径的覆盖关系整理如下(基于各平台公开文档的语义归纳,非某一版本实测):
| 保护能力 | 作用对象 | 能否挡住“新建分支 + 新工作流” |
|---|---|---|
| 要求 PR 评审 | 合并到受保护分支 | 否,创建路径不经过合并 |
| 禁止强制推送 | 受保护分支引用更新 | 否,新分支不受此约束 |
| 要求状态检查 | 合并前检查 | 否,创建即触发 |
| 限制删除 | 受保护分支 | 否,与创建无关 |
| push_restrictions | 谁能向匹配引用推送 | 部分,取决于匹配范围 |
| blocks_creations | 谁能创建匹配引用 | 是,直接封堵创建入口 |
表中最后两行是本文的核心。需要说明的是,不同平台对这两个配置项的命名与粒度略有差异:GitHub 侧更接近“rulesets 中的 bypass 列表 + 分支创建限制”,GitLab 侧对应“protected branch 的 allowed to push / allowed to merge + 通配保护模式”。本文统一用 push_restrictions 指代“推送白名单”,用 blocks_creations 指代“创建拦截”。这种抽象是为了跨平台讨论,落地时请对照平台文档。
2.2 触发条件的信任分层
创建型外泄之所以危险,还因为它常常落在“半信任触发”上。以 GitHub Actions 为例,pull_request 事件来自 fork 时默认不携带 Secrets,这是平台有意设计的信任降级;但 pull_request_target 与 workflow_run 则可能在具备 Secrets 的上下文里运行,且其工作流定义来自默认分支或上游分支。攻击者只要能让“被执行的 YAML”来自自己可控的引用,就可能触达 Secret。
这里有一个容易被忽略的细节:很多团队把“默认分支上的工作流文件受保护”当成充分条件,但 workflow_run 的语义是“在上游工作流完成后运行”,其定义文件通常取自默认分支,这看起来是安全的;然而上游工作流的输入(如 artifact、PR 元数据、分支名)可能被攻击者控制,若下游工作流对这些输入做了不安全的拼接或检出,仍可能被利用。本文评述:信任分层的关键不是“文件在哪”,而是“文件与输入分别由谁控制”。只盯文件位置,会漏掉输入侧的创建型路径。
GitLab CI 侧的对应问题是“合并请求流水线”与“分支流水线”的变量可见性差异。受保护分支上的流水线可以访问受保护变量,而普通分支流水线默认不能;但如果项目允许成员创建与保护模式不匹配的分支,并允许该分支触发流水线,那么“创建分支 + 提交 .gitlab-ci.yml”依然可能构造出读取非受保护变量或探测配置的路径。严格说,受保护变量本身有额外保护,但“配置探测 + 组合利用”仍是现实风险。
2.3 一个最小化的攻击链模型
为了后续论证清晰,笔者给出一个最小化攻击链(仅用于防御分析,不涉及具体利用代码):
步骤 A:攻击者具备“创建分支”权限,但不具备向默认分支推送的权限。
步骤 B:攻击者创建分支x,提交一个读取 Secret 并外带的工作流文件。
步骤 C:攻击者通过某种受信任触发(如对默认分支发起 PR、触发 workflow_dispatch、或利用 workflow_run 的输入)使该定义被执行。
步骤 D:Secret 被写入日志、artifact 或外部请求,完成外泄。
这条链的每一环都有对应的缓解手段,但只有同时封堵“创建”与“推送”两个入口,才能在不依赖触发条件细节的前提下把链路切断。这正是 push_restrictions + blocks_creations 组合的价值所在:它把防御点前移到“引用是否被允许存在”,而不是“引用被如何使用”。
三、push_restrictions 与 blocks_creations 的机制与边界
3.1 push_restrictions:把“谁能写”收敛为白名单
push_restrictions 的本质是把“写引用”的权限从“角色继承”改为“显式白名单”。在默认模型下,具备 write 角色的成员通常可以向未受保护分支推送;一旦对匹配模式启用推送白名单,只有白名单内的用户、团队或应用身份可以推送。它的价值在于缩小可写入主体集合,从而减少“创建分支并放入恶意工作流”的可行主体。
但单独使用它并不充分。原因有二:其一,白名单若配置过宽(例如把整个研发团队列入),等于没有收敛;其二,推送限制通常与“创建”是两个动作,某些平台在创建引用时的权限判定与推送判定并不完全一致,存在“能创建但不能推送”或反之的缝隙。本文评述:把 push_restrictions 当成万能钥匙是常见的过度简化,它更像“收窄入口宽度”,而不是“关闭入口”。
工程上,建议把白名单粒度控制在“服务账号 + 少量发布负责人”,而不是“全员”。同时要定期审计白名单成员的有效权限,避免“离职未移除”“临时授权未回收”导致的隐性扩权。这部分属于权限治理,但与创建型风险直接相关,不能割裂看待。
3.2 blocks_creations:直接关闭“创建”这个动作
blocks_creations 的语义更直接:对匹配的引用模式,禁止不具备豁免资格的主体创建新引用。它封堵的正是 2.3 节攻击链的步骤 B。与 push_restrictions 相比,它的作用点更靠前——不是“创建后能不能写”,而是“能不能创建”。
这里需要澄清一个常见误解:blocks_creations 并不等于“禁止所有分支创建”。它通常支持按模式匹配,例如只对 release/*、ci/* 等与流水线强相关的命名空间生效,而对普通特性分支放行。这种“按命名空间收口”的做法,能在不显著影响日常协作的前提下,把“可承载工作流定义的分支”压缩到受控集合。笔者认为,这是该配置最被低估的工程价值:它不是一刀切,而是把风险面按命名空间重新划分。
边界条件同样要讲清楚。第一,若组织允许通过 API 或应用身份创建引用,需要确认这些身份是否在豁免列表内,否则会出现“人不能建、机器人能建”的绕过。第二,若平台对“创建”与“首次推送”合并判定,需实测确认拦截发生在哪一步,避免出现“引用已创建但内容未写入”的中间态。第三,跨仓库的 fork 场景下,创建发生在 fork 侧,主仓库的创建拦截不生效,此时需要配合“fork 流水线默认不携带 Secret”的信任降级一起看。
3.3 为什么“组合”才是充分条件
把两者放在一起看,逻辑就清楚了:blocks_creations 关闭“新建引用”入口,push_restrictions 收窄“写入既有引用”的主体。前者防“从无到有”,后者防“从有到改”。两者叠加,才覆盖了“创建型 + 修改型”两类路径。单独用前者,可能被“先创建再改写”的路径绕过(若创建豁免过宽);单独用后者,可能被“创建即触发”的路径绕过(若创建未受限)。
本文评述:安全配置的“充分性”从来不是单点属性,而是“覆盖关系”的属性。判断一个配置是否足够,不能只看它挡住了什么,还要看它没挡住的那部分是否被另一层覆盖。push_restrictions + blocks_creations 之所以被称为“唯一能挡住”,是在“不依赖触发条件细节、不依赖开发者自觉、不依赖扫描器覆盖”这三个约束下的唯一组合;若放宽约束,当然还有其他纵深手段,但它们要么是检测型、要么是补偿型,不属于同一层级的封堵。
为便于对照,笔者把常见方案按“封堵层级”归类如下(归纳性整理,非穷举):
| 方案 | 封堵层级 | 对创建型路径的有效性 |
|---|---|---|
| 默认分支保护 | 引用更新 | 低 |
| 工作流文件白名单扫描 | 检测 | 中,依赖规则覆盖 |
| Secret 最小化 | 补偿 | 中,降低影响面 |
| OIDC 短期凭证 | 补偿 | 中高,需正确配置信任策略 |
| push_restrictions + blocks_creations | 入口封堵 | 高 |
四、工程落地:策略设计、验证脚本与迁移路径
4.1 策略设计的三条原则
落地时最容易犯的错误是“照抄配置”。不同组织的分支命名、发布节奏、机器人身份差异很大,直接套用往往导致要么过严影响交付、要么过松形同虚设。笔者建议遵循三条原则:按命名空间收口、按身份收敛、按事件分层。
按命名空间收口,是指把“可承载流水线定义”的分支收敛到少数受控前缀,例如 release/*、ci/*,对其实施 blocks_creations;普通特性分支放行创建,但通过 push_restrictions 限制写入主体。按身份收敛,是指白名单只保留服务账号与发布负责人,避免“全员可写”。按事件分层,是指对 pull_request_target、workflow_run 等高信任事件单独审查其输入来源,不把“文件受保护”当成“输入可信”。
这里给出一份可直接对照的配置清单(以抽象语义描述,具体字段请映射到平台):
# 抽象策略清单(非可直接执行的平台配置)
rules:
- pattern: "release/*"
blocks_creations: true
push_restrictions:
- "svc-ci-bot"
- "release-manager-team"
- pattern: "ci/*"
blocks_creations: true
push_restrictions:
- "svc-ci-bot"
- pattern: "*"
blocks_creations: false
push_restrictions:
- "svc-ci-bot"
- "maintainers"
# 高信任事件审查项
events:
pull_request_target:
require_input_review: true
workflow_run:
require_upstream_allowlist: true
需要提醒的是,上例中的 * 通配规则要谨慎使用。若平台对通配的匹配语义是“最长匹配优先”,则 release/* 会覆盖 *;若语义是“全部匹配”,则可能出现规则叠加导致的意外拦截。上线前务必用测试仓库验证匹配顺序,这是最容易踩的坑之一。
4.2 验证脚本:用最小代价确认拦截生效
配置写完不等于生效。笔者建议用“负向验证”代替“正向确认”:不要只验证“白名单能推”,更要验证“非白名单不能建、不能推”。下面给出一段基于 git 命令行的验证思路(仅演示流程,不包含任何平台 API 密钥):
# 1) 以非白名单身份尝试创建受控命名空间分支(预期失败)
git push origin HEAD:refs/heads/ci/probe-$(date +%s)
# 2) 以非白名单身份尝试向既有受控分支推送(预期失败)
git push origin HEAD:refs/heads/ci/existing-branch
# 3) 以白名单身份执行同样操作(预期成功)
# 注意:验证后立即删除探针分支,避免残留
git push origin --delete ci/probe-xxxx
# 4) 记录返回码与平台审计日志,形成证据链
echo "exit_code=$?"
验证时要注意两点。其一,探针分支命名要带随机后缀,避免与真实分支冲突;其二,验证完成后必须清理,否则“为了验证安全而制造了新的可执行引用”,反而扩大了攻击面。本文评述:安全验证本身也要遵守最小影响原则,这一点常被忽略。
如果团队有条件,可以把上述验证固化为 CI 中的“策略回归测试”:每周以低权限身份跑一次负向用例,失败即告警。这样能把“配置漂移”从“事后发现”变成“持续可见”。关于策略即代码的通用做法,可以参考 GitHub 受保护分支文档 与 GitLab 受保护分支文档,两者对“谁能推、谁能合”的语义描述可以作为映射基准。
4.3 迁移路径:从“默认保护”到“入口封堵”
对已有大量仓库的组织,直接全量开启 blocks_creations 可能引发交付中断。笔者建议分四步走:盘点 → 灰度 → 观测 → 收口。
盘点阶段,统计各仓库的分支命名分布、机器人身份、流水线触发方式,识别哪些命名空间真正承载工作流定义。灰度阶段,先在低风险仓库对 ci/* 开启创建拦截,观察一周的失败推送与告警。观测阶段,重点看“被拦截的创建请求来自谁”,据此调整白名单,而不是简单放宽规则。收口阶段,把验证过的策略固化为组织级 ruleset,并对新仓库默认继承。
迁移中最容易被低估的是“机器人身份”。很多自动化脚本使用个人令牌而非服务账号,导致白名单无法稳定收敛。笔者的建议是:把“创建受控分支”的能力统一收归服务账号,个人令牌只保留读取与评论权限。这一步的收益不止于安全,也能显著降低“人走流程断”的运维风险。关于令牌与权限模型的最佳实践,可参考 GitHub Actions 安全加固指南 与 GitLab CI/CD 专题,其中对 Secret 作用域与触发上下文的说明较为系统。
五、对比、局限与前沿预判
5.1 与“检测型”方案的对比
检测型方案(如工作流文件静态扫描、异常外联告警)在创建型风险面前有其价值,但定位不同。扫描器依赖规则覆盖,面对“语法合法、语义恶意”的工作流,漏报率难以归零;外联告警依赖网络侧可见性,面对“写日志后人工读取”的慢速外带,检出窗口很长。本文评述:检测是必要的纵深,但不能替代入口封堵。把检测当封堵,等于把“是否被利用”交给规则覆盖率;把封堵当检测,等于放弃发现能力。两者应当并存,但优先级不同。
从工程成本看,入口封堵的一次性投入更高(需要盘点、灰度、治理身份),但边际成本低、误报少;检测型方案上线快,但需要持续运营规则、处理告警疲劳。对中大型组织,合理的顺序是“先封堵主干入口,再叠加检测补盲”。
5.2 局限与边界条件
必须承认,push_restrictions + blocks_creations 并非“零风险”。第一,它不覆盖 fork 场景:fork 侧的创建发生在攻击者自己的命名空间,主仓库的创建拦截不适用,此时依赖的是“fork 流水线默认不携带 Secret”的信任降级。第二,它不覆盖“白名单身份被攻陷”:若服务账号令牌泄露,攻击者可以合法创建受控分支,此时需要令牌轮换、最小权限与行为基线来补位。第三,它不覆盖“输入侧污染”:即使文件受控,若工作流对 PR 标题、分支名、artifact 内容做了不安全拼接,仍可能被注入。
因此,准确的表述是:在“不依赖触发条件细节”的约束下,它是唯一能同时封堵创建与推送两条入口的配置组合;但它不是全部,仍需与 Secret 最小化、OIDC 短期凭证、输入校验、审计告警共同构成纵深。笔者反对把它包装成“银弹”,也反对因为“不是银弹”而否定其入口封堵价值——这两种态度都不够工程化。
5.3 前沿预判
从平台演进看,笔者认为有三个方向值得关注。其一是“策略即代码”的进一步下沉:把分支创建限制、推送白名单、事件信任级别统一建模为可版本化、可测试的策略对象,减少“控制台点选”带来的漂移。其二是“身份绑定”的强化:把“谁能创建受控引用”与“工作负载身份”绑定,而不是与人类账号绑定,从而降低令牌泄露的爆炸半径。其三是“输入可信度”的显式化:平台若能在事件元数据中标注“该输入来自不可信来源”,工作流作者就能更早做防御性判断。
这些方向都指向同一个趋势:安全边界正从“文件位置”转向“身份 + 输入 + 动作”的三元组。本文评述:理解这一点,比记住某个配置项的名称更重要。配置会变,事件模型的信任分层不会轻易变;把分层想清楚,迁移到新平台时也能快速映射。
六、结论与展望
回到标题的问题:默认分支保护为什么不够?因为它保护的是“引用更新”,而创建型外泄利用的是“引用创建”。这两者在事件模型上是不同的动作,前者有规则覆盖,后者常常落在默认放行区。要封堵后者,必须显式地关闭“创建”入口,并同时收窄“写入”主体,这就是 push_restrictions + blocks_creations 组合的机制依据。
工程上,建议按“命名空间收口、身份收敛、事件分层”三条原则设计策略,用负向验证确认拦截生效,按“盘点 → 灰度 → 观测 → 收口”四步迁移,并把验证固化为周期性回归。需要清醒的是,这套组合解决的是“入口”问题,不是“全部”问题;fork、令牌泄露、输入污染仍需其他层补位。把边界讲清楚,比把结论喊响更有价值。
展望未来,随着策略即代码、工作负载身份、输入可信度标注的成熟,创建型风险的治理会从“配置技巧”走向“模型约束”。对从业者而言,值得投入的不是记住某个平台的某个开关,而是建立“动作—身份—输入”的三维威胁模型,并在每次平台迁移时重新映射。这才是本文希望留下的、可迁移的部分。
主要参考文献
- GitHub Docs. About protected branches. 2024. https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
- GitHub Docs. Security hardening for GitHub Actions. 2024. https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
- GitLab Docs. Protected branches. 2024. https://docs.gitlab.com/ee/user/project/protected_branches.html
- GitLab Docs. CI/CD variable security. 2024. https://docs.gitlab.com/ee/ci/variables/
- OpenSSF. Supply-chain Levels for Software Artifacts (SLSA) Specification v1.0. 2023. https://slsa.dev/spec/v1.0/
- NIST. Secure Software Development Framework (SSDF) SP 800-218. 2022. https://csrc.nist.gov/publications/detail/sp/800-218/final
- OWASP. CI/CD Security Cheat Sheet. 2024. https://cheatsheetseries.owasp.org/cheatsheets/CI_CD_Security_Cheat_Sheet.html
- CISA & NSA. Defending CI/CD Environments. 2023. https://www.cisa.gov/resources-tools/resources/defending-cicd-environments
- GitHub Docs. Events that trigger workflows. 2024. https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
说明:本文涉及的分支命名分布、白名单规模等为归纳性描述,未使用特定数据集;如需复现验证,请以自有仓库的审计日志为准,并在测试环境执行探针操作。
文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
内容仅供学习参考。如需引用,请以原始文献为准。 全文约 6800 字 | 参考文献 60 篇(主要 9 篇)。

