SOC2審査でCloudTrail・Config設計が半年泥沼になった実録

「既存設定あるし少し手直しすればいいでしょ」が甘かった。SOC2 Type II審査でCloudTrailとAWS Configを根本から見直して気づいた落とし穴と、試行錯誤の末にたどり着いた設計パターンの話。

CloudTrail + AWS Config、SOC2審査で半年泥沼にはまった実録

うちのチームでSOC2 Type IIの審査準備が本格化したのが2025年秋ごろで、そのタイミングでCloudTrailとAWS Configの設計を根本から見直すことになった。最初は「既存の設定あるしちょっと手直しすればいいでしょ」くらいに思ってたんだけど、蓋を開けてみると半年近く泥沼にはまった。その実録と、試行錯誤の末にたどり着いた設計パターンを書いていく。

なお、SOC2のコンプライアンス全体像についてはSOC2対応2026年版の記事でも触れているので、CloudTrail・Configの文脈でそちらを読み返してもらえると理解が深まると思う。

CloudTrail設計、「とりあえず有効化」が一番危ない

マルチアカウント構成(AWS Organizations)を使っているプロジェクトで「CloudTrailはOrganizations単位で有効にしてるから大丈夫」という認識のままにしていたら、後からいくつか深刻な欠落に気づいた。

具体的に何が問題だったかというと、大きく3つある。

  • Management EventsとData Eventsを混同していた。S3バケットのオブジェクトレベル操作(GetObject/PutObject)はData Eventsを明示的に有効化しないと記録されない
  • CloudTrail Lakeを導入していなかったので、90日以上前のイベントをアドホッククエリしようとしたときに詰んだ
  • ログのintegrity validationが無効になってたアカウントが2つあった

特に最後のやつは本当に冷や汗ものだった。インシデント対応の場面で「このログ、改ざんされてない保証あるの?」って話になったとき、なにも言えなくなる。今は全アカウントで EnableLogFileValidation: true を必須にしてCDKのAspectで強制している(CDK Aspects・Nag導入の話でそのパターンも書いた)。

CloudTrail設定の最低ライン(2026年版)

// CDKでOrganizationsレベルのCloudTrail設定
import * as cloudtrail from 'aws-cdk-lib/aws-cloudtrail';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as kms from 'aws-cdk-lib/aws-kms';

const auditBucket = new s3.Bucket(this, 'AuditBucket', {
  bucketName: `cloudtrail-audit-${this.account}`,
  versioned: true,
  encryption: s3.BucketEncryption.KMS,
  encryptionKey: auditKey,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  lifecycleRules: [
    {
      // 直近90日はStandard、その後Glacier IA
      transitions: [
        { storageClass: s3.StorageClass.GLACIER_INSTANT_RETRIEVAL, transitionAfter: Duration.days(90) },
      ],
      expiration: Duration.days(2555), // 7年保持(PCI-DSS準拠想定)
    },
  ],
});

const trail = new cloudtrail.Trail(this, 'OrgTrail', {
  isOrganizationTrail: true,
  bucket: auditBucket,
  encryptionKey: auditKey,
  enableFileValidation: true,          // 必須
  includeGlobalServiceEvents: true,    // IAMイベントを拾う
  isMultiRegionTrail: true,
  sendToCloudWatchLogs: true,
  cloudWatchLogsRetention: logs.RetentionDays.THREE_MONTHS,
});

// S3 Data Eventsは明示的に追加
trail.addS3EventSelector(
  [{ bucket: sensitiveDataBucket }],
  {
    readWriteType: cloudtrail.ReadWriteType.ALL,
    includeManagementEvents: false,
  }
);

// Lambda Data Eventsも忘れがち
trail.addLambdaEventSelector(
  [criticalFunction],
  { readWriteType: cloudtrail.ReadWriteType.ALL }
);

KMSキーの管理についてはAWS KMS・Secrets Managerの実運用記事で詳しく書いたので参考にしてほしい。

CloudTrail Lakeへの移行は正直まだ検証中

CloudTrail Lakeは2026年現在、Event Data Storeの保持期間が最大7年まで対応していて、SQLライクなクエリでイベントを直接検索できる。パフォーマンスも上がってて、以前よりコスト感が改善された印象がある。ただ、料金体系がイベント量ベースなので、大量にData Eventsを取り込んでいると普通にS3+Athenaより高くなる。うちはまだメインはS3ベースで、Lakeはセキュリティチームの分析用途に限定して並行運用している状態。個人的には「クエリが速くて便利だけど、コストと相談」という感じで、ここは好みが分かれると思う。

