tfstate にシークレットが平文で保存されたとき — キーのローテーションと再発防止

読了 5分

RDS のパスワードや API キーを Terraform で扱っていて、tfstate ファイルを開いて驚く瞬間があります。sensitive と指定した値が全部平文で入っているからです。結論を先に書くと、tfstate の平文保存はバグではなく設計で、露出の可能性があると判断したらやるべきことの順序は state の整理ではなくキーのローテーションが先です。 ローテーションが終われば流出した値は死んだ値になり、その次に再発防止を設計します。この対応の順序は Git にシークレットキーをコミットしたときと同じ原則で、この記事は Terraform 特有の部分を扱います。

なぜ平文なのか — sensitive は目隠しにすぎません #

Terraform は次の apply で変更を判定するために現在の値を知る必要があります。パスワードの引数が変わったか比較するには、以前のパスワードが state にある必要があるのです。だから state はリソースのすべての属性を値のまま記録します。 sensitive = true がやるのは plan 出力とコンソール表示で値を隠すことだけで、ファイルの中身には何の影響もありません。

結論が 1 つ出ます。state ファイルへのアクセス権限は、すなわちシークレットへのアクセス権限です。 state を読める人・ロール・パイプラインの一覧がシークレット閲覧者の一覧と同じだという前提で運用すべきで、これが下の再発防止の出発点です。

事故対応 — ローテーション → 履歴 → state の順です #

1. 露出範囲を判断します。 state がどこにあり、誰が読めたかが質問です。ローカルの tfstate が git にコミットされて社内リポジトリにあったのか、公開リポジトリだったのか、S3 バックエンドのバケットポリシーが広く開いていたのか。公開の露出なら即座に流出とみなし、社内限定なら組織の基準に従いつつ、ローテーションを既定の対応にしておくのが安全です。

2. シークレットをローテーションします。 DB パスワードの変更、API キーの再発行、IAM アクセスキーの交換まで、露出した値を無効化することが対応の本体です。state から値を消すどんな作業も、すでに複製された可能性を消してはくれないため、ローテーションなしの整理だけの対応は対応ではありません。

3. git の履歴を整理します。 tfstate がコミットされたことがあるなら、filter-repo で履歴から取り除いて force push の手順を踏みます。手順と注意点はシークレットキーのコミット対応の記事のそれと同じで、同時に .gitignore に *.tfstate* を追加して再コミットを遮断します。そもそもローカルの state を使わずリモートバックエンドに移すのが構造的な解決で、これは基礎 #9 の主題です。

4. state の値はローテーションが終われば自然に更新されます。 新しいパスワードでコード・変数を変えて apply すれば、state の値も新しいものに置き換わります。古い値が state バックエンドの過去バージョン(S3 バージョニング)に残るのが気になるなら、該当オブジェクトバージョンの削除までやってよいですが、これはローテーションが終わった後の後片付けであって、ローテーションの代わりではありません。

再発防止 1 — 値が state に残らない仕組みを使います #

Terraform 1.10〜1.11 でこの問題の構造的な解決策が入りました。ephemeral 値と write-only 引数です。ephemeral リソースで読んだシークレットは実行中にだけ存在し、state にも plan にも記録されません。write-only 引数(例: aws_db_instance の password_wo)は値を provider に 1 回書き込むだけで state に保存せず、値の交換は password_wo_version のようなバージョントリガーで指示します。

つまり新規のコードなら、「パスワードを変数で受けて引数に入れ、state に残る」従来のやり方の代わりに、Secrets Manager や SSM から ephemeral で読んで write-only 引数に流す構成を既定にできます。この構成の実際のコードと SSM vs Secrets Manager の選択は実践 #6 シークレット管理で扱いました。より単純な回避として、シークレットを Terraform がまったく触らず、アプリがランタイムに SSM から読む設計もいまも有効です。Terraform は参照(ARN)だけを扱えば、state には参照だけが残ります。

再発防止 2 — state 自体をシークレットの保管庫として扱います #

値が残る流れをすべてなくすことはできないため(作成されたリソースの属性など)、state 側の防御も並行します。

  • 保存時の暗号化: S3 バックエンドに SSE-KMS を有効にし、KMS キーポリシーで復号できる主体を絞ります。デフォルトの SSE-S3 よりアクセス統制が一層増えます。
  • アクセスの最小化: state バケットの IAM・バケットポリシーを「Terraform の実行ロールだけ」に絞ります。広い read 権限の掛かった state バケットは、シークレットが全社公開されたのと同じです。
  • バージョニングとログ: バージョニングは復旧手段であると同時に、露出調査の手段でもあります。S3 アクセスログ・CloudTrail のデータイベントを有効にしておけば、事故のとき「誰が読んだか」に答えられます。
  • output の最小化: output でシークレットを出すと、上位スタックの state と CI のログへ伝播の範囲が広がります。output は参照だけを載せる原則を立てます。

まとめ #

  • tfstate は設計上すべての値を平文で持ちます。sensitive は表示を隠すだけなので、state へのアクセス権限をシークレットへのアクセス権限と同一視します。
  • 露出対応の本体はキーのローテーションです。state の整理と履歴の整理はローテーション後の後片付けで、代替手段ではありません。
  • tfstate が git にコミットされたことがあるなら、filter-repo の整理と .gitignore の遮断、そしてリモートバックエンドへの移行までがワンセットです。
  • 新規のコードは ephemeral + write-only 引数で、値が state に残らない仕組みを既定にします。アプリがランタイムに読む設計も有効です。
  • state 側の防御は SSE-KMS、アクセス最小化、バージョニングとアクセスログ、output 最小化の 4 つが基本です。
X