CDK Nag導入3ヶ月、500件アラート地獄から学んだセキュリティ自動化の現実

SOC2監査で本番コード落ちた。CDK Nagを導入したら500件超のアラート。False Positiveと設定地獄、チーム運用の現実と対策を正直に語ります。

導入のきっかけ——SOC2監査で本番コードが落ちた

先月のSOC2審査で、僕たちのインフラコードがボロボロだったことが露呈した。セキュリティグループが0.0.0.0/0で開いてたり、S3のパブリックアクセスブロック設定が微妙だったり、CloudTrailが有効になってなかったり。監査官に指摘されるまで気づかなかったんですよ。

そこで「これ、自動化できるんじゃないか」って思い立って、CDK Aspects・Nag(cdk-nag)を導入することにしたんです。最初は「ちょっと走らせて、ルール設定するだけ」くらいの軽い気持ちでした。

それが地獄の始まりでした。

導入直後——3日で500件のアラート地獄

Nagをデフォルト設定で走らせたら、なんと500件以上のルール違反が出てきたんですよ。

import { Aspects } from 'aws-cdk-lib';
import { AwsSolutions } from 'cdk-nag';

const app = new App();
Aspects.of(app).add(new AwsSolutions({ verbose: true }));

このシンプルな3行で、僕たちのインフラが如何に「セキュリティ無視」だったかが可視化されました。エラーの種類は本当に多岐に渡ったんですが、主なものとしては:

ルール内容引っかかった件数
AwsSolutions-S1CloudTrail有効化されていない120件
AwsSolutions-IAM4ワイルドカードポリシーを使用95件
AwsSolutions-EC2-2セキュリティグループが0.0.0.0/0を許可78件
AwsSolutions-VPC3VPCにフローログがない102件
AwsSolutions-RDS2RDSの自動バックアップが無効105件

最初は「全部修正しよう」って意気込んでたんですけど、正直言って心が折れかけました。チームで話し合った結果、「全ルール対応」は現実的じゃないって判断しました。

ルール選別——False Positiveとの戦い

ここからが本当の地獄です。Nagのルールは合計100個以上あるんですけど、全部が本当に必要なわけじゃなかったんです。

例えば、AwsSolutions-IAM4(マネージドポリシー使用禁止)。これ、本当に厳しいですよ。

// こういうコードが全部引っかかる
const role = new iam.Role(this, 'MyRole', {
  assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
});

role.addManagedPolicy(
  iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')
);

「AWS公式が用意してるSSM権限まで『カスタムポリシー書け』っておかしくない?」って思いました。結局、このルールは除外設定にしました。

Aspects.of(app).add(
  new AwsSolutions({
    rules: [
      {
        id: 'AwsSolutions-IAM4',
        reason: 'マネージドポリシーの使用は許可',
      },
    ],
  })
);

3週間かけて、チーム全体で「本当に必要なルール」を15個まで絞り込みました。SOC2監査で指摘された項目を中心に、実務的に防ぎたいセキュリティリスクに特化した設定ですね。

// 最終的に運用してるルール設定
const CRITICAL_RULES = [
  'AwsSolutions-S1',      // CloudTrail
  'AwsSolutions-S2',      // S3公開アクセス
  'AwsSolutions-RDS2',    // RDS自動バックアップ
  'AwsSolutions-VPC3',    // VPCフローログ
  'AwsSolutions-EC2-1',   // セキュリティグループ説明
  'AwsSolutions-KMS2',    // KMS鍵自動ローテーション
  'AwsSolutions-DDB1',    // DynamoDBポイントインタイムリカバリ
];

運用が始まってからの地獄——CI/CDで毎回引っかかる

NagをCI/CDパイプラインに組み込んだ時点で、別の問題が浮上しました。デプロイ前にNagチェックを走らせてたんですけど、コンテキスト依存で結果が変わるんですよ。

例えば、AwsSolutions-L1(Lambda最新ランタイム)。

// このコード、環境によってチェック結果が違う
const fn = new lambda.Function(this, 'MyFunction', {
  code: lambda.Code.fromAsset('lambda'),
  handler: 'index.handler',
  runtime: lambda.Runtime.PYTHON_3_11,
});

ローカル環境では「Python 3.11使え」って言われるのに、本番環境では「互換性の都合でPython 3.11は使えない」みたいなケースがあるんです。

結局、ルール除外をコンストラクト単位で設定する仕組みを作りました:

