LoginSignup
7
0

More than 1 year has passed since last update.

【AWS】cdk-nagで単体テストのメッセージを整形し、コーディングしながら爆速でセキュリティ遵守する!

Posted at

はじめに

近年、セキュリティのシフトレフトが注目を浴びています。セキュリティのシフトレフトとは、開発ライフサイクルの早い段階でセキュリティチェックしていこうねという考え方です。

タイトルにもある cdk-nag とは、AWS CDK と統合したセキュリティ・コンプライアンスをチェックできるツールです。cdk-nag の事例は増えており、私が所属するチームでも、年明けから cdk-nag を本格的に利用し始めました。

公式ブログには、cdk-nag の単体テスト利用例が載っています。単体テストで cdk-nag を使用するというのは、シフトレフトの観点で良いアプローチだと思います。本記事では、実開発での利便性を向上させるため、cdk-nag の単体テスト利用時におけるエラーメッセージを整形してみました。

本記事の後半に cdk-nag を利用した単体テストのデモも載せていますので、ご参考になれば幸いです。

前提

予備知識

cdk-nag

以下は、AWS の公式ブログです。cdk-nag を初めて触る方は、必見です。

Github の docs では、上記にない独自ルールの作り方などが載っているのですが、英語のみです。大変有難いことに、以下ブログでとても分かりやすく解説いただいているので、ご参照ください。

CDK アサーションテスト

AWS CDK のアサーションテストについて、過去に記事を執筆しました。CDK で単体テストを書く際は、ご参照ください。

環境

  • CDK v2.70.0
  • Jest v27.5.1

テスト対象のプロダクトコード

公式ブログの内容をそのまま流用します。

lib/cdk_test-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket } from 'aws-cdk-lib/aws-s3';

export class CdkTestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const bucket = new Bucket(this, 'Bucket')
  }
}

デフォルトのS3バケットを1つ定義しただけのシンプルな内容です。

そのまま使ってみる

以下に、公式ブログのテストコードを転載します。

test/cdkNag.test.ts
import { Annotations, Match } from 'aws-cdk-lib/assertions';
import { App, Aspects, Stack } from 'aws-cdk-lib';
import { AwsSolutionsChecks } from 'cdk-nag';
import { CdkTestStack } from '../lib/cdk_test-stack';

describe('cdk-nag AwsSolutions Pack', () => {
  let stack: Stack;
  let app: App;
  // In this case we can use beforeAll() over beforeEach() since our tests 
  // do not modify the state of the application 
  beforeAll(() => {
    // GIVEN
    app = new App();
    stack = new CdkTestStack(app, 'test');

    // WHEN
    Aspects.of(stack).add(new AwsSolutionsChecks());
  });

  // THEN
  test('No unsuppressed Warnings', () => {
    const warnings = Annotations.fromStack(stack).findWarning(
      '*',
      Match.stringLikeRegexp('AwsSolutions-.*')
    );
    expect(warnings).toHaveLength(0);
  });

  test('No unsuppressed Errors', () => {
    const errors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-.*')
    );
    expect(errors).toHaveLength(0);
  });
});

上記のテストコードを使って、npm run test test/cdkNag.test.tsを実行すると、以下の結果になります。

実行結果1

実行結果をテキスト表示
~/environment/CdkTest # npm run test test/cdkNag.test.ts 

