一个独立开发者在 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/mainenvironment: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,但各自的术语和配置路径不同。

对比项AWSAzureGCP
信任锚点IAM OIDC Identity ProviderApp Registration + Federated CredentialWorkload Identity Pool + Provider
核心 Actionaws-actions/configure-aws-credentials@v4azure/login@v2google-github-actions/auth@v3
敏感信息无(Client ID / Tenant ID 是标识符,存 Variable 即可)
最低 sub claimrepo: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 SecretEnvironment 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 天自动轮换的复杂管道,但需要一个不会出错的四步手动流程:

  1. 生成新 Key 并存为 SECRET_NAME_V2。不要直接覆盖旧值——如果在部署期间出问题,滚回时需要旧 Key 仍然有效。
  2. 更新 workflow 引用。把 ${{ secrets.SECRET_NAME }} 改成 ${{ secrets.SECRET_NAME_V2 }},提交并合并到 main。
  3. 验证生产环境正常运行。用一条端到端测试确认新 Key 能连通外部服务。这一步至少等 24 小时,因为有些外部服务的旧 Key 在新 Key 生成后不会立即失效。
  4. 删除旧 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 → 解密后外传到攻击者服务器。以下三个习惯可以直接切断这条链中的关键环节:

  1. 固定第三方 Action 的 commit hash,而不是 taguses: aws-actions/configure-aws-credentials@v4@v4 是一个可变 tag——攻击者如果可以推一个新的 v4 tag 指向恶意代码,你的 workflow 下次运行就会拉到他改过的版本。改用 @e1e17a757e536b70fe1c0d7b85b2a4d3c6b87ea5(v4 的实际 commit hash),tag 可以被篡改,但 commit hash 不可变。

  2. GITHUB_TOKEN 默认权限设为 read-only。在每个 workflow 文件顶部的 permissions: 块里显式声明 contents: read,而不是依赖仓库的默认设置。如果你的 workflow 不需要写 issue、不需要读 PR meta,就不要给这些权限。pull_request_target 事件是最危险的——它用目标仓库的权限执行 fork 中的代码,能读到所有 Secrets。不到万不得已不要用它;如果非用不可,绝对不要在 pull_request_target 触发的 workflow 里 checkout fork 代码。

  3. 定期审计哪些 workflow 引用了哪些 Secret。GitHub 的 audit log 记录谁在什么时间创建/更新/删除了 Secret,但不记录 runtime 哪些 workflow 实际读取了它。你只能靠自己定期跑一轮 grep 检查每个 Secret 被哪些 workflow 引用,把不再用的删掉。被遗忘的 Secret 是攻击者的第一目标——它被引用、没人盯着、过期了也不会被发现。

相关阅读