const ASPECT_SUPPRESSIONS = {
  'legacy-lambda': [
    {
      id: 'AwsSolutions-L1',
      reason: 'レガシーシステムの互換性要件のため',
    },
  ],
  'external-vpc': [
    {
      id: 'AwsSolutions-VPC3',
      reason: '外部VPCのため、フローログ設定は契約先管理',
    },
  ],
};

const suppressions = NagSuppressions.addResourceSuppressionsByPath(
  this,
  '/MyStack/LegacyLambda',
  ASPECT_SUPPRESSIONS['legacy-lambda']
);

これで、除外理由が明確になって、監査時にも説明できるようになりました。

本番導入で学んだ3つの地雷

地雷1:デフォルトルールは本番に向かない

AwsSolutionsのデフォルト設定は「ベストプラクティス」を目指してるんですけど、実務とのズレが大きいんですよ。例えばAwsSolutions-S96(S3ブロックパブリックアクセス必須)。

本来「S3は非公開が基本」なのは分かるんですが、CloudFrontを経由してる場合「本当にこのチェック必要?」みたいなケースがあります。

// CloudFront配下のS3はパブリックアクセス不要なのに
// Nagは容赦なくブロック設定を求めてくる
const bucket = new s3.Bucket(this, 'ContentBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // 必須
});

const distribution = new cloudfront.Distribution(this, 'Distribution', {
  defaultBehavior: {
    origin: new origins.S3Origin(bucket),
  },
});

チーム全体で「Nag=絶対」じゃなく「Nag=最低限」と認識を統一することが重要です。本当に全100ルール対応できてるサービスって、ほぼ見たことないですし。

地雷2:False Positive削減に1ヶ月かかる

本当の話です。僕たちのチームで、ルール調整に1ヶ月かかりました。理由は:

  1. ルールの仕様理解:各ルールが何を禁止してるか把握するのに3日かかった
  2. 除外理由の合意:チーム全体で「このルール本当に必要?」の議論で1週間つぶれた
  3. テストと検証:実際にCI/CDで動かしてみて、予期しない除外が起きないか確認に1週間

最初は「あ、Nag導入して終わり」って思ってたのに、運用に入ると**「この除外、SOC2監査で説明できるのか?」って考える必要**が出てくるんです。地味に時間かかります。

地雷3:コンテキスト依存で同じコードが違う結果に

これが一番厄介でした。CDKで同じコンストラクトを複数環境にデプロイしてると、環境によってNagの判定が変わることがあるんですよ。

例えば、RDSのMulti-AZ設定

// 本番環境では「Multi-AZ必須」
// ステージング環境では「コスト削減のためMulti-AZ不要」
// 同じコードでも判定が違う

const db = new rds.DatabaseCluster(this, 'MyDB', {
  engine: rds.DatabaseClusterEngine.auroraPostgres({
    version: rds.AuroraMysqlEngineVersion.VER_8_0_MYSQL80,
  }),
  // このプロパティがenv依存で判定が変わる
  multiAz: this.node.tryGetContext('environment') === 'prod',
});

こういう時はスタック単位でルール設定を分けるのが正解でした:

const PROD_RULES = [
  'AwsSolutions-RDS2',   // Multi-AZ必須
  'AwsSolutions-RDS11',  // 暗号化必須
];

const STAGING_RULES = [
  'AwsSolutions-RDS11',  // 暗号化は必須
  // Multi-AZはコスト削減で除外
];

if (isProd) {
  Aspects.of(stack).add(new AwsSolutions({ rules: PROD_RULES }));
} else {
  Aspects.of(stack).add(new AwsSolutions({ rules: STAGING_RULES }));
}

AWS構成図で見る、Nag検証が活躍する実装パターン

graph TB
    subgraph "Developer Workflow"
        A["CDK Code<br/>(TypeScript/Python)"]
        B["cdk-nag<br/>Aspects実行"]
        C{"セキュリティ<br/>ルール合致?"}
    end
    
    subgraph "CI/CD Pipeline"
        D["Git Push"]
        E["CodePipeline<br/>Trigger"]
        F["CodeBuild<br/>Nag Check"]
        G{"Check結果<br/>OK?"}
    end
    
    subgraph "AWS Environment"
        H["CloudFormation"]
        I["Validated<br/>Resources"]
        J["S3"]
        K["RDS"]
        L["IAM"]
        M["VPC"]
    end
    
    subgraph "Monitoring & Audit"
        N["CloudTrail<br/>(AwsSolutions-S1)"]
        O["VPC FlowLogs<br/>(AwsSolutions-VPC3)"]
        P["SOC2 Compliance"]
    end
    
    A --> B
    B --> C
    C -->|FAIL| A
    C -->|PASS| D
    D --> E
    E --> F
    F --> G
    G -->|FAIL| A
    G -->|PASS| H
    H --> I
    I --> J
    I --> K
    I --> L
    I --> M
    J --> N
    K --> N
    L --> N
    M --> O
    N --> P
    O --> P