> CdkTest@0.1.0 test
> jest "test/cdkNag.test.ts"

 FAIL  test/cdkNag.test.ts (11.507 s)
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (88 ms)
    ✕ No unsuppressed Errors (35 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    expect(received).toHaveLength(expected)

    Expected length: 0
    Received length: 6
    Received array:  [{"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}, {"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}, {"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}, {"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}, {"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}, {"entry": [Object], "id": "/test/Bucket/Resource", "level": "error"}]

      32 |       Match.stringLikeRegexp('AwsSolutions-.*')
      33 |     );
    > 34 |     expect(errors).toHaveLength(0);
         |                    ^
      35 |   });
      36 | });
      37 |

      at Object.<anonymous> (test/cdkNag.test.ts:34:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        11.573 s, estimated 12 s
Ran all test suites matching /test\/cdkNag.test.ts/i.

この実行結果から、cdk-nag のエラー 6 件が発生していることは分かります。しかし、どのルールに違反しているのかは分かりません。

npx cdk synthを実行した場合は、以下の結果が表示されます。

cdk synth 実行結果

実行結果をテキスト表示
~/environment/CdkTest # npx cdk synth
[Error at /CdkTestStack/Bucket/Resource] AwsSolutions-S1: 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.

[Error at /CdkTestStack/Bucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked. The bucket should have public access restricted and blocked to prevent unauthorized access.

[Error at /CdkTestStack/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL. You can use HTTPS (TLS) to help prevent potential attackers from eavesdropping on or manipulating network traffic using person-in-the-middle or similar attacks. You should allow only encrypted connections over HTTPS (TLS) using the aws:SecureTransport condition on Amazon S3 bucket policies.

Found errors

この実行結果では、どのルールで違反しているか、明白です。できれば、単体テスト実行時にも上記のような結果を出力したいところです。

ちなみにnpx cdk synthではエラー件数が3件であり、npm run testのエラー6件と相違があります。この点についても、後ほど触れます。

エラーメッセージの整形

前述の通り、どのルールで違反しているか分かるように、メッセージを整形してみます。findError 関数が返す SynthesisMessage では、以下の情報を持ちます。

Name Type 補足
entry MetadataEntry (object) ルール ID が記載されたdata などをもつ
id string aws:cdk:path のメタデータ1
level SynthesisMessageLevel INFO または WARNING または ERROR

SynthesisMessage の構造が分かったので、テストコードに反映します。"No unsuppressed Errors"のテストケースに try-catch 構文を適用して、以下のように変更します2

(抜粋)test/cdkNag.test.ts
  test("No unsuppressed Errors", () => {
    const errors = Annotations.fromStack(stack).findError(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*")
    );
-   expect(errors).toHaveLength(0);
+   try {
+     expect(errors).toHaveLength(0);
+   } catch (e) {
+     let log: string = "\u001b[31m"; // 制御文字で red 表示
+     errors.forEach((error) => {
+       log += `[Error at ${error.id}] ${error.entry.data as string}`;
+     });
+     throw new Error(log + "\u001b[0m");
+   }
  });

上記テストコードで、npm run test test/cdkNag.test.tsを実行すると、以下の結果になります。

実行結果2

実行結果をテキスト表示
~/environment/CdkTest # npm run test test/cdkNag.test.ts 

> CdkTest@0.1.0 test
> jest "test/cdkNag.test.ts"

 FAIL  test/cdkNag.test.ts (13.735 s)
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (91 ms)
    ✕ No unsuppressed Errors (29 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    [Error at /test/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    [Error at /test/Bucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked.
    [Error at /test/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    [Error at /test/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    [Error at /test/Bucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked.
    [Error at /test/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    

      39 |         log += `[Error at ${error.id}] ${error.entry.data as string}`;
      40 |       });
    > 41 |       throw new Error(log + "\u001b[0m");
         |             ^
      42 |     }
      43 |   });
      44 | });

      at Object.<anonymous> (test/cdkNag.test.ts:41:13)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        13.836 s
Ran all test suites matching /test\/cdkNag.test.ts/i.

npx cdk synth実行時と近い形のメッセージになりました。このメッセージならば、プロダクトコードの修正対象が分かり、より実用的です。しかし、エラー件数がnpx cdk synthと一致していないため、修正が必要そうです。

テストコード共通部分の修正

エラー件数の相違については、beforeAllbeforeEach に修正することで、回避可能です。beforeEach に変更すると、各テストケース実行前に app・スタックを再作成します。修正結果は以下の通りです。

  // In this case we can use beforeAll() over beforeEach() since our tests
  // do not modify the state of the application
- beforeAll(() => {
+ beforeEach(() => {
    // GIVEN
    app = new App();
    stack = new CdkTestStack(app, "test");

    // WHEN
    Aspects.of(stack).add(new AwsSolutionsChecks());
  });

上記テストコードで、npm run test test/cdkNag.test.tsを実行すると、以下の結果になります。

実行結果3

実行結果をテキスト表示
~/environment/CdkTest # npm run test test/cdkNag.test.ts 

> CdkTest@0.1.0 test
> jest "test/cdkNag.test.ts"

 FAIL  test/cdkNag.test.ts (12.119 s)
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (100 ms)
    ✕ No unsuppressed Errors (20 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    [Error at /test/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    [Error at /test/Bucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked.
    [Error at /test/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    

      39 |         log += `[Error at ${error.id}] ${error.entry.data as string}`;
      40 |       });
    > 41 |       throw new Error(log + "\u001b[0m");
         |             ^
      42 |     }
      43 |   });
      44 | });

      at Object.<anonymous> (test/cdkNag.test.ts:41:13)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        12.191 s, estimated 14 s
Ran all test suites matching /test\/cdkNag.test.ts/i.

無事に3件分表示されるようになりました。

公式ブログにあるソースコードコメント3の真意は不明ですが、beforeEach を使用する際の問題点については、以前に私が書いた記事でも触れています。

元々、beforeEachを使用していたのですが、執筆時点ではテスト時にNodejsFunctionのbundlingを止める機能がなく、テスト時間爆増の問題があったため、beforeAllに変更しました。bundlingのissueについては以下をご参照ください。
(assertions): argument to skip bundling of assets #18125

cdk-nag のテストに関しては、warning と error の2ケースしかないため、beforeEach を使用してもテスト時間の増加は問題なさそうです。

汎用化してみる

前節までは、Error ケースのみ出力していましたが、warning についても出力されるように変更します。同じ処理をベタ書きするのは芸がないので、テストコードに以下関数を追加します。

(抜粋)test/cdkNag.test.ts
import { SynthesisMessage } from "aws-cdk-lib/cx-api/lib/metadata";

function createCdkNagLog(messages: SynthesisMessage[]): string {
  let log = "";

  messages.forEach((message) => {
    switch (message.level) {
      case "info":
        log += "\u001b[34m"; // blue
        break;
      case "warning":
        log += "\u001b[33m"; // yellow
        break;
      case "error":
        log += "\u001b[31m"; // red
        break;
      default:
        log += "\u001b[30m"; // black
        break;
    }
    log += `[${message.level} at ${message.id}] ${message.entry.data as string}\u001b[0m`;
  });

  return log;
}

level に応じて、出力するメッセージの色を変更しています。テストコードで分岐処理があるのは、一般的にアンチパターンと言われているので、気になる方は制御文字関連の処理を削除してください。ちなみに、ChatGPT に相談したところ、テストコード内で、try-catch構文を使ってエラーメッセージを改行や色分けで見やすくすることは問題ありませんという助言をいただきました。

上記関数を使用するように変更したテストコードの全量は以下です。

test/cdkNag.test.ts
import { Annotations, Match } from "aws-cdk-lib/assertions";
import { App, Aspects, Stack } from "aws-cdk-lib";
import { AwsSolutionsChecks } from "cdk-nag";
import { CdkTestStack } from "../lib/cdk_test-stack";
import { SynthesisMessage } from "aws-cdk-lib/cx-api/lib/metadata";

describe("cdk-nag AwsSolutions Pack", () => {
  let stack: Stack;
  let app: App;

  beforeEach(() => {
    // GIVEN
    app = new App();
    stack = new CdkTestStack(app, "test");

    // WHEN
    Aspects.of(stack).add(new AwsSolutionsChecks());
  });

  // THEN
  test("No unsuppressed Warnings", () => {
    const warnings = Annotations.fromStack(stack).findWarning(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*")
    );
    try {
      expect(warnings).toHaveLength(0);
    } catch (e) {
      throw new Error(createCdkNagLog(warnings));
    }
  });

  test("No unsuppressed Errors", () => {
    const errors = Annotations.fromStack(stack).findError(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*")
    );
    try {
      expect(errors).toHaveLength(0);
    } catch (e) {
      throw new Error(createCdkNagLog(errors));
    }
  });
});

function createCdkNagLog(messages: SynthesisMessage[]): string {
  let log = "";

  messages.forEach((message) => {
    switch (message.level) {
      case "info":
        log += "\u001b[34m"; // blue
        break;
      case "warning":
        log += "\u001b[33m"; // yellow
        break;
      case "error":
        log += "\u001b[31m"; // red
        break;
      default:
        log += "\u001b[30m"; // black
        break;
    }
    log += `[${message.level} at ${message.id}] ${message.entry.data as string}\u001b[0m`;
  });

  return log;
}

上記でテスト実行したいところですが、現状のプロダクトコードには error しか含まれていません。そこで、error 以外も出力されるようにするため、プロダクトコードにシンプルな CloudFront Distribution を追加します。

lib/cdk_test-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket } from "aws-cdk-lib/aws-s3";
+ import { Distribution } from "aws-cdk-lib/aws-cloudfront";
+ import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";

export class CdkTestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const bucket = new Bucket(this, "Bucket");
+   new Distribution(this, "Distro", {
+     defaultBehavior: {
+       origin: new S3Origin(bucket),
+     },
+   });
  }
}

