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 で通知する」

これだけだと、問題があります。

  1. CloudWatch Logs のコストが馬鹿にならない — 日 15GB ≒ 月 450 円のログ取り込み料。地味に積み重なります。
  2. アラートルールが複雑 — 「DeleteSecurityGroup を検知」「DisableTrail を検知」…こういう Rules を CloudWatch Insights で書くのは面倒。JSON Query を毎回考える苦労。
  3. 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 を修正する、みたいなフロー。これはチーム内でもまだ議論中です。

同じような環境で構築してる方、良ければ教えてもらえると嬉しいです。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事