【AWS WAF】ログの手動解凍は不要!既存環境を変更せずAthenaで安全にクエリ・分析する実践手順ガイド

皆さんこんにちは。okamoです。

今回は、前回記事、「CloudFrontログをAthenaで分析!日本時間対応とパーティション設定の実践手順ガイド」の続編として、CloudFrontのAWS WAFログ編をお送りします。

ITの現場では、アクセス解析や不具合調査のたびに、S3から大量の .gz ファイルを手動で1つずつダウンロードして解凍し、テキストエディタの検索機能で苦労しながら不審なアクセスを探している人が今も大勢います。

本記事では、「既存環境を一切変更せず」「追加のコストをほぼかけず」「安全(=安心)して、効率よく」 AWS WAFのログをAthenaで直接クエリ・分析するための実践的な手順を網羅してご紹介します。


1. なぜ WAF ログを Athena でクエリするのか(Why)

課題: WAF コンソールだけでは深い分析ができない

障害発生時や不審なアクセスを検知した際、AWSのコンソールだけで詳細を追うのには限界があります。

手段限界
WAF コンソールのダッシュボード直近のサマリーのみ。IP 単位の行動追跡や時系列相関ができない
WAF Sampled Requests最大 5,000 件、3 時間しか保持されない
CloudWatch Metricsカウントベース。リクエスト本体(URI・ヘッダー等)が見えない

解決: S3 に保存された WAF フルログを SQL でクエリする

S3に蓄積されている生ログファイルを直接Amazon Athenaからクエリすることで、これらの課題を一気にクリアできます。

[ユーザー] → CloudFront → WAF(ここでログ記録)→ オリジン
                              │
                              ▼
                        S3(JSON / gz)
                              │
                              ▼
                        Athena で SQL クエリ

得られる大きなメリット:

  • 全リクエストの全フィールドが残るaction, terminatingRuleId, labels, httpRequest など、詳細なデータへアクセス可能
  • S3 コピーが不要 — WAF ログは最初から日付階層(yyyy/MM/dd/HH/mm/)で保存されるため、Partition Projection(パーティション射影)を使うことでコピーの手間なくそのままクエリ可能
  • gz 圧縮されたまま直接クエリ可能 — 面倒な解凍作業は一切不要
  • 既存の WAF ログ設定をそのまま利用 — 既存のパイプラインに影響を与えず、FirehoseやLambdaを追加・変更する必要もありません

主なユースケース

  • WAF ルールの誤検知(False Positive)調査
  • ブロックされたリクエストの IP・URI・ルール特定
  • Bot / DDoS 攻撃パターンの時系列分析
  • 特定ルールの効果測定(BLOCK / COUNT 比較)
  • インシデント発生時の証跡(エビデンス)取得

前回記事(CloudFrontログ分析)との違い

項目前回:CloudFrontの標準ログ今回:AWS WAFの標準ログ
ログ形式TSV(タブ区切り)JSON
S3 構造フラット(日付フォルダなし)日付階層あり
S3 コピー必要(Hive 形式に変換)不要(そのまま使える)
ワークフロー使い捨て(分析のたびに作って壊す)永続テーブル(作ったら残す)

2. 構成概要と必要な IAM ポリシー

まずは今回の分析環境における全体構成と、セキュリティを担保するための権限関係を確認しておきましょう。

アーキテクチャ構成

既存のWAFログ保存バケットには手を加えず、分析に必要な最小限のリソースだけを用意してセキュアに接続します。

アーキテクチャ図

上記の「アーキテクチャ図」では、AWS WAFからS3バケットへ自動出力されたログを読み取り専用とし、分析用のIAMユーザー「waf-analyst」がGlue Data Catalogのスキーマを参照しながら、Amazon Athenaを通じてクエリを実行。クエリ結果を専用のS3バケットへ出力する流れを示しています。

必要なリソースと権限の関係

分析を行うメンバーに強力すぎる管理者権限(AdministratorAccess)を渡すのは非常に危険です。最小限の閲覧・クエリ実行権限だけを定義したIAMポリシーを適用します。

IAMポリシー構成図

