CloudTrail・Config監査設計で失敗した話。SOC2審査3ヶ月の地獄から学んだ実装パターン
SOC2審査で指摘されたCloudTrailとConfig設定の失敗事例。マルチアカウント環境での落とし穴と、実装パターンをコード例で解説します。
CloudTrail・Config監査設計で失敗した話。本番SOC2審査3ヶ月の地獄
先日、うちのチームがSOC2 Type II対応で3ヶ月地獄を見ました。きっかけは監査法人から「CloudTrailとConfigの設定が不十分」と指摘されたこと。正直、最初は「ログ取ってるから大丈夫でしょ」くらいの気持ちでいたんですが、本気でなめてました。
実装してみると、マルチアカウント環境でのTrail設定、Config Aggregatorの正しい使い方、特にコンプライアンスチェックの粒度まで、全部が甘かったんです。この3ヶ月で学んだ実装パターンを、同じ目に遭わないようにシェアしておきます。
CloudTrailを「有効化すればいい」と思ってた人へ
まず、普通にCloudTrailの設定って、単一アカウントなら簡単なんですよね。ログを S3 に出して、CloudWatch Logs に連携させる。ここまでなら新人でもできます。でも、マルチアカウント環境になった途端、えらい複雑になります。
うちがハマったのはこれ:各アカウントで Trail を作ってたんです。つまり、本社アカウント、開発アカウント、本番アカウント、それぞれでTrailを独立させてた。最初はいいんですよ。ログは取れてる。でも監査法人が来た時に言われました。
「これだと、誰かがTrailを無効化しても、他のアカウントにログが残らないじゃないですか」
あ、マジか。
つまり、Organizations配下の全アカウントのログをManagement Accountで一元管理する必要があるんです。正確には、Organization Trail を Management Account で有効化します。こうすることで、メンバーアカウント側が Trail を無効化しても、管理アカウント側には証跡が残る。監査対象の「誰が何をした」という履歴が改ざんされない状態になるわけです。
実装のコツを書いておきます。
{
"TrailName": "org-trail-main",
"S3BucketName": "audit-logs-bucket-org",
"IncludeGlobalServiceEvents": true,
"IsMultiRegionTrail": true,
"IsOrganizationTrail": true,
"EnableLogFileValidation": true,
"EventSelectors": [
{
"IncludeManagementEvents": true,
"ReadWriteType": "All",
"DataResources": [
{
"Type": "AWS::S3::Object",
"Values": ["arn:aws:s3:::sensitive-bucket/*"]
},
{
"Type": "AWS::Lambda::Function",
"Values": ["arn:aws:lambda:*:*:function/*"]
}
]
}
]
}
ポイントは3つあります。
IsOrganizationTrail: true — これが最重要。Organization Trail を有効化することで、メンバーアカウント側が Trail を無効化できない設計になります。単一アカウント Trail だと、誰かが勝手に無効化する可能性があって、監査で即座に落とされます。
EnableLogFileValidation: true — ログが改ざんされていないことを暗号学的に保証する仕組みです。監査法人はこれを強く求めます。実装すると、CloudTrail ログのメタデータに HMAC が追加されて、後から「このログって本当に本物?」を検証できるようになります。
DataResources — S3やLambdaの詳細ログも取ります。後で「あの時誰が何をアップロードした?」という質問が必ず来ます。素人っぽいですが、監査法人はこういう細かいところを見てきます。
DataResources を全部取るとコストがかかるので、正直まだ検証中なんですが、特に本番環境の S3 や Lambda は必須だと実感してます。
Config Aggregatorで「複数アカウントの今」を見える化する
次に、CloudTrail はあくまで「過去のログ」なんですよ。いつ誰が何をしたか。でも監査法人が見たいのは、「今この瞬間、環境がコンプライアンス的に OK な状態か」という現在地です。
そこで必要なのが AWS Config。各リソースが「許可された設定か」をチェックする仕組みです。
うちが最初にやったミスは、各アカウントで勝手に Config を有効化してたこと。結果、複数アカウントの状態を一箇所で見られない。本当に面倒でした。画面を 3 つ 4 つ開いて、手動で状況を集計してました。
正解は AWS Config Aggregator です。複数アカウント、複数リージョンの Config データを一箇所で集約します。
# Management Account に Config Aggregator を作成(CloudFormation)
Resources:
ConfigAggregator:
Type: AWS::Config::ConfigurationAggregator
Properties:
ConfigurationAggregatorName: org-aggregator
OrganizationAggregationSources:
- AllAwsRegions: true
AwsOrganization:
RoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/service-role/AWSConfigOrganizationsAggregatorRole"
AllAwsRegions: true
AuthorizationFromManagement:
Type: AWS::Config::AggregationAuthorization
Properties:
AccountId: !Ref AWS::AccountId
AwsRegion: !Ref AWS::Region
で、各メンバーアカウント側はこう:
Resources:
ConfigLocalAuthorization:
Type: AWS::Config::AggregationAuthorization
Properties:
AccountId: !Sub "${ManagementAccountId}"
AwsRegion: !Ref AWS::Region
正直、最初ここの役割分担が理解できてませんでした。Aggregator はあくまで集約先で、各メンバーアカウント側には独立した Config Recorder が必要。その上で Aggregator に権限を与える。こういう関係性なんです。図解すると理解しやすいんですが、ドキュメント読むと「あれ、どっちが何?」ってなります。
Config Rules で何を検査するか、が全部じゃないか?
Config Aggregator を作った次は、各リソースが「正しい設定か」をチェックする Config Rules です。
ここが、本気で失敗しました。デフォルト Rules を何個か入れてたんですが、監査法人が来た時に「これだけ?」って言われた。見下したような口調で。
SOC2 Type II 対応に必要な Rules は、かなりの数があります。正直、全部を手作業で作るのは無理です。うちが使ってるのは AWS が提供している Organization-managed Rules のテンプレート + カスタムルール の組み合わせです。
実装例を示します。
import json
from aws_cdk import (
aws_config as config,
core
)
class ConfigRulesStack(core.Stack):
def __init__(self, scope: core.Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
# CloudTrail が有効か?
config.ManagedRule(
self, "CloudTrailEnabled",
config_rule_name="cloudtrail-enabled",
identifier=config.ManagedRuleIdentifier.CLOUD_TRAIL_ENABLED
)
# CloudTrail log validation は有効か?
config.ManagedRule(
self, "CloudTrailLogValidation",
config_rule_name="cloudtrail-log-file-validation-enabled",
identifier=config.ManagedRuleIdentifier.CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED
)
# すべてのリージョンで CloudTrail は有効か?
config.ManagedRule(
self, "CloudTrailMultiRegion",
config_rule_name="multi-region-cloudtrail-enabled",
identifier=config.ManagedRuleIdentifier.MULTI_REGION_CLOUD_TRAIL_ENABLED
)
# S3 Encryption は有効か?
config.ManagedRule(
self, "S3Encrypted",
config_rule_name="s3-bucket-server-side-encryption-enabled",
identifier=config.ManagedRuleIdentifier.S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED
)
# EC2 Instance IMDSv2 を強制?
config.ManagedRule(
self, "EC2MetadataOptions",
config_rule_name="ec2-imdsv2-check",
identifier=config.ManagedRuleIdentifier.EC2_IMDSV2_CHECK
)
# ログは 90 日以上保持?
config.CustomRule(
self, "S3LogRetention",
config_rule_name="s3-log-retention-90days",
lambda_function=core.Duration.days(1),
# カスタム Lambda 関数で実装
)
これだけでも最低限はカバーできます。ただ、SOC2 Type II となると、さらに細かいルールが必要になってきます。特に以下は避けて通れません。
- IAM Policy — 過度な権限(”*“を使ってないか、Principal が限定されてるか)
- KMS Key Rotation — キーローテーションが有効か
- RDS Encryption — DB インスタンスのストレージ暗号化
- VPC Flow Logs — ネットワークトラフィック監視
- Secrets Rotation — シークレット自動ローテーション
この辺り、全部チェックする必要があります。ちなみに、うちは Rules を 50 個近く作ってます。最初は「多すぎ」と思いましたが、監査法人に「これくらいは最低限」と言われて、納得しました。
マルチアカウント環境でのログ集約構成
そもそも、CloudTrail と Config のログをどこに保存するか、が重要です。
うちが構築した構成は、こんな感じです。
graph TB
subgraph "Management Account (監査専用)"
AuditAccount["Audit Account<br/>ログ集約先"]
S3Audit["S3 Bucket<br/>CloudTrail Logs"]
ConfigAgg["Config Aggregator<br/>複数アカウント集約"]
Athena["Athena<br/>ログ検索"]
CloudWatch["CloudWatch Logs<br/>監視"]
end
subgraph "Development Account"
DevAccount["Dev Account"]
Trail1["Organization Trail<br/>ログ出力"]
Config1["Config Recorder"]
end
subgraph "Production Account"
ProdAccount["Prod Account"]
Trail2["Organization Trail<br/>ログ出力"]
Config2["Config Recorder"]
end
subgraph "Staging Account"
StageAccount["Staging Account"]
Trail3["Organization Trail<br/>ログ出力"]
Config3["Config Recorder"]
end
Trail1 -->|CloudTrail Logs| S3Audit
Trail2 -->|CloudTrail Logs| S3Audit
Trail3 -->|CloudTrail Logs| S3Audit
Config1 -->|Config Data| ConfigAgg
Config2 -->|Config Data| ConfigAgg
Config3 -->|Config Data| ConfigAgg
S3Audit --> Athena
S3Audit --> CloudWatch
ConfigAgg --> CloudWatch
Athena -->|クエリ| AuditAccount
CloudWatch -->|監視・アラート| AuditAccount
この構成のポイントは以下の通りです。
Management Account が集約先 — Organization Trail はメンバーアカウントの Trail を無効化できない実装になってます。だから、ここが集約元として機能します。
S3 Bucket は Audit Account 専用 — 他のアカウントが誤ってログを削除できないよう、IAM で制限します。CloudTrail ログって、監査対象なので「見える範囲」と「触れる範囲」を厳密に分ける必要があります。
Athena + CloudWatch で検索・監視 — ログを見つけやすく、異常検知しやすく。CloudWatch Insights で複雑なクエリを実行するより、Athena で SQL を書く方が圧倒的に楽です。
Config Aggregator で一箇所から確認 — 複数アカウントのコンプライアンス状況をダッシュボード化。これで「全体として今どのくらい対応できてるか」が見える。
CloudTrail ログのフィルタリング・パーティション戦略
あ、もう一つ気づいたこと。CloudTrail のログ、全部取るとコストがえらいことになります。
うちの場合、毎日 15GB 程度のログが出てました。S3 ストレージで月 1000 円程度、だいぶ安いんですが、その後 Athena で検索する時に「スキャン量」に応じて課金されます。ここが地味に高い。Athena は 1TB スキャン当たり 6 ドル前後するので、馬鹿にできません。
そこで対策として、ログを Parquet に変換 + パーティション戻してます。
import boto3
import json
from datetime import datetime, timedelta
from awsglue import context
from pyspark.sql import SparkSession
glueContext = context.GlueContext(SparkSession.builder.appName("CloudTrailParquetConversion").getOrCreate())
spark = glueContext.spark_session
# CloudTrail JSON ログを読み込み
df = spark.read.json("s3://audit-logs-bucket-org/AWSLogs/*/*/CloudTrail/*.json.gz")
# タイムスタンプをパーティション用に抽出
df_partitioned = df.withColumn(
"year", spark.substring(df["eventTime"], 1, 4)
).withColumn(
"month", spark.substring(df["eventTime"], 6, 2)
).withColumn(
"day", spark.substring(df["eventTime"], 9, 2)
).withColumn(
"account_id", df["recipientAccountId"]
)
# Parquet で書き込み(パーティション分割)
df_partitioned.write \
.partitionBy("account_id", "year", "month", "day") \
.mode("overwrite") \
.parquet("s3://audit-logs-parquet-org/cloudtrail/")
こうすることで、実際のコスト削減効果は凄い。
- 元ログ(JSON): ストレージコスト用(古いのは Glacier に移す)
- Parquet: Athena クエリ用(パーティションで検索範囲を絞れるので、スキャン量が 90% 削減)
SQL で検索する時はこう:
SELECT
eventTime,
userIdentity.principalId,
eventName,
sourceIPAddress,
errorCode
FROM audit_logs_parquet
WHERE
account_id = '123456789012'
AND year = '2026'
AND month = '06'
AND day = '07'
AND eventName LIKE '%Delete%'
ORDER BY eventTime DESC
LIMIT 100;
この方が、全ログをスキャンするより 100 倍高速です。実は AWS Glue でも自動化できるので、毎日深夜に変換を回してます。
失敗したアラート設計。CloudTrail→CloudWatch→SNS の迷いの道
最初、うちはこんな思考でした。
「CloudTrail が取ったログを CloudWatch Logs に流して、CloudWatch Alarms で検知して、SNS で通知する」
これだけだと、問題があります。
- CloudWatch Logs のコストが馬鹿にならない — 日 15GB ≒ 月 450 円のログ取り込み料。地味に積み重なります。
- アラートルールが複雑 — 「DeleteSecurityGroup を検知」「DisableTrail を検知」…こういう Rules を CloudWatch Insights で書くのは面倒。JSON Query を毎回考える苦労。
- False Positive 多い — 定期メンテナンス時に一杯アラート来て、人間が無視するようになる。これが本当に危ない。
なので、うちは EventBridge を使う設計に変えました。
{
"Name": "cloudtrail-security-events",
"EventPattern": {
"source": ["aws.cloudtrail"],
"detail": {
"eventSource": ["iam.amazonaws.com", "s3.amazonaws.com", "kms.amazonaws.com"],
"eventName": [
"DeleteTrail",
"StopLogging",
"PutBucketPolicy",
"DeleteBucketPolicy",
"DisableKey",
"ScheduleKeyDeletion",
"CreateAccessKey",
"AttachUserPolicy",
"PutUserPolicy"
]
}
},
"State": "ENABLED",
"Targets": [
{
"Arn": "arn:aws:sns:ap-northeast-1:123456789012:security-alerts",
"RoleArn": "arn:aws:iam::123456789012:role/EventBridgeToSNSRole"
},
{
"Arn": "arn:aws:logs:ap-northeast-1:123456789012:log-group:/aws/eventbridge/security-events",
"RoleArn": "arn:aws:iam::123456789012:role/EventBridgeToLogsRole"
}
]
}
この方が、CloudWatch Logs より圧倒的に安いし、ルール管理も簡単。JSON で条件を書くだけなので、新人でも理解しやすいです。
Config Aggregator ダッシュボード化
それぞれの Config Rule の状況を、定期的にチームに報告する必要があります。最初は手動で画面キャプチャしてた(本気で)が、さすがに自動化しました。
import boto3
import json
from datetime import datetime
config = boto3.client('config')
def get_aggregator_compliance():
aggregators = config.describe_configuration_aggregators()
aggregator_name = aggregators['ConfigurationAggregators'][0]['ConfigurationAggregatorName']
compliance_summary = config.get_aggregated_compliance_details_by_config_rule(
ConfigurationAggregatorName=aggregator_name,
AwsRegion='ap-northeast-1'
)
# COMPLIANT / NON_COMPLIANT / NOT_APPLICABLE のカウント
compliant_count = 0
non_compliant_count = 0
for result in compliance_summary.get('AggregateEvaluationResults', []):
if result['EvaluationResultIdentifier']['EvaluationResultQualifier']['ConfigRuleName']:
if result['EvaluationResultQualifier'].get('ComplianceType') == 'COMPLIANT':
compliant_count += 1
elif result['EvaluationResultQualifier'].get('ComplianceType') == 'NON_COMPLIANT':
non_compliant_count += 1
return {
'timestamp': datetime.now().isoformat(),
'compliant': compliant_count,
'non_compliant': non_compliant_count,
'compliance_percentage': round(
(compliant_count / (compliant_count + non_compliant_count)) * 100, 2
)
}
# CloudWatch Metrics に送信
cloudwatch = boto3.client('cloudwatch')
data = get_aggregator_compliance()
cloudwatch.put_metric_data(
Namespace='CustomAudit',
MetricData=[
{
'MetricName': 'ConfigCompliance',
'Value': data['compliance_percentage'],
'Unit': 'Percent',
'Timestamp': datetime.now()
}
]
)
これを毎日実行して、CloudWatch Dashboard に表示。チーム全体で「今このタイミング、どのくらい監査対応できてるか」を見える化します。数字で見えるので、スプリント計画も立てやすくなりました。
大事なのは「設定」じゃなく「運用」
実装した後、気づいたことがあります。CloudTrail と Config、設定自体は難しくないんですよ。つまるところ、CloudFormation で書いて、デプロイするだけです。でも、その後が大変。
False Positive との付き合い方 — Config Rule が「NON_COMPLIANT」を検知しても、実務的には「これはいい」ってケースがある。例えば、一時的にセキュリティグループを開く場合とか。その時の対応フロー、対象期間、誰が判断するのか。こういう運用ルールが必要。
ログ削除ポリシー — 何年分保持するのか。コスト vs 監査期間のバランス。SOC2 は 1 年が基本ですが、それ以上保持する企業も多い。
アラート対応 — 誰が見るのか、誰が調査するのか、レスポンスタイムは。オンコール体制が必要か。
定期検証 — Rules が本当に有効か、定期的に検証する仕組み。半年に 1 回は「この Rule、本当に意味ある?」って見直します。
ここら辺、ぶっちゃけ構築よりも、運用が大変です。やることが決まってるから、ある意味で単純なんですが、ずっと続く。
まとめ
3ヶ月の SOC2 審査で学んだ CloudTrail・Config 監査設計。最後にまとめます。
Organization Trail は必須 — メンバーアカウント側が Trail を無効化できないようにします。単一アカウントの Trail では監査に通りません。これが最も重要な設計ポイントです。
Config Aggregator で複数アカウント集約 — 各アカウントで独立した Config Recorder を作った上で、Management Account の Aggregator で集約。ここの役割分担が重要。
Config Rules は 50 個程度は必要 — CloudTrail・KMS・IAM・RDS・VPC などなど。デフォルト Rules だけでは足りません。Organization-managed Rules + カスタムルール の組み合わせで対応。
ログのパーティション・Parquet 化 — CloudTrail JSON ログは大量。Parquet に変換 + 年月日 + アカウント ID でパーティションすると、Athena クエリのコストが 90% 削減できます。月単位で見ると、結構な差が出ます。
EventBridge で Critical イベント検知 — CloudWatch Logs よりシンプルで安い。DeleteTrail・StopLogging・KMS ScheduleKeyDeletion など、クリティカルなイベントを EventBridge Rules で検知。
運用が全て。設定は簡単 — CloudTrail・Config 自体の設定は難しくない。その後の「どう見続けるか」「どう対応するか」の運用設計が全部です。False Positive との付き合い方、ログ保持期間、定期検証。ここが本当に大変。
この設計、うちは 2026 年 6 月時点で 3 ヶ月運用してます。今のところ良好。ただ、まだ改善余地があると思ってます。特に「自動修復」の部分。NON_COMPLIANT を検知した時に、自動で CloudFormation Stack を修正する、みたいなフロー。これはチーム内でもまだ議論中です。
同じような環境で構築してる方、良ければ教えてもらえると嬉しいです。