AWS Configは「入れたら終わり」ではなく「運用設計」が9割

Config自体は有効化してたんだけど、1年ほど経ったあたりで「で、これどう活用してるの?」という状態になっていた。設定変更履歴は記録されてるけど、それを誰も見てない。これ、あるある過ぎて辛い。

転換点になったのは、GuardDutyが拾ったアラートの原因調査でConfigのタイムラインを使ったとき。「あ、このSecurity Group変更、3日前にあったやつと一致してる」ってわかった瞬間に「これちゃんと使えば強いな」と実感した。ツールを入れることと、ツールを運用することは全然別の話だと痛感した出来事だった。

実際に入れているConfig Rulesの構成

2026年現在、うちのチームで運用しているConfig Rulesをカテゴリ別に整理するとこんな感じ。自動修復の列が「あり」のルールは、副作用が限定的で、かつ誤検知しにくいものだけに絞っている。

カテゴリルール名自動修復優先度
IAMiam-root-access-key-checkなし(アラートのみ)Critical
IAMiam-password-policyあり(SSM自動修復)High
IAMiam-user-mfa-enabledなしHigh
S3s3-bucket-public-read-prohibitedありCritical
S3s3-bucket-ssl-requests-onlyありHigh
S3s3-bucket-versioning-enabledなしMedium
EC2ec2-security-group-open-to-specific-portsなしHigh
EC2ec2-imdsv2-checkあり(SSM)High
RDSrds-instance-public-access-checkなしCritical
RDSrds-storage-encryptedなしHigh
CloudTrailcloud-trail-enabledなしCritical
KMScmk-backing-key-rotation-enabledなしMedium

自動修復は「やりすぎると本番に影響が出る」ので、Critical+副作用が限定的なルールだけに絞った。Security Groupの自動修復は特に怖くて、まだ手が出せていない。誤って必要なポートを閉じてしまったとき、影響が広範囲に及ぶリスクがある。

Custom Config RuleをLambdaで実装する

AWSマネージドのルールだけでは足りない場面もある。うちでは「タグが必須項目を満たしていないリソース」を検出するカスタムルールを入れていて、これが地味に便利だった。

import boto3
import json
from datetime import datetime

def lambda_handler(event, context):
    config = boto3.client('config')
    
    # 必須タグの定義
    REQUIRED_TAGS = {'Environment', 'Owner', 'CostCenter', 'DataClassification'}
    
    invoking_event = json.loads(event['invokingEvent'])
    configuration_item = invoking_event.get('configurationItem')
    
    if not configuration_item:
        # 定期評価の場合
        return evaluate_all_resources(config, event)
    
    return evaluate_single_resource(config, event, configuration_item, REQUIRED_TAGS)


def evaluate_single_resource(config, event, item, required_tags):
    tags = item.get('tags', {})
    existing_tags = set(tags.keys())
    missing_tags = required_tags - existing_tags
    
    if missing_tags:
        compliance = 'NON_COMPLIANT'
        annotation = f'Missing required tags: {sorted(missing_tags)}'
    else:
        compliance = 'COMPLIANT'
        annotation = 'All required tags present'
    
    config.put_evaluations(
        Evaluations=[
            {
                'ComplianceResourceType': item['resourceType'],
                'ComplianceResourceId': item['resourceId'],
                'ComplianceType': compliance,
                'Annotation': annotation,
                'OrderingTimestamp': datetime.fromisoformat(
                    item['configurationItemCaptureTime'].replace('Z', '+00:00')
                )
            }
        ],
        ResultToken=event['resultToken']
    )
    
    return {'compliance': compliance, 'missing': list(missing_tags)}

このルールを入れてから「タグ未設定のリソースがコストエクスプローラーで追えない問題」がかなり改善した。地味だけど、コスト管理の観点でも効いてくるのでおすすめ。

実際の監査ログフロー全体像

うちのマルチアカウント構成(Organizationsで管理している)での監査ログ全体フローをまとめた。アーキテクチャの肝は「監査専用アカウントの分離」で、これによって本番アカウントの管理者でもログを削除できない構造にしている。