上記の「IAMポリシー構成図」が示す通り、IAMユーザー「waf-analyst」には、Athenaのクエリ実行権限、WAFログ用S3バケットへの読み取り(Get/List)権限、クエリ結果保存用S3バケットへの読み書き権限、そしてGlue Data Catalogの特定データベース(waf_logs)およびテーブル操作権限のみを厳密に付与します。


3. Athena ワークグループの初期設定(管理者で実施)

以下は管理者アカウントで1回だけ実施する手順です。 すでに Athena ワークグループが設定済み(前回記事等で実施済み)の場合はスキップして構いません。

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION="ap-northeast-1"

# === Step 1: Athena クエリ結果用バケットの作成 ===
ATHENA_BUCKET="aws-athena-query-results-${ACCOUNT_ID}-${REGION}"
aws s3 mb "s3://${ATHENA_BUCKET}" --region "${REGION}"

# === Step 2: Athena ワークグループに出力先を設定(ユーザー側の上書きを禁止) ===
aws athena update-work-group \
  --work-group primary \
  --configuration-updates "{
    \"ResultConfigurationUpdates\": {
      \"OutputLocation\": \"s3://${ATHENA_BUCKET}/\"
    },
    \"EnforceWorkGroupConfiguration\": true
  }" \
  --region "${REGION}"

# === Step 3: 設定を確認 ===
aws athena get-work-group \
  --work-group primary \
  --query 'WorkGroup.Configuration.{OutputLocation:ResultConfiguration.OutputLocation,EnforceConfig:EnforceWorkGroupConfiguration}' \
  --region "${REGION}"

期待される出力:

{
    "OutputLocation": "s3://aws-athena-query-results-123456789012-ap-northeast-1/",
    "EnforceConfig": true
}

4. 環境変数の定義

本手順で使用する環境変数を以下に定義します。ご自身のAWS環境に合わせて値を設定してください。

環境変数名説明設定例
$WAF_BUCKETWAF ログが保存されている S3 バケット名aws-waf-logs-xxxxxxxx
$WAF_ACCOUNT_IDAWS アカウント ID123456789012
$WAF_SCOPEWAF の保護対象cloudfront または regional
$WAF_NAMEWAF WebACL 名(S3 パスに含まれる文字列)CreatedByCloudFront-xxxxxxxx
$REGIONAWS リージョンap-northeast-1

💡 前回記事との変数名の違い: 混在して作業した際のオペレーションミスを防ぐため、今回のWAF用変数には頭に WAF_ プレフィックスを付与しています。


5. IAM ポリシー作成とユーザーの作成(管理者で実施)

IAM ポリシーの用意(JSON)

前回記事との違い:

  • ユーザー名: log-analyst(CF) → waf-analyst(WAF)
  • ポリシー名: AthenaLogAnalystPolicy(CF) → AthenaWafAnalystPolicy(WAF)
  • Glue Database: cloudfront_logs(CF) → waf_logs(WAF)
  • S3コピーバケット: WAFはログを直接クエリするため、移行用バケット定義は不要です。

以下の内容を waf-policy.json として保存します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AthenaAccess",
      "Effect": "Allow",
      "Action": [
        "athena:StartQueryExecution",
        "athena:StopQueryExecution",
        "athena:GetQueryExecution",
        "athena:GetQueryResults",
        "athena:GetWorkGroup",
        "athena:ListWorkGroups"
      ],
      "Resource": "*"
    },
    {
      "Sid": "S3ReadWafLogBucket",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::${WAF_BUCKET}",
        "arn:aws:s3:::${WAF_BUCKET}/*"
      ]
    },
    {
      "Sid": "AthenaResultsAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::aws-athena-query-results-*"
      ]
    },
    {
      "Sid": "GlueDataCatalog",
      "Effect": "Allow",
      "Action": [
        "glue:GetDatabase",
        "glue:GetDatabases",
        "glue:CreateDatabase",
        "glue:DeleteDatabase",
        "glue:GetTable",
        "glue:GetTables",
        "glue:CreateTable",
        "glue:UpdateTable",
        "glue:DeleteTable",
        "glue:GetPartition",
        "glue:GetPartitions",
        "glue:BatchGetPartition"
      ],
      "Resource": [
        "arn:aws:glue:${REGION}:${WAF_ACCOUNT_ID}:catalog",
        "arn:aws:glue:${REGION}:${WAF_ACCOUNT_ID}:database/waf_logs",
        "arn:aws:glue:${REGION}:${WAF_ACCOUNT_ID}:table/waf_logs/*"
      ]
    }
  ]
}

