Engineering

GitHub Actions の workflow を 4 項目で安全側に倒した

  • GitHub Actions
  • CI/CD
  • Security
  • DevOps

ラビー合同会社のorg配下でworkflowを持つリポジトリに対して、GitHub Actions のセキュリティ設定を4項目に揃えました。workflow level での defaults.run.shell: bash 宣言、actions/checkout への persist-credentials: false、サードパーティアクションの commit SHA pin、permissions の最小化です。4項目をテンプレートに落とすと最小例はこの形になります。

permissions:
  contents: read

defaults:
  run:
    shell: bash

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

ベストプラクティスを当てれば動かなくなる workflow もあります。git push を含むSwiftMilkdownrelease.yml や、actions/checkoutsubmodules: true を併用しているworkflowが該当します。どこで線を引いたかも合わせて記録します。

GitHubLabeeLabee LLC. Labee has 7 repositories available. Follow their code on GitHub.

対象リポジトリと進め方

ラビー合同会社のorgには、CIだけのworkflowもあれば、Cloudflare Workers や Cloudflare Pages へのデプロイ、Swiftパッケージのリリースなど、用途の違うworkflowを持つリポジトリが並んでいます。今回はそのworkflow群に対して、4項目を順に当てていきました。

旧運用のメモには「deploy/release専用ワークフローは対象外」というルールが残っていましたが、適用直前にこのルールは撤回しました。deploy/release こそクレデンシャルを扱うため、persist-credentials: false の効果が大きいからです。CI workflow はテストを動かすだけでGitHubの操作は限定的ですが、release系のworkflowはAPIトークンや署名鍵を扱う上にGitHub操作も多く、デフォルト設定のままだとトークンが各stepから見えるワークスペースに置かれ続けます。守る価値が大きいのは後者の方です。

アーカイブ済みのリポジトリはworkflowが残っていても対象外としました。動いていないものに手を入れる意味はありません。

defaults.run.shell: bash を workflow level で宣言する

1項目目は、workflowのトップレベルで defaults.run.shellbash に固定することです。

defaults:
  run:
    shell: bash

GitHub Actionsの run: ステップは、ランナーOSに応じて既定シェルが変わります。Linux/macOSは bash--noprofile --norc -eo pipefail {0})、Windowsは pwsh です。workflow levelで bash を明示すれば、ステップごとに shell: bash を書く重複が消えます。10ステップあるworkflowで毎回 shell: bash と並べるのは現実的ではありません。

もう1つの効果は、ランナーOSが将来変わったり、matrixで windows-latest を混ぜたりしたときに暗黙の挙動差を抑えられることです。bash の既定オプションには -eo pipefail が含まれており、パイプの途中で失敗したコマンドがあればステップ全体が落ちます。set -e を忘れた sh 系のシェルだと、エラーを握りつぶしたままステップが成功してしまうことがあります。CI が緑になっているのに途中で失敗していた、というのは一番見つけにくい類のバグです。

うちのorgのworkflowはほぼ ubuntu-latest で動いていますが、それでも明示しておく価値があると判断しました。書いてあるworkflowを見て「このスクリプトは pipefail 前提だ」とすぐ分かること自体がドキュメントになります。

persist-credentials: false で GITHUB_TOKEN を残さない

2項目目が一番効果の大きい変更で、actions/checkoutpersist-credentialsfalse にすることです。

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  with:
    persist-credentials: false

actions/checkoutpersist-credentials 入力の既定値は true で、checkoutが終わるとワークスペース上のgit設定にGITHUB_TOKEN(もしくはSSH鍵)が残ります。これは、後続のステップで git pushgit fetch を認証なしに書きたいケースのための仕組みです。v6では $RUNNER_TEMP 配下に別ファイルとして保存される設計に変わり、.git/config への直接埋め込みは避けられるようになりましたが、それでもワークスペースを触れるステップ全てがトークンへ到達可能という構造は変わりません。

ここで気になるのは、後続のステップで動かすツールやスクリプトです。npm installpip installpnpm install といったパッケージインストールは、依存パッケージのインストールスクリプトを実行します。そのなかでgit configを読むものがあれば、トークンが流れる経路ができます。サプライチェーン攻撃の文脈では、悪意のあるパッケージが .git/config$RUNNER_TEMP のクレデンシャルを読み出して外部に送る、というシナリオが現実的です。persist-credentials: false にしておけば、checkout後のgit設定にトークンは残らず、後続のステップからは見えなくなります。

CI workflowで git push が必要なケースは稀です。テスト、lint、型チェック、ビルドが主な仕事で、リポジトリへの書き込みは発生しません。「とりあえず false」を既定にして、必要なケースだけ true に戻す方が健全な運用です。

git push を含む release.yml では false にできない

例外は1つあります。SwiftMilkdownrelease.yml は、Swiftパッケージのバージョンタグを切ってリリースする際に workflow 内で git push を実行します。タグ付与とコミット追加が両方発生するため、persist-credentials: false にすると git push で認証エラーになります。意図的に未適用にして、PR 本文に「git push のため適用不可」と明記しました。

