【アドベントカレンダー2025】CI/CD と単体テストで守る! cdk-nag をアサーションテストに組み込んでセキュアなクリスマスを🎄

はじめに

メリークリスマス! 🎄 アドベントカレンダー最終日を担当します、村田です。 普段はメールを配信するシステムなど社内で共通に利用されるプラットフォームの運用、開発を行っています。

みなさん、クリスマスの準備は万端でしょうか?エンジニアにとって最高のクリスマスプレゼントは「平和で、何も起きない(アラートが鳴らない)夜」ですよね。

AWS CDK はインフラ構築を強力にサポートしてくれますが、その反面、S3バケットのパブリック公開や広すぎるIAM権限など、意図しないセキュリティホールを作ってしまう危険性もあります。

そのようなリスクをコードの段階で発見してくれるツールが、 cdk-nag です。 一般的に cdk-nag はデプロイパイプラインの途中で実行されるケースが多いですが、私たちのチームではもう一歩踏み込んで、「単体テスト(アサーションテスト)の一部」として cdk-nag を実行しています。

CI が失敗するのを待つのではなく、ローカルで npm test を実行した瞬間にセキュリティ違反に気づく——。いわゆる「Shift Left」を実践することで、開発スピードを落とさずに安全性を担保しています。

本記事では、pre-commit やスナップショットテストと組み合わせ、GitHub Actions 上で強固なパイプラインを築く私たちのチームの実践手法を紹介します。 セキュリティチェックを開発の日常に組み込み、安心して年末年始を迎えましょう!

目次

🎁 私たちのチームの「プレゼント検品体制(テスト戦略)」

私たちのチームでは、サンタクロース(開発者)が作ったプレゼント(コード)が子供たち(ユーザー)の手元に届くまでに、壊れていないか、そして、危険でないかを確認するために、3段階の検品体制を敷いています。 いわゆる多層防御(Defense in Depth)の考え方を、開発フローにも適用しています。

1. Lint / Format:包装紙の汚れチェック

まずは、Linter や Formatter を実行し、コードの品質を担保します。

  • 主な内容:ESLint, Prettier
  • 目的:構文エラーやフォーマットのズレを修正します。
  • 例え:プレゼントの包装紙が破れていないか、宛名が読める字で書かれているかを確認する工程です。

2. スナップショットテスト:形のチェック(回帰テスト)

次に、Jest のスナップショットテストを実行します。 これは、前回デプロイされた構成(CloudFormation テンプレート)と今回の構成に 「意図しない差分」 がないかを確認するものです。

  • 主な内容:expect(template).toMatchSnapshot()
  • 目的:デグレの防止。意図せずリソースが削除されたり、プロパティが変わったりしていないかを機械的に検知します。
  • 例え:去年のプレゼントと比べて、勝手に部品が変わっていないか、形が崩れていないかを確認する工程です。

3. アサーションテスト + cdk-nag:中身と安全性のチェック

ここが今回の肝となる部分です。スナップショットだけでは「変更があったこと」は分かりますが、「その変更が正しいか、安全か」までは判断できません。

そこで、aws-cdk-lib/assertions を使った単体テストを行います。私たちのチームでは、このフェーズに cdk-nag も統合しています。

  • 主な内容:
    • ロジック確認:「本番環境の際は削除保護が有効か?」など
    • セキュリティ確認(cdk-nag):「S3バケットはパブリックアクセスになっていないか?」「IAM権限は最小か?」など
  • 例え:おもちゃの電源が入るか(ロジック)、そして、鋭利な部品や誤飲の危険性がないか(セキュリティ)を厳しくチェックする工程です。

🚀 なぜ、テストコード(Jest)に cdk-nagを統合したのか?

一般的に cdk-nag は、cdk synth コマンド実行時にコンソールに出力させる使い方が多いですが、私たちはあえて Jest のテストケースの一部 として組み込んでいます。

その理由は大きく2つあります。

1. フィードバックの高速化(Shift Left)

