この記事でわかること
- 2023年以降のS3デフォルト変更(Block Public Access / SSE-S3 / Object Ownership)を踏まえた最新の構成
- 「BPAを入れたのにACLで公開されていた」を物理的に不可能にする
BucketOwnerEnforced設定 -
aws:SecureTransport/aws:SourceVpce/aws:PrincipalOrgID/s3:ResourceAccountを組み合わせたバケットポリシーの実例 - SSE-KMS + Bucket Key で KMS コストを 99% 削減する仕組みと、誤って外す条件
- Server Access Log と CloudTrail Data Events の使い分け、コストの落とし穴
- 前回記事(Terraform)の stateバケットを「汎用 secure-bucket モジュール」として一般化する Terraform 一式
- IAM Access Analyzer / AWS Config / S3 Storage Lens で「設定漏れを自動検出」する3点セット
この記事を書いた経緯
前回の記事(Terraform 実務 Tips)で、state バケットの必須設定として「バージョニング / KMS / Block Public Access」の3点を挙げました。あれは state バケット固有の話ではなく、S3 バケット全般に当てはまる汎用要件です。今回はそれを起点に、汎用の S3 セキュリティとして書き直します。
きっかけになった事例があります。あるサービス引き継ぎで「セキュリティ監査で1件指摘が出ているので確認してほしい」と渡されたのが、sample-data-public という、いかにも公開向けに作られたバケット名でした。
調べた結果はこうでした。
- Block Public Access は4つすべて ON だった
- それでも IAM Access Analyzer は「外部からの読み取り可能性あり」を報告していた
- 原因は 2018年に設定されたバケット ACL の
public-readが残っていたこと - BPA の
IgnorePublicAcls=trueで実効的にはブロックされていたが、監査ツール側は「ACL が public-read」という事実を検知していた
つまり「BlockPublicAcls が ACL の新規付与を防ぎ、IgnorePublicAcls が既存ACLを実効無効化する」という2段の防御は機能していたものの、ACL そのものを物理的に消す手段を持っていなかったために、監査上は永遠にグレーゾーンの状態でした。
これを解決したのが Object Ownership = BucketOwnerEnforced です。設定するとバケット ACL もオブジェクト ACL も完全に無効化され、public-read というメタデータ自体が存在し得なくなります。S3 のデフォルト変更は段階的に進みました。
| 時期 | 変更内容 |
|---|---|
| 2023年1月 | 新規オブジェクトに SSE-S3 が自動適用(暗号化なしの PUT が消滅) |
| 2023年4月 | 新規バケットで Block Public Access が自動 ON、Object Ownership = BucketOwnerEnforced がデフォルト(ACL 無効化) |
「S3 はデフォルトでもう安全」が成立したのが2023年ということです。
ただし、既存バケットには遡及適用されません。古い環境ほど、デフォルト変更の恩恵を受けていない状態で運用が続いています。本記事は、その差分を埋めるためのチェックリストです。
実務での背景
S3 のセキュリティ設定ミスによる事故は今も毎年起きていますが、事故の中身は2020年頃と2026年でだいぶ変わりました。
| 時代 | 主な事故原因 |
|---|---|
| 〜2020年頃 | バケット ACL を public-read のまま放置(典型例:S3バケットがそのままWeb公開) |
| 2020〜2023年 | バケットポリシーで Principal: "*" を意図せず広く許可 |
| 2023年〜 | 暗号化キー / ロール権限の組み合わせ に起因する事故(KMS キーポリシー / VPC Endpoint policy ミス) |
防御対象が「公開設定の事故」から「権限境界の事故」にシフトしてきています。本記事はその両方をカバーします。
解決方法:7つのレイヤーで守る
「BPA を入れる」「暗号化する」だけでは2026年の要件には足りません。次の7層で多重防御するのが現代の標準です。
下にあるレイヤーほど「そもそも事故が起きえない」物理的な防御。上のレイヤーは「起きたら気付ける」検知的防御。下から組むのが原則です。
具体的な手順
Step 1: ACL を物理的に無効化する(BucketOwnerEnforced)
すべての出発点です。2023年4月以降の新規バケットはデフォルトでこの状態ですが、それ以前に作られたバケットには適用されていません。
aws s3api put-bucket-ownership-controls \
--bucket my-secure-bucket \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
これにより以下が起きます。
- バケット ACL とオブジェクト ACL が無効化(API 自体が拒否される)
- すべてのオブジェクトの所有者がバケット所有者に固定
-
s3:PutObjectAcl/s3:GetObjectAclは呼んでもAccessControlListNotSupportedで失敗 - アクセス制御はバケットポリシーと IAM ポリシーのみで行うことになる
| Object Ownership 設定 | ACL の扱い | 推奨 |
|---|---|---|
BucketOwnerEnforced (2023デフォルト) |
完全無効化 | 新規・既存問わず全バケットで強く推奨 |
BucketOwnerPreferred |
ACL は有効、所有者だけ自動移譲 | 移行期間中の暫定 |
ObjectWriter (旧デフォルト) |
ACL 有効、書き込み元が所有 | クロスアカウント書き込みが必要な特殊用途のみ |
注意: 既存バケットを BucketOwnerEnforced に切り替える前に、ACL に依存している処理がないか必ず確認してください。CloudFront OAI(旧来の方式)や、外部アカウントが直接 PutObject してくる連携は影響を受けます。CloudFront は OAC(Origin Access Control)に移行済みであれば問題ありません。
Step 2: Block Public Access を4つ全部 ON にする(バケット + アカウント)
BucketOwnerEnforced で ACL は無効化されますが、バケットポリシーで Principal: "*" を書けば公開できてしまうため、BPA が必要です。
# バケット単位
aws s3api put-public-access-block \
--bucket my-secure-bucket \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# アカウント全体(最強)
aws s3control put-public-access-block \
--account-id 123456789012 \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
| 設定 | 効果 |
|---|---|
BlockPublicAcls |
public な ACL の新規付与を拒否 |
IgnorePublicAcls |
既存の public な ACL を実効無効化 |
BlockPublicPolicy |
public な内容を含むバケットポリシーの設定を拒否 |
RestrictPublicBuckets |
バケットが public 扱いなら、それを参照する他リソースからのアクセスも拒否 |
アカウント単位 BPA はバケット単位より強い(バケット側で外しても効かない)ので、「意図的に公開するバケットが1つでもあるか」を組織で議論したうえで、なければアカウント単位で全部 ON にしてしまうのが運用上ラクです。例外を1つ作るなら、その1つだけ別アカウントに切り出すほうがクリーン。
Step 3: バケットポリシーで HTTPS / 経路 / 暗号化を強制する
BucketOwnerEnforced + BPA で「意図しない公開」はほぼ防げます。次に防ぐべきは「意図された相手からの、よくない経路でのアクセス」。バケットポリシーで以下を強制します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-secure-bucket",
"arn:aws:s3:::my-secure-bucket/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
},
{
"Sid": "DenyUnencryptedPut",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-secure-bucket/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
},
{
"Sid": "DenyWrongKmsKey",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-secure-bucket/*",
"Condition": {
"StringNotEqualsIfExists": {
"s3:x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:ap-northeast-1:123456789012:key/abcd-..."
}
}
},
{
"Sid": "RestrictToOrg",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-secure-bucket",
"arn:aws:s3:::my-secure-bucket/*"
],
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "o-xxxxxxxxxx"
}
}
},
{
"Sid": "RestrictToVpce",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-secure-bucket",
"arn:aws:s3:::my-secure-bucket/*"
],
"Condition": {
"StringNotEqualsIfExists": {
"aws:SourceVpce": ["vpce-0123456789abcdef0"]
},
"Bool": {
"aws:ViaAWSService": "false"
}
}
}
]
}
ポイントを5つに分けて読みます。
-
DenyInsecureTransport:aws:SecureTransport: falseを Deny。HTTPS 必須化。 -
DenyUnencryptedPut:s3:x-amz-server-side-encryptionがaws:kms以外の PutObject を拒否。デフォルト暗号化(Step 4)が効いていても、明示的に弱い設定でアップロードされる可能性を物理的に塞ぐ。 -
DenyWrongKmsKey: 指定の KMS キー以外を使った PutObject を拒否。「自社管理キーで暗号化しているはずなのに、別アカウントの KMS キーで書かれていた」事故を防ぐ。 -
RestrictToOrg: AWS Organizations 配下からのアクセスのみ許可。Confused Deputy 対策としてaws:PrincipalOrgIDを必ず入れる。 -
RestrictToVpce: 指定 VPC Endpoint 経由でないアクセスを拒否(インターネット経由で持ち出される事故の物理防止)。aws:ViaAWSService: falseを併記しないと S3 内部処理(レプリケーション・インベントリ等)まで止まるため、ここはセットで書く。
StringNotEqualsIfExists を使うのが地味に重要で、条件キーが**存在しないリクエスト(=匿名・外部アクセス)**もちゃんと Deny 側に倒れます。StringNotEquals のみだとキーが無いリクエストでマッチせず素通りすることがあります。
補足: s3:ResourceAccount は VPCe ポリシー側で使う
aws:PrincipalOrgID は「組織内のプリンシパルだけ許可」、対して s3:ResourceAccount は「対象バケットが自社アカウント所有か」を見るキーです。バケット側ポリシーで使うと常に自分自身を指して無意味なので、VPC Endpoint policy や IAM ロール側 に書きます。
// VPC Endpoint policy 側の例(IAM/VPCeに置く)
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"s3:ResourceAccount": ["123456789012"]
}
}
}
これで「自社が所有していない外部 S3 バケットへの書き込み事故(マルウェア・誤設定によるデータ流出)」を経路レベルで遮断できます。aws:PrincipalOrgID(バケット側)と s3:ResourceAccount(VPCe / IAM 側)は 対で運用するのが現代の標準です。
Step 4: SSE-KMS + Bucket Key でコストを抑えつつ強い暗号化
2023年1月以降、全バケットでデフォルト SSE-S3 が自動有効になっています。これで何もしなくても保存時暗号化はかかりますが、コンプライアンス要件(自社管理キー / キーローテーション / アクセス監査)が必要なら SSE-KMS にします。
aws s3api put-bucket-encryption \
--bucket my-secure-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:ap-northeast-1:123456789012:key/abcd-..."
},
"BucketKeyEnabled": true
}]
}'
BucketKeyEnabled: true を必ず入れるのが現代のベストプラクティスです。
| 設定 | 仕組み | KMSコスト |
|---|---|---|
BucketKeyEnabled: false(古い設定) |
オブジェクトごとに KMS GenerateDataKey が呼ばれる | 1リクエスト=1課金。大量PUT/GETでコスト激増 |
BucketKeyEnabled: true(推奨) |
バケット単位の中間鍵を生成し、KMS呼び出しを集約 | KMSリクエスト料を最大99%削減できるケースあり |
セキュリティレベルは同等のまま、コストだけ削減できます。外す理由が無い設定ですが、既存バケットでは false のままになっていることが多いので明示的に切り替えましょう。
さらに強い要件があれば DSSE-KMS(Dual-layer SSE-KMS)も選べます。FIPS 140-3 Level 3 相当の二重暗号化が必要な金融・医療系のみ用途と考えてOKです。一般用途では aws:kms + Bucket Key で十分。
Step 5: バージョニング + Object Lock + ライフサイクル
ランサムウェアや誤削除に対する最後の砦です。
# バージョニング有効化
aws s3api put-bucket-versioning \
--bucket my-secure-bucket \
--versioning-configuration Status=Enabled
# Object Lock(バケット作成時のみ有効化可能。コンプライアンスモード or ガバナンスモード)
aws s3api put-object-lock-configuration \
--bucket my-secure-bucket \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": {
"Mode": "GOVERNANCE",
"Days": 30
}
}
}'
# 古いバージョンを90日後に削除、未完了マルチパートを7日で中止
aws s3api put-bucket-lifecycle-configuration \
--bucket my-secure-bucket \
--lifecycle-configuration '{
"Rules": [{
"ID": "expire-old-versions-and-aborted-mpu",
"Status": "Enabled",
"Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 90 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}]
}'
Object Lock のモード選択は明確です。
| モード | 解除可否 | 用途 |
|---|---|---|
COMPLIANCE |
誰も解除不可(ルートアカウントですら不可) | 金融・医療など法定保管 |
GOVERNANCE |
s3:BypassGovernanceRetention を持つ IAM のみ解除可 |
業務要件の保管・ランサム対策 |
業務系は迷わず GOVERNANCE。COMPLIANCE は本当に消せないので、誤って設定すると保管期間中はバケット自体を整理できません。
AbortIncompleteMultipartUpload は地味ですが必須。大きいファイルの PUT が失敗したときに残った未完了パートが、永遠に料金だけ発生し続けます。
Step 6: アクセスログと CloudTrail Data Events
「誰が何をしたか」を残す方法は2系統あり、用途が違います。
| 方法 | ログ粒度 | コスト | 用途 |
|---|---|---|---|
| S3 Server Access Logs | リクエスト単位、ベストエフォート(一部欠損あり) | S3ストレージ料金のみ | 監査・トラブルシュート全般。安いので原則これ |
| CloudTrail Data Events | API 単位、欠損なし(マネージドサービス品質) | $0.10 / 100,000イベント | 機密データ・コンプライアンス監査が必要なバケット |
CloudTrail Data Events は便利ですが、全バケットに有効化すると簡単に月数万円規模になります。重要なバケットだけ ON にする運用が現実的。
# S3 Server Access Logs(ログ用バケットは別に作る)
aws s3 mb s3://my-s3-access-logs --region ap-northeast-1
aws s3api put-bucket-logging \
--bucket my-secure-bucket \
--bucket-logging-status '{
"LoggingEnabled": {
"TargetBucket": "my-s3-access-logs",
"TargetPrefix": "my-secure-bucket/"
}
}'
ログ用バケットの注意点。
- 監査対象バケットと別バケットにする(同一バケットに書くと監査ループが起きる)
- ログ用バケットにも 同じ7レイヤー を適用する(ログを改竄されたら監査が無意味)
- 30〜90日でライフサイクル削除しないとストレージコストが積み上がる
Step 7: 検出する仕組み(Config / Access Analyzer / Storage Lens)
ここまでは「正しく設定する」話。最後は「設定が崩れたら気付く」仕組みです。
# Config: 公開バケットを検出
aws configservice put-config-rule --config-rule '{
"ConfigRuleName": "s3-bucket-public-read-prohibited",
"Source": {
"Owner": "AWS",
"SourceIdentifier": "S3_BUCKET_PUBLIC_READ_PROHIBITED"
}
}'
aws configservice put-config-rule --config-rule '{
"ConfigRuleName": "s3-bucket-server-side-encryption-enabled",
"Source": {
"Owner": "AWS",
"SourceIdentifier": "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED"
}
}'
# IAM Access Analyzer: 外部アカウントからアクセス可能なバケットを検出
aws accessanalyzer create-analyzer \
--analyzer-name org-public-access \
--type ORGANIZATION
| ツール | 何を検出するか |
|---|---|
| AWS Config | 設定ドリフト(暗号化OFF・BPA OFF・パブリックポリシー など) |
| IAM Access Analyzer | バケットポリシーや ACL が外部に解放されていることを実際の評価で検出 |
| S3 Storage Lens | アカウント横断で「暗号化なしオブジェクト数」「BPA OFFバケット数」をダッシュボード化 |
3つは目的が違うので併用。Configは「設定値の異常」、Access Analyzerは「実効的なアクセス可能性」、Storage Lensは「組織全体の俯瞰」。
構成図
Terraform:汎用 secure-bucket モジュール
前回記事の state バケット要件を、汎用 S3 セキュリティモジュールとして一般化したものです。Step 1〜7 を Terraform 1モジュールに集約しています。
# modules/secure-bucket/variables.tf
variable "bucket_name" {
type = string
validation {
condition = length(var.bucket_name) >= 3 && length(var.bucket_name) <= 63
error_message = "bucket_name は 3〜63 文字"
}
}
variable "kms_key_arn" {
type = string
description = "SSE-KMS で使用する CMK の ARN"
}
variable "org_id" {
type = string
description = "aws:PrincipalOrgID で許可する Organization ID"
}
variable "object_lock_days" {
type = number
default = 30
}
variable "noncurrent_version_expiration_days" {
type = number
default = 90
}
variable "allowed_vpce_ids" {
type = list(string)
description = "アクセスを許可する VPC Endpoint ID。空にすると VPCe 制限を付けない"
default = []
}
# modules/secure-bucket/main.tf
# Layer 1 & 5 (Object Lockはバケット作成時にしか有効化できない)
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
object_lock_enabled = true
}
# Layer 1: ACL 無効化
resource "aws_s3_bucket_ownership_controls" "this" {
bucket = aws_s3_bucket.this.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
# Layer 2: Block Public Access
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Layer 4: SSE-KMS + Bucket Key
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
bucket_key_enabled = true
}
}
# Layer 5: Versioning
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
# Layer 5: Object Lock (GOVERNANCE)
resource "aws_s3_bucket_object_lock_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
default_retention {
mode = "GOVERNANCE"
days = var.object_lock_days
}
}
}
# Layer 5: Lifecycle (古いバージョン・未完了MPU)
resource "aws_s3_bucket_lifecycle_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
id = "expire-old-versions-and-aborted-mpu"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = var.noncurrent_version_expiration_days
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
# Layer 3: バケットポリシー
data "aws_iam_policy_document" "this" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [
aws_s3_bucket.this.arn,
"${aws_s3_bucket.this.arn}/*",
]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
statement {
sid = "DenyUnencryptedPut"
effect = "Deny"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.this.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "StringNotEquals"
variable = "s3:x-amz-server-side-encryption"
values = ["aws:kms"]
}
}
statement {
sid = "DenyWrongKmsKey"
effect = "Deny"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.this.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "StringNotEqualsIfExists"
variable = "s3:x-amz-server-side-encryption-aws-kms-key-id"
values = [var.kms_key_arn]
}
}
statement {
sid = "RestrictToOrg"
effect = "Deny"
actions = ["s3:*"]
resources = [
aws_s3_bucket.this.arn,
"${aws_s3_bucket.this.arn}/*",
]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "StringNotEqualsIfExists"
variable = "aws:PrincipalOrgID"
values = [var.org_id]
}
}
dynamic "statement" {
for_each = length(var.allowed_vpce_ids) > 0 ? [1] : []
content {
sid = "RestrictToVpce"
effect = "Deny"
actions = ["s3:*"]
resources = [
aws_s3_bucket.this.arn,
"${aws_s3_bucket.this.arn}/*",
]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "StringNotEqualsIfExists"
variable = "aws:SourceVpce"
values = var.allowed_vpce_ids
}
# S3 内部処理 (replication / inventory 等) を巻き込まないよう除外
condition {
test = "Bool"
variable = "aws:ViaAWSService"
values = ["false"]
}
}
}
}
resource "aws_s3_bucket_policy" "this" {
bucket = aws_s3_bucket.this.id
policy = data.aws_iam_policy_document.this.json
depends_on = [aws_s3_bucket_public_access_block.this]
}
# 呼び出し側
module "billing_data_bucket" {
source = "git::https://github.com/myorg/terraform-modules.git//secure-bucket?ref=v1.0.0"
bucket_name = "billing-data-prod"
kms_key_arn = aws_kms_key.billing.arn
org_id = "o-xxxxxxxxxx"
object_lock_days = 90
}
前回記事で書いた state バケットも、このモジュール呼び出し1個で済むようになります。
aws_s3_bucket_policy には必ず depends_on = [aws_s3_bucket_public_access_block.this] を入れるのが重要です。BPA が後に作成されると、ポリシー側で public 扱いされる構文があれば apply 中に一時的に拒否される可能性があります。
ハマりポイント
❌ BucketOwnerEnforced を CloudFront OAI 環境でいきなり有効化する
CloudFront OAI(Origin Access Identity、旧方式)はバケット ACL に依存しています。BucketOwnerEnforced を入れた瞬間に配信が落ちます。先に CloudFront OAC(Origin Access Control、新方式)に移行してから切り替えること。OAC はバケットポリシーで認可するため ACL に依存しません。
❌ Block Public Access の4つを部分的に ON にして「公開バケットも残せる」と思う
公開バケットを残したいなら、そのバケットだけ別 AWS アカウントに分離するのが最も安全です。同じアカウントに公開・非公開を混ぜると、アカウント単位 BPA で全部止めることができず、結局運用が複雑化します。
❌ DenyUnencryptedPut の条件キー名を間違える
s3:x-amz-server-side-encryption の値は aws:kms / AES256 / aws:kms:dsse のいずれか。バケットのデフォルト暗号化が SSE-S3 なのにポリシーで aws:kms を要求すると、デフォルト暗号化を頼った PUT が全部失敗します。バケット側設定とポリシー側要求は必ず一致させる。
❌ Bucket Key を「セキュリティが弱まる」と勘違いして切る
Bucket Key は鍵管理の方式を変えるだけで、暗号化強度は同じ AES-256 です。KMS への問い合わせ回数を集約しているだけ。コンプライアンス担当に「セキュリティを弱めるのか」と聞かれたら「監査ログも CloudTrail に残るし、暗号アルゴリズムも同じ」と説明できます。
❌ Server Access Log のログ用バケットを同じバケットにする
ログ用バケットを自分自身にすると書き込みループが起きてストレージ料金が膨らみます。必ず別バケット、できれば別アカウントに置くのが理想(監査独立性のため)。
❌ Object Lock を COMPLIANCE モードで安易に設定する
COMPLIANCE はルートアカウントですら解除不可。テスト目的で短期間設定したつもりが「保管期間が終わるまでバケット自体を削除できない」状態になります。業務用途はすべて GOVERNANCE で十分。COMPLIANCE は法的根拠(FINRA / SEC など)があるときだけ。
❌ RestrictToVpce を aws:ViaAWSService 例外なしで書く
aws:SourceVpce の Deny だけを書くと、S3 のレプリケーション・インベントリ・ストレージクラス遷移などの内部処理が VPCe を経由しないために巻き込まれて停止します。本記事の例のように aws:ViaAWSService: false を併記して、S3 サービス自身からの呼び出しを除外するのが必須。「VPCe Deny を入れたらレプリカが急に止まった」はほぼこれが原因。
❌ MFA Delete を有効にしたまま運用を続ける
MFA Delete はルートアカウントの MFA でしか操作できない極めて厳しい設定で、一度入れると CI からの operate が困難になります。本当に必要なケース(規制対応など)以外では、Object Lock + バージョニングのほうが現実的。
まとめ:明日から手を付ける順番
全バケットを一気に直すのは現実的ではないので、着手順 を示します。
| 優先度 | やること | 効果 |
|---|---|---|
| 1 | アカウント単位 BPA を有効化(公開バケット例外がなければ即時) | 新規バケット作成時に公開設定不能になる |
| 2 | 既存バケットに対し BucketOwnerEnforced の影響有無を棚卸し(CloudFront OAI 等) |
移行ブロッカーの早期発見 |
| 3 | 棚卸し完了したものから BucketOwnerEnforced に切替 |
ACL 起因の事故を物理的に除去 |
| 4 | 暗号化を SSE-KMS + Bucket Key に揃える(コスト削減も兼ねる) | 自社管理キー化、KMSコスト最大99%減 |
| 5 | バケットポリシーに aws:SecureTransport / PrincipalOrgID を追加 |
HTTPS強制 + Confused Deputy 対策 |
| 6 | IAM Access Analyzer を組織アナライザーで有効化 | 外部公開を継続的に検出 |
| 7 | secure-bucket モジュールを社内標準化、新規バケットはモジュール経由のみ | 設定漏れを構造的に防止 |
2026年の S3 セキュリティは「個別設定の積み上げ」ではなく、「ACL は完全に捨てて IAM/ポリシー1本に寄せる」「Block Public Access はアカウント単位で物理ON」という前提に立ち直すと、設定項目数自体を減らせます。少ない設定で強い守りを作るのが現代の S3 セキュリティです。
次回は 「RDS 運用の実践ガイド」 を書きます。本記事で扱った「保管中の暗号化」「ログ保護」をデータベース側に展開し、スナップショット運用とパラメータグループ管理まで踏み込みます。

