AWS Configのルールと修復アクションをTerraformで自動構築する

# はじめに
[AWS Config](https://aws.amazon.com/jp/config/)は、アカウント運用上のコンプライアンス要件をルールに落とし込み、違反しているリソースを検知してアクションを行うところまでを自動化することができる。

ルールについても、多岐にわたる[AWSのマネージドなルール](https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/evaluate-config_use-managed-rules.html)と、それに対する自動修復アクションが容易されているため、アカウント開始後すぐにでも利用開始できるようになっている。

チームがある程度の大きさの規模になり、アカウントを複数人で運用することになった際、ガバナンスのためのトイルを減らしてくれる便利なサービスなので、是非活用できるようにしておきたい。
※AWSに不慣れなメンバがJOINした際の事故防止にも役に立つ。

本記事に必要な前提知識は以下の通り。
- AWS＋Terraformの基本的な知識
- [AWS SDK for Python (Boto3)](https://aws.amazon.com/jp/sdk-for-python/)の基本的な知識

# IAMの準備
AWS Configは[サービスリンクロール](https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/using-service-linked-roles.html)があるので、基本はこれを使えば良い。

サービスリンクロールを使わない場合は、以下のようにAWS Configに対して権限を付与しておこう。
AWS Configのチェックは多岐に渡るため、一つ一つ設定していくとそれだけで大きな手間になってしまうため、```AWS_ConfigRole```のマネージドポリシーを使用する。

AWS ConfigはAmazon S3に情報を記録するため、カスタムポリシーで権限を付与しておく。
オブジェクトの出力先が決まっているので、最小権限構成にするために```resources```で形式を合わせておく。

```HCL
resource "aws_iam_role" "config" {
  name               = local.iam_config_role_name
  assume_role_policy = data.aws_iam_policy_document.config_assume.json
}

data "aws_iam_policy_document" "config_assume" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["config.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

data "aws_iam_policy" "config_managed_policy" {
  name = "AWS_ConfigRole"
}

resource "aws_iam_role_policy_attachment" "config_managed_policy" {
  role       = aws_iam_role.config.id
  policy_arn = data.aws_iam_policy.config_managed_policy.arn
}

resource "aws_iam_role_policy" "config" {
  name   = local.iam_config_policy_name
  role   = aws_iam_role.config.id
  policy = data.aws_iam_policy_document.config_custom.json
}

data "aws_iam_policy_document" "config_custom" {
  statement {
    effect = "Allow"

    actions = [
      "s3:PutObject",
      "s3:PutObjectAcl"
    ]

    resources = [
      "${aws_s3_bucket.example.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
    ]

    condition {
      test     = "StringLike"
      variable = "s3:x-amz-acl"

      values = [
        "bucket-owner-full-control",
      ]
    }
  }
  statement {
    effect = "Allow"

    actions = [
      "s3:GetBucketAcl",
    ]

    resources = [
      "${aws_s3_bucket.example.arn}",
    ]
  }
}
```

# レコーダーの設定
まず最初に、AWS Configの情報を記録するためにレコーダーを設定する。

## レコーダーの情報を格納するAmazon S3バケットの設定
特に難しいことはないので、以下のようにAmazon S3バケットを作っておく。
バージョニングはあってもなくても良いが、この後のAWS Configのルールでバージョニング有無をチェックするようにするので、そこでコンプライアンス違反とならないよう設定をしておく。

```HCL
resource "aws_s3_bucket" "example" {
  bucket = local.s3_bucket_name

  force_destroy = true
}

resource "aws_s3_bucket_ownership_controls" "example" {
  bucket = aws_s3_bucket.example.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "example" {
  bucket = aws_s3_bucket.example.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_versioning" "example" {
  bucket = aws_s3_bucket.example.id

  versioning_configuration {
    status = "Enabled"
  }
}
```

## レコーダーを設定する
レコーダーはTerraformの```aws_config_configuration_recorder```のリソースを扱う。
```role_arn```には、最初に作成したIAMロールを設定する。

また、```aws_config_delivery_channel```のリソースで、上記で作成したAmazon S3に接続を行い、```aws_config_configuration_recorder_status```で```is_enabled = true```して有効化することでAWS Configの利用が開始できる。

:::note info
レコーダーはリージョンに1つあれば良い(というか、2つ以上作ろうとしてもエラーになる)ので、既に存在する場合は定義しなくても良い。
:::

:::note info
```aws_config_delivery_channel```と```aws_config_configuration_recorder_status```には、インプットとなるパラメータに良い感じの依存関係がないため、```depends_on```で明示して新規作成時のエラーを防止する。
:::


```HCL
resource "aws_config_configuration_recorder" "example" {
  name = local.config_recorder_name

  role_arn = aws_iam_role.config.arn
}

resource "aws_config_delivery_channel" "example" {
  depends_on = [aws_config_configuration_recorder.example]

  name = local.config_delivery_channel_name

  s3_bucket_name = aws_s3_bucket.example.bucket
}

resource "aws_config_configuration_recorder_status" "example" {
  depends_on = [aws_config_delivery_channel.example]

  name = aws_config_configuration_recorder.example.name

  is_enabled = true
}
```


# ルールの設定
ルールの設定には、Terraformの```aws_config_config_rule```のリソースを用いる。
今回は、設定が簡単なAmazon S3のバージョニング設定を行っていない場合に、コンプライアンス違反として検知するマネージドなルールを適用する。```scope```および```input_parameters```の```isMfaDeleteEnabled```は省略可能だが、練習のためにちゃんと書いておく。

```HCL
resource "aws_config_config_rule" "example1" {
  depends_on = [aws_config_configuration_recorder.example]

  name = local.config_rule_name1

  scope {
    compliance_resource_types = [
      "AWS::S3::Bucket",
    ]
  }

  source {
    owner             = "AWS"
    source_identifier = "S3_BUCKET_VERSIONING_ENABLED"
  }

  input_parameters = jsonencode(
    {
      isMfaDeleteEnabled = "false",
    }
  )
}
```

これで```terraform apply```すると、マネコンからもルールが参照できるようになる。

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/e3aa6019-b8de-6c42-33fe-3a13af17ee36.png)

上記のリンクを踏んでいくと、詳細の画面で設定を確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/bc26dc50-1824-bd09-e360-34ce0ea6de9b.png)

