GitHub ActionsのGITHUB_TOKENをRead既定に寄せた話

概要

  • ある組織で管理している複数リポジトリの GITHUB_TOKEN 既定権限を「Read and write」から「Read」に切り替えた。
  • そのうえで、各workflowのjobに、必要最小限の permissions: を書き足した。
  • あわせて actions/checkoutpersist-credentials: false を指定し、後段のstepからtokenを持ち出しにくくした。
  • ADRで方針を決め、3リポに展開した。
  • 切替の順序を間違えるとrelease系workflowがまとめて落ちるので、そこだけは注意して回した。

前提: permissions の3階層

GitHub Actionsのjobが使う GITHUB_TOKEN の権限は、次の3階層で決まる。

  • repository default: リポジトリ設定の Settings > Actions > General > Workflow permissions
  • workflow level: workflowファイルの最上位に書く permissions:
  • job level: 各job内に書く permissions:

下の階層で permissions: を書くと、上の階層の指定を丸ごと上書きする。
job levelにscopeを1つでも書いた瞬間、そのjobは他階層の指定を一切継承しない1

repository defaultがWriteのままだと、workflow / jobで permissions: を書き忘れた瞬間、そのjobは contents: write を含む広い権限で走る。
サードパーティactionがそのjobの実行中に GITHUB_TOKEN を触れば、リポジトリへの書き込みまで手が届く。

出発点

  • 全リポジトリでrepository default = Read and write permissions
  • workflowの8割以上で permissions: が未指定。
  • release用のworkflowはtag pushやrelease assetのuploadを実際に必要としており、書き込み権限が要る。
  • サードパーティactionはSHA pin済みだが、pin先のコミット自体を検証しているわけではない。

workflow側は「動けばいい」で書かれており、権限付与は既定値まかせだった。
攻撃者側から見ると、read-onlyな用途のjobにまで書き込みトークンが降ってきており、supply chain攻撃の刺さる面積は広い状態だった。

変更1: repository defaultをReadに寄せる

最初にやるのはrepository defaultの切替だ。
GitHub UIから Settings > Actions > General > Workflow permissions で変えられるが、複数リポで一貫して当てたいのでTerraformで書いた。

resource "github_actions_repository_permissions" "this" {
  repository                       = var.repository
  default_workflow_permissions     = "read"
  can_approve_pull_request_reviews = false
}

この設定だけを先にmergeすると、permissions: を明示していないjobはほぼ全滅する。
書き込みを伴うstepが一斉に落ちる形で表面化する。

  • git push を伴う自動コミット
  • release assetのupload
  • PR / Issueへのコメントやlabel操作

なので後述の順序で回す必要がある。

変更2: job levelに最小権限を書く

repository defaultをReadに落とすので、書き込みが必要なjobだけ、必要なscopeに絞って permissions: を書き足す。

tag pushを伴うrelease jobなら、こうなる。

jobs:
  release:
    runs-on: ubuntu-24.04
    permissions:
      contents: write   # tag と release 作成のため
      # 他の scope は指定しない = Read すら降りてこない
    steps:
      - uses: actions/checkout@<sha>
      - uses: softprops/action-gh-release@<sha>

permissions: をworkflow levelではなく job level に書く理由は2つある。

  • job levelで書けば、そのjob以外はrepository defaultのまま影響を受けない。
  • 同じworkflowに複数jobがあるとき、書き込みが要るjobだけに権限を閉じ込められる。

workflow levelに permissions: を書くと、そのworkflowの全jobに一律で継承される。
「lint jobにまでwriteが降ってくる」構図が復活してしまうので、書き先はjob levelに統一した。

変更3: actions/checkoutpersist-credentials: false

actions/checkout は既定で、GITHUB_TOKEN をローカルの .git/config にextraheaderとして書き込む2
これは後段のstepから git push するために必要な挙動だが、書き込みが不要なjobでは単にtoken漏洩の経路になる。

- uses: actions/checkout@<sha>
  with:
    persist-credentials: false

書き込みが必要なjob(release / bot系)だけ persist-credentials: true を明示し、それ以外はfalseに倒した。
job levelの permissions: contents: read と組み合わせると、GITHUB_TOKEN はcheckoutのHTTPS fetchにしか使われなくなる。

展開の順序

3リポで同じ変更が要ったので、次の順で回した。

  1. ADRで方針を書き、レビューを回す。
  2. 各リポのworkflow側PRで、job levelの permissions:persist-credentials: false を全jobに入れる。
  3. workflow側PRをすべてmergeした状態を確認する。
  4. Terraform側のPR(repository default = Read)をmergeする。

順序を逆にすると、Terraform mergeの直後に既存jobが全滅する。
「workflow側の穴埋めが終わった状態」を先に作ってから、defaultをReadに落とすのが安全側。

ハマったポイント

create-github-app-tokenid-token: write を要求する

App tokenを発行するstepは contents: read だけでは動かず、id-token: write が要る。
これは permissions: のscope一覧を眺めても直感的に選びにくいので、失敗ログを読んで補うしかなかった。
似た例として、aws-actions/configure-aws-credentials のOIDC経路も id-token: write が要る。

workflow_dispatch のRe-runが突然落ちる

repository defaultをReadにしたあと、過去に走らせたrunの Re-run all jobs を押すと、run当時のworkflow定義に対して 現在の repository defaultが適用される。
「昔動いていた再実行」が理由不明で落ちるので、リリース担当は気付きにくい。

📌 Important

repository defaultをReadに切替えた直後は、過去runのRe-runはほぼ確実に壊れる。
切替直後の再実行はRe-runに頼らず、workflow_dispatch を新規に発火し直す運用に寄せた方が事故が少ない。

pull_request_target を使うworkflowの見直し

GITHUB_TOKEN の権限を絞ると、pull_request_target トリガのworkflowで、外部コントリビュータPRに対して発火する処理の権限も同時に狭まる。
既存のlabel付与botやauto-assign系が壊れたので、job levelに pull-requests: write を明示的に足して回した。
このタイミングで「そもそも pull_request_target を使う必要があるのか」も一緒に見直せると、後の作業が減る。

まとめ

  • repository default = Readに落とす。
  • 書き込みが要るjobだけに、permissions:job level で書く。
  • actions/checkout は書き込みが要らないなら persist-credentials: false
  • 展開は「workflow側の穴埋め → repository default切替」の順で。

repository defaultがWriteのままだと、workflow側の書き忘れがそのまま「余計な書き込み権限を持つjob」になる。
defaultをReadに閉じてから、書き込みが要る場所だけ穴を開けていく方が、状態としては安全側に倒せる。

Built with Hugo
Theme Stack designed by Jimmy