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-S1 | CloudTrail有効化されていない | 120件 |
| AwsSolutions-IAM4 | ワイルドカードポリシーを使用 | 95件 |
| AwsSolutions-EC2-2 | セキュリティグループが0.0.0.0/0を許可 | 78件 |
| AwsSolutions-VPC3 | VPCにフローログがない | 102件 |
| AwsSolutions-RDS2 | RDSの自動バックアップが無効 | 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ヶ月かかりました。理由は:
- ルールの仕様理解:各ルールが何を禁止してるか把握するのに3日かかった
- 除外理由の合意:チーム全体で「このルール本当に必要?」の議論で1週間つぶれた
- テストと検証:実際に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ヶ月運用してみた結果、地味だけど効いてる成果が出てきました:
- 新人エンジニアのオンボーディングが楽——「CDKでこういうコード書いたら落ちる」が自動で教えてもらえるから、セキュリティ初心者でも安心
- レビュー時間が短縮——セキュリティ指摘の80%が自動化されたおかげで、本当に必要な指摘に集中できるようになった
- SOC2審査が楽——「これらのルール対応してます」って自動生成レポート出せるから、監査官との議論がスムーズ
ただし、「Nag = 完璧なセキュリティ」じゃないってことは強調したいです。Nag通ってても、設定ロジックのバグとか、アクセス制御の漏れとかはあり得るんですよ。あくまで「最低限」です。
まとめ
CDK Aspects・Nag導入で学んだことを3つ:
-
デフォルト設定は使うな。チーム全体で「本当に必要なルール」を議論して決める——500件から15件に絞るのに3週間かかったけど、その時間が一番価値あった
-
False Positiveとの戦いが本番。除外設定に理由を書いて、監査時に説明できる仕組みを作る——「なぜこのルール除外したのか」を後で説明できなくなるのが最悪
-
環境別にルール設定を変える。本番と開発で同じルール強度は非現実的——コスト削減と本番堅牢性のバランスを取る必要がある
Nagは「セキュリティの完璧を目指すツール」じゃなく「最低限のセキュリティを保証するツール」だと腹落ちしたら、めっちゃ楽になりました。完璧を求めるなら、Nagとの組み合わせで定期的な手動レビューとペネトレーションテストも必要だと思います。
今は週1回、CI/CDでNagチェック走って、引っかかったら「あ、新しいセキュリティ要件が増えたんだ」くらいのカジュアルさで対応できるようになってます。正直、最初の地獄から考えたら、これ以上の改善は期待してません(笑)。