AWS CloudFormation Guard の単体テスト機能を試してみた

AWS CloudFormation Guard の単体テスト(Unit Testing)機能を使って、AWS Config カスタムルールのテストをしてみました。
2022.10.05

AWS CloudFormation Guard では単体テスト(Unit Testing)の機能が提供されているため試してみました。今回のブログでは、AWS Config カスタムルールのテストを行っています。

単体テストの関連ドキュメント

単体テスト機能は次のドキュメントで仕様が公開されています。

cloudformation-guard/UNIT_TESTING.md at main · aws-cloudformation/cloudformation-guard

AWS ユーザーガイドでは次のページです。

Testing AWS CloudFormation Guard rules - AWS CloudFormation Guard

test - AWS CloudFormation Guard

単体テストを試してみた

単体テストのために 2 つのファイルを用意します。

  1. AWS CloudFormation Guard ルールファイル
  2. 単体テストファイル(JSON or YAML)


#1の Guard ルールファイルは AWS Config カスタムルールに設定しているコードそのものです。今回のテストでは、デフォルト VPC を利用しているかを確認するルールでテストを試してみます。

check_default_vpc.guard

rule no_default_vpc {
    configuration.isDefault == false
}


#2 の単体テストファイル次の形式となります。JSON もしくは YAML に対応しており、今回は YAML 形式で作成します。

---
- name: <TEST NAME>
  input:
     <SAMPLE INPUT>
   expectations:
     rules:
       <RULE NAME>: [PASS|FAIL|SKIP]

inputにはテスト対象とする AWS Config の設定項目を記載します。テストデータとして、デフォルト VPC に Internet Gateway をアタッチしただけの AWS Config の設定項目を利用します。

VPCの設定項目(折りたたんでいます)
version: '1.3'
accountId: '111122223333'
configurationItemCaptureTime: '2022-10-04T13:59:57.783Z'
configurationItemStatus: ResourceDiscovered
configurationStateId: '1664891997783'
configurationItemMD5Hash: ''
arn: 'arn:aws:ec2:ap-northeast-1:111122223333:vpc/vpc-09436d7abf4685717'
resourceType: 'AWS::EC2::VPC'
resourceId: vpc-09436d7abf4685717
awsRegion: ap-northeast-1
availabilityZone: 'Multiple Availability Zones'
tags:
  Name: test-vpc
relatedEvents: {  }
relationships:
  -
    resourceType: 'AWS::EC2::Subnet'
    resourceId: subnet-0ee9b1ddfe5b4b1a8
    relationshipName: 'Contains Subnet'
  -
    resourceType: 'AWS::EC2::SecurityGroup'
    resourceId: sg-019212d5be91ecc89
    relationshipName: 'Contains SecurityGroup'
  -
    resourceType: 'AWS::EC2::Subnet'
    resourceId: subnet-051eab0072aa9e630
    relationshipName: 'Contains Subnet'
  -
    resourceType: 'AWS::EC2::NetworkAcl'
    resourceId: acl-02cd2667144e8c650
    relationshipName: 'Contains NetworkAcl'
  -
    resourceType: 'AWS::EC2::RouteTable'
    resourceId: rtb-0ac24364c616c863a
    relationshipName: 'Contains RouteTable'
  -
    resourceType: 'AWS::EC2::InternetGateway'
    resourceId: igw-0367be58d3c2fa692
    relationshipName: 'Is attached to InternetGateway'
  -
    resourceType: 'AWS::EC2::Subnet'
    resourceId: subnet-0fd4640c7313c55cc
    relationshipName: 'Contains Subnet'
configuration:
  cidrBlock: 172.31.0.0/16
  dhcpOptionsId: dopt-0491e761
  state: available
  vpcId: vpc-09436d7abf4685717
  ownerId: '111122223333'
  instanceTenancy: default
  ipv6CidrBlockAssociationSet: {  }
  cidrBlockAssociationSet:
    -
      associationId: vpc-cidr-assoc-0305a268b56e510bf
      cidrBlock: 172.31.0.0/16
      cidrBlockState:
        state: associated
  isDefault: true
  tags:
    -
      key: Name
      value: test-vpc
supplementaryConfiguration: {  }
resourceTransitionStatus: None