代替案としては、checkoutを2回呼んで「ビルドだけは persist-credentials: false、push する直前のステップだけ persist-credentials: true」と分ける書き方があります。SwiftMilkdown の場合、release workflow は短く、依存パッケージのインストールも限定的なため、現状はそのまま既定動作を許容しています。判断のラインは「サプライチェーン由来でトークンが盗まれるリスクと、checkoutを2回呼ぶ複雑さのどちらが大きいか」です。

submodules: true を併用する workflow

もう1つ気にしたのが、actions/checkoutsubmodules: true を併用するworkflowです。submodule が public なら persist-credentials: false でもチェックアウトは成功しますが、private な submodule では認証ができず失敗します。適用時点では現状の submodule が public だったので問題は出ませんでしたが、将来 private に切り替えたときに気づけるよう、PR 本文に「private submodule に切り替えた場合は CI が失敗する」と注記しました。動いた直後ではなく、後から仕様変更が起きたときに気づける形で残しておく方が安全です。

SHA pin でサプライチェーンを切る

3項目目は、サードパーティアクションを commit SHA で pin することです。

# 望ましい
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# 望ましくない
- uses: actions/checkout@v6

タグ参照は書き換え可能で、v6 のような major タグはマイナーバージョンが上がるたびに最新コミットを指すように更新されます。アクション作者にとっては便利な仕組みですが、利用者からすると 今日 v6 で動いていたコードが明日には別のコードを実行する ことを意味します。アクションのメンテナーアカウントが乗っ取られたり、メンテナーが悪意あるコードを混ぜたりすれば、それを参照する全リポジトリのCIが影響を受けます。

commit SHA で pin すれば、その時点のソースコードに対する不変の参照になります。後でアップデートしたいときは RenovateDependabot が pin を新しい SHA へ書き換える PR を作るので、人がレビューしてからマージできます。コメントとして # v6.0.2 のように正確なバージョンを残しておけば、人間が読むときの可読性も保てます。

うちのorgでは過去のRenovate運用ですでにSHA pinが適用済みのリポジトリが大半でした。今回の作業は「全アクションがSHA参照になっているか」の確認と、漏れていた数件の修正で済んでいます。新規のworkflowを書くときに actions/checkout@v6 のような書き方で済ませてしまわないよう、レビュー観点として明示しておきます。

permissionscontents: read から始める

4項目目は、workflow全体の permissions: を明示し、必要なスコープだけを付けることです。

permissions:
  contents: read

GitHub Actionsの GITHUB_TOKEN には、組織やリポジトリの既定設定に応じて広いスコープが付与されることがあります。permissions: を書かないまま動いている workflow は、知らないうちに contents: writepull-requests: write を握っている可能性があります。CIで動くテストスクリプトに、リポジトリへ書き込む権限まで持たせる必要はありません。

うちのorgでは、まずworkflowのトップレベルで contents: read を既定値として宣言し、jobごとに必要なスコープだけを上書きする方針にしました。GitHub Pagesへのデプロイには pages: writeid-token: write が必要なので、そのjobだけ追加します。packages: write が必要なリリースjobには、そこだけ追加します。

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

  deploy-pages:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

この方針には事故が起きたときの被害範囲を狭められる効果があります。CIのスクリプトが何らかの理由で悪意ある操作を試みたとしても、contents: read しか持っていなければissueをcloseしたりreleaseを消したりはできません。同時に、workflowを読むだけで「この workflow は何を触る権限があるか」がすぐ分かるので、コードレビューでも permissions の宣言から差分の確認観点を絞り込めます。

GitHubGitHub - LabeeHive/.githubContribute to LabeeHive/.github development by creating an account on GitHub.

SHA pin は Renovate とセットで運用する

SHA pin の最大の懸念は更新作業のコストです。タグ参照なら v6 のまま放っておけば最新が自動で当たりますが、SHA pin は手作業で書き換えない限り古いまま固まります。これを解消するのが RenovateDependabot との組み合わせです。

ブログRenovateでCloudflare Workers SDKのパッケージをグルーピングするCloudflare Workers SDKはモノレポで管理されていますが、Renovateのデフォルト設定ではパッケージごとに個別のPRが作られます。wrangler、@cloudflare/vite-plugin、miniflareを1つのPRにまとめるpackageRulesの設定と、monorepo presetへの提案で浮上したimmortal PRs問題を紹介します。