この状態で、npm run test test/cdkNag.test.tsを実行すると、以下の結果になります。

実行結果4

実行結果をテキスト表示
~/environment/CdkTest # npm run test test/cdkNag.test.ts 

> CdkTest@0.1.0 test
> jest "test/cdkNag.test.ts"

 FAIL  test/cdkNag.test.ts (12.477 s)
  cdk-nag AwsSolutions Pack
    ✕ No unsuppressed Warnings (128 ms)
    ✕ No unsuppressed Errors (60 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Warnings

    [warning at /test/Distro/Resource] AwsSolutions-CFR1: The CloudFront distribution may require Geo restrictions.
    [warning at /test/Distro/Resource] AwsSolutions-CFR2: The CloudFront distribution may require integration with AWS WAF.
    

      27 |       expect(warnings).toHaveLength(0);
      28 |     } catch (e) {
    > 29 |       throw new Error(createCdkNagLog(warnings));
         |             ^
      30 |     }
      31 |   });
      32 |

      at Object.<anonymous> (test/cdkNag.test.ts:29:13)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    [error at /test/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    [error at /test/Bucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked.
    [error at /test/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    [error at /test/Bucket/Policy/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    [error at /test/Distro/Resource] AwsSolutions-CFR3: The CloudFront distribution does not have access logging enabled.
    [error at /test/Distro/Resource] AwsSolutions-CFR4: The CloudFront distribution allows for SSLv3 or TLSv1 for HTTPS viewer connections.
    

      39 |       expect(errors).toHaveLength(0);
      40 |     } catch (e) {
    > 41 |       throw new Error(createCdkNagLog(errors));
         |             ^
      42 |     }
      43 |   });
      44 | });

      at Object.<anonymous> (test/cdkNag.test.ts:41:13)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   0 total
