AWS Configの外部リソース(GitLab)に対するカスタムルール構築をTerraformで自動化する

# はじめに
AWS Config記事第2弾。
前回は、AWS管理リソースに対するルールと修復アクションの作成を自動化したが、今回は、外部のリソースをカスタムルールで検査してみる。

***前回記事***

https://qiita.com/neruneruo/items/79d795005e2547514c5e

本記事に必要な前提知識は以下の通り。

- AWS＋Terraformの基本的な知識
- AWS Configの基本的な知識
- AWS SDK for Python (Boto3)の基本的な知識

# カスタムリソースの定義
まずは、検出する対象のカスタムリソースを定義する必要がある。
カスタムリソースはAWS公式の開発者ガイドを参考にしながら作成する。

https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/customresources.html

:::note warn
なお、ドキュメント中に突如```mvn package```というコマンドが登場するが、おそらく間違っていて、```cfn package```が正しいと思われる。
:::

上記ページに書いてある```cfn init```後に、mycustomnamespace-testing-wordpress.jsonの代わりに```myresource-gitlab-repository.json```を配置して、```cfn package```まで実行しよう。

```JSON:myresource-gitlab-repository.json
{
  "typeName": "MyResource::GitLab::Repository",
  "description": "GitLab Repository Custom Resource",
  "properties": {
    "Name": {
      "description": "Repository Name",
      "type": "string",
      "pattern": "^[a-zA-Z0-9/]{1,255}$",
      "minLength": 1, "maxLength": 255
    },
    "Endpoint": {
      "description": "Endpoint of GitLab",
      "type": "string"
    }
  },
  "required": [ "Name", "Endpoint" ],
  "primaryIdentifier": [ "/properties/Name" ],
  "readOnlyProperties": [ "/properties/Name", "/properties/Endpoint" ],
  "additionalProperties": false
}
```

```cfn package```すると、myresource-gitlab-repository.zipというファイルが作られているはずだ。

この後、```cfn submit```まで実行しても良いが、cfnコマンドで作ってしまうと消し方がよく分からなくないため、Terraformで作成することを推奨する。パッケージしたZipファイルはAmazon S3からアップロードするため、適当なAmazon S3バケットを作っておこう。

```HCL
resource "aws_s3_object" "config_custom_resource" {
  bucket = aws_s3_bucket.example.id

  source = "myresource-gitlab-repository.zipのファイルパス"
  key    = "myresource-gitlab-repository.zip"

  etag = filemd5(myresource-gitlab-repository.zipのファイルパス)
}

resource "aws_cloudformation_type" "config_custom_resource" {
  schema_handler_package = "s3://${aws_s3_object.config_custom_resource.bucket}/${aws_s3_object.config_custom_resource.key}"
  type                   = "RESOURCE"
  type_name              = "MyResource::GitLab::Repository"

  lifecycle {
    create_before_destroy = true
  }
}
```

# カスタムルールの定義
カスタムルールは以下のようにAWS Lambdaでルール定義する。
今回は、以下の仕様のPythonスクリプトを作成する。

- GitLabの全ブランチの保護状態を確認し、mainブランチのマージ権限を持っているのがMaintainerのみになっているかチェックをする
- GitLabのアクセストークンはシークレット情報であるため、AWS Secrets Managerから取得をする
- PutEvaluationsのAPIをラップした```put_evaluations()```で、各カスタムリソースの評価情報を配列で渡す

ブランチの保護状態については、以下のAPI仕様を確認して作成しよう。

https://docs.gitlab.com/ee/api/protected_branches.html

AWS LambdaでAWS Configの情報を返すスクリプトは以下のAWS公式の開発者ガイドを参考にする。

https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/evaluate-config_develop-rules_python-sample.html

```Python
import base64
import boto3
from botocore.exceptions import ClientError
import pprint
import json
import requests
import os
import sys

def get_secrets():
    AWS_SECRETSMANAGER_CLIENT = boto3.client('secretsmanager')

    try:
      get_secret_value_response = AWS_SECRETSMANAGER_CLIENT.get_secret_value(SecretId = os.environ['GITLAB_TOKEN'])
    except ClientError as e:
      raise e
    else:
      if 'SecretString' in get_secret_value_response:
        secret = get_secret_value_response['SecretString']
      else:
        secret = base64.b64decode(get_secret_value_response['SecretBinary'])
    return secret

def get_project_list():
  project_list = []

  try:
    response = requests.get(
      'https://' + GITLAB_ENDPOINT + '/api/v4/projects',
      verify = False,
      headers = {
        'Authorization': 'Bearer ' + GITLAB_TOKEN
      },
    )
  except Exception as e:
    print(e)
    sys.exit(-1)
  
  response_json = json.loads(response.text)
  for project in response_json:
     project_list.append({ 'id': project['id'], 'name': project['path_with_namespace']})

  return project_list

def build_evaluation(resource_id, compliance_type, event, resource_type, annotation=None):
  eval_cc = {}
  if annotation:
    eval_cc['Annotation'] = annotation
  eval_cc['ComplianceResourceType'] = resource_type
  eval_cc['ComplianceResourceId'] = resource_id
  eval_cc['ComplianceType'] = compliance_type
  eval_cc['OrderingTimestamp'] = str(json.loads(event['invokingEvent'])['notificationCreationTime'])
  return eval_cc

#--------------------

RESOURCE_TYPE   = 'MyResource::GitLab::Repository'
GITLAB_TOKEN    = get_secrets()
GITLAB_ENDPOINT = os.environ['GITLAB_ENDPOINT']

def lambda_handler(event, context):
  pprint.pprint(event)

  AWS_CONFIG_CLIENT = boto3.client('config')
  evaluations       = []

  project_list = get_project_list()

  for project in project_list:
    try:
      response = requests.get(
        'https://' + GITLAB_ENDPOINT + '/api/v4/projects/' + str(project['id']) + '/protected_branches',
        verify = False,
        headers = {
          'Authorization': 'Bearer ' + GITLAB_TOKEN
        },
      )

      protected_branches = json.loads(response.text)
      compliance_value  = 'NON_COMPLIANT'
      for protected_branch in protected_branches:
        if protected_branch['name'] == 'main':
          if len(protected_branch['merge_access_levels']) == 1 and protected_branch['merge_access_levels'][0]['access_level'] == 40:
            compliance_value  = 'COMPLIANT'
      evaluations.append(build_evaluation(project['name'], compliance_value, event, RESOURCE_TYPE))
    except Exception as e:
      print(e)

  response = AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluations, ResultToken=event['resultToken'])
```