graph TB
    subgraph OrgRoot["Organizations 管理アカウント"]
        OrgTrail["CloudTrail<br/>Organizations Trail"]
        ConfigAgg["Config Aggregator<br/>(全アカウント集約)"]
        SecurityHub["Security Hub"]
    end

    subgraph AuditAccount["監査専用アカウント"]
        AuditBucket["S3 Audit Bucket<br/>(CloudTrail ログ)"]
        ConfigBucket["S3 Config Bucket<br/>(Config スナップショット)"]
        CTLake["CloudTrail Lake<br/>Event Data Store"]
        Athena["Athena<br/>(アドホック分析)"]
        AuditKey["KMS CMK<br/>(監査専用キー)"]
    end

    subgraph ProdVPC["本番アカウント / VPC"]
        subgraph AZ1["AZ-a"]
            App1["App Server"]
            RDS1["RDS Primary"]
        end
        subgraph AZ2["AZ-c"]
            App2["App Server"]
            RDS2["RDS Standby"]
        end
        ConfigRec["Config Recorder"]
        CWLogs["CloudWatch Logs<br/>(API アクティビティ)"]
    end

    subgraph AlertFlow["アラートフロー"]
        CWAlarms["CloudWatch Alarms<br/>(MetricFilter)"]
        SNS["SNS Topic"]
        Slack["Slack<br/>(#security-alerts)"]
        PD["PagerDuty<br/>(Critical のみ)"]
    end

    App1 -->|API Call| OrgTrail
    App2 -->|API Call| OrgTrail
    RDS1 -->|変更記録| ConfigRec
    ConfigRec -->|集約| ConfigAgg
    OrgTrail -->|ログ転送| AuditBucket
    OrgTrail -->|イベント取り込み| CTLake
    OrgTrail -->|ログストリーム| CWLogs
    CWLogs -->|Metric Filter| CWAlarms
    CWAlarms -->|通知| SNS
    SNS -->|Webhook| Slack
    SNS -->|Critical| PD
    AuditBucket -->|KMS暗号化| AuditKey
    ConfigBucket -->|KMS暗号化| AuditKey
    ConfigAgg -->|非準拠検出| SecurityHub
    SecurityHub -->|集約アラート| SNS
    CTLake -->|SQL クエリ| Athena

ここはAWS Organizationsでのマルチアカウント設計記事でも触れたパターンと同じ考え方で、「誰かが意図的にログを消せる状態」を設計で排除するのが目的だ。

CloudWatch MetricFilterによるリアルタイムアラート設計

CloudTrailのログをCloudWatch Logsに流して、MetricFilterでリアルタイム検知するのが現時点でコスパ的に優れている。以下はCDKでの実装例。

import * as logs from 'aws-cdk-lib/aws-logs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as actions from 'aws-cdk-lib/aws-cloudwatch-actions';

// rootアカウントの使用検知
const rootUsageFilter = new logs.MetricFilter(this, 'RootUsageFilter', {
  logGroup: trailLogGroup,
  metricNamespace: 'CloudTrailMetrics',
  metricName: 'RootAccountUsage',
  filterPattern: logs.FilterPattern.literal(
    '{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }'
  ),
  metricValue: '1',
});

const rootUsageAlarm = new cloudwatch.Alarm(this, 'RootUsageAlarm', {
  metric: rootUsageFilter.metric(),
  threshold: 1,
  evaluationPeriods: 1,
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
  alarmName: 'CRITICAL-RootAccountUsage',
  alarmDescription: 'Root account has been used. Immediate investigation required.',
});
rootUsageAlarm.addAlarmAction(new actions.SnsAction(criticalTopic));

// IAMポリシー変更検知
const iamChangesFilter = new logs.MetricFilter(this, 'IAMChangesFilter', {
  logGroup: trailLogGroup,
  metricNamespace: 'CloudTrailMetrics',
  metricName: 'IAMPolicyChanges',
  filterPattern: logs.FilterPattern.literal(
    '{ ($.eventName=DeleteGroupPolicy) || ($.eventName=DeleteRolePolicy) || ($.eventName=DeleteUserPolicy) || ($.eventName=PutGroupPolicy) || ($.eventName=PutRolePolicy) || ($.eventName=PutUserPolicy) || ($.eventName=CreatePolicy) || ($.eventName=DeletePolicy) || ($.eventName=CreatePolicyVersion) || ($.eventName=DeletePolicyVersion) || ($.eventName=SetDefaultPolicyVersion) }'
  ),
  metricValue: '1',
});