御覧の通り、非準拠のリソースは存在しない。
せっかくなので、ここで、あえて非準拠のAmazon S3のリソースを作成してみる。
Terraformの```aws_s3_bucket_versioning```のリソースの```versioning_configuration```を```status = "Disabled"```にすることで、今回のコンプライアンス要件を違反することになる。

```HCL
resource "aws_s3_bucket" "example_test" {
  bucket = local.s3_bucket_for_test_name
}

resource "aws_s3_bucket_ownership_controls" "example_test" {
  bucket = aws_s3_bucket.example_test.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "example_test" {
  bucket = aws_s3_bucket.example_test.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_versioning" "example_test" {
  bucket = aws_s3_bucket.example_test.id

  versioning_configuration {
    status = "Disabled"
  }
}
```

これを```terraform apply```してしばらく待つと、ルールの画面下部、「対象範囲内のリソース」が以下のようになる。なお、分かりやすくするために、リソースは「すべて」を表示している。```config-example-bucket```はレコーダー記録用のバケットで、最初にバージョニングを有効にしているため、コンプライアンス準拠となっているが、新規に作ったバケットはコンプライアンス非準拠の結果になっている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/c0340c61-d9d7-18f8-ff4d-d6f333736757.png)

これで、ルールに違反したリソースを自動で検知できるようになった。

# 修復アクションの設定
さて、せっかくルール違反を検知できるようになったのだから、違反したものを検知したら可能な限り速やかに正しい状態にしておきたい。AWS Configには[修復アクション](https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/remediation.html)という機能があるので、設定をしていこう。

修正アクションはTerraformの```aws_config_remediation_configuration```のリソースを使用する。
```config_rule_name```でルールとの紐づけを行う。
修正アクションそのものは、AWS Systems ManagerのAutomation機能を呼び出すことになるが、```aws_config_remediation_configuration```が呼び出しを行ってくれるため、自分でのAmazon EventBridgeの設定は不要だ。

Amazon S3のバージョニング設定は、予めAWSが用意したAWS Systems Manager Automationのドキュメントがあるため、それを指定すれば良い。

```maximum_automatic_attempts```と```retry_attempt_seconds```のパラメータは、エラーになった際のリトライ回数と間隔、```execution_controls```はAWS Systems Managerの同時実行数の制御のために用いるパラメータであるため、必要に応じて修正する。

