作者视角:15 年经验安全工程师,习惯从现象倒推根因 栈:GitHub Actions(GHES 3.11 / github.com 通用)、Node 20、Docker 24、Harbor 2.9 结论先行:GitHub 的
GITHUB_TOKEN默认权限不是"只读",是"按仓库默认权限包给";而pull_request_target与pull_request的密钥可见性是两套完全不同的模型,混用其中任何一个,都等于把仓库钥匙递给一个能写 workflow 文件的人。
一、现象:一次"看起来正常"的 CI 失败,拖走了生产镜像拉取凭据
上个月给一家做 SaaS 的客户做季度安全复盘,触发点很具体:某次发版后,运维在 Harbor 的 audit log 里翻到一条不该出现的拉取记录——一个不在白名单里的 IP([已脱敏])在 02:47:13 成功 docker pull 了 harbor.internal/mall/order-svc:v3.8.2,用的凭据是 CI 专用的 CI_PULLER 机器人账号(该账号在 Harbor 侧权限是 read + pull,理论上只允许构建机网段 10.20.0.0/16 访问)。
倒查下来,攻击链完整且时间线很短:
- 01:12:03,一个外部 fork 的仓库向主仓库提交 PR,分支名
fix/typo-in-readme,diff 里 README 改动 3 行,另加了一个.github/workflows/ci-patch.yml(11 行,藏在 README diff 下方,肉眼极易漏看)。 - 01:12:09,主仓库的
pull_request_target事件触发,跑的就是攻击者写的ci-patch.yml——不是主仓库的那份。 - workflow 里第一步
env: TOKEN: ${{ secrets.GITHUB_TOKEN }},第二步curl -s -H "Authorization: token $TOKEN" https://api.github.com/user把 token 回显到 stdout(GitHub 会把 secrets 在日志里自动 mask,但只对"完整字符串"做替换,攻击者把 token 拆成两段拼接输出,就绕过了 mask)。 - 01:12:14,token 被 POST 到攻击者的 vps;01:13:02,攻击者拿这个 token 调
GET /org/<org>/actions/variables拿到CI_PULLER_PASSWORD(该 token 的默认权限里有read:actions)。 - 02:47:13,Harbor 拉取记录落地。
从 PR 到凭据落地,全程 10 分 10 秒,中间没有任何告警——因为每一步都"看起来是合法的 CI 行为"。
二、根因:三个默认值叠加,缺一不可
这件事的根因不是某一行代码写错,而是三个GitHub 侧的默认行为被同时踩中:
GITHUB_TOKEN的默认权限是"仓库默认 token 权限",不是"无权限"。 在没显式写permissions:的仓库里,这个 token 会带上contents:read、pull-requests:read、issues:read、actions:read(org 级 Actions 变量和 secrets 对read:actions可见)等一整套。也就是说,"我只是想跑个 lint"的 workflow,实际拿到的是一个能读 Actions 变量的 token。pull_request_target用的是"基线仓库的 workflow 文件",但触发条件是"任意 fork 的 PR"。 这是 GitHub 官方文档里明确写过的"powerful but dangerous"——它的存在意义是让"来自 fork 的 PR 也能用 secrets",代价就是你等于信任了所有能提 PR 的人写的那份 workflow 文件(在 fork 场景下,pull_request_target跑的是主仓库的 workflow,但pull_request事件跑的是fork 的 workflow——两者方向相反,很多人记反)。- secrets 的自动 mask 只防"整串出现在同一行"。 把 token 拆成
${{ secrets.GITHUB_TOKEN }}取出来后再做字符串切片、Base64 编码、URL 编码,任何一种变形都能让 mask 失效——这是设计上的限制,不是 bug,但所有"靠日志 mask 兜底"的假设都站不住。
把这三条叠起来:一个外部 PR + 一个显式 permissions: 缺失的 workflow + 一次 stdout 回显,就是一条完整的凭据外泄链。
三、具体做法:四道加固,按优先级排
1. 每个 workflow 文件显式声明 permissions:(最小权限)
# .github/workflows/ci.yml
permissions:
contents: read
# 明确不要 actions:read —— 这一行是本次事故的关键
# 明确不要 packages:read
on:
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
判断标准:写一个 workflow,先回答"这一步需要哪个 scope",答不上来就不给。默认 permissions: {}(空对象)等价于"一个什么也读不了的 token",对绝大多数 lint / test / build 场景足够了。
2. 事件类型选型:能用 pull_request 就别用 pull_request_target
pull_request(来自 fork):跑 fork 仓库的 workflow,secrets 不可见(GitHub 对 fork PR 默认不注入 secrets,除非你显式开secrets: inherit——不要开)。适合:lint、单测、构建(这些本来就不该碰 secrets)。pull_request_target:跑主仓库的 workflow,secrets 可见。适合:需要打 tag / 发 release / 写评论回 PR 的场景,且 workflow 文件本身必须被主仓库信任。workflow_run:跑主仓库的 workflow,触发条件是"另一个 workflow 跑完",不直接暴露给 PR 作者,是pull_request_target更安全替代。
反直觉点:很多人以为 pull_request_target 比 pull_request 更"安全",因为它跑的是主仓库的 workflow——错。它跑主仓库 workflow 的代价是"任意 fork 的 PR 都能触发它",而 pull_request 跑 fork workflow 的代价只是"fork 拿不到 secrets"。前者把触发面放大了,后者只把数据面收窄了。触发面 > 数据面,这是选型的核心原则。
3. 组织级兜底:token 权限默认收紧
在 GitHub 侧(不是 workflow 文件里)做两层兜底:
- Repository settings → Actions → Workflow permissions:选 "Read repository contents and packages permission",不要选 "Read and write"。
- Organization settings → Actions → Default permissions for organization-owned repositories:同样选 read-only,并且显式勾上 "Limit repository permissions for github_token"——这一项就是本次事故的直接开关。
- 如果开了 GitHub Advanced Security(AS):开启 "Block fork PRs from accessing secrets",这一项会让
pull_request_target在 fork 来源时直接拒绝注入 secrets,从根上切断本次链路。
4. 出站白名单:凭据不该有"能出去"的出口
CI 构建机(或 self-hosted runner)的 egress 应该走代理 + 白名单:
# runner 容器里
export HTTPS_PROXY=http://egress-proxy.internal:3128
# egress-proxy 只放行:
# api.github.com(拉代码/查状态)
# registry.npmjs.org / 内部 verdaccio
# harbor.internal:443
# 其他一律 403 + 落 audit log
本次事故里,token 外泄靠的是 curl 到攻击者 vps——如果 egress 白名单只放行 api.github.com 和镜像仓库,这一步 01:12:14 就会被 403 掉,攻击链在这里断掉。这是最后一道、也是最便宜的一道防线。
四、踩过的坑(都是反直觉的)
secrets的 mask 不是"加密",是"日志替换"。它只替换"完整字符串 + 出现在同一行 + 没被编码变形"的片段。任何"我打印一下调试看看"的习惯,在pull_request_target场景下都等于主动投递。permissions:写在 workflow 文件里 ≠ 写在仓库设置里。仓库设置是"上限",workflow 文件是"申请",最终权限 = min(上限, 申请)。所以上限收紧了,文件里写再宽也没用;上限放开了,文件里忘写就是全量。GITHUB_TOKEN和secrets.GITHUB_TOKEN是同一个东西,但 workflow 里env: TOKEN: ${{ secrets.GITHUB_TOKEN }}之后,TOKEN这个变量名不再被 mask(mask 只认secrets.GITHUB_TOKEN这个字面量)。所以"我先取出来再切片"是绕过 mask 的标准姿势。- fork 的 PR 标题、分支名、commit message 都是不可信输入。任何"根据 PR 标题决定跑哪个 workflow"的逻辑,都是把调度权交给了攻击者。
- self-hosted runner 比 GitHub-hosted runner 风险高一个量级。self-hosted runner 跑在你自己的机器上,egress 不经过 GitHub 的代理,所以出站白名单必须自己建——本次事故如果用的是 self-hosted runner,第 3 步的
curl会直接成功,攻击链更短。
五、结论与可复用清单
一句话结论:CI 密钥泄露的绝大多数案例,不是"密钥被偷",而是"触发面被放大"——一个能写 workflow 文件的人,拿到了一个不该有的 token,做了一件"看起来合法"的事。
可复用清单(按落地成本从低到高):
- 每个 workflow 文件显式写
permissions:,默认contents: read,不写actions:read。 - 能用
pull_request就别用pull_request_target;需要 secrets 的场景用workflow_run。 - 仓库设置 + 组织设置两层都把
github_token上限锁到 read-only,并勾 "Limit repository permissions"。 - self-hosted runner 必须挂 egress 代理 + 白名单,只放行代码源、镜像仓库、包源。
- 把"CI 凭据出现在非白名单 IP 的出站日志里"做成告警规则,本次事故里这条规则如果存在,01:12:14 就会有人被叫醒。
- 复盘时先问"触发面在哪",再问"数据面在哪"——触发面收窄比数据面加密便宜一个量级。
引用:镜像里"分层追加、永不改写"的密钥,和这里"token 拆分后绕过 mask"的密钥,本质是同一类问题——密钥一旦进入"攻击者可读"的介质,就默认已经泄露。另见 heapdump 端点把 3.2GB 内存转储拖走、AK/SK 当场落地的事故复盘,同样是"一个默认打开的端点 + 一个默认宽松的权限"叠加出来的攻击链。