Blogへ戻る

Language: 日本語

「PR で terraform plan を走らせて大丈夫?」 機密情報を漏らさず環境ドリフトを相殺する安全な CI 設計

プルリクエスト(PR)を作成したとき、**「この変更でインフラがどう変わるか」を GitHub 上で即座に `terraform plan` してコメントしてくれる仕組み** は、コードレビューの生産性を劇的に高めてくれます。

プルリクエスト(PR)を作成したとき、「この変更でインフラがどう変わるか」を GitHub 上で即座に terraform plan してコメントしてくれる仕組み は、コードレビューの生産性を劇的に高めてくれます。

しかし、セキュリティの観点で見ると、PR 上で plan を走らせる行為は 「信頼できないブランチの入力に、本番・検証クラウドの読み取り権限と State バケットへのアクセス権を渡す」 極めてリスクの高い操作です。

  • 「Plan だけなら安全」という誤認: terraform plan は一見すると dry-run に見えますが、論理的に悪意があれば Plan の実行フェーズだけでリソースの改変や機密奪取が可能 です(data "external" による任意コマンド実行、悪意ある Provider の副作用、CI ランナー環境変数の窃取など)
  • State や Secret の露出: 安易に plan の生出力をコメントに流すと、State 内の機密情報や Secret の平文が PR 画面に漏洩する
  • 失敗時ログの漏洩: 失敗時のスタックトレースやエラーログからクラウド内部のリソース構成が露出する
  • 環境ドリフトのノイズ: クラウド側で既に発生している環境ドリフト(手動変更など)が PR の差分に混ざり込み、レビューを著しく混乱させる

プレビュー環境の安全な構築(『「手元で動かして確認」をなくす。PR ごとに実アプリを触れる Preview QA 環境を Cloud Run で作った話』)や大規模 Terraform リファクタリング(『「terraform state mv」を手動で叩いていませんか? 745 件のリソースを安全に移動させた moved ブロック設計』)を支える基盤として、この「安全な PR Plan CI」を段階的に構築しました。

この記事では、「Plan 時のリソース改変リスク」を封じ込める権限の最小化・失敗時診断のマスキング・そして環境ドリフトを自動相殺する「Base との 2-Plan Diff 設計」 の全容を解説します。

得られること

  • terraform plan でもリソース改変が可能である」という攻撃ベクトルと、それを遮断する信頼境界の引き方がわかる
  • PR 上で terraform plan を安全に動かすための Planner 専用サービスアカウントの最小権限設計が学べる
  • 失敗時のスタックトレースやエラー出力を機密漏洩させずに安全に診断するフェイルセーフ手法が身につく
  • Base ブランチと PR ブランチの 2 つの plan を比較し、既存の環境ドリフトを相殺して「純粋な変更」だけを抽出する Diff 設計がわかる!

1. 信頼境界の分離と「Plan 時の改変リスク」の封じ込め

多くの開発者は「terraform apply をしなければインフラは変わらない」と考えがちです。しかし、これは 「PR の提出者が善意である」という前提に立った危険な思い込み です。

もし悪意あるコードが PR に含まれていた場合、terraform plan を実行させるだけで次のような攻撃が成立します。

【 terraform plan 時に成立しうる攻撃ベクトル 】
1. data "external" や local-exec:
   plan のデータソース評価時に任意のシェルスクリプトを実行し、CI ランナーの権限で外部 API を叩いてリソースを変更する
2. 悪意あるカスタム Provider の読み込み:
   Provider の Configure() や Read() 内でクラウド API を直接呼び出し、リソースの破壊や権限昇格を行う
3. State やクレデンシャルの外部送信:
   ランナー内の環境変数(OIDC トークン、復号鍵、State)を外部の C2 サーバーへ不正送信する

「Plan だから壊れない」のではなく、「Plan であっても悪意があればリソース改変や破壊が可能である」 という脅威モデルを前提に、厳格な 信頼境界(Trust Boundary) を構築する必要があります。

【 PR Plan CI の信頼境界アーキテクチャ 】
[ PR (same-repo 検証) ]

[ Trusted Runner (develop 固定) ] ── backend 定義 / provider lockfile を保護 🔒

[ Planner 専用 Service Account ] ── 読み取り専用権限(書き込みは 100% 遮断)

[ PR Head の config で plan 実行 ] ──→ 2-Plan Diff ──→ PR コメントへ投稿 💬

Trusted Checkout によるワークフロー保護

PR 側で GitHub Actions ワークフロー定義や Provider ロックファイルを書き換えられてしまうと、不正なコードが実行される危険があります。 そのため、実行用ワークフロー、Backend 定義、Provider lockfile は 常に保護されたベースブランチ(develop 固定)から Checkout して固定 し、PR 側がこれらを改変しても実行に影響を与えないフェイルクローズ構成にしました。 また、同一リポジトリ内の PR のみを対象とし、外部からの Fork PR は OIDC トークンを発行させず静的解析のみで終了させます。

Apply 用権限からの完全分離と Read-Only の徹底

クラウド側への防御線として、インフラを変更する Deployer(Apply 用)権限と、差分を計算する Planner 権限を物理的に分離 しました。