```HCL
resource "aws_config_remediation_configuration" "example1" {
  config_rule_name = aws_config_config_rule.example1.name

  resource_type  = "AWS::S3::Bucket"
  target_type    = "SSM_DOCUMENT"
  target_id      = "AWS-ConfigureS3BucketVersioning"
  target_version = "1"

  parameter {
    name         = "AutomationAssumeRole"
    static_value = aws_iam_role.ssm_remediation.arn
  }
  parameter {
    name           = "BucketName"
    resource_value = "RESOURCE_ID"
  }
  parameter {
    name         = "VersioningState"
    static_value = "Enabled"
  }

  automatic                  = true
  maximum_automatic_attempts = 5
  retry_attempt_seconds      = 60

  execution_controls {
    ssm_controls {
      concurrent_execution_rate_percentage = 25
      error_percentage                     = 35
    }
  }
}
```

ここで注意しなければいけないのが、AWS Systems Managerに修正のための権限が必要であるということだ。
以下のように、IAMロールを作成して、今回必要になるAmazon S3のバージョニング設定の変更の権限を不要しよう。
なお、修正アクションはアカウント内のすべてのリソースに適用する可能性があるため、```resources```に関しては最小権限で絞ることはできず```*```を設定している。

```HCL
resource "aws_iam_role" "ssm_remediation" {
  name               = local.iam_ssm_remediation_role_name
  assume_role_policy = data.aws_iam_policy_document.ssm_remediation_assume.json
}

data "aws_iam_policy_document" "ssm_remediation_assume" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["ssm.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role_policy" "ssm_remediation" {
  name   = local.iam_ssm_remediation_policy_name
  role   = aws_iam_role.ssm_remediation.id
  policy = data.aws_iam_policy_document.ssm_remediation_custom.json
}

data "aws_iam_policy_document" "ssm_remediation_custom" {
  statement {
    effect = "Allow"

    actions = [
      "s3:GetBucketVersioning",
      "s3:PutBucketVersioning",
    ]

    resources = [
      "*",
    ]
  }
}
```

上記をApplyしてしばらく待つと、修復アクションが自動実行され、以下の通り、コンプライアンスが準拠した状態になる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/fb17832c-587c-c005-8efa-f5a2ac374be8.png)

Amazon S3側でも、バージョニング設定が変わっていることを確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/0af8583f-4ec8-2d80-d671-6478649debb3.png)

バージョニングの設定は一度Enabledにすると、Terraformでは一度```aws_s3_bucket_versioning```のリソースを削除しないと、Disabledに戻すことはできない。更新時に誤って戻してしまうことはないので、期待する状態を維持しやすくなったと言える。

# カスタム修復アクションの設定
マネージドルールに対して、AWSがプレフィックスしている修復アクションが無いものもある。
例えば、```S3_LIFECYCLE_POLICY_CHECK```(その名の通りAmazon S3のライフサイクルポリシーの設定有無や、設定内容を細かくチェックする定義ができるルール)だ。

本項では、これを自動で修復するアクションを作っていく。

## AWS Systems Manager Automationドキュメントの作成
カスタム修復アクションもAWS Systems Manager Automationのドキュメントで制御する。
以下のようにカスタム修復アクションを作成しよう。

内容の詳細は本記事の趣旨からは外れるため割愛する。
Amazon S3のライフサイクルポリシーを取得し、

- 重複するルール名がないこと
- 1000件を超えないこと

を確認したうえで、既存のルールに修復用のルールを追加してライフサイクルポリシーを更新している。