CI(GitHub Actions)の失敗を待つのは時間がかかります(5〜10分)。テストコードに組み込んであれば、開発者はローカルで npm test を実行するだけで、数秒以内にセキュリティ違反に気づくことができます。

2. テストスイートの一元化

「機能テストは通ったけど、セキュリティチェックを忘れていた」という事態を防げます。npm test が通れば、機能もセキュリティも基準を満たしているという状態を保証できるため、開発者の認知負荷を下げることができます。

また、今回構築したプラットフォームは高いセキュリティ要件を求められたため、「機能テストは通ったけど、セキュリティチェックを忘れていた」や「そもそも、そんなセキュリティ要件知らなかった」という事態を可能な限り防ぐために pre-commit 時に 1 から 3 を実施するようなテスト戦略を取りました。

🛠️ テストコードへの cdk-nag 実装:コンストラクト単位の実践ガイド

ここでは、実際に cdk-nag をテストコード内で活用する方法を解説します。

通常、 cdk-nag はエントリーポイント(bin/app.ts)でアプリケーション全体に適用されがちですが、本プロジェクトではテストコード内(*.test.ts)で適用するアプローチを採用しています。

さらに、スタック全体ではなくコンストラクト単位でテストを行うことで、認知負荷を下げ、改修しやすい堅牢なコードベースを維持する方法を解説します。

💡コンストラクト(Construct)とは?

AWS CDK における「基本的な構成要素」のことです。 単なる1つの AWS リソース(例:S3バケット)だけでなく、論理的に関連する複数のリソース(例:S3バケット + ライフサイクルルール + 関連するIAMロール)をひとまとめにして、再利用可能な部品(クラス)として定義したものを指します。

本記事では、この「部品」単位に絞ってテストを行うことを推奨しています。

🧱 なぜ、コンストラクト単位なのか?

巨大なスタック全体をテストしようとすると、リソース間の依存関係が複雑になり、テストコードのメンテナンスコストが増大します。そのためコンストラクト単位でテストを行うことには以下のメリットがあります。

  1. 認知負荷の低減:テスト対象の責務が明確になり、仕様書のように読める。

  2. 高速なフィードバック:テスト実行時間が短く、開発サイクルを回しやすい。

  3. 責任境界の明確化:エラーが発生した場合、どこに問題があったのか特定しやすい。

💻 実装例

以下は、回帰テスト(スナップショット)、機能要件(アサーション)、そしてセキュリティ要件(cdk-nag)を、1つのテストスイートで網羅する実装例です。

ここでは例として、セキュアなS3バケットを提供するカスタムコンストラクト MySecureBucket をテストします。

import * as cdk from 'aws-cdk-lib';
import { Template, Annotations, Match } from 'aws-cdk-lib/assertions';
import { AwsSolutionsChecks } from 'cdk-nag';
// テスト対象のコンストラクト
import { MySecureBucket } from '../lib/constructs/my-secure-bucket'; 