// Security Group変更検知
const sgChangesFilter = new logs.MetricFilter(this, 'SGChangesFilter', {
  logGroup: trailLogGroup,
  metricNamespace: 'CloudTrailMetrics',
  metricName: 'SecurityGroupChanges',
  filterPattern: logs.FilterPattern.literal(
    '{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }'
  ),
  metricValue: '1',
});

このアラートパターンはCISベンチマーク(AWS Foundations Benchmark v2.0)の推奨事項とほぼ対応している。CISが推奨している14項目のメトリクスをすべて実装するのが理想だが、最低限 rootアカウント使用IAMポリシー変更CloudTrail停止Config変更 の4つは必ず入れてほしい。インシデント対応の現場で何度助けられたか分からない。

アラート疲れとの戦い

正直、最初にアラートを全部入れたら通知が多すぎてSlackが大変なことになった。GuardDutyの話と似てるんだけど(GuardDutyのFalse Positive対策記事にも書いた)、Config Rulesの違反通知も同じ問題が起きる。

うちが取った対策は「重大度レベルの分離」。導入直後は週180件あったアラートが、3ヶ月かけて25件まで絞り込めた。

xychart-beta
    title "アラート対応状況の改善(週次件数)"
    x-axis ["導入直後", "1ヶ月後", "2ヶ月後", "3ヶ月後"]
    y-axis "件数" 0 --> 200
    bar [180, 120, 60, 25]
    line [180, 120, 60, 25]

具体的な仕分けはこんな感じで運用している。

  • Critical(PagerDuty): rootアカウント使用、CloudTrail停止、S3パブリック公開
  • High(Slack #security-alerts): IAM変更、SCP違反、RDS公開設定
  • Medium(Slack #security-log / 営業時間内のみ): タグ未設定、バージョニング無効
  • Low(週次Configダッシュボード確認のみ): 細かいベストプラクティス違反

この仕分けをするだけで「深夜に非緊急アラートで起こされる」問題が激減した。アラートを全部同じ重みで扱うのは、結果的にアラートを誰も信用しなくなる一番の近道だと思う。

コスト設計を最初に決めておかないと後悔する

CloudTrailとConfigは「入れるだけなら安い」と思いがちだけど、Data Eventsを有効化した瞬間にコストが跳ね上がる場合がある。うちの経験値で言うと:

設定月額概算(中規模: 100アカウント想定)
Management Eventsのみ$0(無料枠内)
+ S3 Data Events(全バケット)$15,000〜$50,000
+ S3 Data Events(対象バケット限定)$500〜$3,000
+ Lambda Data Events$200〜$1,000
Config(全リソース記録)$1,000〜$5,000
CloudTrail Lake(保持7年)$2,000〜$8,000

S3のData Eventsを「全バケット対象」にしてしまうと、ビルドアーティファクトやCloudFrontのアクセスログが爆発的な量になる。最初から「機密データを含むバケットのみ」にスコープを限定するのが現実的。コスト削減の取り組み全般についてはAWS費用削減の実装記録でも触れているので参考にしてほしい。

まとめ

半年かけて整理した内容なので、かなり長くなってしまったけど、要点をまとめると:

  1. CloudTrailは「有効化」と「監査に使える状態」は別物。Data Events・integrity validation・Lakeの活用まで設計しきって初めてスタートライン
  2. Config Rulesは自動修復の範囲を慎重に設計する。副作用が限定的なルールから始めて、徐々に拡張するのが安全
  3. MetricFilter+CloudWatch Alarmsは今でも強力。CISベンチマーク14項目を全部実装するのが理想
  4. アラート設計は重大度別の通知先分離が必須。全部Slackに流すとアラート疲れが起きて誰も見なくなる
  5. S3 Data Eventsのスコープは最初から絞る。全バケット対象にするとコストが爆発する

次のアクションとしては、AWS Config Conformance Packを使ったコンプライアンスパック管理をまだ本格導入できていないので、そこを整理したい。CISやPCI-DSSのパックがAWSから提供されているので、カスタムルールとの組み合わせで管理コストを下げられそうという仮説は持っているんだけど、正直まだ検証中。進捗があればまた記事を書く予定。

みなさんの環境ではCloudTrail・ConfigのConfig Rulesどんな構成にしてますか?特にマルチアカウント環境での集約方法は各社で工夫が分かれそうなので、知見を共有できると嬉しい。

U

Untanbaby

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

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

関連記事