AWS Lambdaの関数は以下の通り定義する。
今回、スクリプト中でrequestsモジュールを使用していて、標準のPythonのランタイムには今は入っていない(3.7までは入っていたが今は使えない)ため、Lambda Layersを使用する。自分で作るのは面倒なので、AWS SDK Pandasのマネージドレイヤーを取り込む。

https://aws-sdk-pandas.readthedocs.io/en/stable/layers.html

:::note info
Lambdaのタイムアウトは別にいくつでも良いが、将来的にリポジトリが増えてきたときにタイムアウトしないよう、上限の900を設定している
:::

```HCL
data "archive_file" "lambda_config_customrule" {
  type        = "zip"
  source_dir  = "Lambdaの格納パス"
  output_path = "出力パス"
}

resource "aws_lambda_function" "config_customrule" {
  depends_on = [
    aws_cloudwatch_log_group.lambda_config_customrule,
  ]

  function_name    = local.lambda_function_config_customrule_name
  filename         = data.archive_file.lambda_config_customrule.output_path
  role             = aws_iam_role.lambda_config_customrule.arn
  handler          = "configcustomrule.lambda_handler"
  source_code_hash = data.archive_file.lambda_config_customrule.output_base64sha256
  runtime          = "python3.10"

  memory_size = 128
  timeout     = 900

  layers = ["arn:aws:lambda:us-east-1:336392948345:layer:AWSSDKPandas-Python310:15"]

  environment {
    variables = {
      GITLAB_TOKEN    = aws_secretsmanager_secret.example.name
      GITLAB_ENDPOINT = "GitLabのエンドポイントのドメイン部"
    }
  }
}

resource "aws_lambda_permission" "allow_config" {
  statement_id  = "AllowExecutionFromConfig"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.config_customrule.function_name
  principal     = "config.amazonaws.com"
}

################################################################################
# CloudWatch Logs                                                              #
################################################################################
resource "aws_cloudwatch_log_group" "lambda_config_customrule" {
  name = "/aws/lambda/${local.lambda_function_config_customrule_name}"
}

################################################################################
# IAM                                                                          #
################################################################################
resource "aws_iam_role" "lambda_config_customrule" {
  name               = local.iam_lambda_config_customrule_role_name
  assume_role_policy = data.aws_iam_policy_document.lambda_config_customrule_assume.json
}

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

    actions = [
      "sts:AssumeRole",
    ]

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

resource "aws_iam_role_policy" "lambda_config_customrule" {
  name   = local.iam_lambda_config_customrule_policy_name
  role   = aws_iam_role.lambda_config_customrule.id
  policy = data.aws_iam_policy_document.lambda_config_customrule_custom.json
}

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

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = [
      aws_cloudwatch_log_group.lambda_config_customrule.arn,
      "${aws_cloudwatch_log_group.lambda_config_customrule.arn}:log-stream:*",
    ]
  }
  statement {
    effect = "Allow"

    actions = [
      "config:PutEvaluations",
    ]

    resources = [
      "*"
    ]
  }
  statement {
    effect = "Allow"

    actions = [
      "secretsmanager:GetSecretValue",
    ]

    resources = [
      aws_secretsmanager_secret_version.example.arn
    ]
  }
}
```

# AWS Configの定義
AWS Configの定義は前回の記事と大体同じなので割愛する。
下記に加えて、IAMロールやAmazon S3バケットの設定を行っておこう(内容は前回記事と同じ)。

```aws_config_config_rule```の```source```ブロックのみ異なるので確認しておいていただきたい。

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

  role_arn = aws_iam_role.config.arn
}

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

  name = aws_config_configuration_recorder.example.name

  is_enabled = true
}

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

  snapshot_delivery_properties {
    delivery_frequency = "One_Hour"
  }
}

resource "aws_config_config_rule" "example" {
  depends_on = [
    aws_config_configuration_recorder.example,
    aws_lambda_permission.allow_config,
  ]

  name = local.config_rule_name

  source {
    owner             = "CUSTOM_LAMBDA"
    source_identifier = aws_lambda_function.config_customrule.arn

    source_detail {
      message_type                = "ScheduledNotification"
      maximum_execution_frequency = "One_Hour"
    }
  }
}
```

これをApplyしてしばらく待つと、AWS Lambdaが実行され、以下のようにリポジトリごとの準拠状況が確認できるようになった！

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/7ec85959-a38c-39d0-8739-7ea314625ad0.png)

なお、リソースのタイムラインを追うには、カスタムリソースの1つ1つをAWS Configに登録する必要がある。GitLab側でリポジトリが作成されたり、保護設定が変更されたときにフックするには、EventBridgeが必要だが、GitLabはAmazon Eventbridgeのクイックスタートの対応がまだ行われていないため、また別途検討を行う必要がある。