describe('MySecureBucket Construct Tests', () => {
  let stack: cdk.Stack;
  let template: Template;

  beforeEach(() => {
    // コンストラクトをホストするための最小限のスタックを作成
    const app = new cdk.App();
    stack = new cdk.Stack(app, 'TestStack');

    // 1. テスト対象のコンストラクトを配置
    new MySecureBucket(stack, 'TargetConstruct', {
      bucketName: 'test-bucket-example', // バケット名はグローバルでユニークである必要があります
    });

    // 2. cdk-nag の Aspects をスタックに適用
    // これにより、合成時にセキュリティチェックが走り、結果がメタデータとして付与されます
    cdk.Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true }));

    // 3. 検証用にテンプレートを生成
    template = Template.fromStack(stack);
  });

  // ----------------------------------------------------------------
  // 第1の層:スナップショットテスト
  // 意図しない構成変更(デグレ)を検知します
  // ----------------------------------------------------------------
  test('Snapshot: コンストラクトの構成が以前と一致すること', () => {
    expect(template.toJSON()).toMatchSnapshot();
  });

  // ----------------------------------------------------------------
  // 第2の層:ファイングレーンアサーション(機能要件)
  // ビジネスロジック通りにプロパティが設定されているか確認します
  // ----------------------------------------------------------------
  test('Assertion: バージョニングが有効化されたS3バケットであること', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      VersioningConfiguration: {
        Status: 'Enabled',
      },
    });
  });

  // ----------------------------------------------------------------
  // 第3の層:cdk-nag セキュリティチェック(非機能要件)
  // AWS Solutions ルールに違反するエラーがないことを保証します
  // ----------------------------------------------------------------
  test('Security: AwsSolutions ルールに違反するエラーがないこと', () => {
    // Template.fromStack ではなく Annotations を使用して警告/エラーを取得
    const annotations = Annotations.fromStack(stack);

    // エラーレベルの指摘が0件であることをアサート
    // 違反がある場合、Jest のエラーログに違反内容(リソースとルールID)が出力されます
    annotations.hasNoError('/AwsSolutions/.*', Match.anyValue());
  });
});
コンストラクト単位でテストをする際のデメリット

コンストラクト単位でテストをすると、結合部分の検証が漏れてしまいます。例えば「コンストラクトAが作成したQueueを、コンストラクトBのLambdaが読む」といった、コンストラクトをまたぐ権限設定のミスは、単体テストで発見することが難しい場合があります。

対策としては重要な連携部分のみ別途スタック単位でテストするなどと言った方法があります。

🔇 ルールの抑制(Suppression)方法

セキュリティルールは重要ですが、実際のプロジェクトでは「コスト等の理由でアクセスログを無効にしたい」「管理用リソースのため強い権限が必要」といった、正当な理由でルールを除外したいケースが発生します。

cdk-nag では、コード内で明示的にルールを抑制(Suppress)することで、エラーを除外し、テストを通過させることができます。

実装例

今回は「IAMポリシーにワイルドカード(*)を使用してはいけない」という AwsSolutions-IAM5 ルールに対し、正当な理由があって例外としたい場合の記述例を示します。

修正対象:テストファイルではなく、コンストラクト(実装コード)側を修正します。

import { Stack, App } from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
// 1. NagSuppressions をインポート
import { NagSuppressions } from 'cdk-nag';

export class MyAdminRole extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const role = new iam.Role(this, 'AdminRole', {
      assumedBy: new iam.AccountRootPrincipal(),
    });

    // 強い権限を持つポリシー(通常は cdk-nag でエラーになる)
    role.addToPolicy(new iam.PolicyStatement({
      actions: ['*'], 
      resources: ['*'],
    }));

    // 2. 特定のリソースに対してルールを抑制する
    NagSuppressions.addResourceSuppressions(role, [
      {
        id: 'AwsSolutions-IAM5', // エラーログに表示されるルールID
        reason: '管理者用ロールのため、全リソースへのアクセス権限が必要です。',
      },
    ]);
  }
}

Suppression時のポイント

  1. reason は必ず具体的に書く:なぜそのルールを無視しても安全なのか(あるいは必要なのか)を reason に記述します。これは単なるコメントではなく、CloudFormation テンプレートのメタデータとして残り、監査時の証跡となります。

  2. テストコードの変更は不要:実装コード側で適切に抑制が行われると、cdk-nag はそれをエラーとしてカウントしなくなります。そのため、前章で書いた annotations.hasNoError(...) のテストコードは、そのままパスするようになります。

特に1の 「reason は必ず具体的に書く」の点は後の修正においても「なぜ、そうなっている?」がすぐに分かるので、ぜひ取り入れていただきたいです。

⚖️ なぜ、 cdk synth ではなくテストコードなのか?

ここまでテストコード内への実装方法を紹介してきましたが、従来よくある「bin/app.ts に Aspects を記述し、cdk synth 時に検証する手法」と比べて、何が嬉しいのでしょうか?

