3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

cdk-nagが良さそうなので使ってみる

3
Posted at

はじめに

CDKでインフラを書いていて、「これ、セキュリティ的に大丈夫なんだろうか...」と不安になることがよくあります。
レビューをお願いするにしても、レビュワーがAWSに詳しくないと判断が難しいんですよね。

そこで前から気になっていた cdk-nag を試してみました。

結論から言うとかなり良さそうだったのですが、v3で書き方がガラッと変わっていたり、思わぬ落とし穴があったりしたので、その辺りもまとめておきます。

cdk-nagとは

一言でいうと インフラ用のESLint です。

ESLintが「そのコードの書き方、まずいですよ」と教えてくれるように、cdk-nagは「そのAWS構成、セキュリティ的にまずいですよ」を自動で指摘してくれます。作っているのはAWS(cdklabs)です。

一番うれしいのは、デプロイする前にチェックが走ることです。

CDKは「TypeScriptで書いたコード」を「CloudFormationテンプレート」に変換してからデプロイしますが(synth と呼ばれる工程です)、cdk-nagはこの変換のタイミングで割り込みます。

コードを書く → synth(ここでcdk-nagがチェック) → デプロイ

なのでAWSアカウントに何も作らないうちに、お金も一切かけずにチェックできます。設計図の段階でダメ出ししてもらえるイメージですね。

ルールパックは AwsSolutionsChecks(汎用)のほか、NIST・HIPAA・PCI DSS・サーバーレス向けなど6種類あります。今回は一番オーソドックスな AwsSolutionsChecks を使いました。ルール数は131個ありました。

とりあえず使ってみる

インストール

npm install cdk-nag

試したときのバージョンは cdk-nag が 3.0.2、aws-cdk-lib が 2.265.0 です。

適用する

bin/<app>.ts に3行足すだけです。

import { AwsSolutionsChecks } from 'cdk-nag';

// ...スタックの定義...

cdk.Validations.of(app).addPlugins(
  new AwsSolutionsChecks(app, { verbose: true })
);

verbose: true を付けると「なぜダメなのか」の説明まで出してくれます。レビュー用途なら必須だと思います。

ネットの記事だと Aspects.of(app).add(new AwsSolutionsChecks()) と書かれていることが多いですが、それはv2の書き方です。v3では動きません。後述します。

わざと雑に書いたスタックを用意する

生成AIアプリでよくある構成(S3にドキュメント、DynamoDBに会話履歴、LambdaからBedrockを呼ぶ、API Gatewayで公開)を、レビュー観点を一切入れずに書いてみました。

// ドキュメント保管用バケット
const documentBucket = new s3.Bucket(this, 'DocumentBucket', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

// 会話履歴テーブル
const historyTable = new dynamodb.Table(this, 'HistoryTable', {
  partitionKey: { name: 'sessionId', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});

// Lambda実行ロール(とりあえず動かしたい状態)
const handlerRole = new iam.Role(this, 'HandlerRole', {
  assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonS3FullAccess'),
  ],
});
handlerRole.addToPolicy(
  new iam.PolicyStatement({
    actions: ['bedrock:*', 'dynamodb:*'],
    resources: ['*'],
  })
);

const handler = new lambda.Function(this, 'ChatHandler', {
  runtime: lambda.Runtime.NODEJS_18_X,
  role: handlerRole,
  // ...
});

// 誰でも叩けるAPI
new apigw.LambdaRestApi(this, 'ChatApi', { handler });

書いていて「まあ、最初はこう書くよな...」という感じのコードです。

実行してみる

npx cdk synth --quiet

結果、19件の指摘が出ました。

lib/insecure-stack.ts:22:28
ERROR The S3 Bucket has server access logs disabled. The bucket should have
server access logging enabled to provide detailed records for the requests
that are made to the bucket. (AwsSolutions)
   InsecureStack/DocumentBucket/Resource aws-cdk-lib.aws_s3.CfnBucket
   Acknowledge with 'AwsSolutions::AwsSolutions-S1'

ソースコードの行番号が出るのが地味にうれしいポイントです(lib/insecure-stack.ts:22:28)。どこを直せばいいか一目でわかります。

内訳はこんな感じでした。

リソース 書いた行数 指摘
S3 4行 3件
DynamoDB 5行 1件
IAM 13行 5件
Lambda 14行 2件
API Gateway 3行 8件

new apigw.LambdaRestApi(this, 'ChatApi', { handler }) の3行で8件というのがなかなかの破壊力でした。認可なし、アクセスログなし、リクエスト検証なし、WAFなし...と次々に指摘されます。

あと、指摘があると synth 自体が終了コード1で失敗します。そのままCIのゲートに使えますね。

指摘を直していく

19件と聞くと大変そうですが、実際にやってみるとほとんどはプロパティを足すだけでした。

// S3(3件)
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,                    // S10対策
serverAccessLogsBucket: logBucket,   // S1対策

// DynamoDB(1件)
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },

// Lambda(1件)
runtime: lambda.Runtime.NODEJS_24_X,

このあたりは知ってさえいれば一瞬なんですが、知らないと永遠に気づけないやつですね...

一番考えたのはIAMでした。

// Before:とりあえず動かしたい
actions: ['bedrock:*', 'dynamodb:*'],
resources: ['*'],

// After:使うモデルだけに絞る
actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
resources: [
  `arn:aws:bedrock:${this.region}::foundation-model/anthropic.claude-...`,
  `arn:aws:bedrock:${this.region}:${this.account}:inference-profile/us.anthropic.claude-...`,
],

Beforeの状態だと、このLambdaが乗っ取られたときにアカウント内の全S3バケットを読み書きできて、全DynamoDBテーブルを削除できます。生成AIアプリだとプロンプトインジェクションの経路もあるので、ここは本当に大事だなと思いました。