rules には Guard ルールファイルにおいてテスト対象としたい rule ブロックの名前と期待する評価結果を記載します。評価結果には次の 3 種類があります。

評価結果 説明
PASS ルールの評価結果が ture (AWS Config ルールにおける準拠)
FAIL ルールの評価結果が false (AWS Config ルールにおける非準拠)
SKIP ルールがトリガーされていない


Guard ルールファイルの情報を反映すると単体テストファイルは下記のようになります。期待する評価結果はFAILとしています。なお、単体テストファイルのサフィックスは_test.yamlまたは_tests.yamlとすることが推奨されています。

check_default_vpc_test.yaml

---
- name: MyTest
  input:
    version: '1.3'
    accountId: '111122223333'
    configurationItemCaptureTime: '2022-10-04T13:59:57.783Z'
    configurationItemStatus: ResourceDiscovered
    configurationStateId: '1664891997783'
    configurationItemMD5Hash: ''
    arn: 'arn:aws:ec2:ap-northeast-1:111122223333:vpc/vpc-09436d7abf4685717'
    resourceType: 'AWS::EC2::VPC'
    resourceId: vpc-09436d7abf4685717
    awsRegion: ap-northeast-1
    availabilityZone: 'Multiple Availability Zones'
    tags:
      Name: test-vpc
    relatedEvents: {  }
    relationships:
      -
        resourceType: 'AWS::EC2::Subnet'
        resourceId: subnet-0ee9b1ddfe5b4b1a8
        relationshipName: 'Contains Subnet'
      -
        resourceType: 'AWS::EC2::SecurityGroup'
        resourceId: sg-019212d5be91ecc89
        relationshipName: 'Contains SecurityGroup'
      -
        resourceType: 'AWS::EC2::Subnet'
        resourceId: subnet-051eab0072aa9e630
        relationshipName: 'Contains Subnet'
      -
        resourceType: 'AWS::EC2::NetworkAcl'
        resourceId: acl-02cd2667144e8c650
        relationshipName: 'Contains NetworkAcl'
      -
        resourceType: 'AWS::EC2::RouteTable'
        resourceId: rtb-0ac24364c616c863a
        relationshipName: 'Contains RouteTable'
      -
        resourceType: 'AWS::EC2::InternetGateway'
        resourceId: igw-0367be58d3c2fa692
        relationshipName: 'Is attached to InternetGateway'
      -
        resourceType: 'AWS::EC2::Subnet'
        resourceId: subnet-0fd4640c7313c55cc
        relationshipName: 'Contains Subnet'
    configuration:
      cidrBlock: 172.31.0.0/16
      dhcpOptionsId: dopt-0491e761
      state: available
      vpcId: vpc-09436d7abf4685717
      ownerId: '111122223333'
      instanceTenancy: default
      ipv6CidrBlockAssociationSet: {  }
      cidrBlockAssociationSet:
        -
          associationId: vpc-cidr-assoc-0305a268b56e510bf
          cidrBlock: 172.31.0.0/16
          cidrBlockState:
            state: associated
      isDefault: true
      tags:
        -
          key: Name
          value: test-vpc
    supplementaryConfiguration: {  }
    resourceTransitionStatus: None
  expectations:
    rules:
      no_default_vpc: FAIL


テストを実行する前に環境を整えます。

ローカルで実行するためには、AWS CloudFormation Guard をインストールする必要があります。

https://github.com/aws-cloudformation/cloudformation-guard#installation

Installing AWS CloudFormation Guard - AWS CloudFormation Guard


今回の環境は macOS のため、次のコマンドでインストールしています。

% brew install cloudformation-guard
% cfn-guard --version
cfn-guard 2.1.0


テストを実行してみます。AWS Config のテストデータはデフォルト VPC のため期待通り非準拠(FAIL)となっていることを示しています。

% cfn-guard test --rules-file check_default_vpc.guard --test-data check_default_vpc_test.yaml
Test Case #1
Name: "MyTest"
  PASS Rules:
    no_default_vpc: Expected = FAIL

単体テストファイルを修正して、期待する評価結果をPASSに変更してみます。

check_default_vpc_test.yaml

  expectations:
    rules:
      no_default_vpc: PASS