権限付与した理由
Project の Viewerrefresh 付き plan が既存リソースの現在値を読み取るため
State バケットの objectViewerRemote State の状態を取得するため(書き込み権限は剥奪)
環境変数復号用の限定 Secret Accessor特定の暗号化鍵のみにスコープを絞り、全 Secret へのアクセスは拒否
roles/iam.securityReviewer全リソースの IAM Policy を読み取るため(getIamPolicy の read-only 権限)

万が一、PR 内で data "external" 等を使ってクラウド API を叩くコードが動いたとしても、Planner には書き込み権限(Editor/Owner 等)が 1 つも存在しないため、クラウド側のリソース変更は IAM レベルで 100% 拒絶 されます。

このロール集合は契約テスト(Contract Test)で固定し、書き込み権限が紛れ込まないよう CI で機械的に監視しています。

2. 失敗時の診断情報と漏洩防止の段階的アプローチ

Plan の構成を動かし始めた初期、IAM 権限の不足などで plan が失敗することがありました。

ここで問題になるのが 「失敗時に何を出力するか」 です。Terraform のエラーメッセージには機密情報や変数値が含まれやすいため、安易に stderr をコメントへ流すと情報漏洩につながります。

段階的なフェイルセーフ設計

  1. 固定文言への分類: 初期の失敗時は詳細を出さず、エラー種別(Plan failed: init / dotenvx / plan)という固定ラベルのみを返す。
  2. 監査ログによる最小限の診断: 原因究明のため、Cloud Audit Logs からサニタイズされた最小 4 項目(serviceName, methodName, status.code 等の最大 5 件)のみを照会して原因(IAM getIamPolicy 不足)を特定。
  3. 安全なログ出力の確立: 監査ログの暫定診断を撤去し、失敗時に限り GitHub Actions のログに stderr を出力(GitHub ワークフローコマンドとして誤解釈されないようランダムなトークンでエスケープ)。

成功時には raw output や state の中身を一切外部に出さず、失敗時にも段階的なマスキングを行うことで、機密の完全な封じ込めを実現しました。

3. Base との 2-Plan Diff による「環境ドリフト」の相殺

成功時の PR コメントにおいて、単なる「1 to add, 0 to change」といった件数表示だけでは、レビュアーは何がどう変わるのかを具体的に把握できません。

しかし、普通に terraform plan の出力をそのまま貼ると、「開発環境で誰かが手動で触ったリソースの環境ドリフト」が大量の差分として PR に現れ、今回の変更と混ざって読めなくなる という問題が発生します。

そこで、「Base コミット」と「PR コミット」の 2 つの plan を並列実行し、両者のテキスト差分を取る設計 を導入しました。

【 2-Plan Diff による環境ドリフト相殺の仕組み 】
1. Base commit で plan 実行  ──→ [ Plan A (既存ドリフト X を含む) ]
2. PR head で plan 実行     ──→ [ Plan B (既存ドリフト X + 今回の変更 Y) ]

3. terraform show の diff を取得 ──→ [ 純粋な変更 Y だけが抽出される! ⚡️ ]
# 2-Plan Diff の生成イメージ
terraform -chdir=base_dir show -no-color base.tfplan > base.txt
terraform -chdir=pr_dir show -no-color pr.tfplan > pr.txt

# 既存のドリフトは両方に現れるため、diff で綺麗に相殺される
diff -u base.txt pr.txt > plan_diff.patch

既存の環境ドリフトは Plan A と Plan B の両方に等しく現れるため、diff を取ることで完全に相殺されます。その結果、「この PR が原因で発生する純粋なインフラ変更」だけがクリアに浮き彫り になります。

4. タイムラインを汚さない Sticky コメント運用

PR のレビュー体験を向上させるため、コメントの更新方式にも工夫を凝らしました。

  • 既存コメントの編集ではなく新規投稿+旧コメント削除: 既存コメントを edit するとタイムラインの過去の位置に埋もれてしまうため、「新しい plan を末尾に新規投稿 ➔ 自分より古い識別マーカー付き bot コメントを削除」する方式を採用。
  • 最新 SHA の照合: 連続 push された際に古い workflow の結果が後から上書き投稿されないよう、投稿直前に PR の Head SHA を GitHub API で再照合。
  • スタックごとの折りたたみ: 複数スタックの差分を <details> タグでスタック単位に折りたたみ、PR の可読性を維持。

まとめ: 安全な Plan CI がインフラ運用のスピードを生む

PR の上で terraform plan を安全に動かすための要点は、次の 4 つに集約されます。

  1. 権限を分ける: Apply 用 Deployer と Plan 用 Planner を分離し、Read-Only な最小権限を契約テストで固定する
  2. 失敗時も漏らさない: エラー出力やスタックトレースを安易に生ログとして晒さず、サニタイズされた診断を行う
  3. ドリフトを相殺する: Base と PR の 2 つの plan の差分(2-Plan Diff)を取り、既存のノイズを消して純粋な変更だけを見せる
  4. 最新を届ける: Sticky コメントと SHA 照合により、常に最新かつ正確な plan 結果をレビューの場に届ける

安全な信頼境界とノイズのない差分表示があってこそ、チーム全員が安心してインフラの継続的デリバリーを進めることができます。

参考リンク