AWS Consoleのログイン通知をDiscordに飛ばす

AWSコンソールに入った記録を残したい!!
というわけで、Discord WebHookでログイン通知を飛ぶ仕組みを作成してみました。

はじめに

ログイン通知を実装する方法は、少なくとも2パターンあるようです。
今回は、コストが下げられる & 手順が簡単なので1.の方を設定します。

1. EventBridge経由

フロー: CloudTrail(標準機能) → EventBridge → Lambda → Discord

メリット

  • ほぼ即時でアラートが飛ぶ
  • 比較的低コストで済む

デメリット

  • 各リージョンでEventBridge設定が必要
    ※ イベントバスやCloudFormationを使えば、ある程度集約できるらしい

2. CloudWatch Logs経由

フロー: CloudTrail(証跡追加) → CloudWatch Logs → サブスクリプションフィルタ → Lambda → Discord

メリット

  • 1テナントの設定で、全リージョンのアラートが取れる
    (Virginiaを選択すれば、全リージョンの証跡が取れる)

デメリット

  • 証跡(=操作)に応じてコストが嵩む(軽く使って$1/月?)
  • アラートに遅延がある(数分程度、最大15分?)
  • ログの処理が少し複雑(BASE64デコード → gzip解凍 → JSON Parse)

手順

今回はこの手順で設定します。

  1. CloudTrailの証跡を確認する
  2. DiscordのWebHook URLを取得する
  3. Discordに送信するLambdaを作成する
  4. EventBridgeの作成

1. CloudTrailの証跡を確認する

CloudTrailのログイン証跡があることを確認する

そもそもログが出ていないとかなりハマります。
念のためログイン証跡が記録されていることを確認しましょう。

CloudTrail > イベント履歴 > 「ConsoleLogin」を探す

※ 表示されない場合は別のリージョンを選択するなどして「ConsoleLogin」を探しましょう。
絞り込みはルックアップ属性に「イベント名」を選択→「ConsoleLogin」で検索。

2. DiscordのWebHook URLを取得する

※ ご存じの方・Slack等を使う方は3.に進んでください。

AWSの設定を行う前に、送信先の設定を行います。
Botは難しそうだったので、今回はWebHookを設定していきます。

2022/5時点では、この手順でURLを取得できます。
Discordの通知先サーバー > サーバー設定 > 連携サービス > WebHook > 「新しいウェブフック」

この「ウェブフックURLをコピー」でWebHook URLが取得できます。どこかに保存しておきましょう。

3. Discordに送信するLambdaを作成する

ここからが本題。
まずはEventBridgeで発行される値を元に、Pythonで処理を作ります。

Lamdaが受けるデータ形式はこんな感じです。

データ形式

{
  "version": "0",
  "id": "76f2f25f-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "detail-type": "AWS API Call via CloudTrail",
  "source": "aws.cloudtrail",
  "account": "123456789012",
  "time": "2022-05-31T14:00:00Z",
  "region": "ap-northeast-1",
  "resources": [],
  "detail": {
    "eventVersion": "1.08",
    "userIdentity": {
      "type": "IAMUser",
      "principalId": "PRINCIPALID1234567890",
      "arn": "arn:aws:iam::123456789012:user/testuser",
      "accountId": "123456789012",
      "accessKeyId": "PRINCIPALID1234567890",
      "userName": "testuser",
      "sessionContext": {
        "sessionIssuer": {},
        "webIdFederationData": {},
        "attributes": {
          "creationDate": "2022-05-31T14:00:00Z",
          "mfaAuthenticated": "true"
        }
      }
    },
    "eventTime": "2022-05-31T14:00:00Z",
    "eventSource": "cloudtrail.amazonaws.com",
    "eventName": "LookupEvents",
    "awsRegion": "ap-northeast-1",
    "sourceIPAddress": "AWS Internal",
    "userAgent": "AWS Internal",
    "requestParameters": {
      "lookupAttributes": [
        {
          "attributeKey": "ReadOnly",
          "attributeValue": "false"
        }
      ],
      "maxResults": 50
    },
    "responseElements": null,
    "requestID": "b1a9fc9c-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "eventID": "15e67291-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "readOnly": true,
    "eventType": "AwsApiCall",
    "managementEvent": true,
    "recipientAccountId": "123456789012",
    "eventCategory": "Management",
    "sessionCredentialFromConsole": "true"
  }
}

コードのテスト用に、これをLambdaのTestにコピペして入れておくと良いです。

Lambdaには、こんな感じのコードをデプロイしました。(雑)
※ rootユーザの表記がバグりますが、知らないログイン通知が来た時点で事件なので保留

コード lambda_function.py

from dateutil import parser
from dateutil.tz import gettz
import urllib.request
import logging
import json

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    url = "取得したDiscordのWebHook URL"

    headers = {
        "Content-type" : "application/json",
        "User-Agent": "AWS/Lambda"
    }
    payload = {
        "username":"discordbot",
        "content": "ログインがありました。",
        "embeds": [
            {
                "fields":[
                    {
                        "name": "ユーザ名",
                        "value": event['detail']['userIdentity']['userName'],
                    },
                    {
                        "name": "リージョン",
                        "value": event['detail']['awsRegion'],
                        "inline": True
                    },
                    {
                        "name": "ログイン日時",
                        "value": parser.parse(event['detail']['eventTime']).astimezone(gettz('Asia/Tokyo')).strftime("%Y/%m/%d %H:%M:%S"),
                        "inline": True

                    },
            ]}
        ]
    }

    req = urllib.request.Request(
        url=url, 
        headers=headers, 
        data=json.dumps(payload).encode('utf-8')
        )
        
    with urllib.request.urlopen(req) as res:
        logger.info(res.read().decode("utf-8"))

コードのデプロイ後、上に記載したデータでテストで通知が届くことを確認しておくと確実です。

4. EventBridgeの作成

最後に、EventBridgeでCloudTrailとLambdaを繋げましょう。

EventBridgeはこのように設定します。

EventBridge > ルール > ルールを作成

サンプルイベント: AWS Console Sign In via CloudTrail
※ 設定しなくても動きますが、内容テストが使えるため設定を推奨。

イベントパターン:

{
  "detail-type": ["AWS Console Sign In via CloudTrail"],
  "detail": {
    "eventSource": ["signin.amazonaws.com"],
    "eventName": ["ConsoleLogin"]
  }
}

ターゲット指定
先ほど作成したLambdaを指定しましょう。

動作確認

設定が完了したらログアウトして、設定を行ったリージョンでログインし直しましょう。
Discordにログが出力されるはずです。

おわりに

勉強のため手動で設定しましたが、全リージョンの設定であればCloudFormationなどにした方が良さそうです。
送信コードの共通化はEventBridge イベントバスを設定した方がよさそう。
改良の余地があるので、そのあたりが今後の課題。