【AWS】WAFのレートベースでブロックされたIPアドレスをメールアドレスへ通知する

こんにちは

 

今回は表題の通り AWS WAF V2 のレートベースルールでブロックされたIPアドレスをSNSで登録したメールアドレスへ飛ばしてみたので、その方法を紹介します

なお、レートベースの詳細な挙動は説明せず、あくまで全体の構成と動作確認などに焦点を当てたいと思います

それでは、さっそくやっていきましょう

 

■構成

全体の構成としては下記となります(今回もClaudeCodeくんに構成図を作成してもらったのでちょっと見にくいかもしれません)

構成の肝は、CloudWatch Logs のサブスクリプションフィルターで設定した文字列を検知した場合のみ Lambda をキックし、Lambdaで整形したメッセージをSNSのメールアドレスへ飛ばすことです

また、レートベースルールの閾値は 同一IPアドレスから1分間で10回以上のアクセスがあった場合にブロックするようにしており、ブロックが発生するとCloudWatch Logs へ下記のようなログが出力されます

{
~省略~
    "terminatingRuleId": "example-dev-rate-based-rule",
    "terminatingRuleType": "RATE_BASED",
    "action": "BLOCK",
~省略~
}

 

 

■やってみた

※事前にネットワーク回り、EC2、ALB、CloudWatch、SNSは事前に作成しておいてください

また、ブラウザなどのインターネット上からEC2上のWebサーバーへFQDNでアクセス可能な状態にしてください

 

1. LambdaからSNSを操作するためのIAMポリシーとロールを作成

まずは、LambdaからSNSを操作するためのIAMポリシーとロールを作成します

権限は Publish を利用し、リソースは検証なのですべてを設定します

・ポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "*"
        }
    ]
}

 

・ロール

Trusted entities

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

→ ポリシーは先ほど作成したものを選択します

 

2. Lmabdaの作成

Python 3.13 で下記コードを作成しました

import base64
import gzip
import json
import os
from datetime import datetime, timedelta, timezone
 
import boto3
 
sns = boto3.client("sns")
 
SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]
 
# 表示タイムゾーン(既定は日本時間 JST = UTC+9)
_TZ_OFFSETS = {"Asia/Tokyo": 9, "UTC": 0}
_TZ_NAME = os.environ.get("TIMEZONE", "Asia/Tokyo")
_TZ = timezone(timedelta(hours=_TZ_OFFSETS.get(_TZ_NAME, 9)), _TZ_NAME)
 
# ISO 3166-1 alpha-2 → 日本語国名(主要国。未登録はコードをそのまま表示)
COUNTRY_NAMES = {
    "JP": "日本", "US": "アメリカ合衆国", "CN": "中国", "KR": "韓国",
    "TW": "台湾", "HK": "香港", "SG": "シンガポール", "IN": "インド",
    "TH": "タイ", "VN": "ベトナム", "ID": "インドネシア", "PH": "フィリピン",
    "MY": "マレーシア", "RU": "ロシア", "GB": "イギリス", "DE": "ドイツ",
    "FR": "フランス", "NL": "オランダ", "BR": "ブラジル", "CA": "カナダ",
    "AU": "オーストラリア", "UA": "ウクライナ", "TR": "トルコ", "IR": "イラン",
}
 
 
def _country_label(code):
    """国コードを『日本 (JP)』の形式に整形。未登録・不明はコードのみ。"""
    if not code:
        return "不明"
    name = COUNTRY_NAMES.get(code.upper())
    return f"{name} ({code})" if name else code
 
 
def _format_datetime(epoch_ms):
    """エポックミリ秒を表示タイムゾーンの文字列に整形。"""
    if epoch_ms is None:
        return "不明"
    dt = datetime.fromtimestamp(epoch_ms / 1000, tz=_TZ)
    return dt.strftime("%Y-%m-%d %H:%M:%S ") + _TZ_NAME
 
 
