はじめに
AWS の事故の多くは、難しい攻撃ではなく「うっかり全公開」で起きます。S3 が public だった、Security Group が 0.0.0.0/0 で全開だった、IAM が *:* だった——。Terraform でインフラをコード化するなら、この手の事故をコードレビューと自動チェックで止められます。この記事は、最低限これだけは入れておきたい設計をまとめます。
前提: 事故は「設定漏れ」より「明示していない設定」で起きる
「AWS はデフォルトが緩い」と言われてきましたが、近年はセキュアバイデフォルトに寄っています。S3 は 2023 年 4 月以降、新規バケットで ブロックパブリックアクセスが自動的に有効になり、ACL もデフォルトで無効(Bucket owner enforced)になりました。2023 年 1 月からは新規オブジェクトが SSE-S3 で自動暗号化されます。
それでも Terraform で「締める設定を明示的に書く」意味は残ります。
- 既存リソースは変わりません。 2023 年 4 月より前に作ったバケットは緩いままです
-
デフォルトはコードに現れません。 誰かがコンソールで設定を外しても
terraform planに差分が出ず、drift として検出できません - 緩いままのリソースもあります。 Security Group、IAM ポリシー、暗号化キーの種類などは、書かなければ意図が固定されません
だから 「締める設定を明示的に書く」 のが基本方針になります。明示してあれば差分として検出でき、静的解析にも引っかけられます。
1. S3: アカウントレベルで public を封じる
バケット単位の設定は書き忘れます。まず アカウント全体で public アクセスをブロックしておくと、個別バケットの設定ミスがあっても最後の砦になります。
resource "aws_s3_account_public_access_block" "this" {
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
その上で、バケット側にも同じブロックを重ねます(多層防御)。
resource "aws_s3_bucket" "assets" {
bucket = "myapp-assets"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.assets.arn # 省略すると AWS マネージドキー aws/s3
}
bucket_key_enabled = true # KMS 呼び出しを削減。Terraform ではデフォルト無効
}
}
暗号化については補足が必要です。前述の通り 2023 年 1 月以降、S3 は新規オブジェクトを SSE-S3 で自動暗号化するため、この設定を書かなくても「暗号化なし」にはなりません。SSE-KMS を明示する意味は「暗号化の有無」ではなく 鍵管理と監査にあります。
- SSE-S3 は KMS を経由しないため鍵の利用が CloudTrail に残りませんが、SSE-KMS は
Decrypt/GenerateDataKey単位で記録されます - 鍵ポリシーで IAM とは別レイヤーの制御ができ、ローテーションや鍵の削除によるクリプトシュレッディングも可能になります
一方でコストには注意が必要です。SSE-KMS はオブジェクト単位で KMS を呼ぶため課金され、KMS の RPS クォータも消費します。S3 Bucket Key(bucket_key_enabled = true)で呼び出し回数を大幅に削減できます(AWS は最大 99% の削減としています)。ただし Bucket Key はどの経路でも「明示的に有効化する」ものです。コンソールでもバケット作成時に Bucket Key の「有効にする」を選ぶ操作が必要で、Terraform でも bucket_key_enabled を書かなければ有効になりません。書き忘れると KMS のリクエスト料金をまるまる払うことになります。
「静的サイトを公開したい」ときも、S3 を public にするのではなく CloudFront + OAC(Origin Access Control) で配信し、S3 自体は非公開のままにします。バケットを直接開ける理由はほぼありません。OAI(Origin Access Identity)は現在レガシー扱いで、AWS も OAC を推奨しています。
なお SSE-KMS と OAC を併用する場合は、KMS キーポリシーに CloudFront のサービスプリンシパル(cloudfront.amazonaws.com)を許可する必要があります。これを忘れると CloudFront から配信できません。公式サンプルでは kms:Decrypt / kms:Encrypt / kms:GenerateDataKey* を許可し、AWS:SourceArn 条件で対象のディストリビューションに限定します。
{
"Sid": "AllowCloudFrontServicePrincipalSSE-KMS",
"Effect": "Allow",
"Principal": { "Service": ["cloudfront.amazonaws.com"] },
"Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*"],
"Resource": "*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/<distribution ID>"
}
}
}
AWS:SourceArn 条件を必ず付けます。 これが無いと、他人のディストリビューションからでもこの鍵を使えてしまう「混乱した代理人(confused deputy)」問題が起きます。S3 バケットポリシー側で CloudFront を許可するときも同様です。
2. Security Group: 0.0.0.0/0 は egress 以外で書かない
ingress を全開にするのは大抵事故です。SSH(22) や DB ポートを 0.0.0.0/0 で開けると、公開した瞬間から総当たりが来ます。
# NG: どこからでも SSH できてしまう
# cidr_ipv4 = "0.0.0.0/0" on port 22
# OK: ALB の SG からのみ許可し、SG を参照で繋ぐ
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id # CIDRではなくSG参照
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
CIDR ではなく SG 参照で繋ぐと、IP レンジ管理から解放され、経路も自明になります。ingress で 0.0.0.0/0 を書いていたら、レビューで止めます。
使うリソースに注意
古い記事では aws_security_group_rule が使われていますが、公式ドキュメントは現在このリソースを避けるよう明記しています(複数 CIDR の管理が苦手で、タグや description を持てないため)。現在のベストプラクティスは aws_vpc_security_group_ingress_rule / aws_vpc_security_group_egress_rule で、1 ルール 1 CIDR で書きます。
引数名が変わっている点に注意してください。
旧 aws_security_group_rule
|
新 aws_vpc_security_group_*_rule
|
|---|---|
protocol |
ip_protocol |
source_security_group_id |
referenced_security_group_id |
cidr_blocks(リスト) |
cidr_ipv4(単一の文字列) |
type = "ingress" |
リソース種別で表現するため不要 |
新旧を混在させてはいけません。 ルールの競合、plan に差分が出続ける、ルールが上書きされるといった問題が起きます。
egress は「書かないと閉じる」
よくある誤解ですが、AWS が SG 作成時に自動生成する ALLOW ALL の egress ルールを、Terraform は削除します。つまり egress は明示的に書かない限り全部閉じます。
# 外向きを開けるなら明示的に書く(絞れるならもっと絞る)
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
「なぜか外部 API を叩けない」の原因はたいていこれです。裏を返せば、egress を絞るチャンスが最初から与えられているということでもあります。
3. IAM: Action: "*" と Resource: "*" を同時に書かない
権限は「必要な操作を、必要なリソースにだけ」が原則です。特に "*": "*" は論外ですが、Action が具体的でも Resource: "*" は広すぎることが多いです。
data "aws_iam_policy_document" "app" {
statement {
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject"] # 操作を限定
resources = ["${aws_s3_bucket.assets.arn}/*"] # 対象を限定
}
}
アプリにアクセスキーを埋め込まず、実行環境に紐づくロールを使います。長期キーは漏洩の主因なので、そもそも発行しないのが最善です。
ここで用語を混同しないことが重要です。ECS のロールは 3 種類あり、役割が違います。
| ロール | 対象 | 用途 |
|---|---|---|
| タスクロール | 全起動タイプ | アプリが AWS API を呼ぶための権限 |
| タスク実行ロール | 全起動タイプ | ECS エージェントが ECR から pull、ログを送る |
| コンテナインスタンスロール | EC2 起動タイプ・外部インスタンス | コンテナインスタンス自体の権限(Fargate には存在しない) |
アプリの権限はタスクロールで与えます。 Fargate にはそもそもインスタンスプロファイルが存在せず、EC2 起動タイプでもコンテナからインスタンスロールを使うのは避けるべきです。同一インスタンス上の別タスクからも同じ権限が使えてしまうためで、AWS もインスタンスロールの権限を最小化し、コンテナから IMDS 経由で認証情報を取らせないことを推奨しています。
その手段はネットワークモードによって異なります。awsvpc モードのタスクには ECS エージェントの設定変数 ECS_AWSVPC_BLOCK_IMDS=true が使えますが、これは awsvpc モード専用です。bridge モードでは iptables の DOCKER-USER チェーンで 169.254.169.254 宛てを DROP する必要があり、host モードにはそもそも効きません。
EC2 で直接アプリを動かす場合は、インスタンスプロファイルを使います。
4. tfstate に平文で秘密が載ることを忘れない
意外な穴が Terraform の state ファイルです。作成した RDS のパスワードなどが terraform.tfstate に平文で残ります。HashiCorp も「Terraform stores your state in a plaintext file」と明記しています。state は必ず暗号化した S3 バックエンドに置き、リポジトリにコミットしません。
terraform {
backend "s3" {
bucket = "myorg-tfstate"
key = "app/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true # SSE
use_lockfile = true # S3 ネイティブロック
}
}
ロックは DynamoDB ではなく S3 ネイティブロックへ
古い記事の多くは dynamodb_table によるロックを紹介していますが、この引数は Terraform 1.11 で非推奨になりました。1.10 で導入された S3 ネイティブロック(use_lockfile = true)が現在の方法で、S3 上に .tflock オブジェクトを置いてロックを取ります。DynamoDB テーブル自体が不要になります。
dynamodb_table のまま terraform init すると、次の警告が出ます。
Warning: Deprecated Parameter
The parameter "dynamodb_table" is deprecated. Use parameter "use_lockfile" instead.
移行期は両方を併記して二重にロックを取ることもできますが、新規に書くなら use_lockfile だけで十分です。
そもそも state に載せない
state を守るだけでなく、秘密を state に載せない選択肢も増えています。RDS なら次の 2 つです。
resource "aws_db_instance" "app" {
# ...
manage_master_user_password = true # パスワードを Secrets Manager に委譲
}
manage_master_user_password(AWS provider v4.61.0 以降)を使うと、マスターパスワードの生成と管理を Secrets Manager に任せられ、state にパスワードが載りません。参照は master_user_secret[0].secret_arn で行います。
もう一つは write-only 引数の password_wo / password_wo_version です(Terraform 1.11 以降の機能で、aws_db_instance には provider v5.88.0 で追加)。値が state に一切書き込まれません。manage_master_user_password とは併用できないので、どちらかを選びます。
.gitignore に *.tfstate* を入れておくのも忘れないようにします。
5. 「締まっているか」を人間の目視に頼らない
上のルールは、レビュー時に見落とせば意味がありません。静的解析を CI に入れて機械に見張らせます。
# GitHub Actions: Trivy で IaC の危険な設定を検出
- name: Run Trivy in IaC mode
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'config'
severity: 'CRITICAL,HIGH'
exit-code: '1'
ローカルで動かすなら trivy config . です。Terraform の HCL に加えて plan の JSON もスキャンできます。
tfsec ではなく Trivy を使う
以前は tfsec がよく使われていましたが、現在は Trivy に統合されています。tfsec のリポジトリは残っているものの description が "Tfsec is now part of Trivy" となっており、最終リリースは 2025 年 5 月で事実上メンテナンスが止まっています。GitHub Action の aquasecurity/tfsec-action に至っては最終更新が 2023 年 2 月です。新規に入れるなら Trivy を選んでください。
checkov は現在も活発にメンテナンスされているので、こちらは引き続き選択肢になります。Trivy と併用して両方を CI に入れる構成も有効です。
これらは「public な S3」「全開の SG」「過剰な IAM 権限」などを検出してくれます。人間はルールを書くのが仕事で、遵守チェックは機械にやらせる。これで「うっかり」の大半は PR の段階で落ちます。
なお、S3 の「暗号化なし」は前述の通り現在の AWS では作れないため、検出項目としての重要度は下がっています。代わりに「意図した鍵で暗号化されているか」を見る発想に切り替えるとよいでしょう。
まとめ
| 事故 | 止め方 |
|---|---|
| S3 全公開 | アカウント&バケットで public block、配信は CloudFront+OAC |
| SG 全開 | ingress は SG 参照で限定。aws_vpc_security_group_*_rule を使う |
| 通信不達 | egress は Terraform が消すので明示的に書く |
| IAM 過剰権限 | action と resource を両方限定。ECS はタスクロール、長期キーは発行しない |
| state 漏洩 | 暗号化 S3 バックエンド + use_lockfile、コミットしない |
| 秘密の state 混入 |
manage_master_user_password や write-only 引数で載せない |
| うっかり | Trivy / checkov を CI に入れて機械に見張らせる |
派手な対策より、暗黙のデフォルトに頼らず明示的に締めるのが AWS セキュリティの基本です。Terraform ならそれを「コードとして固定し、CI で守る」ところまで持っていけます。ここまでやれば、少なくとも「うっかり全公開」による事故はかなり防げます。
参考記事・データ
Terraform 公式
- Terraform: S3 backend(
use_lockfileと DynamoDB ロックの非推奨) - Terraform v1.11.0 CHANGELOG(S3 ネイティブロックの GA と DynamoDB 引数の非推奨化)
- Terraform: Sensitive Data in State
AWS provider リファレンス
aws_vpc_security_group_ingress_ruleaws_security_group(egress のデフォルトルールを Terraform が削除する旨の NOTE)aws_security_group_rule(Avoid using の記載)aws_s3_bucket_server_side_encryption_configurationaws_db_instance(manage_master_user_password/password_wo)
AWS のデフォルト変更
- Amazon S3 updates default bucket security settings(2023年4月・BPA 自動有効化と ACL 無効化)
- Amazon S3 now automatically encrypts new objects(2023年1月・SSE-S3 の自動適用)
AWS ドキュメント
- Blocking public access to your Amazon S3 storage(most restrictive combination が適用される)
- Reducing the cost of SSE-KMS with Amazon S3 Bucket Keys
- Configuring an S3 Bucket Key(コンソールでも Enable の明示選択が必要)
- Restricting access to an Amazon S3 origin(OAC 推奨、OAI はレガシー)
- Amazon ECS task IAM role(Fargate にインスタンスプロファイルが無い旨・
ECS_AWSVPC_BLOCK_IMDSの推奨) - Amazon ECS container instance IAM role