AWS Control Towerにおける非準拠リソースの通知

この記事は[カバー株式会社 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/cover) 11日目の記事になります。
カバー株式会社でSREをしているSです。よろしくお願いします。

前回の記事は @kura_cvr による[ホロライブアプリのアーキテクチャ その2](https://qiita.com/kura_cvr/items/ee4d140f719570a9c4fa)でした。こちらの記事もぜひご覧ください。

# この記事について

[AWS Control Tower](https://aws.amazon.com/jp/controltower/)において適用したコントロールに対する非準拠リソースを通知する仕組みについて書きます。

# AWS Control Tower

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/what-is-control-tower.html

AWS Control Towerは複数アカウント環境を設定して管理するためのサービスです。コントロールによってAWS環境全体にベストプラクティス、標準、規制要件を適用します。その他にもAccount Factoryによるアカウント作成の自動化や、ダッシュボードによってプロビジョニングされているアカウントを監視でき、コントロールに非準拠のリソースを確認できます。

## コントロール

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/controls.html

AWS Control Towerのコントロールは複数のアカウントを束ねる組織単位(OU)に対して、例えば「[SSH を介した無制限のインターネット接続が許可されているかどうかを検出する](https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/strongly-recommended-controls.html#ssh-disallow-internet)」といったルールを適用することができます。

検出以外にも、予防やプロアクティブによってリソース作成、更新時にコントロールに非準拠な場合はアクションを禁止することができます。

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/controls.html#control-behavior

これによりAWS環境全体に継続的なガバナンスを提供することができます。

# 非準拠リソースの通知

AWS Control Towerを既存のOrganizationに適用する場合、最初から予防コントロールを適用すると非準拠のリソースを更新できないため生産性が下がります。よって、AWS Control Tower導入時は検出コントロールで非準拠リソースを検出することから始めます。この時に検出された非準拠リソースの情報を通知し素早く修正することが求められます。

検出コントロールはAWS Configルールによって実装されます。

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/how-control-tower-works.html#how-controls-work

よって、検出コントロールの通知はAWS Configのイベントを捕捉することで実装することができます。
このイベントを捕捉するのは容易で、AWS Control Towerが作成する監査アカウントのSNSにイベントを収集する `aws-controltower-AggregateSecurityNotifications` トピックが作成されています。このトピックをサブスクライブしてSlackに通知することができます。

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/receive-notifications.html

しかし、このトピックをAWS Chatbotでサブスクライブするだけでは、非準拠に関するイベント以外のイベントも通知されてしまうためフィルタリングが必要となります。
なので、AWS Lambdaでこのトピックをサブスクライブしつつフィルタリングし、Slackに通知する実装をします。

https://repost.aws/ja/knowledge-center/config-resource-non-compliant

## AWS Lambda

AWS LambdaでAWS Configのイベントにおける `NON_COMPLIANT` のみをSlackに通知をします。Javascriptの場合は以下の実装になります。

```javascript
import https from 'https';
import url from 'node:url';
import {
  GetSecretValueCommand,
  SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';

export const handler = async (event) => {
  // NOTE: ドリフトのイベントが飛んでくる場合がある
  if (event.Records[0].Sns == null) {
    return;
  }

  const message = JSON.parse(event.Records[0].Sns.Message);
  const newComplianceType = message.detail.newEvaluationResult.complianceType;
  if (newComplianceType !== 'NON_COMPLIANT') {
    return;
  }

  const secret = await getSecretValue('config_alert');
  const secrets = JSON.parse(secret);

  const configRuleName = message.detail.configRuleName;
  const region = message.region;

  const payload = {
    icon_emoji: ':exclamation:',
    text: 'AWS Config Rule Compliance State Alert',
    attachments: [
      {
        color: '#ff0000',
        blocks: [
          {
            type: 'section',
            block_id: 'sectionConfigRuleName',
            text: {
              type: 'mrkdwn',
              text: '*Config Rule Name*: ' + configRuleName,
            }
          },
          {
            type: 'section',
            block_id: 'sectionComplianceState',
            text: {
              type: 'mrkdwn',
              text: '*Compliance State*: ' + message.detail.newEvaluationResult.complianceType,
            }
          },
          {
            type: 'section',
            block_id: 'sectionAWSAccountID',
            text: {
              type: 'mrkdwn',
              text: '*AWS Account ID*: ' + message.account,
            }
          },
          {
            type: 'section',
            block_id: 'sectionAWSRegion',
            text: {
              type: 'mrkdwn',
              text: '*AWS Region*: ' + region,
            }
          },
          {
            type: 'section',
            block_id: 'sectionResourceType',
            text: {
              type: 'mrkdwn',
              text: '*Resource Type*: ' + message.detail.resourceType,
            }
          },
          {
            type: 'section',
            block_id: 'sectionResource',
            text: {
              type: 'mrkdwn',
              text: '*Resource*: ' + message.detail.resourceId,
            }
          },
          {
            type: 'section',
            block_id: 'sectionTimestamp',
            text: {
              type: 'mrkdwn',
              text: '*Timestamp*: ' + message.time
            }
          },
          {
            type: 'section',
            block_id: 'sectionLinkToRule',
            text: {
              type: 'mrkdwn',
              text: `*Link to Rule*: https://console.aws.amazon.com/config/home?region=${region}#rules/rules/rule-details/${encodeURIComponent(configRuleName)}`
            }
          },
        ],
      }
    ]
  };

  const body = JSON.stringify(payload);
  await sendMessage(secrets.SLACK_URL, body)
};

const getSecretValue = async (secretName) => {
  const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
  const response = await client.send(new GetSecretValueCommand({ SecretId: secretName }));
  return response.SecretString;
};

const sendMessage = async (uri, body) => {
  let options = url.parse(uri);
  options.method = 'POST';
  options.headers = {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),
  };

  return new Promise((resolve, reject) => {
    let req = https
      .request(options, (res) => {
        res.on('end', () => {
          resolve(res.statusCode);
        });
      })
      .on('error', (e) => {
        console.log('error postRequest:' + e.message);
        reject(e);
      });
    req.write(body);
    req.end();
  });
};
```

内容はシンプルで `NON_COMPLIANT` 以外のイベントはreturnし、 `NON_COMPLIANT` SlackのWebhookで通知しています。
この関数をTerraformの `aws_lambda_function` で管理する場合、シークレットがtfstateに乗らないようにAWS Secret Managerからシークレットを取得しています。実行環境がコンテナでない場合はAWS Parameters and Secrets Lambda ExtensionによってAWS Secret Managerからの取得をキャッシュすることもできます。

https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/retrieving-secrets_lambda.html

## 通知

Slackに通知した結果は以下のようになります。 `AWS Account ID` などはマスクしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622257/1d9dafc6-49eb-d3ac-e3d3-ddaa7530aa3d.png)

# まとめ

AWS Control Towerにおける非準拠リソースの通知について書きました。AWS Lambdaによるフィルタリングの例がなかったためまとめました。皆さんの時間の節約になれば幸いです。

次回は[@tk-cover](https://qiita.com/tk-cover)による「Mermaidでドキュメントを作成する」です。こちらも是非ご覧ください。