はじめに
本記事で使用したコードは GitHub で公開しています。
Terraformを使っていると、tfstateにパスワードが平文で書かれてしまう問題に気が付きます。
RDSのマスターパスワードやAPIキーなど、コード上で指定した機密情報がそのままtfstateに記録されます。S3バックエンドを使っていても、バケットを読める権限があれば誰でも中身を見られてしまいます。
本記事では、この問題への対策を4つ検証した結果をまとめます。どの対策をいつ使うべきかの判断基準も合わせて整理しましたので、Terraformを本番運用している方にぜひ参考にしてください。
問題:tfstateに機密情報が平文で書かれる
以下のようにRDSを設定してapplyします。
resource "aws_db_instance" "this" {
identifier = "tfstate-secret-demo"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
db_name = "demodb"
username = "admin"
password = "MyS3cretPassw0rd"
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = false
publicly_accessible = false
skip_final_snapshot = true
deletion_protection = false
backup_retention_period = 0
}
applyしてtfstateを確認すると、パスワードが平文で記録されていることがわかります。
$ grep -A1 '"password"' terraform.tfstate
"password": "MyS3cretPassw0rd",
"password_wo": null,
S3バックエンドを使っている場合、バケットへの読み取り権限があれば誰でもこのパスワードを取得できてしまいます。
対策の方針
tfstateの機密情報問題には、大きく2つのアプローチがあります。
| アプローチ | 概要 |
|---|---|
| 見せなくする | tfstateへのアクセスを制限する |
| 書かせなくする | tfstateに機密情報を書き込まない |
具体的な対策は以下の4つです。
| # | 対策 | アプローチ | 機密情報がtfstateに残るか | 備考 |
|---|---|---|---|---|
| 1 | バックエンドの暗号化 | 見せなくする | 残る(平文) | アクセス制御で補う |
| 2 | manage_master_user_password |
書かせなくする | 残らない | RDS等の対応リソースのみ |
| 3 | ephemeralリソース + write-only引数 | 書かせなくする | 残らない | Terraform >= 1.10 が必要。書き込み専用引数のあるリソースのみ |
| 4 |
lifecycle.ignore_changes + 手動設定 |
書かせなくする(部分的) | 初期パスワードは残る | 最後の手段 |
どの対策を選ぶかは、以下のフローで判断するとよいでしょう。
まず「対策1:バックエンドの暗号化」は基本として実施する
↓
さらに機密情報をtfstateに残したくない場合
↓
対象リソースが manage_master_user_password に対応している?
→ YES:対策2(最もシンプル)
→ NO ↓
write-only引数(password_wo 等)がある?(Terraform >= 1.10)
→ YES:対策3
→ NO :対策4(初期パスワードはtfstateに残るため、バックエンドのアクセス管理を徹底する)
※ 対策2・3が使えるリソースでも対策4を採用し、全リソースで管理方法を統一するという選択肢もある
対策1:バックエンドの暗号化
S3バックエンドを使う場合、S3バケットをKMSで暗号化し、KMSへのアクセスをTerraform実行ロールに限定します。これによりTerraform実行ロール以外はバケット内のデータを復号できなくなります。
検証
S3バックエンドを使うにはバケットが先に存在している必要があるため、2段階構成で検証しました。
-
step1_backend/: Terraform実行専用IAMロール・KMSキー・S3バケットをローカルstateで作成 -
step2_rds/: S3バックエンド(KMS暗号化)を使いRDSを管理。providerとbackendの両方でTerraform実行ロールにAssumeRole
step1_backend/main.tf(抜粋)
Terraform実行専用IAMロールを作成し、KMSキーポリシーでそのロール以外のDecryptを明示的に拒否します。
# Terraform実行専用IAMロール
resource "aws_iam_role" "terraform_executor" {
name = "tfstate-secret-terraform-executor"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
Action = "sts:AssumeRole"
}]
})
}
# KMSキーポリシー
resource "aws_kms_key" "tfstate" {
description = "KMS key for Terraform state encryption"
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM root administration"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow Terraform executor role to use the key"
Effect = "Allow"
Principal = { AWS = aws_iam_role.terraform_executor.arn }
Action = ["kms:GenerateDataKey", "kms:Decrypt", "kms:DescribeKey"]
Resource = "*"
},
{
# rootへのkms:*許可があってもDenyが優先される
Sid = "Deny decrypt to anyone except Terraform executor role"
Effect = "Deny"
Principal = { AWS = "*" }
Action = ["kms:Decrypt", "kms:GenerateDataKey"]
Resource = "*"
Condition = {
StringNotEquals = { "aws:PrincipalArn" = aws_iam_role.terraform_executor.arn }
}
},
]
})
}
# S3バケット(KMS暗号化)
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
}
bucket_key_enabled = true
}
}
step2_rds/backend.tf
S3バックエンドへのアクセスもTerraform実行ロールで行います。
terraform {
backend "s3" {
bucket = "tfstate-secret-demo-<ACCOUNT_ID>"
key = "terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-northeast-1:<ACCOUNT_ID>:key/<KEY_ID>"
assume_role = {
role_arn = "arn:aws:iam::<ACCOUNT_ID>:role/tfstate-secret-terraform-executor"
}
}
}
step2_rds/main.tf(抜粋)
providerもTerraform実行ロールにAssumeRoleします。
provider "aws" {
region = "ap-northeast-1"
assume_role {
role_arn = "arn:aws:iam::<ACCOUNT_ID>:role/tfstate-secret-terraform-executor"
}
}
検証結果
AdministratorAccessユーザーでのS3取得 → AccessDenied
$ aws s3 cp s3://<BUCKET>/terraform.tfstate /tmp/test.tfstate
download failed: ... An error occurred (AccessDenied) when calling the GetObject operation:
User: arn:aws:iam::<ACCOUNT_ID>:user/<USER> is not authorized to perform: kms:Decrypt
on resource: arn:aws:kms:... with an explicit deny in a resource-based policy
IAMポリシーのAllowよりKMSキーポリシーの明示的DenyのほうがAWSの評価順で優先されるため、AdministratorAccessを持っていても拒否されます。
Terraform実行ロールでのS3取得 → 成功
$ aws sts assume-role \
--role-arn arn:aws:iam::<ACCOUNT_ID>:role/tfstate-secret-terraform-executor \
--role-session-name verify-tfstate
$ aws s3 cp s3://<BUCKET>/terraform.tfstate /tmp/downloaded.tfstate
$ grep -A1 '"password"' /tmp/downloaded.tfstate
"password": "MyS3cretPassw0rd",
Terraform実行ロールでAssumeRoleすればKMSキーのDecryptが許可されているためtfstateを取得でき、パスワードが平文で確認できます。
考察
この対策の本質は「パスワードをtfstateに書かない」ではなく「tfstateを読める人を限定する」アクセス制御です。
- パスワードはtfstate内に平文で記録されたまま
- Terraform実行ロールが侵害された場合はリスクが残る
- 「見えないようにする」だけで「存在しない」状態にはできない
バックエンドの暗号化はあくまでもベースラインの対策です。根本的な解決には次に紹介する manage_master_user_password や ephemeralリソースが有効です。
対策2:manage_master_user_password
RDS等、一部のリソースは manage_master_user_password オプションに対応しています。このオプションを有効にするとパスワードはSecrets Managerで管理され、tfstateには書き込まれません。
検証
02_backend_encryption/step2_rdsのコードをベースにpassword = "MyS3cretPassw0rd" を削除し、manage_master_user_password = true に置き換えた構成で検証しました。
resource "aws_db_instance" "this" {
# ...
username = "admin"
# passwordを指定せず、Secrets Managerでパスワードを自動管理する
manage_master_user_password = true
# ...
}
検証結果
tfstateのpasswordフィールド → null
$ grep -A1 '"password"' terraform.tfstate
"password": null,
"password_wo": null,
パスワードは一切tfstateに書き込まれません。
Secrets Managerにシークレットが自動作成されます
$ aws secretsmanager list-secrets --region ap-northeast-1
Name: rds!db-8de7d025-f1b9-4a8c-a154-2a800d5ed81f
ARN: arn:aws:secretsmanager:ap-northeast-1:<ACCOUNT_ID>:secret:rds!db-...
rds!db-<DB_RESOURCE_ID> という命名でシークレットが自動作成されます。
シークレットの中身を確認する
#IDに!が含まれるため\でエスケープしている
$ aws secretsmanager get-secret-value \
--secret-id "rds\!db-8de7d025-f1b9-4a8c-a154-2a800d5ed81f" \
--region ap-northeast-1 \
--query SecretString \
--output text
{"username":"admin","password":"<自動生成されたパスワード>"}
--secret-id にはシークレット名またはARNを指定します。SecretString フィールドにJSON形式でusernameとpasswordが格納されています。
tfstateにはSecrets Managerのシークレット参照情報が記録されます
"master_user_secret": [
{
"kms_key_id": "arn:aws:kms:...",
"secret_arn": "arn:aws:secretsmanager:...:secret:rds!db-...",
"secret_status": "active"
}
]
パスワード本体ではなく、シークレットのARNとKMSキーARNのみがtfstateに記録されます。
考察
manage_master_user_password はパスワードをtfstateに一切書き込まない根本的な解決策です。バックエンド暗号化と異なり、tfstateを読めるユーザーがいてもパスワードは漏洩しません。RDSがSecrets Managerを管理するため、パスワードローテーションや取得はSecrets Manager経由になります。ただし対応しているリソースが限られる点に注意が必要です。
対策3:ephemeralリソース + write-only引数
ephemeral変数やephemeralリソースはtfstateに書き込まれません。これをRDSの password_wo(write-only引数)に渡すことで、パスワードをtfstateに記録せずにRDSを管理できます。
ephemeral値はwrite-only引数にのみ渡せる。
検証
variable に ephemeral = true を設定し、環境変数でパスワードを渡してRDSの password_wo に設定する構成で検証しました。
main.tf(抜粋)
# ephemeral = trueにするとこの変数の値はtfstateに書き込まれない
variable "db_password" {
type = string
ephemeral = true
}
resource "aws_db_instance" "this" {
# ...
username = "admin"
# ephemeral変数はwrite-only属性にのみ渡せる
password_wo = var.db_password
password_wo_version = 1
# ...
}
apply方法
TF_VAR_db_password="MyS3cretPassw0rd" terraform apply
検証結果
tfstateのpasswordフィールド → null
$ grep -A1 '"password"' terraform.tfstate
"password": null,
"password_wo": null,
パスワードはtfstateに書き込まれません。
考察
- ephemeral値はtfstateに書き込まれません
- ephemeral値は
password_woのような write-only 以外の引数 には渡せない -
password_wo_versionにはephemeral値を渡せないため整数(1,2, ...)で管理します。パスワードを更新する際はpassword_wo_versionをインクリメントし、password_woに新しいパスワードを設定します - Terraform
>= 1.10、AWS provider>= 5.83以上が必要です - write-only引数が定義されているリソースでのみ使えます
対策4:lifecycle.ignore_changes + 手動設定
manage_master_user_password も ephemeral変数も使えない場合の最後の手段です。初期パスワードをコードに含めてapplyし、apply後に手動でパスワードを変更します。lifecycle.ignore_changes によりTerraformがパスワードの差分を無視するため、以降のapplyで差分が出なくなります。
検証
main.tf(抜粋)
resource "aws_db_instance" "this" {
# ...
password = "InitialPassw0rd"
lifecycle {
# passwordをTerraform管理から外す。手動でパスワードを変更してもapplyで差分にならない
ignore_changes = [password]
}
}
手順
-
terraform apply→ RDSがInitialPassw0rdで作成される - AWSコンソール or CLIで手動パスワード変更
- 再度
terraform apply→passwordはignore_changes対象なので差分なし
apply直後のtfstate確認
$ grep -A1 '"password"' terraform.tfstate
"password": "InitialPassw0rd",
"password_wo": null,
初期パスワードはtfstateに書かれます。
手動でパスワード変更後のtfstate確認
手動変更後にapplyしてもtfstateのpasswordフィールドはInitialPassw0rdのまま(ignore_changesによりTerraformは現実との差分を無視し、stateを更新しません)。
$ grep -A1 '"password"' terraform.tfstate
"password": "InitialPassw0rd",
"password_wo": null,
考察
- tfstateに初期パスワードが平文で残ります。手動変更後も初期パスワードがstateに残り続けるため、機密性は確保できません
-
ignore_changesはTerraformに「このフィールドの差分は無視せよ」と指示するだけで、stateからパスワードを消すわけではありません - 初期パスワードが使い捨て(apply直後に必ず変更)かつtfstateへのアクセス管理ができていれば許容できるケースもありますが、根本的な解決策にはなりません
まとめ
tfstateの機密情報問題に対する対策を4つ検証しました。
| 対策 | tfstateに機密情報が残るか | 難易度 | 推奨度 |
|---|---|---|---|
| 対策1:バックエンドの暗号化 | 残る(平文) | 低 | 必須(他の対策と併用) |
対策2:manage_master_user_password
|
残らない | 低 | 対応リソースなら第一候補 |
| 対策3:ephemeral + write-only引数 | 残らない | 中 | 対策2が使えない場合 |
対策4:lifecycle.ignore_changes + 手動 |
残る(初期値) | 低〜中 | 最後の手段 |
各対策の要点:
- バックエンドの暗号化はアクセス制御として有効だが、機密情報はtfstateに平文で残る。単体ではなく、他の対策と組み合わせることが望ましい
-
manage_master_user_passwordはRDSのパスワードをtfstateから完全に排除できる最もシンプルな解決策。対応リソースが限定的 -
ephemeralリソース + write-only引数 は
manage_master_user_passwordに対応していないリソースでも機密情報をtfstateに書き込まない手段として有効。Terraform 1.10以上が必要な新機能のため、環境の制約を事前に確認する。また、対応リソースが限定的 -
lifecycle.ignore_changes+ 手動設定 は上記の手段が使えない場合の最後の手段。初期パスワードはtfstateに残り続けるため、バックエンドのアクセス管理を特に徹底する必要がある
tfstateのセキュリティは「どうせ暗号化してるから大丈夫」で終わらせず、そもそも機密情報を書き込まない設計を目指すことが根本的な解決につながります。