:::note warn
今回は練習用であまり深く気にせずこの設定を行っているが、ライフサイクルポリシーは複数設定ができるため、実際は競合時の動作が問題ないかをよく検証したうえで設定を導入した方が良い。
競合時の考え方は[AWS公式のユーザーガイド](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/lifecycle-configuration-examples.html#lifecycle-config-conceptual-ex5)を参照。
:::

```HCL
resource "aws_ssm_document" "example" {
  name            = local.ssm_document_name
  document_type   = "Automation"
  document_format = "YAML"

  content = file("${path.module}/ssmdoument.yaml")
}
```

```YAML:ssmcodument.yaml
schemaVersion: "0.3"
description: |
    Remediation Action for AWS Config managed rule S3_LIFECYCLE_POLICY_CHECK.
assumeRole: "{{ AutomationAssumeRole }}"
parameters:
    AutomationAssumeRole:
        type: AWS::IAM::Role::Arn
        default: ""
    BucketName:
        type: AWS::S3::Bucket::Name
    DaysUntilTransit:
        type: Integer
        allowedPattern: ^[1-9][0-9]{0,1023}$
    RuleId:
        type: String
        allowedPattern: ^[a-zA-Z0-9_-]{1,1024}$
outputs:
    - VerifyTransitionRule.VerifyTransitionRuleResponse
    - VerifyTransitionRule.LifecycleConfigurationRule

mainSteps:
    - name: PutTransitionRule
      action: aws:executeScript
      timeoutSeconds: 120
      inputs:
        Runtime: python3.8
        Handler: handler
        InputPayload:
            BucketName: "{{ BucketName }}"
            DaysUntilTransit: "{{ DaysUntilTransit }}"
            RuleId: "{{ RuleId }}"            
        Script: |
            import time
            import boto3
            import botocore

            def get_bucket_rules(s3_client, bucket_name):
                try:
                    response = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name)
                    return response["Rules"]
                except botocore.exceptions.ClientError as error:
                    response = error.response
                    error_code = response["Error"]["Code"]
                    error_message = response["Error"]["Message"]
                    if error_code == "NoSuchLifecycleConfiguration" and "does not exist" in error_message:
                        return []
                    raise Exception from error

            def handler(event, context):
                bucket_name = event["BucketName"]
                days_until_transit = event["DaysUntilTransit"]
                rule_id = event["RuleId"]

                s3_client = boto3.client("s3")
                bucket_rules = get_bucket_rules(s3_client=s3_client, bucket_name=bucket_name)

                if bucket_rules and len(bucket_rules) == 1000:
                    raise Exception(f"S3 BUCKET {bucket_name} CANNOT HAVE MORE THAN 1,000 LIFECYCLE CONFIGURATION RULES")

                if bucket_rules:
                    bucket_rule_ids = []
                    for rule in bucket_rules:
                        bucket_rule_ids.append(rule["ID"])
                    if rule_id in bucket_rule_ids:
                        raise Exception(
                            f"S3 BUCKET {bucket_name} ALREADY CONTAINS A LIFECYCLE CONFIGURATION WITH ID {rule_id}"
                        )

                transitions = {
                    "ID": rule_id,
                    "Filter": {"Prefix": ""},
                    "Status": "Enabled",
                    "Transitions": [{"Days": days_until_transit, "StorageClass": "INTELLIGENT_TIERING"}],
                }

                bucket_rules.append(transitions)

                print(bucket_rules)

                try:
                    s3_client.put_bucket_lifecycle_configuration(
                        Bucket=bucket_name, LifecycleConfiguration={"Rules": bucket_rules}
                    )
                    time.sleep(5)
                except botocore.exceptions.ClientError as error:
                    raise Exception from error

    - name: VerifyTransitionRule
      action: aws:executeScript
      timeoutSeconds: 120
      maxAttempts: 5
      inputs:
        Runtime: python3.8
        Handler: handler
        InputPayload:
            BucketName: "{{ BucketName }}"
            RuleId: "{{ RuleId }}"
        Script: |
            import boto3
            import botocore

            def handler(event, context):
                bucket_name = event["BucketName"]
                rule_id = event["RuleId"]

                s3_client = boto3.client("s3")

                try:
                    response = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name)
                    rules = response["Rules"]
                    for rule in rules:
                        if rule_id == rule.get("ID"):
                            success_message = (
                                "Verification of transit configured for Amazon S3 Bucket is successful."
                            )
                            return {"VerifyTransitionRule": success_message, "Rule": rule}
                    raise Exception(
                        f"FAILED TO VERIFY LIFECYCLE CONFIGURATION RULE WITH ID {rule_id} ON S3 BUCKET {bucket_name}"
                    )
                except botocore.exceptions.ClientError as error:
                    raise Exception from error
      outputs:
        - Name: VerifyTransitionRuleResponse
          Type: String
          Selector: $.Payload.VerifyTransitionRule
        - Name: LifecycleConfigurationRule
          Type: StringMap
          Selector: $.Payload.Rule
```

## AWS Systems ManagerのIAM権限の付与
上記の通り、スクリプト内でGet/PutLifecycleConfigurationのAPIを呼び出しているため。IAM権限の更新が必要だ。もともと作っていたポリシーに以下の権限を追加しよう。

```diff_HCL
data "aws_iam_policy_document" "ssm_remediation_custom" {
  statement {
    effect = "Allow"

    actions = [
      "s3:GetBucketVersioning",
+     "s3:GetLifecycleConfiguration",
      "s3:PutBucketVersioning",
+     "s3:PutLifecycleConfiguration",
    ]

    resources = [
      "*",
    ]
  }
}
```

## AWS Configルールと修復アクションの設定
上記で作成したAWS Systems Manager Automationドキュメントを以下のように呼び出す。
今回は、バケットの移行設定が40日になっていないものを、40日に補正する修復アクションを実行する。

基本的にプレフィックスの修復アクションの呼び出しと変わらないが、特筆すべき点は、```target_id```と```target_version```を、上記で作成したリソースへの参照にしているところだ。特に、```target_version```は固定にしていると、AWS Systems Manager Automationに更新されず思わぬ動作をする可能性があるので注意が必要だ。

```HCL
resource "aws_config_config_rule" "example2" {
  depends_on = [aws_config_configuration_recorder.example]

  name = local.config_rule_name2

  scope {
    compliance_resource_types = [
      "AWS::S3::Bucket",
    ]
  }

  source {
    owner             = "AWS"
    source_identifier = "S3_LIFECYCLE_POLICY_CHECK"
  }

  input_parameters = jsonencode(
    {
      targetTransitionDays = "40",
    }
  )
}

resource "aws_config_remediation_configuration" "example2" {
  config_rule_name = aws_config_config_rule.example2.name

  resource_type  = "AWS::S3::Bucket"
  target_type    = "SSM_DOCUMENT"
  target_id      = aws_ssm_document.example.name
  target_version = aws_ssm_document.example.latest_version

  parameter {
    name         = "AutomationAssumeRole"
    static_value = aws_iam_role.ssm_remediation.arn
  }
  parameter {
    name           = "BucketName"
    resource_value = "RESOURCE_ID"
  }
  parameter {
    name         = "DaysUntilTransit"
    static_value = "40"
  }
  parameter {
    name         = "RuleId"
    static_value = "AWSConfigRemedation_S3Lifecycle_Transition"
  }

  automatic                  = true
  maximum_automatic_attempts = 5
  retry_attempt_seconds      = 60

  execution_controls {
    ssm_controls {
      concurrent_execution_rate_percentage = 25
      error_percentage                     = 35
    }
  }
}
```

上記をApplyした後に、

```HCL
resource "aws_s3_bucket_lifecycle_configuration" "example_test" {
  bucket = aws_s3_bucket.example_test.id

  rule {
    id = "rule"

    status = "Enabled"

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
  }
}
```

と、ライフサイクルポリシーの移行までの期間が30日の設定を行うと、追加されたルールの詳細画面で、

![キャプチャ5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/a1036c06-5ccf-8fcb-390e-0757eed89b19.png)

といったかたちで検知したい違反が見つかり、しばらく待つとカスタム修復アクションによる修復で以下のようにステータスが変わる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/a5019c7d-ab9e-8d6e-1edb-335452f428a0.png)

Amazon S3のコンソールでも、修復アクション呼び出しのパラメータで指定した```AWSConfigRemedation_S3Lifecycle_Transition```でライフサイクルルールが作成されているのが確認できる。

![キャプチャ6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/c42404ca-5e88-b41f-4c09-efe8ee0e34b3.png)

これで、修復ルールを自分でも作成できるようになった！

# 参考：エラー発生時のトラブルシュート方法まとめ
記事を書くにあたり参考にしていた情報。

- ルール設定が上手く動かない場合: [AWS re:Post「AWS Config ルールが機能しません。その理由は？」](https://repost.aws/ja/knowledge-center/config-rule-not-working)
- 修正アクションが上手く動かない場合: [AWS re:Post「AWS Config で失敗した修復アクションをトラブルシューティングする方法を教えてください。」](https://repost.aws/ja/knowledge-center/config-remediation-executions)

また、カスタム修復アクション内のスクリプトのエラーについては、AWS Systems Manager Automationのコンソールで確認ができる。デバッグ時の参考になれば。

![キャプチャ7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/fed47a50-7a78-b58a-00ad-c880b670dda4.png)

![キャプチャ8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/01dfbb01-2811-3e3c-a57b-94471943fb0b.png)