def _iter_waf_logs(event):
    """CloudWatch Logs サブスクリプションイベントを展開し、各 WAF ログ(dict)を返す。"""
    data = event.get("awslogs", {}).get("data")
    if not data:
        return
    decoded = gzip.decompress(base64.b64decode(data))
    payload = json.loads(decoded)
    for log_event in payload.get("logEvents", []):
        try:
            yield json.loads(log_event["message"])
        except (KeyError, json.JSONDecodeError):
            continue
 
 
def _build_message(waf_log):
    """WAF ログ 1 件から SNS 送信用の件名・本文を生成。"""
    http_request = waf_log.get("httpRequest", {})
    client_ip = http_request.get("clientIp", "不明")
    country = _country_label(http_request.get("country"))
    blocked_at = _format_datetime(waf_log.get("timestamp"))
    rule_name = waf_log.get("terminatingRuleId", "不明")
 
    subject = f"[WAF] レートベースルールでブロックしました (IP: {client_ip})"
 
    body = (
        "AWS WAF のレートベースルールにより、以下の通信をブロックしました。\n"
        "\n"
        "----------------------------------------\n"
        f"ブロック日時 : {blocked_at}\n"
        f"IP アドレス  : {client_ip}\n"
        f"国名         : {country}\n"
        f"ルール名     : {rule_name}\n"
        "----------------------------------------\n"
        "\n"
        "内容をご確認ください。"
    )
    return subject, body
 
 
def lambda_handler(event, context):
    published = 0
    for waf_log in _iter_waf_logs(event):
        subject, body = _build_message(waf_log)
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            # SNS の件名は最大 100 文字・ASCII 制約があるため安全な固定件名にする
            Subject="[WAF] Rate-based rule blocked an IP",
            Message=body,
        )
        published += 1
        print(f"Published: {subject}")
 
    return {"statusCode": 200, "published": published}

→ CloudWatchLogsの中身をデコードし、その中の httpRequest から必要な情報を整形しています

また、SNSのARNを環境変数から設定できるようにしています

 

次に、IAMロールで先ほど 1 の手順で作成したものを指定し、環境変数でSNS_TOPIC_ARNを指定します

 

3. CloudWatch Logs でサブスクリプションフィルターを設定します

サブスクリプションフィルターの種類は Create Lambda subscription filter  を選択し、

フィルターパターンで RATE_BASED と BLOCK を半角でつなげることで AND 条件を実現しています

 

下記条件で作成しました

Lambda function:2 で作成したLambda関数

フィルターパターン:RATE_BASED BLOCK

 

4. 動作確認

それでは、一度レートベースに引っ掛かるようにして通知されるか確認してみます

適当にブラウザから対象のFQDNへF5を連打し、403 Forbidden の画面になることを確認します

 

次に、Lambda が実行されているか確認します

→ Error count and success rate の Success rate のカウントが1になっているので、Lambda関数自体は実行されてました

 

最後にメールを確認してみます

→ 秘匿すべき箇所はマスクしていますが、ブロック日時、IPアドレス、国名など最低限の情報が記載されたメールを受信したので、きちんと動作してそうです

なお、今回の構成では CloudWatch Logs にログが出力されるたびにメールが通知されるので、もしこの辺りを調整したい場合は、DynamoDBでの状態保持などが必要になってくると思います

 

■最後に

いかがでしたでしょうか

割と定番の構成にLambdaとSNSを追加するだけで簡単にブロックされたIPアドレスをメール通知できました

これにより毎回AWSコンソールへ行かなくても、ブロックされたIPアドレスが確認できるので、少し楽になるかなと思います

ただ、最後にも記載したようにもし大量にブロックされた場合、大量のメールが飛んでくる可能性があるので、そのあたりはDynamoDBを追加した状態保持が必要になってくるかなと思います

もし時間があればこっちもやってみようかなと思います

それではまたー

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA