AWS CDKでAspectを使ってアーキテクチャのコンプライアンス準拠をチェックする方法

AWS CDKでAspectを使ってConstructがコンプライアンスに準拠できているかを検証する方法を紹介します。
2021.04.16

はじめに

おはようございます、加藤です。先日AWSブログでAWS CDKでクラウドアプリケーションを開発するためのベストプラクティスという記事が公開されました。ほとんどの内容は私も取り入れていたり納得のできるものでしたが、コンプライアンスのためにConstructを使わないという事が推奨されており衝撃を受けました。

今までコンプライアンスのためにConstructを作りライブラリとして公開したことはありませんが、これは好ましいことと認識しており、これまでの登壇で何度も活用方法として発信していました。

記事を読むとコンプライアンスに基づいてConstructをラップしてしまうと、AWS Solutions Constructsなどが公開している便利なライブラリが利用できなくなるからという理由でした。確かにこれは納得のできる理由です。(今まではラップしたConstructを使っているAWSなどが公開しているライブラリが直接使えなくなるのはやむを得ないことだと考えていました)

では、どうやってコンプライアンス準拠を行うか、ブログでは下記のように紹介されています。

代わりに組織での SCP や permission boundary の使い方を調べて、セキュリティガードレールを適用しましょう。また aspects や CFN Guardのようなツールを使用して、デプロイ前にインフラストラクチャのプロパティに関するアサーションを行いましょう。

Aspectの存在を初めて知ったのでこれを使ってコンプライアンス準拠を検証する方法をまとめてみました。

Aspectとは

ドキュメントによるとAspectは特定のスコープ内のすべての構成に操作を適用する方法です。タグを追加するなどして構成を変更したり、すべてのバケットが暗号化されていることを確認するなど、構成の状態について何かを検証したりできます。

ドキュメントでは下記のようなサンプルコードも公開されており、デザインパターンのVisitorパターンで実装されているようです。

class BucketVersioningChecker implements IAspect {
  public visit(node: IConstruct): void {
    // See that we're dealing with a CfnBucket
    if (node instanceof s3.CfnBucket) {

      // Check for versioning property, exclude the case where the property
      // can be a token (IResolvable).
      if (!node.versioningConfiguration 
        || (!Tokenization.isResolvable(node.versioningConfiguration)
            && node.versioningConfiguration.status !== 'Enabled')) {
        Annotations.of(node).addError('Bucket versioning is not enabled');
      }
    }
  }
}

// Later, apply to the stack
Aspects.of(stack).add(new BucketVersioningChecker());

Aspectでコンプライアンス準拠を検証する

S3バケットに対してバージョン管理を行うコンプライアンスがあると仮定してそれに準拠できているかを検証します。

まず、バージョン管理が設定されているか検証するライブラリを書きます。

lib/bucket-versioning-checker.ts

import { CfnBucket } from '@aws-cdk/aws-s3'
import { Annotations, IAspect, IConstruct, Tokenization } from '@aws-cdk/core'

/**
 * Verify that the S3 bucket configures for versioning.
 */
export class BucketVersioningChecker implements IAspect {
  public visit(node: IConstruct) {
    if (node instanceof CfnBucket) { // ①
      if (
        !node.versioningConfiguration ||
        (!Tokenization.isResolvable(node.versioningConfiguration) &&
          node.versioningConfiguration.status !== 'Enabled') // ②
      ) {
        Annotations.of(node).addError('Bucket versioning is no enabled') // ③
      }
    }
  }
}

① visitメソッドには全てのConstructが流れ込んできます。そのためS3バケットのConstructだけを抽出する必要があります。

② バージョン管理が無効であること(式としては有効になっていないこと)を確認します。ただこれだけですが、node.versioningConfigurationの型がCfnBucket.VersioningConfigurationProperty | cdk.IResolvable | undefinedなので型を絞り込むためにTypeGuard関数や比較をしています。

③ Constructのメタデータにエラーを追加します。エラーを含むConstructが存在するとシンセサイズする際に失敗するためデプロイを防ぐ事ができます。

続いてこのライブラリをエントリーポイントから使用します。

bin/cdk-aspect-demo.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import * as CdkAspectDemo from '../lib/cdk-aspect-demo-stack';
import * as lib from '../lib/bucket-versioning-checker';

const app = new cdk.App();
const cdkAspectDemoStack = new CdkAspectDemo.CdkAspectDemoStack(
  app,
  'CdkAspectDemoStack'
);

cdk.Aspects.of(cdkAspectDemoStack).add(new lib.BucketVersioningChecker());

Aspects.ofでスタック(上位のConstruct)を指定し作成したライブラリをaddすることで利用ができます。

cdk deploy
[Error at /CdkAspectDemoStack/bucket/Resource] Bucket versioning is not enabled

このスタックにはバージョン管理が無効のS3バケットが1つ含まれています。デプロイを行おうとすると設定した通りのエラーが出力されます。

テスト実行時にエラーを検出する

デプロイ前にコンプライアンスしたがってエラーを出力し停止してくれるのは便利ですが、そもそもマージする前にエラーを検出し修正してからマージできるとなお便利です。

test/cdk-aspect-demo.test.ts

import * as cdk from '@aws-cdk/core';
import * as assert from '@aws-cdk/assert';
import * as CdkAspectDemo from '../lib/cdk-aspect-demo-stack';
import * as checkers from '@intercept6/cdk-checkers';

test('aspects test', () => {
  const app = new cdk.App();
  const stack = new CdkAspectDemo.CdkAspectDemoStack(app, 'test-stack');

  cdk.Aspects.of(stack).add(new checkers.BucketVersioningChecker());
  const assembly = assert.SynthUtils.synthesize(stack);
  assembly.messages.forEach(message => {
    expect(message).toEqual(
      expect.objectContaining({entry: {type: 'aws:cdk:error'}})
    );
  });
});

単純にテスト内でAspectsを呼び出すだけではエラーが検出されません。なので、スタックのメタデータを確認し、それがエラーだったらテスト失敗とします。objectContainingを使って比較しているのはテストが失敗した際にどのConstructが原因かログから特定を可能にする為です。

npm test

> cdk-aspect-demo@0.1.0 test
> jest

 FAIL  test/cdk-aspect-demo.test.ts
  ✕ aspects test (81 ms)

  ● aspects test

    expect(received).toEqual(expected) // deep equality

    Expected: ObjectContaining {"entry": {"type": "aws:cdk:error"}}
    Received: {"entry": {"data": "Bucket versioning is no enabled", "trace": ["Annotations.addMessage (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/annotations.ts:92:25)", "Annotations.addError (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/annotations.ts:51:10)", "BucketVersioningChecker.visit (${WORK_DIR}/cdk-aspect-demo/node_modules/@intercept6/cdk-checkers/lib/bucket-versioning-checker.ts:12:30)", "recurse (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:84:14)", "recurse (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:99:9)", "recurse (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:99:9)", "recurse (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:99:9)", "invokeAspects (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:69:3)", "Object.synthesize (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/private/synthesis.ts:16:3)", "App.synth (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/core/lib/stage.ts:188:23)", "synthesizeApp (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/assert/lib/synth-utils.ts:76:15)", "Function.synthesize (${WORK_DIR}/cdk-aspect-demo/node_modules/@aws-cdk/assert/lib/synth-utils.ts:12:22)", "Object.<anonymous> (${WORK_DIR}/cdk-aspect-demo/test/cdk-aspect-demo.test.ts:11:38)", "Object.asyncJestTest (${WORK_DIR}/cdk-aspect-demo/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:106:37)", "${WORK_DIR}/cdk-aspect-demo/node_modules/jest-jasmine2/build/queueRunner.js:45:12", "new Promise (<anonymous>)", "mapper (${WORK_DIR}/cdk-aspect-demo/node_modules/jest-jasmine2/build/queueRunner.js:28:19)", "${WORK_DIR}/cdk-aspect-demo/node_modules/jest-jasmine2/build/queueRunner.js:75:41", "processTicksAndRejections (internal/process/task_queues.js:93:5)"], "type": "aws:cdk:error"}, "id": "/test-stack/bucket/Resource", "level": "error"}

      11 |   const assembly = assert.SynthUtils.synthesize(stack);
      12 |   assembly.messages.forEach(message => {
    > 13 |     expect(message).toEqual(
         |                     ^
      14 |       expect.objectContaining({entry: {type: 'aws:cdk:error'}})
      15 |     );
      16 |   });

      at test/cdk-aspect-demo.test.ts:13:21
          at Array.forEach (<anonymous>)
      at Object.<anonymous> (test/cdk-aspect-demo.test.ts:12:21)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        3.56 s
Ran all test suites.

あとがき

Aspectsを使ってConstructのコンプライアンスを検証してみました。今回は検証しか行っていませんが、visit関数内でnode.versioningConfiguration = {status: 'Enabled'};と書いたりaddOverrideメソッドを使ってパラメーターをコンプライアンスに従い書き換えることもできます。

これらの方法であればAWSやサードパーティによって配布されているパターンConstructを自分たちにとって最適であるか検証および最適に変更することが比較的簡単に行なえますね。

以上でした。