0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Softetherの認証ログ分析スクリプトを更新

0
Posted at

以前、SoftetherVPNの認証ログ集計スクリプトを公開していました。

当初は深夜にその日の認証ログを集計する想定だったのですが、期間を指定して集計したいという状況になり、Claudeに更新してもらいました。

分析するデータが増えるとスクリプト動いているのかわからないので、プログレスバーを表示したいと伝えたところ、Pythonで書き換えることになりました。

それで出来上がったのは以下のコード。

auth_log_aggregator.py
#!/usr/bin/env python3
# ============================================================
# auth_log_aggregator.py
#
# SoftEther VPN の認証ログ(認証成功/認証失敗)を集計し、
# 結果をコンソールに表示、またはオプションで
# Google Chat の Webhook に通知するツールです。
#
# 使い方・オプション一覧・攻撃サマリー機能の説明・cron運用例・注意事項は
# 以下を実行して確認してください:
#   python3 auth_log_aggregator.py --help
#
# 事前設定: すぐ下の「--- 設定 ---」セクションで
#          VPN_SERVER_DIR / VIRTUAL_HUB / GCHAT_WEBHOOK_URL を
#          環境に合わせて編集してください。
# ============================================================

import argparse
import glob
import json
import os
import re
import socket
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta

# --- 設定 ---------------------------------------------------
# SoftEther VPN Server のインストールディレクトリ(環境に合わせて変更)
#
# ここは環境によって2パターンあります。使う方だけ残し、もう片方はコメントアウトしてください。
#
#   [パターンA] softether-automator.sh 等でOSに直接インストールしている場合 ← デフォルトはこちら
#     通常通り /usr/vpnserver がインストール先になります。
#
#   [パターンB] softether-docker (Docker運用) の場合
#     ホストから見えるセキュリティログの実体は /usr/vpnserver ではなく、
#     docker-compose.yml でbind mountしているデータ領域になります。
#     本スクリプトはホストで実行する前提のため、Docker運用の場合はこちらに切り替えてください。
#
VPN_SERVER_DIR = "/usr/vpnserver"                # [パターンA] OSに直接インストールの場合
# VPN_SERVER_DIR = "/opt/softether-docker/data"    # [パターンB] Docker運用の場合

# 集計対象の仮想HUB名(環境に合わせて変更)
VIRTUAL_HUB = "YourVirtualHubName"

# セキュリティログの格納ディレクトリは VPN_SERVER_DIR と 仮想HUB名 から組み立てる。
# 仮想HUB名は --hub で実行時に上書きできるため、モジュールレベルの定数にはせず、
# 実際に使う箇所 (find_target_files) で都度組み立てる。

# ログファイル名のプレフィックス(通常は変更不要, 例: sec_20240101.log)
LOG_PREFIX = "sec_"

# 認証成功/失敗を判定するためのキーワード(通常は変更不要)
SUCCESS_KEYWORD = "Successfully authenticated as user"
FAIL_KEYWORD_RE = re.compile(r"(User authentication failed|User .* not found)")

# Google Chat Webhook URL
# -w オプション指定時に集計結果を送信します。
# 未設定(デフォルト値のまま)の場合は送信をスキップします。
# 取得方法: Google Chat のスペース > 設定 > 「Webhookを管理」
GCHAT_WEBHOOK_URL = "your_webhook_url"

# 認証失敗詳細を表示する際、1ユーザーあたり最大何件表示するか
MAX_FAIL_DETAIL_LINES = 20
# --- 設定終わり -----------------------------------------------

# ログファイル名から日付部分 (YYYYMMDD) を抽出する正規表現
FILENAME_RE = re.compile(re.escape(LOG_PREFIX) + r"(\d{8})\.log$")

# 認証失敗ログからユーザー名を抽出するための正規表現パターン群
# (SoftEtherのログ形式のバリエーションに対応するため複数用意)
FAIL_USER_PATTERNS = [
    re.compile(r'The user name that has been provided was "([^"]+)"'),
    re.compile(r'user "([^"]+)"'),
    re.compile(r"User (\S+) not found"),
]