Time:        12.546 s, estimated 13 s
Ran all test suites matching /test\/cdkNag.test.ts/i.

warning と error の明細が、出力されるようになりました。

Watch オプションのススメ

前節までは、npm run testを都度実行していましたが、公式ブログに記載があるように、Jest CLI の watch オプション利用をおススメします。npm run test -- --watchを実行することで、ファイルの変更を検出し、cdk-nag を含むテストの自動実行が可能になります4

以下は、watch オプションのデモンストレーションです。

cdk-nag UTデモ
なお、前節まではデフォルトの ts-jest を使っていましたが、本デモでは、esbuild-jest を使っています。ts-jest の実行時間は 12~13 秒でしたが、 esbuild-jest の実行時間は 3 秒以内と高速だったためです。

おわりに

本記事では、cdk-nag を単体テストで活用しやすいように、エラーメッセージの整形方法を紹介しました。公式ブログの高度な使用法と追加情報に記載されていた内容を基にしていたので、さらっと書く予定でしたが、結局長めになってしまいました。

他のテストと併せて、cdk-nag のルールを単体テストで確認するのは、シフトレフトしている感があるなと感じています。本記事がどなたかのお役になれば幸いです。

参考資料

  1. aws:cdk:path では、スタック名・リソースの論理名を含む文字列が出力されます。例えば、「CdkTestStack/Bucket/Resource」といった形式です。

  2. メッセージを赤で強調するため、制御文字を利用しています。不要な方は、\u001b[31m\u001b[0m の部分を削除してください。

  3. テストコードの beforeAll 直前に記載されている // In this case we can use beforeAll() over beforeEach() since our tests // do not modify the state of the application の部分です。

  4. watch オプションを利用する場合は、jest.config.js の設定に注意してください。cdk init --language=typescriptでプロジェクトを作成した場合、jest.config.js では roots: ['<rootDir>/test']が設定されています。この設定では test ディレクトリ配下のみを検索するため、lib ディレクトリ配下のプロダクトコードを変更しても、watch オプションで検出されません。roots: ['<rootDir>/test', '<rootDir>/lib']などに変更することで、プロダクトコード変更時でもテストが自動実行されるようになります。

7
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
7
0