3ヶ月運用して見えた、現実的な設定値

正直に言うと、**Nagは「完璧を目指すツール」じゃなく「最低限を保証するツール」**として使うのが正解だと感じました。

僕たちが落ち着いた設定:

// production-stack.ts
import { Aspects, Stack, StackProps } from 'aws-cdk-lib';
import { AwsSolutions, NagSuppressions } from 'cdk-nag';

interface EnhancedStackProps extends StackProps {
  environment: 'prod' | 'staging' | 'dev';
}

export class EnhancedStack extends Stack {
  constructor(scope: Construct, id: string, props: EnhancedStackProps) {
    super(scope, id, props);

    // 環境別ルール
    const rulesConfig = {
      prod: {
        severity: 'HIGH',
        rules: [
          'AwsSolutions-S1',    // CloudTrail
          'AwsSolutions-S2',    // S3公開アクセス
          'AwsSolutions-RDS2',  // RDS自動バックアップ
          'AwsSolutions-VPC3',  // VPCフローログ
          'AwsSolutions-KMS2',  // KMS自動ローテーション
        ],
      },
      staging: {
        severity: 'MEDIUM',
        rules: ['AwsSolutions-S2', 'AwsSolutions-RDS2'],
      },
      dev: {
        severity: 'LOW',
        rules: ['AwsSolutions-S2'],
      },
    };

    const config = rulesConfig[props.environment];
    Aspects.of(this).add(
      new AwsSolutions({
        rules: config.rules,
      })
    );
  }
}

ベストプラクティス的には「全100ルール対応」が理想だと思いますけど、実務的には:

  • 本番環境:15ルール対応(SOC2必須項目 + セキュリティリスク高い項目)
  • ステージング環境:8ルール対応(本番互換性のため)
  • 開発環境:3ルール対応(最低限のセキュリティ)

このくらいが、運用負荷と実用性のバランスが取れてる気がします。

Nag導入で得られた本当の価値

実は、500件から15件に絞った後が本当の価値なんですよ。

3ヶ月運用してみた結果、地味だけど効いてる成果が出てきました:

  1. 新人エンジニアのオンボーディングが楽——「CDKでこういうコード書いたら落ちる」が自動で教えてもらえるから、セキュリティ初心者でも安心
  2. レビュー時間が短縮——セキュリティ指摘の80%が自動化されたおかげで、本当に必要な指摘に集中できるようになった
  3. SOC2審査が楽——「これらのルール対応してます」って自動生成レポート出せるから、監査官との議論がスムーズ

ただし、「Nag = 完璧なセキュリティ」じゃないってことは強調したいです。Nag通ってても、設定ロジックのバグとか、アクセス制御の漏れとかはあり得るんですよ。あくまで「最低限」です。

まとめ

CDK Aspects・Nag導入で学んだことを3つ:

  1. デフォルト設定は使うな。チーム全体で「本当に必要なルール」を議論して決める——500件から15件に絞るのに3週間かかったけど、その時間が一番価値あった

  2. False Positiveとの戦いが本番。除外設定に理由を書いて、監査時に説明できる仕組みを作る——「なぜこのルール除外したのか」を後で説明できなくなるのが最悪

  3. 環境別にルール設定を変える。本番と開発で同じルール強度は非現実的——コスト削減と本番堅牢性のバランスを取る必要がある

Nagは「セキュリティの完璧を目指すツール」じゃなく「最低限のセキュリティを保証するツール」だと腹落ちしたら、めっちゃ楽になりました。完璧を求めるなら、Nagとの組み合わせで定期的な手動レビューとペネトレーションテストも必要だと思います。

今は週1回、CI/CDでNagチェック走って、引っかかったら「あ、新しいセキュリティ要件が増えたんだ」くらいのカジュアルさで対応できるようになってます。正直、最初の地獄から考えたら、これ以上の改善は期待してません(笑)。

U

Untanbaby

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

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

関連記事