# 接続ID(例: "CID-12345")を抽出するための正規表現。
# SoftEtherのログでは、認証成功/失敗の行にはIPアドレスが
# 含まれない代わりに、接続試行時の行と共通の接続IDが振られている。
CID_RE = re.compile(r'"(CID-\d+)"')

# 「接続試行」ログから 接続ID と 接続元IPアドレス を抽出する正規表現。
# 例:
#   The connection "CID-2324" (IP address: 126.103.173.27, Host name: ...)
#   is attempting to connect to the Virtual Hub. ...
ATTEMPT_RE = re.compile(
    r'The connection "(CID-\d+)" \(IP address: (\d{1,3}(?:\.\d{1,3}){3})'
)


def extract_cid(line):
    """ログ行から接続ID(CID-xxxxx)を抽出する。
    見つからない場合は None を返す。
    """
    m = CID_RE.search(line)
    return m.group(1) if m else None


# ユーザー名の正規化で除去する文字。
#   - _ .  : メールアドレスやログインIDで実際によく使われる区切り文字
#   ,      : アカウント名として正規に使われることはまずないが、
#            キーボード上で "." のすぐ隣にあるため、"." の打ち間違いで
#            誤って入力されるケースが多い(例: "taro.yamada" のつもりが
#            "taro,yamada" になる)。そのためタイプミスとして扱う。
USERNAME_SEPARATOR_RE = re.compile(r"[-_.,]")


def normalize_username(username):
    """ユーザー名から区切り文字およびタイプミスされやすい記号
    (- _ . ,)を除去し、小文字化して正規化する。
    タイプミス判定(同一アカウントかどうかの比較)に使う。
    """
    return USERNAME_SEPARATOR_RE.sub("", username).lower()


def count_distinct_accounts(usernames):
    """
    同一IPから試行された複数のユーザー名について、区切り文字や
    打ち間違いされやすい記号の違い(例: "toshiaki.yoshida""toshiaki,yoshida""yuki""yuki.nomura")を同一アカウントの
    タイプミスとみなしてグルーピングし、実質的に異なるアカウントの
    数を返す。

    正規化後の文字列が完全一致、またはどちらかがもう一方の部分文字列
    であれば「同じアカウントへのタイプミス」とみなして1グループに
    まとめる。これにより、単なる入力ミスが「複数アカウント試行」の
    誤検知につながるのを防ぐ。
    """
    usernames = list(usernames)
    n = len(usernames)
    if n <= 1:
        return n

    normalized = [normalize_username(u) for u in usernames]
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[ra] = rb

    for i in range(n):
        for j in range(i + 1, n):
            a, b = normalized[i], normalized[j]
            if a == b or a in b or b in a:
                union(i, j)

    return len({find(i) for i in range(n)})