再度テストを実行したところ、期待した評価結果PASSに対して、実際の評価結果がFAILになっていることが分かります。

% cfn-guard test --rules-file check_default_vpc.guard --test-data check_default_vpc_test.yaml
Test Case #1
Name: "MyTest"
  FAIL Rules:
    no_default_vpc: Expected = PASS, Evaluated = [FAIL]

オプション-v, --verboseで詳細を出力することもできます。

% cfn-guard test --rules-file check_default_vpc.guard --test-data check_default_vpc_test.yaml -v
Test Case #1
Name: "MyTest"
`- File(, Status=FAIL)[Context=File(rules=1)]
   `- Rule(no_default_vpc, Status=FAIL)[Context=no_default_vpc]
      `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block configuration.isDefault EQUALS  false]
         `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/configuration/isDefault[L:0,C:0] Value=true), to=(resolved, Path=[L:0,C:0] Value=false))[Context= configuration.isDefault EQUALS  false]
  FAIL Rules:
    no_default_vpc: Expected = PASS, Evaluated = [FAIL]

以上で、テストの実行は終わりです。

その他 Tips

ここからは Tips の紹介となります。

単体テストファイルは次のように複数のルール評価を記載することができます。

---
- name: <TEST NAME>
  input:
     <SAMPLE INPUT>
   expectations:
     rules:
       <RULE NAME>: [PASS|FAIL|SKIP]
- name: <TEST NAME>
  input:
     <SAMPLE INPUT>
   expectations:
     rules:
       <RULE NAME>: [PASS|FAIL|SKIP]


詳細出力を利用して変数の中身を確認することもできます。

例えば、次の Guard ルールファイルで変数vpc_relationshipsの中身を詳細出力から確認できます。評価ルールに意味はありませんのでご注意ください。

let vpc_relationships = relationships[resourceType == "AWS::EC2::InternetGateway"]

rule sample_rule {
    %vpc_relationships empty
}

テスト結果に変数の中身が表示されており、意図通り設定項目を絞れているか確認できます。

% cfn-guard test --rules-file sample.guard --test-data sample_test.yaml -v
Test Case #1
Name: "MyTest"
`- File(, Status=FAIL)[Context=File(rules=1)]
   `- Rule(sample_rule, Status=FAIL)[Context=sample_rule]
      `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block %vpc_relationships EMPTY  ]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/0/resourceType[L:0,C:0] Value="AWS::EC2::Subnet"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/1/resourceType[L:0,C:0] Value="AWS::EC2::SecurityGroup"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/2/resourceType[L:0,C:0] Value="AWS::EC2::Subnet"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/3/resourceType[L:0,C:0] Value="AWS::EC2::NetworkAcl"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/4/resourceType[L:0,C:0] Value="AWS::EC2::RouteTable"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=PASS)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = PASS)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseValueCheck(Status=PASS)[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |- Filter/ConjunctionsBlock(Status=FAIL)[Context=Filter/List#1]
         |  `- GuardClauseBlock(Status = FAIL)[Context=GuardAccessClause#block resourceType EQUALS  "AWS::EC2::InternetGateway"]
         |     `- GuardClauseBinaryCheck(Status=FAIL, Comparison= EQUALS, from=(resolved, Path=/relationships/6/resourceType[L:0,C:0] Value="AWS::EC2::Subnet"), to=(resolved, Path=[L:0,C:0] Value="AWS::EC2::InternetGateway"))[Context= resourceType EQUALS  "AWS::EC2::InternetGateway"]
         `- GuardClauseUnaryCheck(Status=FAIL, Comparison= EMPTY, Value-At=(resolved, Path=/relationships/5[L:0,C:0] Value={"resourceType":"AWS::EC2::InternetGateway","resourceId":"igw-0367be58d3c2fa692","relationshipName":"Is attached to InternetGateway"}))[Context= %vpc_relationships EMPTY  ]
  FAIL Rules:
    sample_rule: Expected = PASS, Evaluated = [FAIL]

さいごに

AWS CloudFormation Guard の単体テスト(Unit Testing)機能を試してみました。AWS Config カスタムルールを作成する際に毎回 AWS Config の設定をしていては手間がかかるので、ローカル環境でテストできるのはありがたいです。

以上、このブログがどなたかのご参考になれば幸いです。