直せないものは「抑制」する

全部の指摘に従えばいい、とはならないのが現実です。

たとえば私は「アクセスログを保存するための専用バケット」を作りましたが、そこにも「アクセスログを取っていない」と指摘が出ます。ログ置き場のログを取り始めたらキリがないですよね。

そこで 抑制(acknowledge) という仕組みを使います。ESLintの // eslint-disable-next-line のAWS版だと思ってもらえれば。

cdk.Validations.of(api).acknowledge({
  id: 'AwsSolutions::AwsSolutions-APIG3',
  reason: 'WAFv2 WebACL はセキュリティ共通アカウントで一元管理し、' +
          'Firewall Manager 経由で関連付ける運用のため、アプリ側スタックでは設定しない。',
});

id は synth の出力に表示される Acknowledge with '...' をそのままコピペすればOKです。

大事なのは reason のほうです。cdk-nagにとっては単なる文字列なので何を書いても通りますが、逆に言うとここが人間のレビュー対象になります

reasonの例 判断
「WAFは共通アカウントで一元管理しているため」 筋が通っている
「対応が大変なので」 ...差し戻しですね

AWSの深い知識がなくても、日本語として筋が通っているかは判断できます。レビュワーの仕事が「全部を目視で探す」から「言い訳を読む」に変わるのが、個人的には一番の価値だと感じました。

なお AwsSolutions-IAM5 のようなルールは、ルール名だけでは抑制できませんでした。synthが表示するfinding単位のIDを使います。

// これでは消えない
{ id: 'AwsSolutions::AwsSolutions-IAM5', reason: '...' }

// finding単位で指定する
{ id: 'AwsSolutions-IAM5[Action::s3:GetObject*]', reason: '...' }

bucket.grantRead() などは内部で s3:GetObject* のようなワイルドカードを作るので必ずIAM5に引っかかりますが、これは最小権限の範囲内なので、直すのではなく理由を書いて抑制するのが正解ですね。

ここまでやって、19件 → 0件になりました。

ハマったポイント

ネットの情報がほとんどv2だった

これが一番の落とし穴でした。v3で書き方が別物になっています。

v2(旧) v3
適用 Aspects.of(app).add(...) Validations.of(app).addPlugins(...)
抑制 NagSuppressions.addResourceSuppressions(...) Validations.of(construct).acknowledge({ id, reason })
指摘があるとき 警告が出るだけでsynthは通る synthが失敗する
レポート コンソールのみ cdk.out/validation-report.json にも出力

v3では NagSuppressions が消えて、CDK標準の Validations APIに寄せられています。記事を参考にするときはバージョンを確認したほうがよさそうです。

抑制がスタックを跨いで効いてしまう

これは検証中に気づいたのですが、なかなか怖い挙動でした。

Validations.of(bucketB).acknowledge(...) のようにスコープを指定しても、そのスコープは無視されて、同じApp内の全スタックに抑制が効きます

最小構成で確かめてみました。StackAとStackBに同じバケットを置いて、StackBにだけ抑制を書きます。

const stackA = new cdk.Stack(app, 'StackA');
new s3.Bucket(stackA, 'BucketA');           // 抑制なし

const stackB = new cdk.Stack(app, 'StackB');
const bucketB = new s3.Bucket(stackB, 'BucketB');
cdk.Validations.of(bucketB).acknowledge({   // StackBにだけ抑制
  id: 'AwsSolutions::AwsSolutions-S1',
  reason: 'StackB側の事情による抑制',
});

結果がこちらです。

[acknowledgeなし]
  S1が検出されたリソース: StackA/BucketA/Resource, StackB/BucketB/Resource

[StackBにだけacknowledge]
  S1が検出されたリソース: (なし)

抑制していないStackAの指摘まで消えてしまいました。finding単位の細かいIDでも同じでした。

実務だと「スタックAで書いた正当な抑制が、スタックBの本物の脆弱性を黙って隠す」ことになるので、これはちょっと怖いですね...

公式ドキュメントには「ルール名だけを指定して全findingを抑制する機能は未実装」と書かれていたので、そちらは既知のようです。ただスコープが効かない方は、同じような報告を見つけられませんでした。私の使い方が間違っている可能性もあるので、詳しい方がいたら教えていただきたいです。

対策としては、CIのゲートを cdk synth ではなくテスト側に置くのが良さそうでした。テストならスタックごとに独立した App を作れるので、抑制が漏れません。

test('SecureStackは指摘ゼロ', () => {
  const app = new cdk.App({ outdir, context: cdkJson.context });
  new SecureStack(app, 'SecureStack');
  cdk.Validations.of(app).addPlugins(new AwsSolutionsChecks(app));
  expect(() => app.synth({ force: true })).not.toThrow();
});

なお、テスト内で new cdk.App() を作ると cdk.json のcontextが読まれないので、上のように明示的に渡す必要がありました。これを忘れると「ローカルのsynthは通るのにテストだけ落ちる」という現象が起きます。

さいごに

実は cdk-nag には BedrockやAgentCoreのルールが1つもありません(3.0.2時点で確認しました)。Lambdaに15個、RDSに14個ルールがある一方で、Bedrock系はゼロです。上流にもIssueは立っているのですが、2025年11月に作られて以降コメントがない状態でした。

つまり「cdk-nagが通った」=「生成AIアプリとして安全」ではないということになります。ガードレールの設定漏れとか、プロンプトインジェクション対策とかは1件も検出されません。この辺りは別の手段で見るしかなさそうです。

とはいえ、3行足すだけで131項目を毎回チェックしてくれるのはコスパが良すぎるので、まずは手元で回してみるところから始めるのが良いんじゃないかなと思います。

参考

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?