def parse_args():
    """コマンドライン引数を解析する。"""
    epilog = """\
■ 前提条件
  - Python 3.7 以上(標準ライブラリのみで動作、pip install 不要)
  - security_log が有効な SoftEther VPN Server
  - 本スクリプトを実行するユーザーがログディレクトリを読み取れる権限を持っていること

■ 事前設定(このファイル冒頭の「--- 設定 ---」セクションを編集)
  VPN_SERVER_DIR    : SoftEther VPN Server のインストールディレクトリ
                       (OS直接インストール/Docker運用の2パターンをコメントで用意。使う方を選ぶ)
  VIRTUAL_HUB       : 集計対象の仮想HUB名(--hub で実行時にも上書き可)
  GCHAT_WEBHOOK_URL : Google Chat の Webhook URL(-w/--webhook 使用時のみ必要。
                       --webhook <URL> で実行時にも上書き可。秘密情報として扱うこと)

■ 使用例
  auth_log_aggregator.py
      当日分を集計してコンソールに表示(デフォルト)
  auth_log_aggregator.py -d 30
      過去30日分を集計
  auth_log_aggregator.py -d 7 -q -w
      cron向け: 進捗バー等を抑制しつつGoogle Chat Webhookへ通知
  auth_log_aggregator.py --details
      認証失敗ログの詳細(日時・ユーザー名等)も表示
  auth_log_aggregator.py --caution-threshold 15 --warning-threshold 30
      攻撃判定の閾値を変更(デフォルト: 注意10回/警告20回)
  auth_log_aggregator.py --hub AnotherHub
      仮想HUB名を実行時に指定(複数HUBを運用している場合など)
  auth_log_aggregator.py -w --webhook https://chat.googleapis.com/v1/spaces/xxx/messages?key=...
      Webhook送信先URLを実行時に指定

■ 攻撃サマリー機能について
  SoftEtherのセキュリティログでは、接続元IPアドレスは「接続試行」ログにのみ記録され、
  認証成功/失敗そのもののログ行には含まれません。両者は同じ接続ID(CID)で紐付けられて
  いるため、本ツールはCID経由でIPを逆引きして特定します。
  以下いずれかに該当すると「攻撃の疑いあり」として表示します(どちらも該当しなければ
  「該当期間に攻撃の疑いのあるログはありませんでした」と表示)。
    - 失敗回数が --warning-threshold 以上 → 🚨警告 / --caution-threshold 以上 → ⚠️注意
      (ブルートフォース攻撃の疑い)
    - 試行された実質的に異なるアカウント数が --multi-user-threshold 以上
      → 🎯複数アカウント試行(アカウント総当たり/クレデンシャルスタッフィングの疑い)
      ※ 区切り文字( - _ . , )の違いはタイプミスとみなして同一アカウント扱いにする
        (例: "taro.yamada""taro,yamada" は同一人物の打ち間違いとみなす)
  CIDが解決できず送信元IPを特定できなかった失敗は「IP不明」として攻撃判定の対象外です
  (--details 指定時のみ件数を注記表示)。

■ cron運用例(毎朝9時に過去1日分をGoogle Chatへ自動通知)
  0 9 * * * /usr/bin/python3 /path/to/auth_log_aggregator.py -q -w

■ 注意事項
  - 本スクリプトはログファイルを読み取り専用で扱い、ログの削除・変更は行いません。
  - Webhook通知内容には --details 指定時、ユーザー名等の個人情報が含まれる場合が
    あります。通知先のアクセス範囲にご注意ください。
"""
    p = argparse.ArgumentParser(
        description="SoftEther VPN 認証ログ集計ツール - "
                     "認証成功/失敗を集計してコンソール表示、"
                     "またはGoogle Chat Webhookへ通知する",
        epilog=epilog,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument(
        "-d", "--days", type=int, default=1,
        help="集計対象の日数(過去n日、当日を含む。デフォルト: 1)",
    )
    p.add_argument(
        "-w", "--webhook", nargs="?", const="", default=None, metavar="URL",
        help="集計結果をGoogle Chat Webhookに送信する。"
             "URLを省略した場合はスクリプト内のデフォルト値(GCHAT_WEBHOOK_URL)を使う。"
             "値を指定した場合はそのURLへ送信する(デフォルト値を上書き)",
    )
    p.add_argument(
        "--hub", type=str, default=None, metavar="HUB_NAME",
        help="集計対象の仮想HUB名を指定する。"
             "省略した場合はスクリプト内のデフォルト値(VIRTUAL_HUB)を使う",
    )
    p.add_argument(
        "-q", "--quiet", action="store_true",
        help="進捗バーや対象ファイルの詳細表示を抑制する(cron実行等向け)",
    )
    p.add_argument(
        "--details", action="store_true",
        help="認証失敗ログの詳細(日時・元ログ行)も表示する(通常は失敗回数のみ)",
    )
    p.add_argument(
        "--caution-threshold", type=int, default=10,
        help="同一IPからの1日あたり失敗回数がこの値以上で「⚠️注意」表示する"
             "(デフォルト: 10回。想定運用: 1日1回の夜間集計)",
    )
    p.add_argument(
        "--warning-threshold", type=int, default=20,
        help="同一IPからの1日あたり失敗回数がこの値以上で「🚨警告」表示する"
             "(デフォルト: 20回)",
    )
    p.add_argument(
        "--multi-user-threshold", type=int, default=5,
        help="同一IPが試行した「実質的に異なるアカウント数」"
             "(区切り文字違いのタイプミスは同一アカウントとみなして"
             "除外した後の数)がこの値以上の場合、失敗回数に関わらず"
             "複数アカウント総当たりの疑いとして表示する(デフォルト: 5)",
    )
    return p.parse_args()


def find_target_files(days, quiet, virtual_hub):
    """
    LOG_DIR (VPN_SERVER_DIR/security_log/virtual_hub) 配下から、
    指定した日数分の対象ログファイルを探して日付の古い順にソートした
    パスのリストを返す。
    対象ファイルが1件も見つからない場合はエラー終了する。
    """
    log_dir = os.path.join(VPN_SERVER_DIR, "security_log", virtual_hub)
    all_files = glob.glob(os.path.join(log_dir, f"{LOG_PREFIX}*.log"))
    cutoff_date = datetime.now().date() - timedelta(days=days - 1)

    dated_files = []
    for path in all_files:
        m = FILENAME_RE.search(os.path.basename(path))
        if not m:
            continue
        file_date = datetime.strptime(m.group(1), "%Y%m%d").date()
        if file_date >= cutoff_date:
            dated_files.append((file_date, path))
    dated_files.sort(key=lambda x: x[0])

    if not dated_files:
        print(
            f"🚨 過去{days}日間の対象ログファイルが見つかりませんでした。"
            f"LOG_DIR ({log_dir}) の設定を確認してください。",
            file=sys.stderr,
        )
        sys.exit(1)

    if not quiet:
        first_date = dated_files[0][0].strftime("%Y-%m-%d")
        last_date = dated_files[-1][0].strftime("%Y-%m-%d")
        print(f"--- 🔰 SoftEther VPN 認証ログ集計ツール (過去 {days} 日間) 🔰 ---")
        print(f"サーバー名: {socket.gethostname()}")
        print(f"仮想HUB名: {virtual_hub}")
        print(
            f"--- 🔍 集計対象ログファイル: {len(dated_files)} 件 / 全{len(all_files)}件中 "
            f"(最初の日: {first_date} 〜 最後の日: {last_date}) ---"
        )

    return [p for _, p in dated_files]


def print_progress(current, total, quiet, width=40):
    """ログ集計の進捗をバー表示する(--quiet時は何もしない)。"""
    if quiet:
        return
    ratio = (current / total) if total else 1.0
    filled = int(width * ratio)
    bar = "" * filled + "-" * (width - filled)
    sys.stdout.write(f"\r集計中: |{bar}| {ratio * 100:5.1f}% ({current}/{total} バイト)")
    sys.stdout.flush()
    if current >= total:
        sys.stdout.write("\n")


def extract_fail_username(line):
    """認証失敗ログの1行からユーザー名を抽出する。
    どのパターンにも一致しない場合は 'UNKNOWN' を返す。
    """
    for pattern in FAIL_USER_PATTERNS:
        m = pattern.search(line)
        if m:
            return m.group(1)
    return "UNKNOWN"


def aggregate(target_files, quiet):
    """
    対象ログファイル群を1行ずつ読み、認証成功/失敗を集計する。

    戻り値の辞書:
      user_success      : {ユーザー名: 成功回数}
      user_fail_counts  : {ユーザー名: 失敗回数}
      user_fail_details : {ユーザー名: [失敗ログの詳細行, ...]}
      ip_fail_counts     : {送信元IP: 失敗回数}(外部攻撃検出用。
                            IPが特定できなかった失敗は "IP不明" に集計)
      ip_fail_usernames  : {送信元IP: {試行されたユーザー名の集合}}
      total_success      : 成功の合計回数
      total_fail         : 失敗の合計回数
    """
    user_success = {}
    user_fail_counts = {}
    user_fail_details = {}
    ip_fail_counts = {}
    ip_fail_usernames = {}
    total_success = 0
    total_fail = 0

    # 接続ID(CID) → 接続元IPアドレス の対応表。
    # 「接続試行」ログを読んだ時点でここに登録し、後続の認証失敗
    # ログが来たときに同じCIDでIPを逆引きする。
    cid_to_ip = {}

    total_bytes = sum(os.path.getsize(p) for p in target_files) or 1
    processed_bytes = 0
    print_progress(0, total_bytes, quiet)

    for path in target_files:
        fname = os.path.basename(path)
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                for line in f:
                    # --- 接続試行ログ: CID と 接続元IP の対応を記録 ---
                    # (認証成功/失敗より必ず先に出現する行)
                    m = ATTEMPT_RE.search(line)
                    if m:
                        cid_to_ip[m.group(1)] = m.group(2)

                    if SUCCESS_KEYWORD in line:
                        total_success += 1
                        # SoftEtherのログはダブルクォート区切りでユーザー名を含む
                        parts = line.split('"')
                        username = parts[3] if len(parts) > 3 and parts[3] else "UNKNOWN"
                        user_success[username] = user_success.get(username, 0) + 1
                    elif FAIL_KEYWORD_RE.search(line):
                        total_fail += 1
                        username = extract_fail_username(line)
                        user_fail_counts[username] = user_fail_counts.get(username, 0) + 1
                        timestamp = " ".join(line.split()[:2])
                        user_fail_details.setdefault(username, []).append(
                            f"{fname} {timestamp}: {line.strip()}"
                        )

                        # --- 外部攻撃検出用: 同じCIDから接続元IPを逆引きし、
                        #     IPごとに失敗回数と試行されたユーザー名を集計する ---
                        cid = extract_cid(line)
                        ip = cid_to_ip.get(cid) if cid else None
                        ip_key = ip if ip else "IP不明"
                        ip_fail_counts[ip_key] = ip_fail_counts.get(ip_key, 0) + 1
                        ip_fail_usernames.setdefault(ip_key, set()).add(username)
        except OSError as e:
            print(f"\n⚠️ ファイルを読み込めませんでした: {path} ({e})", file=sys.stderr)

        processed_bytes += os.path.getsize(path)
        print_progress(processed_bytes, total_bytes, quiet)

    return {
        "user_success": user_success,
        "user_fail_counts": user_fail_counts,
        "user_fail_details": user_fail_details,
        "ip_fail_counts": ip_fail_counts,
        "ip_fail_usernames": ip_fail_usernames,
        "total_success": total_success,
        "total_fail": total_fail,
    }


def build_attack_summary(result, caution_threshold, warning_threshold,
                          multi_user_threshold, show_details):
    """
    ip_fail_counts / ip_fail_usernames から「外部からの攻撃」の
    疑いがある送信元IPのサマリー行リストを組み立てる。

    判定は独立した2つの観点で行う(どちらか一方でも該当すれば「攻撃の疑いあり」):
      1. 失敗回数(ブルートフォース攻撃の疑い)
           count >= warning_threshold  → 🚨警告
           count >= caution_threshold   → ⚠️注意
      2. 試行された「実質的に異なるアカウント数」(アカウント総当たり/
         クレデンシャルスタッフィングの疑い)
           distinct_accounts >= multi_user_threshold → 🎯複数アカウント試行
         ※ ここでの distinct_accounts は、区切り文字(- _ . ,)違いの
           タイプミス(例: "toshiaki.yoshida""toshiaki,yoshida")を
           同一アカウントとみなして除外した後の数(count_distinct_accounts
           参照)。単純にタイプミスを2〜3回しただけの正当な利用者を
           誤検知しないようにするための工夫。

    CIDが解決できず送信元IPを特定できなかった失敗は「IP不明」として
    別枠で扱い、攻撃疑いの判定対象からは除外する
    (複数の異なる送信元が混在している可能性があるため)。

    表示は3パターン:
      A. 認証失敗ログが1件もない            → その旨だけ表示
      B. 認証失敗はあるが、該当するIPが0件   → その旨だけ表示
      C. 該当するIPが1件以上ある            → ⚠️注意/🚨警告/🎯複数アカウント試行
                                              それぞれの該当件数を表示
                                              (--details指定時は該当IPの内訳も表示)
    """
    lines = []
    lines.append("## 🛡️ 外部からの攻撃サマリー (Suspicious Source IPs)")

    ip_fail_counts = result["ip_fail_counts"]
    ip_fail_usernames = result["ip_fail_usernames"]

    # --- パターンA: 認証失敗ログが1件もない ---
    if not ip_fail_counts:
        lines.append("該当期間に認証失敗ログはありませんでした。")
        return lines

    known_ip_counts = {ip: c for ip, c in ip_fail_counts.items() if ip != "IP不明"}
    unknown_count = ip_fail_counts.get("IP不明", 0)

    def judge(ip, count):
        distinct_accounts = count_distinct_accounts(ip_fail_usernames.get(ip, set()))
        flags = []
        if count >= warning_threshold:
            flags.append("🚨警告")
        elif count >= caution_threshold:
            flags.append("⚠️注意")
        if distinct_accounts >= multi_user_threshold:
            flags.append("🎯複数アカウント試行")
        return distinct_accounts, flags

    # IPごとに判定結果を先に計算しておく
    judged = {ip: judge(ip, c) for ip, c in known_ip_counts.items()}
    caution_ips = {ip for ip, (_, flags) in judged.items() if "⚠️注意" in flags}
    warning_ips = {ip for ip, (_, flags) in judged.items() if "🚨警告" in flags}
    multi_ips = {ip for ip, (_, flags) in judged.items() if "🎯複数アカウント試行" in flags}
    suspicious_ips = {ip for ip, (_, flags) in judged.items() if flags}

    # --- パターンB: 認証失敗はあるが、該当する(攻撃の疑いがある)IPが0件 ---
    if not suspicious_ips:
        lines.append("該当期間に攻撃の疑いのあるログはありませんでした。")
        if show_details and unknown_count:
            lines.append(
                f"(参考: 送信元IPを特定できなかった失敗が別途 {unknown_count} 件あります)"
            )
        return lines

    # --- パターンC: 該当するIPが1件以上ある ---
    lines.append(f"⚠️注意: 失敗{caution_threshold}回以上 : {len(caution_ips)}")
    lines.append(f"🚨警告: 失敗{warning_threshold}回以上 : {len(warning_ips)}")
    lines.append(f"🎯複数アカウント試行 : {len(multi_ips)}")

    if show_details:
        if unknown_count:
            lines.append(
                f"(参考: 送信元IPを特定できなかった失敗が別途 {unknown_count}"
                f"あります。複数の送信元が混在している可能性があるため、"
                f"上記の集計には含めていません)"
            )
        lines.append("")
        lines.append("```")
        lines.append(f"{'送信元IP':<17} | {'失敗回数':>8} | {'異なるアカウント数':>14} | 判定")
        lines.append("-" * 17 + " | " + "-" * 8 + " | " + "-" * 14 + " | " + "-" * 6)
        for ip in sorted(suspicious_ips, key=lambda x: known_ip_counts[x], reverse=True):
            count = known_ip_counts[ip]
            distinct_accounts, flags = judged[ip]
            flag = " ".join(flags)
            lines.append(f"{ip:<17} | {count:>8} | {distinct_accounts:>14} | {flag}")
        lines.append("```")

    return lines


def build_report(days, result, show_details, virtual_hub,
                  caution_threshold=10, warning_threshold=20, multi_user_threshold=5):
    """集計結果からMarkdown形式のレポート文字列を組み立てる。"""
    lines = []
    lines.append(f"## 📊 認証サマリー (過去 {days} 日間) 📊")
    lines.append(f"### サーバー: {socket.gethostname()} / HUB: {virtual_hub}")
    lines.append("---")
    lines.append(f"✅ 成功合計: **{result['total_success']}** 回")
    lines.append(f"❌ 失敗合計: **{result['total_fail']}** 回")
    lines.append("---")
    lines.append("")

    lines.append("## 👤 ユーザーごとの認証成功回数 (Success Count)")
    if result["user_success"]:
        lines.append("```")
        lines.append(f"{'ユーザー名':<35} | {'成功回数'}")
        lines.append("-" * 37 + " | " + "-" * 8)
        for username, count in sorted(
            result["user_success"].items(), key=lambda x: x[1], reverse=True
        ):
            lines.append(f"{username:<35} | {count:>8}")
        lines.append("```")
    else:
        lines.append("該当期間に認証成功ログは見つかりませんでした。")
    lines.append("")

    lines.append("## ❌ ユーザーごとの認証失敗回数 (Fail Count)")
    if result["user_fail_counts"]:
        lines.append("```")
        lines.append(f"{'ユーザー名':<35} | {'失敗回数'}")
        lines.append("-" * 37 + " | " + "-" * 8)
        for username, count in sorted(
            result["user_fail_counts"].items(), key=lambda x: x[1], reverse=True
        ):
            lines.append(f"{username:<35} | {count:>8}")
        lines.append("```")
    else:
        lines.append("該当期間に認証失敗ログは見つかりませんでした。")
    lines.append("")

    lines.extend(build_attack_summary(
        result, caution_threshold, warning_threshold, multi_user_threshold, show_details
    ))
    lines.append("")

    if show_details:
        lines.append("## ⚠️ ユーザーごとの認証失敗ログ詳細 (Fail Details)")
        if result["user_fail_details"]:
            for username, detail_lines in result["user_fail_details"].items():
                lines.append(f"### ユーザー: {username} (失敗件数: {len(detail_lines)})")
                lines.append("```")
                for dl in detail_lines[:MAX_FAIL_DETAIL_LINES]:
                    lines.append(dl)
                if len(detail_lines) > MAX_FAIL_DETAIL_LINES:
                    lines.append(
                        f"... (以降 {len(detail_lines)} 件中、上位"
                        f"{MAX_FAIL_DETAIL_LINES}件のみ表示)..."
                    )
                lines.append("```")
                lines.append("")
        else:
            lines.append("該当期間に認証失敗ログは見つかりませんでした。")

    lines.append("--- 🏁 集計完了 🏁 ---")
    return "\n".join(lines)


def send_webhook(message, webhook_url):
    """
    レポート文字列を Google Chat の Webhook に POST する。
    webhook_url が未設定(ダミー値のまま)の場合は送信をスキップする。
    """
    if webhook_url in ("your_webhook_url", "", None):
        print(
            "⚠️ Webhookオプションが指定されましたが、送信先URLが"
            "未設定(ダミー値)のままです。通知をスキップします。"
            "\n   → スクリプト冒頭の設定セクションでGCHAT_WEBHOOK_URLを設定するか、"
            "実行時に --webhook <URL> を指定してください。",
            file=sys.stderr,
        )
        return

    payload = json.dumps({"text": message}).encode("utf-8")
    req = urllib.request.Request(
        webhook_url,
        data=payload,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            body = resp.read().decode("utf-8", errors="replace")
            if '"error"' in body:
                print(
                    f"⚠️ Webhook送信に成功しましたが、Google Chat APIからエラー応答が"
                    f"ありました: {body}",
                    file=sys.stderr,
                )
    except urllib.error.URLError as e:
        print(f"🚨 エラー: Webhook送信に失敗しました ({e})", file=sys.stderr)


def main():
    args = parse_args()
    virtual_hub = args.hub if args.hub else VIRTUAL_HUB

    target_files = find_target_files(args.days, args.quiet, virtual_hub)
    result = aggregate(target_files, args.quiet)
    report = build_report(
        args.days, result, args.details, virtual_hub,
        args.caution_threshold, args.warning_threshold, args.multi_user_threshold,
    )

    print()
    print(report)

    if args.webhook is not None:
        webhook_url = args.webhook if args.webhook else GCHAT_WEBHOOK_URL
        send_webhook(report, webhook_url)


if __name__ == "__main__":
    main()

基本の実行例(-d 7

$ ./auth_log_aggregator.py -d 7
--- 🔰 SoftEther VPN 認証ログ集計ツール (過去 7 日間) 🔰 ---
サーバー名: vpn01.example.com
仮想HUB名: Sample-Hub
--- 🔍 集計対象ログファイル: 7 件 / 全92件中 (最初の日: 2026-07-23 〜 最後の日: 2026-07-29) ---
集計中: |████████████████████████████████████████| 100.0% (48213899/48213899 バイト)

## 📊 認証サマリー (過去 7 日間) 📊
### サーバー: vpn01.example.com / HUB: Sample-Hub
---
✅ 成功合計: **342** 回
❌ 失敗合計: **187** 回
---

## 👤 ユーザーごとの認証成功回数 (Success Count)
ユーザー名                               | 成功回数
------------------------------------- | --------
taro.yamada                         |       58
hanako.suzuki                       |       47
ichiro.tanaka                       |       39
misaki.sato                         |       33
kenji.watanabe                      |       28
yui.kobayashi                       |       24

## ❌ ユーザーごとの認証失敗回数 (Fail Count)
ユーザー名                               | 失敗回数
------------------------------------- | --------
admin                               |       62
root                                |       41
test                                |       28
taro.yamada                         |        3
taro,yamada                         |        1

## 🛡️ 外部からの攻撃サマリー (Suspicious Source IPs)
⚠️注意: 失敗10回以上 : 1 件
🚨警告: 失敗20回以上 : 1 件
🎯複数アカウント試行 : 1 件

送信元IP             |     失敗回数 |      異なるアカウント数 | 判定
----------------- | -------- | -------------- | ------
203.0.113.45      |       98 |             12 | 🚨警告 🎯複数アカウント試行
198.51.100.7      |       15 |              2 | ⚠️注意

--- 🏁 集計完了 🏁 ---

攻撃サマリー欄の3パターン(記事で判定ロジックを説明する際に)

認証失敗が0件の場合

## 🛡️ 外部からの攻撃サマリー (Suspicious Source IPs)
該当期間に認証失敗ログはありませんでした。

失敗はあるが、どの基準にも該当しない場合(タイプミス程度)

## 🛡️ 外部からの攻撃サマリー (Suspicious Source IPs)
該当期間に攻撃の疑いのあるログはありませんでした。

該当するIPがある場合(上の基本例と同じパターン)

## 🛡️ 外部からの攻撃サマリー (Suspicious Source IPs)
⚠️注意: 失敗10回以上 : 1 件
🚨警告: 失敗20回以上 : 1 件
🎯複数アカウント試行 : 1 件

Webhook送信時のメッセージ例(-w実行時にターミナルへ出る補足)

$ ./auth_log_aggregator.py -d 7 -q -w
⚠️ Webhookオプションが指定されましたが、送信先URLが未設定(ダミー値)のままです。通知をスキップします。
   → スクリプト冒頭の設定セクションでGCHAT_WEBHOOK_URLを設定するか、実行時に --webhook <URL> を指定してください。

実際にURLを設定していれば、この警告は出ずにそのままGoogle Chatへ投稿されます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?