うちのorgのRenovate設定では、actions/* 系のSHA pinをまとめて追従させています。新しいバージョンがリリースされると、SHAを新しい値に書き換えるPRがBotから飛んできて、CIが通れば自動マージされます。pin自体は手作業で更新する仕組みではなく、Botが新しいSHAへの追従PRを出し続ける運用です。actions/checkout のように頻繁にアップデートされるアクションでも、SHA pinが運用上の足かせにはなっていません。

機械的にチェック・適用するツールを使う

4項目を手作業で見て回るのは規模が大きくなると現実的ではありません。次の運用ではいくつかのツールを組み合わせて、新規workflowと既存workflowの両方を機械的に検査する形に寄せたいと考えています。1ツールで4項目すべてをカバーできるものは見つからなかったので、役割を分けて組む前提です。

pinact — SHA pin の自動適用と検証

suzuki-shunsuke/pinact は GitHub Actions と Reusable Workflow の uses: 行を commit SHA に書き換える CLI です。actions/checkout@v6 のような参照を actions/checkout@<sha> # v6.0.2 の形に変換し、コメントとして元のバージョンを残します。記事中で言及した可読性とのバランスにそのまま合致する書式です。

# .github/workflows/*.yml と action.yml を一括で pin
pinact run

# 既存の pin を最新 SHA へ追従
pinact run -update

# CI 用にチェックのみ (差分があれば exit code 非ゼロ)
pinact run -check

公式の GitHub Action ラッパー suzuki-shunsuke/pinact-action も用意されています。Renovate と併用する場合の役割分担は、初回の SHA 化を pinact で当て、その後の追従を Renovate に任せる形が分かりやすいです。

ratchet — pin / unpin の双方向変換

sethvargo/ratchet も SHA pin ツールです。pin / unpin / update / lint のサブコマンドを持ち、lint は CI で「全 workflow が pin されているか」をチェックして失敗させる用途に使えます。pinact との違いは、unpin でタグ参照に戻す逆変換を持つ点と、GitHub Actions 以外の CircleCI などにも対応している点です。

ratchet pin workflow.yml
ratchet lint workflow.yml   # CI 検査用

書式の好み (<sha> # version コメントの扱い) と運用フローで選ぶことになりそうです。

zizmor — workflow 全体の静的解析

zizmorcore/zizmor は GitHub Actions の静的解析ツールで、記事で触れた3項目が個別のルールとしてカバーされています。

  • artipacked: actions/checkoutpersist-credentials 既定値を検出する。--fix で自動修正にも対応
  • unpinned-uses: SHA pin されていない uses: を検出する。--fix で SHA に書き換え、ポリシー設定で「actions/* はタグ pin 許可、サードパーティは SHA pin 必須」のような切り分けもできる
  • excessive-permissions / undocumented-permissions: workflow / job レベルの permissions: 宣言を検査する。トップレベルで permissions: {} を既定にして job ごとに付け足す書き方を推奨する形になっている
# .github/workflows/ 以下を一括で監査
zizmor .

# 自動修正
zizmor --fix .

# CI でレポートを SARIF として出す
zizmor --format sarif .

defaults.run.shell のチェックは zizmor のルールには含まれていません。この項目だけは、PR レビューでカバーするか、後述の actionlint や独自スクリプトに任せる前提になります。

actionlint — 構文と式の検査

rhysd/actionlint は workflow の構文・式・shell スクリプト (shellcheck 連携) を検査するツールで、上記4項目のセキュリティ観点を直接当てるツールではありません。ただし ${{ }} の型チェック、ジョブ依存関係、cron 式の妥当性など workflow を書き間違えたときの早期発見には有効で、zizmor とは補完関係になります。

actionlint

Harden-Runner — runtime 側からの検知

step-security/harden-runner は静的解析ではなく、runner の実行時にネットワーク egress とプロセス活動を監視する step を差し込む形のツールです。GITHUB_TOKEN がどのスコープを実際に使ったかを記録して 「この job に必要な最小権限」のレコメンドを返す機能があるので、4項目目の permissions: 最小化を後から詰めるときには静的解析より精度が出る場面があります。

steps:
  - name: Harden Runner
    uses: step-security/harden-runner@<sha> # v2.x.x
    with:
      egress-policy: audit

うちのorgで今すぐ全リポジトリに入れる判断はしていませんが、CIから外部にデータを送るリスクが高いリポジトリには検討対象に入れたいツールです。

組み合わせ案

現時点で検討している組み合わせは次の通りです。決定はまだ先送りです。

  • SHA pin の追従: pinact + Renovate(初回適用は pinact、継続追従は Renovate)
  • PR 単位の静的検査: zizmor を CI のPRチェックに追加し、artipacked unpinned-uses excessive-permissions で4項目のうち3つを機械的に押さえる
  • runtime 側の検査: Harden-Runner はリリース workflow など影響が大きい workflow から段階的に導入

defaults.run.shell を機械的に当てるツールは見つけられていません。この1項目は PR レビュー観点として残し、新規 workflow の追加時に確認する運用にする想定です。

4 項目を機械的に当てない

org 全体へ適用してみて一番効いたのは、4項目をすべて機械的に当てるのは間違いだった、という気づきです。SwiftMilkdown の release workflow のように、ベストプラクティスを当てると壊れるケースがあります。「全リポジトリに同じパッチを当てる」ではなく、各workflowが何をしているかを読んでから判断するフローを残しました。一括適用のPRテンプレートに「適用しないステップがある場合は理由を本文に書く」という項目を加えています。

4項目はどれもworkflowの処理そのものを変えるものではありません。それでも、書いてあるかどうかでセキュリティの設計が変わります。新規のworkflowを書くときはまずこの4項目を満たしているかを確認し、満たせない場合は理由をPR本文で残す、という運用に揃えました。