一个独立开发者在 Hacker News 上看到 Megalodon 供应链攻击的新闻后,打开自己的 GitHub 仓库查看 Settings → Secrets,发现 AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY 已经在那里放了 14 个月没有轮换过,同时被 6 个 workflow 引用,其中 2 个还引用了第三方的社区 Action。
这才是 GitHub Actions 密钥管理的真实现状:密钥创建时觉得「先放进去,以后再说」,然后就没有以后了。
2026 年 5 月 27 日曝光的 Megalodon 攻击事件是一次清晰的警示——攻击者通过注入恶意 workflow 文件,在 6 小时内感染了 5,561 个公开仓库,专门收集 CI/CD Secrets 和云平台凭证。如果你仓库里的 AWS Access Key 被泄露,攻击者获得的不是临时会话,而是一把不会过期的钥匙。
AWS:用 OIDC 彻底删掉 Access Key
GitHub Actions OIDC 的工作方式不是「把密钥存得更安全」,而是根本不需要存。
原理很简单:GitHub 运行 workflow 时生成一个签名的 JWT,JWT 里包含仓库名、分支名、环境名、run_id 等 claim。AWS 端的 IAM 角色信任 GitHub 的 OIDC 端点(token.actions.githubusercontent.com),验证 JWT 签名和 claim 条件后,STS 发一个有效期默认 1 小时的临时 token。
第一步:在 AWS 创建 OIDC 身份提供商
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
一个 AWS 账号只需要执行一次。拇指指纹是 GitHub OIDC 证书的 SHA-1 值,当前仍是这个值。
第二步:创建 IAM 角色,信任策略只绑你的仓库
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:myorg/myrepo:ref:refs/heads/main",
"repo:myorg/myrepo:environment:production"
]
}
}
}
sub claim 别用 repo:myorg/myrepo:* 通配符。这个写法会让任何分支、任何 tag、甚至 fork 的 PR 都能拿到凭证。限定到 refs/heads/main 和 environment:production 才真正收紧了入侵面。
另一个容易被忽略的细节:用 repository_id 而不是 repository 名称。如果有人抢注了你删掉的仓库原名,repository 名称匹配会让他的仓库也能获取你的云权限。repository_id 是 GitHub 内部不可变的数字 ID,不存在重名攻击。
第三步:workflow 只需两行权限声明
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
role-session-name: gha-${{ github.run_id }}
role-session-name 不是装饰:它会出现在 CloudTrail 的 AssumeRoleWithWebIdentity 事件里,让你能把每条 API 调用追溯到具体的 workflow run。否则 CloudTrail 只记录一个随机 session ID,审计价值为零。
独立开发者在 GitHub Actions 到 AWS 的 OIDC 链路上经常遇到一个微妙问题:configure-aws-credentials Action 需要从 GitHub 下载,如果你的构建环境到 GitHub 的网络不稳定,整条 pipeline 会在第一步卡住。保持海外服务跑 GitHub Actions / Cloudflare 的稳定线路可以避免 CI 十分钟 timeout 只因为一个 Action 下载失败。
Azure 和 GCP 的 OIDC 配置差异
三朵云都支持 OIDC,但各自的术语和配置路径不同。
| 对比项 | AWS | Azure | GCP |
|---|---|---|---|
| 信任锚点 | IAM OIDC Identity Provider | App Registration + Federated Credential | Workload Identity Pool + Provider |
| 核心 Action | aws-actions/configure-aws-credentials@v4 | azure/login@v2 | google-github-actions/auth@v3 |
| 敏感信息 | 无 | 无(Client ID / Tenant ID 是标识符,存 Variable 即可) | 无 |
| 最低 sub claim | repo:org/repo:ref:refs/heads/main | 同左,在 Azure Portal 配 | 同左,且 2026 年起 GCP 强制要求 --attribute-condition |
| 动态凭证有效期 | 默认 1 小时,最长 12 小时 | 默认 1 小时 | 默认 1 小时 |
Azure 的关键区别是:Client ID、Tenant ID、Subscription ID 不是 Secret——它们只是指向资源的标识符。所以应该存在 GitHub Variables 里(不是 Secrets),这样 workflow 日志里被打印出来也不会造成安全风险。
GCP 在 2026 年的一个重要变化:新建 Workload Identity Provider 时强制要求 --attribute-condition 参数。这意味着你不能再创建一个没有任何过滤条件的 Provider,然后期望只靠 IAM binding 做控制——GCP 在入口层就要求你声明「什么仓库的令牌我才接受」。
GitHub Environments:按环境隔离密钥
即使你已全面 OIDC,仍然有一些密钥无法去掉:Stripe Secret Key、Resend API Key、Supabase Service Role Key、数据库连接字符串。这些第三方服务的密钥不支持 OIDC,你必须把它们放在某个地方。
GitHub Environments 为这些密钥提供了三层隔离:
第一层:值隔离。同一个 Key 名(如 DATABASE_URL)在 dev、staging、production 下绑定不同的值,workflow 里只引用 ${{ secrets.DATABASE_URL }},GitHub 根据 environment: 字段自动注入对应环境的值。不需要在代码里写环境判断。
第二层:审批门禁。在 Settings → Environments → production 里配置 Required reviewers(最多 6 人)和 Wait timer(最多 30 分钟)。生产部署前 GitHub 会暂停 workflow,等审批人点 Approve 或 Wait timer 倒计时结束才继续。
第三层:分支限制。production 环境可以限定只有 main 分支的 workflow 才能引用它的 Secrets。这意味着即使有人在你 feature 分支里写了一条 echo ${{ secrets.PRODUCTION_DATABASE_URL }},GitHub Actions 也不会注入密钥——workflow 连这个 Secret 的值都读不到。
Environment Secret vs Repository Secret 的关键差异
| 特性 | Repository Secret | Environment Secret |
|---|---|---|
| 读取时机 | workflow 排队时(queue time) | job 开始时(job start time) |
| 轮换后何时生效 | 下一次 workflow run | 同一 workflow 内立即可用(如果下一个 job 引用) |
| 审批门禁 | 不支持 | 支持 Required reviewers + Wait timer |
| 适用范围 | 仓库内所有 workflow | 仅声明了 environment: xxx 的 job |
Environment Secret 在 job 开始时读取,这个行为对轮换是利好:一个 workflow 跑 30 分钟,你在第 10 分钟通过 API 更新了一个 Environment Secret,它的下一个 job 会读取新值而不需要重跑整个 workflow。
剩余静态密钥的轮换流程
对于那些走不了 OIDC 的第三方 API Key,你需要一套可操作的轮换方案。一个独立开发者的 SaaS 不需要 90 天自动轮换的复杂管道,但需要一个不会出错的四步手动流程:
- 生成新 Key 并存为
SECRET_NAME_V2。不要直接覆盖旧值——如果在部署期间出问题,滚回时需要旧 Key 仍然有效。 - 更新 workflow 引用。把
${{ secrets.SECRET_NAME }}改成${{ secrets.SECRET_NAME_V2 }},提交并合并到 main。 - 验证生产环境正常运行。用一条端到端测试确认新 Key 能连通外部服务。这一步至少等 24 小时,因为有些外部服务的旧 Key 在新 Key 生成后不会立即失效。
- 删除旧 Secret 并重命名。确认新 Key 稳定后,在外部服务端吊销旧 Key,然后删除
SECRET_NAME,把SECRET_NAME_V2重命名为SECRET_NAME(GitHub UI 不支持重命名,需要通过 API)。
用 API 自动化写入
如果你需要批量轮换(比如 10 个仓库同时更新同一个第三方 Key),手动点 UI 太慢。GitHub REST API 写入 Secret 时需要先用 libsodium 加密:
# 获取仓库公钥
gh api repos/:owner/:repo/actions/secrets/public-key
# 用 Python 加密(libsodium sealed box)
python3 -c "
from base64 import b64encode
from nacl import encoding, public
import json, sys
pubkey = public.PublicKey('$(echo $PUBLIC_KEY | jq -r .key)', encoding.Base64Encoder())
sealed = public.SealedBox(pubkey)
encrypted = sealed.encrypt(b'new-secret-value')
print(b64encode(encrypted).decode())
"
# 写入新值
gh api --method PUT repos/:owner/:repo/actions/secrets/SECRET_NAME \
-f encrypted_value="$ENCRYPTED" \
-f key_id="$KEY_ID"
轮换的难点不是加密算法,而是保证旧 Key 在滚回路径上仍然可用。不要先删旧再建新——先建新再删旧,两次部署之间留足够的安全窗口。
Megalodon 之后的三个防御习惯
2026 年 5 月的 Megalodon 攻击揭示了一条清晰的攻击链:恶意 workflow 文件被注入仓库 → 执行时读取所有可访问的 Secrets → 解密后外传到攻击者服务器。以下三个习惯可以直接切断这条链中的关键环节:
-
固定第三方 Action 的 commit hash,而不是 tag。
uses: aws-actions/configure-aws-credentials@v4的@v4是一个可变 tag——攻击者如果可以推一个新的 v4 tag 指向恶意代码,你的 workflow 下次运行就会拉到他改过的版本。改用@e1e17a757e536b70fe1c0d7b85b2a4d3c6b87ea5(v4 的实际 commit hash),tag 可以被篡改,但 commit hash 不可变。 -
GITHUB_TOKEN默认权限设为 read-only。在每个 workflow 文件顶部的permissions:块里显式声明contents: read,而不是依赖仓库的默认设置。如果你的 workflow 不需要写 issue、不需要读 PR meta,就不要给这些权限。pull_request_target事件是最危险的——它用目标仓库的权限执行 fork 中的代码,能读到所有 Secrets。不到万不得已不要用它;如果非用不可,绝对不要在pull_request_target触发的 workflow 里 checkout fork 代码。 -
定期审计哪些 workflow 引用了哪些 Secret。GitHub 的 audit log 记录谁在什么时间创建/更新/删除了 Secret,但不记录 runtime 哪些 workflow 实际读取了它。你只能靠自己定期跑一轮
grep检查每个 Secret 被哪些 workflow 引用,把不再用的删掉。被遗忘的 Secret 是攻击者的第一目标——它被引用、没人盯着、过期了也不会被发现。