テストコードで実装するメリットは、「責務の分離」と「運用のシンプルさ」にあります。

1. 本番コード(bin/app.ts)が汚れない

従来の手法では、エントリーポイントである bin/app.ts に検証ロジックを書く必要があり、「本番デプロイ時もチェックするのか?」「開発時だけか?」といった分岐ロジックが混入しがちでした。 今回の手法なら、検証はすべて *.test.ts 内で完結するため、デプロイコードと検証コードの責務を完全に分離できます。

2. エラー箇所の特定が圧倒的に速い

cdk synth でスタック全体を一気に検証すると、大量のログの中にエラーが埋もれてしまいがちです。 コンストラクト単位のテストであれば、エラーが出た際に「どのテストケース(=どの部品)で失敗したか」が Jest の結果としてピンポイントで分かるため、修正にかかる時間を大幅に短縮できます。

3. CI/CD パイプラインが「ゼロコンフィグ」になる

これが最大のメリットです。 通常、CI 環境で cdk synth ベースのチェックを行う場合、合成コマンドを実行し、そのログ出力を解析したり、エラーコードを拾ってパイプラインを止めるための専用のステップやシェルスクリプトを YAML に記述する必要があります。

しかし、テストコードに統合するこの手法であれば、CI 側の設定変更は一切不要(ゼロコンフィグ)です。 既存の単体テストコマンド npm test が通ること、それは即ちセキュリティチェックもパスしたことを意味するため、パイプラインの定義をシンプルに保つことができます。

🔄 CI/CD への統合

最後にローカルでのテストも重要ですが、チーム開発において「テストの実行忘れ」は致命的です。 そこで、GitHub Actions を使用して、プルリクエストが作成されるたびに自動でテストを実行し、セキュリティ違反があるコードをブロックする仕組みを構築します。

これが、安心してクリスマスを迎えるための最後の砦、「セキュリティゲート」となります。

ワークフローファイルの作成

プロジェクトのルートディレクトリに .github/workflows/ 配下に YAMLファイル(例: .github/workflows/test-ci.yml)を作成し、以下のコードを記述します。 特別な設定は必要なく、標準的な Node.js のテストフローを実行するだけで、先ほど実装した cdk-nag のチェックが自動的に機能します。

name: CDK Security & Unit Tests

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  test:
    name: Run Jest Tests (inc. cdk-nag)
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Install Dependencies
        run: npm ci

      # ここが最重要ポイント!
      # npm test が実行されると、Jest 内の cdk-nag アサーションも走ります
      - name: Run Tests
        run: npm test

仕組みの解説

このワークフローの最大のポイントは、最後の npm test です。

  1. 自動実行:PR を作ると自動的にテストが走ります。

  2. マージのブロック:もし cdk-nag がセキュリティ違反を見つけた場合、テストは失敗し、GitHub 上のチェックマークが「❌」になります。

さらに GitHub の Branch Protection Rule で「このテストが通らないとマージできない」設定を入れておけば、意図しないセキュリティホールが本番環境に紛れ込むことはありません。

🎅 まとめ

ここまでの手順で、以下のサイクルが完成しました。

  1. 書く:コンストラクト単位でコードを書く。

  2. 守る:テストコード内で cdk-nag を使い、機能とセキュリティを同時に検証する。

  3. 自動化する:GitHub Actions でテストを必須化し、違反コードの混入を未然に防ぐ。

セキュリティ対策は「面倒」だと形骸化します。 だからこそ、いつもの npm test に統合し、CI/CD で自動化する。この「シンプルさ」こそが、忙しい開発現場でもセキュアな状態を維持し続けるための秘訣です。

この仕組みがあれば、不意なアラートに怯える必要はありません。 自信を持ってリリースし、穏やかな気持ちでクリスマスケーキを食べましょう!

それでは、良いお年を! 🎅


常に用具沼にハマっているエンジニアです。主にバックエンドやプラットフォームを担当しています。AI駆動開発はじめました。。