⚠️注意: JSON内の ${WAF_BUCKET}, ${WAF_ACCOUNT_ID}, ${REGION} はプレースホルダーです。保存する前にお使いの実際の値へ置換してください。

作成手順(CLI)

# === 環境変数の定義 ===
WAF_BUCKET="aws-waf-logs-xxxxxxxx"
WAF_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
WAF_SCOPE="cloudfront"
WAF_NAME="CreatedByCloudFront-xxxxxxxx"
REGION="ap-northeast-1"

# === 分析用ユーザーの作成 ===
aws iam create-user --user-name waf-analyst

# === 最小権限ポリシーをアタッチ ===
aws iam put-user-policy \
  --user-name waf-analyst \
  --policy-name AthenaWafAnalystPolicy \
  --policy-document file://waf-policy.json

調査用の一時アクセスキーを発行

aws iam create-access-key --user-name waf-analyst

調査終了後の無効化・削除(クリーンアップ)

# キーの一覧確認
aws iam list-access-keys --user-name waf-analyst

# キーを無効化(一時的な停止)
aws iam update-access-key \
  --user-name waf-analyst \
  --access-key-id AKIA**************** \
  --status Inactive

# 不要になったキーを完全に削除
aws iam delete-access-key \
  --user-name waf-analyst \
  --access-key-id AKIA****************

6. ログ分析の実施(ログ分析ユーザーで実施)

これより先の手順は、すべて作成した waf-analyst ユーザーの認証情報を利用して実行します。

Windows ユーザー向け: WSL + AWS CLI セットアップ

Windows環境から安全かつ強力なシェル操作環境を利用するため、WSL(Windows Subsystem for Linux)を導入して作業しましょう。

1. WSL のインストール(PowerShell を管理者権限で実行)

wsl --install

※インストール後、PCを再起動し、スタートメニューから「Ubuntu」を起動して初期のユーザー名・パスワードを設定します。

2. AWS CLI のインストール(WSL Ubuntu 内で実施)

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
sudo apt update && sudo apt install -y unzip
unzip awscliv2.zip
sudo ./aws/install
rm -rf aws awscliv2.zip

# バージョン確認
aws --version

3. クレデンシャルの設定(環境変数)

管理者から入手した一時アクセスキーをターミナルの環境変数へセットします。

# 既存の設定が混ざらないよう、念のためアンセット
export -n AWS_PROFILE
export -n AWS_DEFAULT_PROFILE

export AWS_ACCESS_KEY_ID="取得した AccessKeyId"
export AWS_SECRET_ACCESS_KEY="取得した SecretAccessKey"
export AWS_DEFAULT_REGION="ap-northeast-1"

🔒 セキュリティのポイント: 環境変数への設定は、ターミナルを閉じれば自動的に消去されるため安全です。.bashrc などの設定ファイルへ直接書き込まないようにしてください。

動作確認:

aws sts get-caller-identity

出力に waf-analyst のARNが表示されていれば、準備完了です。


Step 0: 解析作業用変数のセット

WAF_BUCKET="aws-waf-logs-xxxxxxxx"
WAF_ACCOUNT_ID="123456789012"
WAF_SCOPE="cloudfront"
WAF_NAME="CreatedByCloudFront-xxxxxxxx"
REGION="ap-northeast-1"

Step 1: データベース・テーブル作成

ここでもS3コピーは不要です。 Partition Projection を設定することで、S3上に最初から作成されている日付フォルダのパスを、クエリ実行時に動的スキャンさせます。

# Glue データベース作成
QID=$(aws athena start-query-execution \
  --query-string "CREATE DATABASE IF NOT EXISTS waf_logs;" \
  --work-group primary --region "${REGION}" \
  --query 'QueryExecutionId' --output text)
sleep 3
aws athena get-query-execution --query-execution-id "$QID" --region "${REGION}" \
  --query 'QueryExecution.Status.State' --output text

# テーブルを再作成
QID=$(aws athena start-query-execution \
  --query-string "DROP TABLE IF EXISTS waf_logs.firewall_logs;" \
  --work-group primary --region "${REGION}" \
  --query-execution-context Database=waf_logs \
  --query 'QueryExecutionId' --output text)
sleep 3

# テーブル定義(Partition Projection を盛り込み)
QID=$(aws athena start-query-execution \
  --query-string "
    CREATE EXTERNAL TABLE waf_logs.firewall_logs (
      \`timestamp\`                    bigint,
      \`formatversion\`                int,
      \`webaclid\`                     string,
      \`terminatingruleid\`            string,
      \`terminatingruletype\`          string,
      \`action\`                       string,
      \`terminatingrulematchdetails\`   array<struct<
        conditiontype:string,
        sensitivitylevel:string,
        location:string,
        matcheddata:array<string>
      >>,
      \`httpsourcename\`               string,
      \`httpsourceid\`                 string,
      \`rulegrouplist\`                array<struct<
        rulegroupid:string,
        terminatingrule:struct<
          ruleid:string,
          action:string,
          rulematchdetails:array<struct<
            conditiontype:string,
            sensitivitylevel:string,
            location:string,
            matcheddata:array<string>
          >>
        >,
        nonterminatingmatchingrules:array<struct<
          ruleid:string,
          action:string,
          overriddenaction:string,
          rulematchdetails:array<struct<
            conditiontype:string,
            sensitivitylevel:string,
            location:string,
            matcheddata:array<string>
          >>,
          challengeresponse:struct<responsecode:string,solvetimestamp:string>,
          captcharesponse:struct<responsecode:string,solvetimestamp:string>
        >>,
        excludedrules:string
      >>,
      \`ratebasedrulelist\`            array<struct<
        ratebasedruleid:string,
        limitkey:string,
        maxrateallowed:int
      >>,
      \`nonterminatingmatchingrules\`  array<struct<
        ruleid:string,
        action:string,
        rulematchdetails:array<struct<
          conditiontype:string,
          sensitivitylevel:string,
          location:string,
          matcheddata:array<string>
        >>,
        challengeresponse:struct<responsecode:string,solvetimestamp:string>,
        captcharesponse:struct<responsecode:string,solvetimestamp:string>
      >>,
      \`requestheadersinserted\`       array<struct<name:string,value:string>>,
      \`responsecodesent\`             string,
      \`httprequest\`                  struct<
        clientip:string,
        country:string,
        headers:array<struct<name:string,value:string>>,
        uri:string,
        args:string,
        httpversion:string,
        httpmethod:string,
        requestid:string,
        fragment:string,
        scheme:string,
        host:string
      >,
      \`labels\`                       array<struct<name:string>>,
      \`captcharesponse\`              struct<
        responsecode:string,
        solvetimestamp:string,
        failurereason:string
      >,
      \`challengeresponse\`            struct<
        responsecode:string,
        solvetimestamp:string,
        failurereason:string
      >,
      \`ja3fingerprint\`               string,
      \`ja4fingerprint\`               string,
      \`oversizefields\`               string,
      \`requestbodysize\`              int,
      \`requestbodysizeinspectedbywaf\` int
    )
    PARTITIONED BY (year STRING, month STRING, day STRING)
    ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
    STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat'
    OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
    LOCATION 's3://${WAF_BUCKET}/AWSLogs/${WAF_ACCOUNT_ID}/WAFLogs/${WAF_SCOPE}/${WAF_NAME}/'
    TBLPROPERTIES (
      'projection.enabled'       = 'true',
      'projection.year.type'     = 'integer',
      'projection.year.range'    = '2024,2099',
      'projection.month.type'    = 'integer',
      'projection.month.range'   = '1,12',
      'projection.month.digits'  = '2',
      'projection.day.type'      = 'integer',
      'projection.day.range'     = '1,31',
      'projection.day.digits'    = '2',
      'storage.location.template' = 's3://${WAF_BUCKET}/AWSLogs/${WAF_ACCOUNT_ID}/WAFLogs/${WAF_SCOPE}/${WAF_NAME}/\${year}/\${month}/\${day}/'
    );
  " \
  --work-group primary --region "${REGION}" \
  --query-execution-context Database=waf_logs \
  --query 'QueryExecutionId' --output text)

sleep 5
aws athena get-query-execution --query-execution-id "$QID" --region "${REGION}" \
  --query 'QueryExecution.Status.{State:State,Reason:StateChangeReason}' --output json

💡 テーブル定義のポイント:

  • MSCK REPAIR TABLEALTER TABLE ADD PARTITION を手動実行する必要はありません。Partition Projection(射影設定)のおかげで、日時フォルダが作成された瞬間に自動検出され、参照可能となります。
  • 前回と同じ year, month, day パーティション構成を採用しているため、クエリ構文の整合性が保てます。

Step 1.5: jq のインストール(結果確認用)

分析時の結果確認や、JSONデータの整形用に jq コマンドを用意します(未インストールの場合のみ)。

sudo apt update && sudo apt install -y jq

Step 2: 動作確認クエリの実行

実際にテーブルから正常にデータがロードできているか、サンプルを5件出力してみます。 (例として「2026年6月1日〜5日」をJST基準で指定しています。UTCでは5月31日から前日分を含むため、パーティションフィルタを1日前まで広げています)

QID=$(aws athena start-query-execution \
  --query-string "
    SELECT
      from_unixtime(timestamp / 1000) AS datetime_utc,
      DATE_FORMAT(
        from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR,
        '%Y-%m-%d %H:%i:%s'
      ) AS datetime_jst,
      action,
      terminatingruleid,
      httprequest.clientip,
      httprequest.country,
      httprequest.uri
    FROM waf_logs.firewall_logs
    WHERE (
        (year = '2026' AND month = '05' AND day = '31')
        OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
      )
      AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
          BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
    ORDER BY timestamp
    LIMIT 5;
  " \
  --work-group primary --region "${REGION}" \
  --query-execution-context Database=waf_logs \
  --query 'QueryExecutionId' --output text)

sleep 5
aws athena get-query-execution --query-execution-id "$QID" --region "${REGION}" \
  --query 'QueryExecution.Status.State' --output text

aws athena get-query-results --query-execution-id "$QID" --region "${REGION}" \
  --output json | jq -r '.ResultSet.Rows[] | .Data | map(.VarCharValue) | @tsv' | column -t -s $'\t'

7. 実践 SQL 例

⚠️ クエリを投げる前に、以下のヘルパー関数をターミナルに貼り付けて Enter してください。

waf-query() {
  local qid=$(aws athena start-query-execution \
    --query-string "$1" \
    --work-group primary \
    --query-execution-context Database=waf_logs \
    --region ap-northeast-1 \
    --query 'QueryExecutionId' --output text)
  echo "Waiting... (${qid})"
  while true; do
    local state=$(aws athena get-query-execution \
      --query-execution-id "$qid" --region ap-northeast-1 \
      --query 'QueryExecution.Status.State' --output text)
    [[ "$state" == "RUNNING" || "$state" == "QUEUED" ]] || break
    sleep 2
  done
  echo "State: ${state}"
  [[ "$state" == "SUCCEEDED" ]] && aws athena get-query-results \
    --query-execution-id "$qid" --region ap-northeast-1 \
    --output json | jq -r '.ResultSet.Rows[] | .Data | map(.VarCharValue) | @tsv' | column -t -s $'\t'
}

※前回記事の athena-query 関数とは重複しない名前になっています。

7.0 データ範囲の検証(クエリが動くか、データが存在するかチェック)

💡超重要: スキャン料金の無駄を省くため、必ず year/month/day のパーティションフィルタを組み合わせましょう。

waf-query "$(cat <<'SQL'
SELECT
  COUNT(*) AS total_rows,
  DATE_FORMAT(
    MIN(from_unixtime(timestamp / 1000)) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS min_datetime_jst,
  DATE_FORMAT(
    MAX(from_unixtime(timestamp / 1000)) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS max_datetime_jst
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59';
SQL
)"

7.1 BLOCK された IP ランキング(悪質アクセスの発信源特定)

ブロック数が異常に高くなっている攻撃元IPアドレスと、ブロック判定したルール、国を特定します。

waf-query "$(cat <<'SQL'
SELECT
  httprequest.clientip AS ip,
  httprequest.country AS country,
  COUNT(*) AS block_count,
  DATE_FORMAT(
    MIN(from_unixtime(timestamp / 1000)) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS first_seen_jst,
  DATE_FORMAT(
    MAX(from_unixtime(timestamp / 1000)) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS last_seen_jst,
  ARRAY_AGG(DISTINCT terminatingruleid) AS rules
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
  AND action = 'BLOCK'
GROUP BY httprequest.clientip, httprequest.country
ORDER BY block_count DESC
LIMIT 30;
SQL
)"

7.2 特定 IP の全リクエスト履歴を時系列で追跡

不審な行動を取った特定IP(以下は例として 203.0.113.50)が、どのようなパス(URI)に対し、何のメソッドでアクセスしてきたかを時間順に追いかけます。

waf-query "$(cat <<'SQL'
SELECT
  DATE_FORMAT(
    from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS datetime_jst,
  action,
  terminatingruleid,
  httprequest.httpmethod AS method,
  httprequest.uri,
  httprequest.args,
  httprequest.country
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
  AND httprequest.clientip = '203.0.113.50'
ORDER BY timestamp;
SQL
)"

7.3 特定 WAF ルールの発火状況を深掘り

特定のマネージドルール(例: AWS-AWSManagedRulesCommonRuleSet)がどのアクセスに検知しているかを可視化します。

waf-query "$(cat <<'SQL'
SELECT
  DATE_FORMAT(
    from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR,
    '%Y-%m-%d %H:%i:%s'
  ) AS datetime_jst,
  action,
  httprequest.clientip AS ip,
  httprequest.country AS country,
  httprequest.uri,
  httprequest.httpmethod AS method
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
  AND terminatingruleid = 'AWS-AWSManagedRulesCommonRuleSet'
ORDER BY timestamp
LIMIT 100;
SQL
)"

7.4 ルール別の BLOCK / COUNT 集計(効果測定)

本番ブロックする前に様子見(COUNT設定)にしているルールの発火率や、稼働中ルールのブロック貢献度を確認します。

waf-query "$(cat <<'SQL'
SELECT
  terminatingruleid,
  action,
  COUNT(*) AS cnt
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
GROUP BY terminatingruleid, action
ORDER BY cnt DESC;
SQL
)"

7.5 Label ベースの分析(Bot Control や ログイン保護 ATP の脅威特定)

AWSが提供する高度な「Bot Control」や「Account Takeover Prevention (ATP)」マネージドルールでは、ログの labels 配列に特徴的なラベルが付与されます。この配列をフラット化(CROSS JOIN UNNEST)して集計します。

waf-query "$(cat <<'SQL'
SELECT
  label.name AS label_name,
  COUNT(*) AS cnt
FROM waf_logs.firewall_logs
CROSS JOIN UNNEST(labels) AS t(label)
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
GROUP BY label.name
ORDER BY cnt DESC
LIMIT 30;
SQL
)"

7.6 国別アクセス集計(アクションの割合)

waf-query "$(cat <<'SQL'
SELECT
  httprequest.country AS country,
  action,
  COUNT(*) AS cnt
FROM waf_logs.firewall_logs
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
GROUP BY httprequest.country, action
ORDER BY cnt DESC
LIMIT 30;
SQL
)"

7.7 ユーザーエージェント(User-Agent)分布(スクレイピング対策)

どのようなブラウザ・クローラー・ツール(User-Agent)から大量のリクエストが送りつけられているかを丸裸にします。

waf-query "$(cat <<'SQL'
SELECT
  header.value AS user_agent,
  COUNT(*) AS cnt,
  ARRAY_AGG(DISTINCT action) AS actions
FROM waf_logs.firewall_logs
CROSS JOIN UNNEST(httprequest.headers) AS t(header)
WHERE (
    (year = '2026' AND month = '05' AND day = '31')
    OR (year = '2026' AND month = '06' AND day BETWEEN '01' AND '05')
  )
  AND from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR
      BETWEEN TIMESTAMP '2026-06-01 00:00:00' AND TIMESTAMP '2026-06-05 23:59:59'
  AND header.name = 'user-agent'
GROUP BY header.value
ORDER BY cnt DESC
LIMIT 30;
SQL
)"

8. JST での日付指定パターン(早見表)

WAFログの本体に含まれる timestamp フィールド(エポックミリ秒)およびS3のパーティション(year/month/day)は、すべて UTC(世界協定時) ベースで保存されています。 JST(日本標準時)の指定期間に適合する、実際のクエリ設定のベストプラクティスをパターン別にまとめました。

調査対象(JST)パーティション指定(UTC)JST 精密フィルタ
2026-06-01 のみ(year='2026' AND month='05' AND day='31') OR (year='2026' AND month='06' AND day='01')BETWEEN '2026-06-01 00:00:00' AND '2026-06-01 23:59:59'
2026-06-01 〜 05 (5日間)(year='2026' AND month='05' AND day='31') OR (year='2026' AND month='06' AND day BETWEEN '01' AND '05')BETWEEN '2026-06-01 00:00:00' AND '2026-06-05 23:59:59'
2026-06-03 09:00〜18:00year='2026' AND month='06' AND day='03'BETWEEN '2026-06-03 09:00:00' AND '2026-06-03 18:00:00'

基本パターン:

  1. パーティションはJST範囲より 前日に1日分余裕を持って広めに指定(パーティションを絞っているためスキャンコストの上昇は極めて軽微です)
  2. 月跨ぎの場合は、OR 句で前月の日(例: 05-31)も含める
  3. from_unixtime(timestamp / 1000) + INTERVAL '9' HOUR の変換式を BETWEEN TIMESTAMP で挟み込み、JST単位での精密な絞り込みをかける

9. クリーンアップ

調査がすべて完了し、不要になったAthenaテーブルを完全に削除します。

QID=$(aws athena start-query-execution \
  --query-string "DROP TABLE IF EXISTS waf_logs.firewall_logs;" \
  --work-group primary --region "${REGION}" \
  --query-execution-context Database=waf_logs \
  --query 'QueryExecutionId' --output text)
sleep 3
aws athena get-query-execution --query-execution-id "$QID" --region "${REGION}" \
  --query 'QueryExecution.Status.State' --output text

💡 安心ポイント: 削除されるのはAthena上における「テーブル定義(スキーマなどのメタデータ)」だけです。S3内のオリジナルのWAF生ログは一切削除されませんのでご安心ください。テーブルを再作成すればいつでもまたクエリ可能になります。

※なお、Athenaクエリの結果が格納されたCSVファイル(aws-athena-query-results-*バケット配下)を削除したい場合は、以下のCLIコマンドでバケットを掃除できます。

aws s3 rm s3://${ATHENA_BUCKET}/ --recursive

10. コスト目安

項目料金目安
Athena クエリ$5 / スキャン 1TB あたり
S3(ログのGETリクエスト)$0.0004 / 1,000 GET リクエスト
Glue Data Catalogテーブル数が少なければ無料枠内に収まります

Partition Projectionによって、スキャン対象は指定した年月日のフォルダのみに厳格に限定されるため、1日〜数日程度のログを分析するだけであれば、1回のクエリにかかる料金は通常 $0.01(日本円で約1.5円)未満です。


11. トラブルシューティング

エラー/症状主な原因対処方法
HIVE_CANNOT_OPEN_SPLITWAFログバケットに対するアクセス権限が不足しているIAMポリシーの S3ReadWafLogBucket でバケット名が正しいか、およびリソースARNの末尾に /* があるか再確認します。
結果が0件(テーブルはできている)① 指定期間内のパスに物理データが存在しない<br>LOCATION のS3パスが物理パス構造と不一致aws s3 ls s3://${WAF_BUCKET}/AWSLogs/.../ コマンドで、実際の日にちのパス配下に .log.gz があるか確認します。<br>② 後述の「S3 パス構造リファレンス」を参考に LOCATION が一字一句一致しているか精査してください。
HIVE_BAD_DATA: Error parsing field value定義されたカラム型とログ側のJSON構造の不一致WAFログのJSONスキーマは全て小文字(CamelCaseではなくSnakeCaseなど)で解釈されます。独自設定がある場合はテーブル構造のスキーマを確認してください。
ACCESS_DENIED on AthenaGlue Data Catalog に対する操作権限不足IAMポリシーの GlueDataCatalog リソース定義内で、データベース名が waf_logs と不一致になっていないかを確認します。
クエリの実行が極端に遅いパーティションによる事前絞り込み(WHEREフィルタ)が不足必ず WHERE year = '...' AND month = '...' AND day = '...' の形で、日付条件を真っ先に指定してスキャンの対象範囲を物理的に限定させてください。

12. S3 パス構造リファレンス

WAF ログがS3上へ自動出力される際の階層構造は、以下の順に固定されています。

s3://${WAF_BUCKET}/
  └── AWSLogs/
      └── ${WAF_ACCOUNT_ID}/
          └── WAFLogs/
              └── ${WAF_SCOPE}/          ← cloudfront もしくは regional
                  └── ${WAF_NAME}/       ← WebACL 名
                      └── 2026/
                          └── 06/
                              └── 01/
                                  └── 00/
                                      └── 15/
                                          └── ${WAF_ACCOUNT_ID}_waflogs_${WAF_SCOPE}_${WAF_NAME}_20260601T0015Z_xxxxx.log.gz

ファイル命名規則: {AccountId}_waflogs_{Scope}_{WebACL-Name}_{Timestamp}_{Hash}.log.gz


最後に

さて、ここまででAWS WAFの標準ログを安全かつ迅速にAthenaで分析する手法を紹介させていただきました。

最後に、今回ノウハウを応用し、現場で発生した不正アクセスの実態に迫ったリアルな分析記事をご紹介します。

👉 【実録】RateLimitが無力化する瞬間。「1IP=1リクエスト」のボットネット攻撃をAWS Athenaで完全分析する

Webサービスを日々運用していると、突然DBの負荷が異常急増したり、特定のAPIパスへの怪しいクロールが発生することがあります。そんなとき、 「AWS WAFで『一定時間にXX回以上アクセスされたらブロックする』というRate-based Rule(レート制限)を入れているから、うちは大丈夫!」 と思い込んでいませんか?

実は落とし穴も存在します。

結論から言うと、賢くなった攻撃者たちは 「数万〜数十万の膨大なIPアドレスを使い捨てにして攻撃する」 戦略をとってくるのです。1つのIPから5分間にアクセスされる回数を、制限の閾値よりはるかに低い「たった1回だけ」に抑制しながら、システム全体にはスパイクアクセスを叩き込んできます。この場合、一般的なRate Limitは無力化されてしまいます。

上記の実録記事(有料)では、南米や東南アジアのボットネットから実際に仕掛けられた、この「1IP=1リクエスト」型の攻撃を例に、AWS Athenaでログを完全解剖した「事実」と、それに対抗してサイトを守り抜く具体的なセキュリティアプローチを、実際のSQLクエリと生の分析データを交えて共有しています。


おまけ:okamoちゃんねるのレビュー

この記事について、3人のAI仮想読者がレビューしてくれました。

  • クロード(辛口エンジニア)
    • 「Partition ProjectionでのJST処理、今回のほうが説明が丁寧。」
  • GPT(税理士)
    • 「誰でも再現可能ではないですね。正確に言うなら、CLI作業に拒否感のない担当者向けの、かなり親切な現場手順書です。」
  • Gemini(お母さん)
    • 「そんな風に現場で涙ぐましい努力をしている人たちのために、okamoさんは「その面倒な作業、もう不要よ!安全で簡単にクエリで見られる方法を教えるわね!」って、この超大作の手順書を書いてくれたのよね。」

👉 AI 仮想読者3人による辛口レビュー全編はこちら