"""Continuous malicious-IP detection, persistent list, local web dashboard. Design ------ * Every ``interval`` seconds the detector runs a **fixed set of terms queries** over the last ``window`` seconds, so the load on Elasticsearch does not grow with the number of IPs. * Rules were derived from an 8 hour exploration of the F5 access logs (2026-10-17). The four observed patterns: 1. 漏洞探测 -- low volume, hits /actuator, /.env, /wp-login, /druid, /nacos, /swagger, /ISAPI, /console ... (very high signal, few requests) 2. 登录爆破 -- repeated 401/403 on /login, /passport, /captcha endpoints 3. 爬虫/机器人 -- known bot UA (DotBot) + high 404 ratio + many content URIs 4. 自动化客户端 -- one single business API at a steady rate (a broken token, not an attacker) -> downgraded, never bans * Every candidate is persisted in SQLite (``data/ips.db``) so the list survives restarts, and the same process serves a small read-only dashboard on ``http://127.0.0.1:8088``. """ import csv import html import io import ipaddress import re import json import os import sqlite3 import threading import time from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from . import detect from .client import normalize_histogram, normalize_terms from .http import GraylogError from .notify import load_notifier, notifier_from_env from .behavior import BehaviorAnalyzer, notify_fused, RULE_TITLES from .asn import AsnDB # --------------------------------------------------------------------- rules # Queries run against Graylog's query_string. Field names are substituted from # the profile (client_ip / status / uri / ua). Slashes must be escaped: an # unescaped /wp-login* would be parsed as a Lucene regex. PROBE_RULES = ("probe_java", "probe_config", "probe_cms", "probe_iot", "probe_devops", "probe_webshell", "exploit_payload") RULE_DEFS = ( { "name": "probe_java", "title": "Spring/中间件探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/actuator* OR \\/druid* OR \\/nacos* OR \\/swagger* " "OR \\/v2\\/api-docs* OR \\/v3\\/api-docs* OR \\/openapi* OR \\/jmx* " "OR \\/heapdump* OR \\/threaddump* OR \\/console* OR \\/manager\\/html* " "OR \\/solr* OR \\/struts* OR \\/weblogic* OR \\/log4j*)" ) % f["uri"], }, { "name": "probe_config", "title": "配置/凭证探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/.env* OR \\/.git* OR \\/.svn* OR \\/.hg* OR \\/.aws* OR \\/.ssh* " "OR \\/.kube* OR \\/.docker* OR \\/appsettings* OR \\/web.config " "OR \\/WEB-INF* OR \\/META-INF* OR \\/backup* OR \\/dump* " "OR \\/db.sql* OR \\/.DS_Store)" ) % f["uri"], }, { "name": "probe_cms", "title": "CMS 探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/wp-* OR \\/xmlrpc.php OR \\/phpmyadmin* OR \\/pma* " "OR \\/administrator* OR \\/joomla* OR \\/drupal* OR \\/thinkphp* " "OR \\/uc_server* OR \\/dede* OR \\/index.php?s=*)" ) % f["uri"], }, { "name": "probe_iot", "title": "IoT/路由器探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/ISAPI* OR \\/LAPI* OR \\/HNAP1* OR \\/boaform* OR \\/GponForm* " "OR \\/cgi-bin\\/luci* OR \\/goform* OR \\/setup.cgi* OR \\/login.cgi*)" ) % f["uri"], }, { "name": "probe_devops", "title": "DevOps/监控探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/jenkins* OR \\/grafana* OR \\/kibana* OR \\/zabbix* " "OR \\/prometheus* OR \\/consul* OR \\/eureka* OR \\/harbor* " "OR \\/nexus* OR \\/sonarqube* OR \\/gitlab* OR \\/portainer*)" ) % f["uri"], }, { "name": "probe_webshell", "title": "WebShell/上传探测", "weight": 60.0, "saturate": 2.0, "query": lambda f: ( "%s:(\\/shell.php OR \\/shell.asp OR \\/shell.jsp OR \\/cmd.php " "OR \\/cmd.asp OR \\/webshell* OR \\/phpinfo* OR \\/dbadmin* " "OR \\/eval-stdin* OR \\/vendor\\/phpunit*)" ) % f["uri"], }, { # Regex: only over 1 hour max, a 25h regex aggregation kills the ES node. "name": "exploit_payload", "title": "注入/穿越载荷", "weight": 65.0, "saturate": 2.0, "window_cap": 3600, "query": lambda f: ( "%s:/.*(etc\\/passwd|proc\\/self|jndi:|union.*select|information_schema" "|sleep\\(|benchmark\\(|\\.\\.%%2f|%%2e%%2e|%%252f|base64_decode|xp_cmdshell).*/" ) % f["uri"], }, { "name": "login_attack", "title": "登录失败尝试", "weight": 55.0, "saturate": 8.0, "query": lambda f: ( "(%s:(\\/login* OR \\/passport* OR \\/captcha* OR \\/jsxy\\/api\\/login*)) " "AND %s:(401 OR 403)" ) % (f["uri"], f["status"]), }, { "name": "login_burst", "title": "登录接口高频", "weight": 15.0, "saturate": 2000.0, "query": lambda f: ( "%s:(\\/login* OR \\/passport* OR \\/captcha* OR \\/jsxy\\/api\\/login*)" ) % f["uri"], }, { "name": "bad_ua", "title": "扫描器 UA", "weight": 55.0, "saturate": 1.0, "query": lambda f: ( "%s:(sqlmap* OR nikto* OR nuclei* OR nmap* OR masscan* OR zgrab* " "OR wpscan* OR gobuster* OR dirsearch* OR dirbuster* OR ffuf* " "OR feroxbuster* OR wfuzz* OR joomscan* OR droopescan* OR whatweb* " "OR xray* OR vulmap* OR afrog* OR hydra*)" ) % f["ua"], }, { "name": "bot_ua", "title": "爬虫 UA", "weight": 50.0, "saturate": 20.0, "query": lambda f: ( '%s:"Mozilla/5.0 (compatible; DotBot/1.2; +https://opensiteexplorer.org/dotbot; help@moz.com)"' ) % f["ua"], }, { "name": "not_found", "title": "404", "weight": 0.0, # scored via the 404 ratio, not the raw count "saturate": 1.0, "query": lambda f: "%s:404" % f["status"], }, ) DEFAULT_FIELDS = { "client_ip": "client_ip", "status": "status", "uri": "uri", "ua": "ua", "method": "method", "host": "host", "server_ip": "server_ip", "bigip": "bigip", } AUTH_PATH_HINTS = ("login", "signin", "auth", "token", "captcha", "oauth", "password") # How far back to look when dating the first attack of an IP. BACKFILL_WINDOW = 129600 # ~36h, as far back as the index retention allows TIER_MALICIOUS = "malicious" TIER_SUSPICIOUS = "suspicious" TIER_AUTOMATION = "automation" TIER_OBSERVE = "observe" TIER_LABELS = { TIER_MALICIOUS: "恶意", TIER_SUSPICIOUS: "可疑", TIER_AUTOMATION: "自动化", TIER_OBSERVE: "观察", } # ------------------------------------------------------------------ classify RATE_RULES = ("login_attack", "login_burst") def classify(total, hits, window_scale=1.0): """Pure scoring function: request counts per rule -> (tier, score, reasons). ``window_scale`` normalises rate-like rules to a 15 minute baseline, so an 8 hour analysis does not turn a student who mistyped a password six times into a "suspicious IP". Behavioural rules (probing / bad UA / crawler) are never scaled: any probing is bad regardless of window length. """ scale = max(1.0, float(window_scale)) score = 0.0 reasons = [] def add(rule, title, count): nonlocal score if count <= 0: return saturate = rule["saturate"] if rule["name"] in RATE_RULES: saturate *= scale points = rule["weight"] * min(1.0, count / saturate) score += points reasons.append("%s %d 次" % (title, count)) for rule in RULE_DEFS: if rule["weight"] <= 0: continue add(rule, rule["title"], hits.get(rule["name"], 0)) not_found = hits.get("not_found", 0) if not_found and total: ratio = not_found / float(total) if ratio >= 0.5 and not_found >= 20: score += 25.0 * min(1.0, ratio / 0.8) reasons.append("404 占比 %.0f%%(%d/%d)" % (ratio * 100, not_found, total)) score = min(100.0, round(score, 1)) if score >= 60: tier = TIER_MALICIOUS elif score >= 30: tier = TIER_SUSPICIOUS elif score > 0: tier = TIER_OBSERVE else: tier = TIER_OBSERVE return tier, score, reasons def is_public(text): try: return not detect.is_internal(detect.as_ip(text)) except (TypeError, ValueError): return False # --------------------------------------------------------------------- store class Store: def __init__(self, path): directory = os.path.dirname(os.path.abspath(path)) if directory: os.makedirs(directory, exist_ok=True) self.path = path self.lock = threading.Lock() self.conn = sqlite3.connect(path, check_same_thread=False) try: self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA busy_timeout=5000") except sqlite3.Error: pass self.conn.row_factory = sqlite3.Row self._init() def _init(self): with self.conn: self.conn.execute( """CREATE TABLE IF NOT EXISTS ip_stats ( ip TEXT PRIMARY KEY, tier TEXT NOT NULL, score REAL NOT NULL, total INTEGER NOT NULL DEFAULT 0, rules TEXT NOT NULL DEFAULT '{}', reasons TEXT NOT NULL DEFAULT '[]', first_seen TEXT, last_seen TEXT, risk TEXT, window INTEGER, evidence TEXT, evidence_at TEXT, ever_malicious INTEGER NOT NULL DEFAULT 0, last_malicious_at TEXT, hits INTEGER NOT NULL DEFAULT 1, updated_at TEXT )""" ) for column in ("risk TEXT", "window INTEGER", "evidence TEXT", "evidence_at TEXT", "ever_malicious INTEGER DEFAULT 0", "last_malicious_at TEXT", "country_code TEXT", "country_name TEXT", "region TEXT", "city TEXT", "asn TEXT", "asn_org TEXT", "asn_brand TEXT", "asn_type TEXT", "asn_icon TEXT"): try: self.conn.execute("ALTER TABLE ip_stats ADD COLUMN %s" % column) except sqlite3.OperationalError: pass # column already exists try: self.conn.execute( """UPDATE ip_stats SET ever_malicious = 1, last_malicious_at = COALESCE( last_malicious_at, (SELECT MAX(e.ts) FROM events e WHERE e.ip = ip_stats.ip AND e.tier = 'malicious'), last_seen) WHERE COALESCE(ever_malicious, 0) = 0 AND ( tier = 'malicious' OR EXISTS ( SELECT 1 FROM events e WHERE e.ip = ip_stats.ip AND (e.tier = 'malicious' OR e.detail LIKE '%malicious%')))""" ) except sqlite3.OperationalError: pass # events table may not exist yet on a fresh db self.conn.execute( """CREATE TABLE IF NOT EXISTS behavior_profiles ( ip TEXT PRIMARY KEY, score REAL, verdict TEXT, signals TEXT, reason_text TEXT, share_note TEXT, behavior_type TEXT, asn TEXT, asn_org TEXT, shared_type TEXT, asn_brand TEXT, asn_type TEXT, asn_icon TEXT, total INTEGER, pct4 REAL, uri_card INTEGER, uri_templates INTEGER, ua_card INTEGER, host_card INTEGER, coverage REAL, peak_share REAL, top_uri TEXT, top_ua TEXT, last_ts TEXT, updated_at TEXT, country_code TEXT, country_name TEXT, region TEXT, city TEXT )""" ) for column in ("asn_brand TEXT", "asn_type TEXT", "asn_icon TEXT", "historical INTEGER DEFAULT 0"): try: self.conn.execute( "ALTER TABLE behavior_profiles ADD COLUMN %s" % column) except sqlite3.OperationalError: pass self.conn.execute( """CREATE TABLE IF NOT EXISTS ip_fused ( ip TEXT PRIMARY KEY, sources TEXT NOT NULL, feature_score REAL, behavior_score REAL, first_banned_at TEXT, last_activity_at TEXT, last_pushed_at TEXT, push_count INTEGER NOT NULL DEFAULT 0, fused_reason TEXT, updated_at TEXT )""" ) self.conn.execute( """CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, ip TEXT, tier TEXT, score REAL, detail TEXT )""" ) def upsert(self, record, now): with self.lock, self.conn: row = self.conn.execute( "SELECT tier, score, ever_malicious, last_malicious_at, " "last_seen, rules, total FROM ip_stats WHERE ip = ?", (record["ip"],), ).fetchone() malicious = record["tier"] == TIER_MALICIOUS geo = record.get("geo") or {} info = record.get("asn") or {} if row is None: last_seen = record.get("last_seen") or now last_malicious_at = record.get("last_malicious_at") or ( last_seen if malicious else None) self.conn.execute( """INSERT INTO ip_stats (ip, tier, score, total, rules, reasons, first_seen, last_seen, risk, window, hits, updated_at, ever_malicious, last_malicious_at, country_code, country_name, region, city, asn, asn_org, asn_brand, asn_type, asn_icon) VALUES (?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?)""", (record["ip"], record["tier"], record["score"], record["total"], json.dumps(record["rules"], ensure_ascii=False), json.dumps(record["reasons"], ensure_ascii=False), record.get("first_seen") or now, last_seen, record.get("risk"), record.get("window"), now, 1 if malicious else 0, last_malicious_at, geo.get("country_code"), geo.get("country_name"), geo.get("region"), geo.get("city"), info.get("asn"), info.get("org"), info.get("brand"), info.get("type"), info.get("icon")), ) self._event(now, record, "new") return "new" else: ever = 1 if (malicious or row["ever_malicious"]) else 0 # Timestamps only move when the detector verified new evidence, # otherwise a stale window would keep refreshing them to "now". last_seen = record.get("last_seen") or row["last_seen"] or now if record.get("last_malicious_at"): last_malicious_at = record["last_malicious_at"] elif malicious and row["tier"] != TIER_MALICIOUS: last_malicious_at = last_seen else: last_malicious_at = row["last_malicious_at"] self.conn.execute( """UPDATE ip_stats SET tier=?, score=?, total=?, rules=?, reasons=?, last_seen=?, risk=?, window=?, hits=hits+1, updated_at=?, ever_malicious=?, last_malicious_at=?, country_code=COALESCE(?, country_code), country_name=COALESCE(?, country_name), region=COALESCE(?, region), city=COALESCE(?, city), asn=COALESCE(?, asn), asn_org=COALESCE(?, asn_org), asn_brand=COALESCE(?, asn_brand), asn_type=COALESCE(?, asn_type), asn_icon=COALESCE(?, asn_icon) WHERE ip=?""", (record["tier"], record["score"], record["total"], json.dumps(record["rules"], ensure_ascii=False), json.dumps(record["reasons"], ensure_ascii=False), last_seen, record.get("risk"), record.get("window"), now, ever, last_malicious_at, geo.get("country_code"), geo.get("country_name"), geo.get("region"), geo.get("city"), info.get("asn"), info.get("org"), info.get("brand"), info.get("type"), info.get("icon"), record["ip"]), ) if row["tier"] != record["tier"]: self._event(now, record, "%s->%s" % (row["tier"], record["tier"])) if malicious and row["tier"] != TIER_MALICIOUS: return "upgraded" return "unchanged" if row["tier"] == record["tier"] else "changed" def _event(self, now, record, detail): self.conn.execute( "INSERT INTO events (ts, ip, tier, score, detail) VALUES (?,?,?,?,?)", (now, record["ip"], record["tier"], record["score"], detail), ) SORT_COLUMNS = {"first_seen": "first_seen", "last_malicious": "last_malicious_at", "score": "score", "total": "total"} def list(self, limit=500, tier=None, scope=None, sort="first_seen", order="desc"): query = ("SELECT ip, tier, score, total, rules, reasons, first_seen, " "last_seen, risk, window, hits, updated_at, evidence_at, " "ever_malicious, last_malicious_at, country_code, country_name, " "region, city, asn, asn_org, asn_brand, asn_type, asn_icon " "FROM ip_stats") args = [] where = [] if tier: where.append("tier = ?") args.append(tier) if scope == "core": where.append("ever_malicious = 1") if where: query += " WHERE " + " AND ".join(where) column = self.SORT_COLUMNS.get(sort, "first_seen") direction = "DESC" if str(order).lower() == "desc" else "ASC" query += (" ORDER BY (%s IS NULL) ASC, %s %s, " "COALESCE(last_malicious_at, last_seen) DESC, ip ASC LIMIT ?" % (column, column, direction)) args.append(limit) with self.lock: rows = self.conn.execute(query, args).fetchall() result = [] for row in rows: item = dict(row) item["rules"] = json.loads(item["rules"] or "{}") item["reasons"] = json.loads(item["reasons"] or "[]") result.append(item) return result def get(self, ip): with self.lock: row = self.conn.execute("SELECT * FROM ip_stats WHERE ip = ?", (ip,)).fetchone() if row is None: return None item = dict(row) item["rules"] = json.loads(item["rules"] or "{}") item["reasons"] = json.loads(item["reasons"] or "[]") return item # ---------------------------------------------------------- behaviour def upsert_historical_profiles(self, items): """Insert backfilled behaviour profiles; live rows win on conflict.""" stamp = now_iso() with self.lock, self.conn: for item in items: self.conn.execute( """INSERT OR IGNORE INTO behavior_profiles (ip, score, verdict, signals, reason_text, share_note, behavior_type, asn, asn_org, shared_type, asn_brand, asn_type, asn_icon, total, pct4, uri_card, uri_templates, ua_card, host_card, coverage, peak_share, top_uri, top_ua, last_ts, updated_at, country_code, country_name, region, city, historical) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)""", (item["ip"], item.get("score"), item.get("verdict"), json.dumps(item.get("signals") or [], ensure_ascii=False), item.get("reason_text"), item.get("share_note"), item.get("behavior_type"), item.get("asn"), item.get("asn_org"), item.get("shared_type"), item.get("asn_brand"), item.get("asn_type"), item.get("asn_icon"), int(item.get("total") or 0), item.get("pct4"), item.get("uri_card"), item.get("uri_templates"), item.get("ua_card"), item.get("host_card"), item.get("coverage"), item.get("peak_share"), item.get("top_uri"), item.get("top_ua"), item.get("last_ts") or stamp, stamp, item.get("country_code"), item.get("country_name"), item.get("region"), item.get("city"))) def replace_behavior_profiles(self, items): stamp = now_iso() with self.lock, self.conn: self.conn.execute( "DELETE FROM behavior_profiles WHERE COALESCE(historical,0)=0") for item in items: self.conn.execute( """INSERT OR REPLACE INTO behavior_profiles (ip, score, verdict, signals, reason_text, share_note, behavior_type, asn, asn_org, shared_type, asn_brand, asn_type, asn_icon, total, pct4, uri_card, uri_templates, ua_card, host_card, coverage, peak_share, top_uri, top_ua, last_ts, updated_at, country_code, country_name, region, city, historical) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)""", (item["ip"], item.get("score"), item.get("verdict"), json.dumps(item.get("signals") or [], ensure_ascii=False), item.get("reason_text"), item.get("share_note"), item.get("behavior_type"), item.get("asn"), item.get("asn_org"), item.get("shared_type"), item.get("asn_brand"), item.get("asn_type"), item.get("asn_icon"), int(item.get("total") or 0), item.get("pct4"), int(item.get("uri_card") or 0), int(item.get("uri_templates") or 0), int(item.get("ua_card") or 0), int(item.get("host_card") or 0), item.get("coverage"), item.get("peak_share"), item.get("top_uri"), item.get("top_ua"), stamp, stamp, item.get("country_code"), item.get("country_name"), item.get("region"), item.get("city")), ) def behavior_profile(self, ip): with self.lock: row = self.conn.execute( "SELECT * FROM behavior_profiles WHERE ip = ?", (ip,)).fetchone() if row is None: return None item = dict(row) try: item["signals"] = json.loads(item.get("signals") or "[]") except ValueError: item["signals"] = [] return item def fused_row(self, ip): with self.lock: row = self.conn.execute( "SELECT * FROM ip_fused WHERE ip = ?", (ip,)).fetchone() return dict(row) if row else None def fused_items(self, limit=500): with self.lock: rows = self.conn.execute( """SELECT f.*, s.tier AS tier, s.total AS total, s.rules AS s_rules, b.total AS b_total, s.asn AS s_asn, s.asn_org AS s_asn_org, s.asn_brand AS s_asn_brand, s.asn_type AS s_asn_type, s.asn_icon AS s_asn_icon, b.asn AS b_asn, b.asn_org AS b_asn_org, b.asn_brand AS b_asn_brand, b.asn_type AS b_asn_type, b.asn_icon AS b_asn_icon, s.country_code AS s_cc, s.country_name AS s_cn, s.region AS s_region, s.city AS s_city, b.country_code AS b_cc, b.country_name AS b_cn, b.region AS b_region, b.city AS b_city FROM ip_fused f LEFT JOIN ip_stats s ON s.ip = f.ip LEFT JOIN behavior_profiles b ON b.ip = f.ip ORDER BY f.first_banned_at DESC, f.ip ASC LIMIT ?""", (int(limit),)).fetchall() items = [] for row in rows: item = dict(row) item["country_code"] = item.get("s_cc") or item.get("b_cc") or "" item["country_name"] = item.get("s_cn") or item.get("b_cn") or "" item["region"] = item.get("s_region") or item.get("b_region") or "" item["city"] = item.get("s_city") or item.get("b_city") or "" item["asn"] = item.get("s_asn") or item.get("b_asn") or "" item["asn_org"] = item.get("s_asn_org") or item.get("b_asn_org") or "" item["asn_brand"] = item.get("s_asn_brand") or item.get("b_asn_brand") or "" item["asn_type"] = item.get("s_asn_type") or item.get("b_asn_type") or "" item["asn_icon"] = item.get("s_asn_icon") or item.get("b_asn_icon") or "" item["total"] = item.get("total") or item.get("b_total") or 0 items.append(item) return items def _fused_reason(self, feature_rules, behavior_reason): parts = [] if feature_rules: try: rules = json.loads(feature_rules or "{}") except ValueError: rules = {} if rules: ranked = sorted(rules.items(), key=lambda kv: -kv[1])[:3] parts.append("特征命中:" + "、".join( "%s %d 次" % (RULE_TITLES.get(name, name), count) for name, count in ranked)) if behavior_reason: parts.append("行为表现:" + behavior_reason) return ";".join(parts) def sync_fused(self, now=None): """Merge feature + behaviour verdicts into the fused block list. Rule: feature-confirmed IPs always stay (option A, historical list); behaviour adds IPs only when its verdict is "建议封禁" (shared/NAT networks never enter the block list). first_banned_at is pinned so an IP only reaches the top of the list once. """ now = now or now_iso() with self.lock, self.conn: feature_rows = self.conn.execute( "SELECT ip, score, rules, first_seen, last_seen, " "last_malicious_at, country_code, country_name, region, city " "FROM ip_stats WHERE ever_malicious = 1").fetchall() behavior_rows = self.conn.execute( "SELECT * FROM behavior_profiles").fetchall() existing = {row["ip"]: row for row in self.conn.execute( "SELECT * FROM ip_fused").fetchall()} initial = not existing behavior = {row["ip"]: row for row in behavior_rows} wanted = {} def iso(value): return value or now for row in feature_rows: ip = row["ip"] beh = behavior.get(ip) beh_ban = bool(beh and beh["verdict"] == "建议封禁") prev = existing.get(ip) first = (prev["first_banned_at"] if prev else iso(row["first_seen"] or row["last_malicious_at"])) activity = max(filter(None, [row["last_seen"], beh["last_ts"] if beh else None]), default=now) wanted[ip] = { "sources": "both" if beh_ban else "feature", "feature_score": row["score"], "behavior_score": beh["score"] if beh else None, "first_banned_at": first, "last_activity_at": iso(activity), "fused_reason": self._fused_reason( row["rules"], beh["reason_text"] if beh else None), } for ip, beh in behavior.items(): if beh["verdict"] != "建议封禁" or ip in wanted: continue prev = existing.get(ip) historical = bool(beh["historical"]) if "historical" in beh.keys() else False fallback = (beh["last_ts"] if historical and beh["last_ts"] else now) wanted[ip] = { "sources": "behavior", "feature_score": None, "behavior_score": beh["score"], "first_banned_at": prev["first_banned_at"] if prev else fallback, "last_activity_at": iso(beh["last_ts"]), "fused_reason": "行为表现:" + (beh["reason_text"] or ""), } for ip, data in wanted.items(): prev = existing.get(ip) if prev: self.conn.execute( """UPDATE ip_fused SET sources=?, feature_score=?, behavior_score=?, last_activity_at=?, fused_reason=?, updated_at=? WHERE ip=?""", (data["sources"], data["feature_score"], data["behavior_score"], data["last_activity_at"], data["fused_reason"], now, ip)) else: self.conn.execute( """INSERT INTO ip_fused (ip, sources, feature_score, behavior_score, first_banned_at, last_activity_at, last_pushed_at, push_count, fused_reason, updated_at) VALUES (?,?,?,?,?,?,?,0,?,?)""", (ip, data["sources"], data["feature_score"], data["behavior_score"], data["first_banned_at"], data["last_activity_at"], now if initial else None, data["fused_reason"], now)) stale = [ip for ip in existing if ip not in wanted] for ip in stale: self.conn.execute("DELETE FROM ip_fused WHERE ip = ?", (ip,)) return len(wanted) def due_pushes(self, active_hours=24, cooldown_hours=24): now = time.time() active_cut = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - active_hours * 3600)) push_cut = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - cooldown_hours * 3600)) with self.lock: rows = self.conn.execute( """SELECT f.*, s.country_name AS s_cn, s.region AS s_region, s.total AS s_total, s.asn AS s_asn, s.asn_brand AS s_asn_brand, s.asn_type AS s_asn_type, s.asn_icon AS s_asn_icon, b.country_name AS b_cn, b.region AS b_region, b.total AS b_total, b.asn AS b_asn, b.asn_brand AS b_asn_brand, b.asn_type AS b_asn_type, b.asn_icon AS b_asn_icon FROM ip_fused f LEFT JOIN ip_stats s ON s.ip = f.ip LEFT JOIN behavior_profiles b ON b.ip = f.ip WHERE f.last_activity_at >= ? AND (f.last_pushed_at IS NULL OR f.last_pushed_at <= ?) ORDER BY f.first_banned_at DESC LIMIT 50""", (active_cut, push_cut)).fetchall() items = [] for row in rows: item = dict(row) item["country_name"] = item.get("s_cn") or item.get("b_cn") or "" item["region"] = item.get("s_region") or item.get("b_region") or "" item["total"] = item.get("s_total") or item.get("b_total") or 0 item["asn"] = item.get("s_asn") or item.get("b_asn") or "" item["asn_brand"] = (item.get("s_asn_brand") or item.get("b_asn_brand") or "") item["asn_type"] = (item.get("s_asn_type") or item.get("b_asn_type") or "") item["asn_icon"] = (item.get("s_asn_icon") or item.get("b_asn_icon") or "") items.append(item) return items def mark_pushed(self, ips, stamp): with self.lock, self.conn: for ip in ips: self.conn.execute( "UPDATE ip_fused SET last_pushed_at=?, push_count=push_count+1 " "WHERE ip=?", (stamp, ip)) def stats(self): with self.lock: rows = self.conn.execute( "SELECT tier, COUNT(*) AS n FROM ip_stats GROUP BY tier" ).fetchall() core = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_stats WHERE ever_malicious = 1" ).fetchone()["n"] permanent = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_stats WHERE ever_malicious = 1" ).fetchone()["n"] events = self.conn.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"] updated = self.conn.execute( "SELECT MAX(updated_at) AS ts FROM ip_stats" ).fetchone()["ts"] counts = {row["tier"]: row["n"] for row in rows} with self.lock: fused_total = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_fused").fetchone()["n"] fused_both = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_fused WHERE sources='both'" ).fetchone()["n"] fused_behavior = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_fused WHERE sources='behavior'" ).fetchone()["n"] fused_new = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_fused WHERE first_banned_at >= ?", (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 86400)),) ).fetchone()["n"] pushed = self.conn.execute( "SELECT COUNT(*) AS n FROM ip_fused WHERE last_pushed_at IS NOT NULL" ).fetchone()["n"] return { "total": sum(counts.values()), "core": core, "permanent": permanent, "by_tier": counts, "events": events, "updated_at": updated, "fused_total": fused_total, "fused_new_24h": fused_new, "fused_both": fused_both, "fused_behavior_only": fused_behavior, "fused_pushed": pushed, } def update_first_seen(self, ip, timestamp): with self.lock, self.conn: cursor = self.conn.execute( "UPDATE ip_stats SET first_seen = ? " "WHERE ip = ? AND (first_seen IS NULL OR first_seen > ?)", (timestamp, ip, timestamp), ) return cursor.rowcount def update_asn(self, ip, info): with self.lock, self.conn: self.conn.execute( "UPDATE ip_stats SET asn=?, asn_org=?, asn_brand=?, asn_type=?, " "asn_icon=? WHERE ip=?", (info.get("asn"), info.get("org"), info.get("brand"), info.get("type"), info.get("icon"), ip)) def update_geo(self, ip, geo): with self.lock, self.conn: self.conn.execute( "UPDATE ip_stats SET country_code=?, country_name=?, region=?, " "city=? WHERE ip=?", (geo.get("country_code"), geo.get("country_name"), geo.get("region"), geo.get("city"), ip)) def update_times(self, ip, last_seen, last_malicious_at=None): with self.lock, self.conn: self.conn.execute( "UPDATE ip_stats SET last_seen = ?, " "last_malicious_at = COALESCE(?, last_malicious_at) WHERE ip = ?", (last_seen, last_malicious_at, ip), ) def get_evidence(self, ip): with self.lock: row = self.conn.execute( "SELECT evidence FROM ip_stats WHERE ip = ?", (ip,) ).fetchone() return row["evidence"] if row else None def set_evidence(self, ip, payload, now): with self.lock, self.conn: self.conn.execute( "UPDATE ip_stats SET evidence = ?, evidence_at = ? WHERE ip = ?", (json.dumps(payload, ensure_ascii=False), now, ip), ) def forget(self, older_than_seconds, now_epoch): cutoff = now_epoch - older_than_seconds with self.lock, self.conn: self.conn.execute( "DELETE FROM ip_stats WHERE last_seen < ? " "AND COALESCE(ever_malicious, 0) = 0", (_iso(datetime.fromtimestamp(cutoff, timezone.utc)),), ) def close(self): self.conn.close() def _iso(moment): return moment.strftime("%Y-%m-%dT%H:%M:%SZ") def now_iso(): return _iso(datetime.now(timezone.utc)) # ------------------------------------------------------------------ detector class Detector: def __init__(self, client, store, fields, window=900, size=200, min_requests=3, verify_limit=8, forget=7200, allowlist=None, filter_str=None, min_observe_score=10.0, warm_limit=5, geo_db=None, asn_db=None): self.client = client self.store = store self.fields = fields self.window = window self.size = size self.min_requests = min_requests self.verify_limit = verify_limit self.forget = forget self.allowlist = allowlist self.filter_str = filter_str self.min_observe_score = float(min_observe_score) self.warm_limit = int(warm_limit) self.asn = None if asn_db: try: self.asn = AsnDB(asn_db) except (FileNotFoundError, sqlite3.Error): self.asn = None self.geo = None if geo_db: try: from .geo import GeoIP self.geo = GeoIP(geo_db) except (FileNotFoundError, sqlite3.Error): self.geo = None self.rules = [ {"name": rule["name"], "title": rule["title"], "window_cap": rule.get("window_cap"), "query": rule["query"](fields)} for rule in RULE_DEFS ] self.last_run = None self.last_success = None self.last_error = None self.runs = 0 def _terms(self, query, window_cap=None): window = self.window if window_cap: window = min(window, window_cap) data = self.client.terms( field=self.fields["client_ip"], query=query, size=self.size, range_seconds=window, filter_str=self.filter_str, ) return {row["term"]: int(row["count"] or 0) for row in normalize_terms(data) if row["term"] not in (None, "")} def _batch_totals(self, ips, chunk=40): """One terms query can return totals for many IPs at once.""" field = self.fields["client_ip"] result = {} for start in range(0, len(ips), chunk): part = ips[start:start + chunk] query = "%s:(%s)" % (field, " OR ".join('"%s"' % ip for ip in part)) try: result.update(self._terms(query)) except GraylogError as exc: self.last_error = "batch totals: %s" % exc return result def collect(self): hits = {} for rule in self.rules: try: hits[rule["name"]] = self._terms( rule["query"], window_cap=rule.get("window_cap")) except GraylogError as exc: hits[rule["name"]] = {} self.last_error = "%s: %s" % (rule["name"], exc) # Low-volume scanners never make the "top requests" list, so the # candidate pool must be the union of every rule hit plus the volume # top. Totals for the missing ones are fetched in batches. totals = self._terms("*") candidates = set(totals) for mapping in hits.values(): candidates.update(mapping) missing = [ip for ip in candidates if ip not in totals] if len(missing) > 1200: # keep the load bounded missing = missing[:1200] if missing: totals.update(self._batch_totals(missing)) return totals, hits def _verify(self, records): """Downgrade single-endpoint automation (broken token, not an attacker).""" limit = self.verify_limit for record in records[:limit]: if record["tier"] not in (TIER_MALICIOUS, TIER_SUSPICIOUS): continue try: data = self.client.terms( field=self.fields["uri"], query='%s:"%s"' % (self.fields["client_ip"], record["ip"]), size=8, range_seconds=self.window, filter_str=self.filter_str, ) except GraylogError as exc: record["reasons"].append("核验失败: %s" % exc.message) continue rows = [(row["term"], row["count"]) for row in normalize_terms(data) if row["term"] not in (None, "")] total = sum(count for _, count in rows) if not rows or not total: continue top_uri, top_count = rows[0] ratio = top_count / float(total) login_like = any(hint in str(top_uri).lower() for hint in AUTH_PATH_HINTS) if ratio >= 0.9 and len(rows) <= 2 and not login_like: record["tier"] = TIER_AUTOMATION record["score"] = min(record["score"], 45.0) record["rules"]["automation"] = { "top_uri": top_uri, "ratio": round(ratio, 4)} record["reasons"].append( "流量集中在单一接口 %s(%.0f%%),疑似业务客户端" % (top_uri, ratio * 100)) def first_attack_at(self, ip, window=None): """Earliest message for this IP inside the window (probe-only first).""" if getattr(self, "client", None) is None: return None window = int(window or self.window) base = '%s:"%s"' % (self.fields["client_ip"], ip) probe = _probe_query(self, None, window) queries = [] if probe: queries.append("(%s) AND %s" % (probe, base)) queries.append(base) for query in queries: try: messages = self.client.search_views_messages( query=query, range_seconds=window, limit=1, filter_str=self.filter_str, sort_order="ASC") except GraylogError: continue if messages and messages[0].get("timestamp"): return messages[0]["timestamp"] return None def last_message_at(self, ip, window=None): if getattr(self, "client", None) is None: return None window = int(window or self.window) base = '%s:"%s"' % (self.fields["client_ip"], ip) try: messages = self.client.search_views_messages( query=base, range_seconds=window, limit=1, filter_str=self.filter_str, sort_order="DESC") except GraylogError: return None return messages[0].get("timestamp") if messages else None def last_attack_at(self, ip, window=None): if getattr(self, "client", None) is None: return None window = int(window or self.window) base = '%s:"%s"' % (self.fields["client_ip"], ip) probe = _probe_query(self, None, window) if probe: try: messages = self.client.search_views_messages( query="(%s) AND %s" % (probe, base), range_seconds=window, limit=1, filter_str=self.filter_str, sort_order="DESC") except GraylogError: messages = [] if messages and messages[0].get("timestamp"): return messages[0]["timestamp"] return self.last_message_at(ip, window) def backfill_asn(self, limit=5000): if self.asn is None: return 0 done = 0 for row in self.store.list(limit=limit, scope=None): if row.get("asn"): continue info = self.asn.lookup(row["ip"]) if info: self.store.update_asn(row["ip"], info) done += 1 return done def backfill_geo(self, limit=5000): if self.geo is None: return 0 done = 0 for row in self.store.list(limit=limit, scope=None): if row.get("country_code"): continue geo = self.geo.lookup(row["ip"]) if geo: self.store.update_geo(row["ip"], geo) done += 1 return done def backfill_times(self, limit=500): """Re-derive first-attack / last-attack / last-seen from the logs.""" if getattr(self, "client", None) is None: return 0 rows = self.store.list(limit=20000, scope=None) done = 0 for row in rows: if limit and done >= limit: break window = max(row.get("window") or self.window, BACKFILL_WINDOW) first = self.first_attack_at(row["ip"], window) last = self.last_message_at(row["ip"], window) attack = (self.last_attack_at(row["ip"], window) if row.get("ever_malicious") else None) if first: self.store.update_first_seen(row["ip"], first) if last: self.store.update_times(row["ip"], last, attack) done += 1 return done # backwards-compatible alias backfill_first_seen = backfill_times def warm_snapshots(self, limit=10): """Pre-generate compact evidence for listed malicious IPs.""" if getattr(self, "client", None) is None: return 0 rows = self.store.list(limit=10000, tier=TIER_MALICIOUS) done = 0 for row in rows: if limit and done >= limit: break if row.get("evidence_at"): continue try: evidence = collect_evidence(self, row["ip"], row.get("rules"), row.get("window") or self.window) except Exception: continue if evidence.get("available"): self.store.set_evidence(row["ip"], _compact_evidence(evidence), now_iso()) done += 1 return done def run_once(self): started = time.time() totals, hits = self.collect() records = [] for ip, total in totals.items(): if total < self.min_requests or not is_public(ip): continue if self.allowlist and self.allowlist(ip): continue rule_hits = {name: mapping.get(ip, 0) for name, mapping in hits.items()} tier, score, reasons = classify( total, rule_hits, window_scale=max(1.0, self.window / 900.0)) risk = None if tier == TIER_OBSERVE and (not reasons or score < self.min_observe_score): continue records.append({ "ip": ip, "tier": tier, "score": score, "total": total, "rules": {name: count for name, count in rule_hits.items() if count}, "reasons": reasons, "risk": risk, "window": self.window, }) records.sort(key=lambda row: (-row["score"], -row["total"])) for record in records: if self.geo is not None: record["geo"] = self.geo.lookup(record["ip"]) if self.asn is not None: record["asn"] = self.asn.lookup(record["ip"]) existing = self.store.get(record["ip"]) if existing is None: window = max(self.window, BACKFILL_WINDOW) first = self.first_attack_at(record["ip"], window) last = self.last_message_at(record["ip"], window) if first: record["first_seen"] = first if last: record["last_seen"] = last if record["tier"] == TIER_MALICIOUS: attack = self.last_attack_at(record["ip"], window) if attack: record["last_malicious_at"] = attack elif last: record["last_malicious_at"] = last continue changed = (existing.get("total") != record["total"] or (existing.get("rules") or {}) != record["rules"]) if not changed: # Same evidence as last round: do not move any timestamp. continue last = self.last_message_at(record["ip"], self.window) if last: record["last_seen"] = last if record["tier"] == TIER_MALICIOUS: attack = self.last_attack_at(record["ip"], self.window) if attack: record["last_malicious_at"] = attack elif last: record["last_malicious_at"] = last self._verify(records) stamp = now_iso() # Notifications are sent only by the fused pipeline (notify_fused) so a # new IP produces exactly one message, never one per detection source. for record in records: self.store.upsert(record, stamp) self.store.forget(self.forget, time.time()) try: self.warm_snapshots(int(getattr(self, "warm_limit", 0) or 0)) except Exception: pass self.last_run = stamp self.last_success = time.time() self.runs += 1 return { "window": self.window, "request_seconds": round(time.time() - started, 2), "candidates": len(records), "malicious": sum(1 for row in records if row["tier"] == TIER_MALICIOUS), "suspicious": sum(1 for row in records if row["tier"] == TIER_SUSPICIOUS), "automation": sum(1 for row in records if row["tier"] == TIER_AUTOMATION), } def _csv_text(items): buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow(["ip", "tier", "score", "total", "first_seen", "last_malicious_at", "risk", "reasons", "country_code", "country_name", "region", "city", "asn", "asn_org", "asn_brand", "asn_type"]) for item in items: writer.writerow([ item.get("ip", ""), item.get("tier", ""), item.get("score", ""), item.get("total", ""), item.get("first_seen") or "", item.get("last_malicious_at") or "", item.get("risk") or "", "; ".join(item.get("reasons") or []), item.get("country_code") or "", item.get("country_name") or "", item.get("region") or "", item.get("city") or "", item.get("asn") or "", item.get("asn_org") or "", item.get("asn_brand") or "", item.get("asn_type") or "", ]) # BOM so Excel opens the UTF-8 file without mojibake return "\ufeff" + buffer.getvalue() def export_blocklist(store, path): """Write every IP ever convicted as malicious.""" rows = store.list(limit=20000, scope="core", sort="first_seen", order="asc") chosen = [row["ip"] for row in rows] directory = os.path.dirname(os.path.abspath(path)) if directory: os.makedirs(directory, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write("\n".join(chosen) + ("\n" if chosen else "")) return chosen # --------------------------------------------------------------------- web EVIDENCE_CACHE = {} EVIDENCE_TTL = 600 SAMPLE_KEYS = ("timestamp", "client_ip", "client_port", "server_ip", "server_port", "host", "bigip", "vs", "method", "uri", "query", "status", "ua", "referer", "req_len", "rt", "client_ip_country_code", "client_ip_city_name", "client_ip_geolocation", "event_type", "source", "gl2_remote_ip", "message") HEADER_FIELDS = {"method", "status", "uri", "host", "client_ip", "server_ip", "client_port", "server_port", "vs", "bigip", "event_type", "source", "gl2_remote_ip"} def _clean_text(value, limit=280): """Strip control chars / replacement chars that source logs sometimes carry.""" out = [] for char in str(value): code = ord(char) if char in "\n\r\t": out.append(" ") elif code < 32 or code == 127 or char == "\ufffd": out.append("·") else: out.append(char) text = re.sub(r"·{2,}", "·", "".join(out)) return _clip(text, limit) def _scrub_binary(text): """Header-ish fields must be ASCII; a high non-ASCII ratio means garbage.""" if not text: return text non_ascii = sum(1 for char in text if ord(char) > 127) if non_ascii / max(1, len(text)) > 0.2: return "[非 UTF-8 数据]" return text def _clip(value, limit=280): text = str(value) return text[:limit] + ("…" if len(text) > limit else "") def _sample(message): payload = {} for key in SAMPLE_KEYS: value = message.get(key) if value in (None, "", "N/A"): continue text = _clean_text(value, 4000 if key == "message" else 600) if key in HEADER_FIELDS: text = _scrub_binary(text) payload[key] = text return payload def _compact_evidence(evidence): """Small summary persisted in SQLite: survives Graylog index rotation.""" return { "compact": True, "window": evidence.get("window"), "captured_at": now_iso(), "uris": (evidence.get("uris") or [])[:10], "statuses": (evidence.get("statuses") or [])[:6], "methods": (evidence.get("methods") or [])[:6], "hosts": (evidence.get("hosts") or [])[:6], "server_ips": (evidence.get("server_ips") or [])[:6], "vips": (evidence.get("vips") or [])[:6], "uas": (evidence.get("uas") or [])[:3], "peak": evidence.get("peak"), "interval": evidence.get("interval"), "timeline": (evidence.get("timeline") or [])[-72:], "sample_count": len(evidence.get("samples") or []), } def _probe_query(detector, item_rules=None, window=None): """Sample only the probe families that actually fired for this IP. Combining all seven families (plus a regex) over a 24h window made the Elasticsearch node drop the connection, so keep the sample query narrow and skip rules whose window cap is smaller than the evidence window. """ names = {rule["name"] for rule in detector.rules if rule["name"] in PROBE_RULES} if item_rules: fired = sorted(((name, item_rules.get(name) or 0) for name in names if item_rules.get(name)), key=lambda pair: -pair[1]) if fired: # Only the strongest two families: keeps the sample query cheap for # IPs that fired six or seven rules at once. names = {name for name, _ in fired[:2]} queries = [] window = window or detector.window for rule in detector.rules: if rule["name"] not in names or not rule.get("query"): continue cap = rule.get("window_cap") if cap and window > cap: continue queries.append(rule["query"]) if not queries: return None return " OR ".join("(%s)" % query for query in queries) def collect_evidence(detector, ip, item_rules=None, window=None): """Fetch a handful of typical requests for one IP (never the whole set). All Graylog calls run concurrently and the result is persisted by the caller, so opening the same IP a second time is instant. """ if detector is None or getattr(detector, "client", None) is None: return {"available": False, "reason": "未连接 Graylog,仅展示入库统计"} window = int(window or detector.window) cache_key = (ip, window) cached = EVIDENCE_CACHE.get(cache_key) if cached and time.time() - cached[0] < EVIDENCE_TTL: return cached[1] from concurrent.futures import ThreadPoolExecutor fields = detector.fields client = detector.client query = '%s:"%s"' % (fields["client_ip"], ip) result = {"available": True, "window": window, "samples": [], "uris": [], "uas": [], "statuses": [], "methods": [], "hosts": [], "timeline": [], "rule_titles": {rule["name"]: rule.get("title") or rule["name"] for rule in detector.rules}} def terms_call(field, size): data = client.terms(field=field, query=query, size=size, range_seconds=window, filter_str=detector.filter_str) rows = [{"term": _scrub_binary(_clean_text(row["term"], 240)), "count": row["count"]} for row in normalize_terms(data) if row["term"] not in (None, "")] if field == fields.get("status"): # "200 OK" and "200" are the same code; merge them for the chart. merged = {} for row in rows: match = re.match(r"\s*(\d{3})", str(row["term"])) key = match.group(1) if match else str(row["term"]) merged[key] = merged.get(key, 0) + row["count"] rows = [{"term": key, "count": count} for key, count in sorted(merged.items(), key=lambda pair: (-pair[1], pair[0]))] return rows def samples_call(): """Latest 3 messages per fired rule, so each attack type keeps evidence.""" fired = [] for rule in detector.rules: name = rule["name"] if not item_rules or not int(item_rules.get(name) or 0): continue if not rule.get("query"): continue cap = rule.get("window_cap") rule_window = min(window, int(cap)) if cap else window fired.append((name, rule["query"], rule_window)) if not fired: try: messages = client.search_views_messages( query=query, range_seconds=window, limit=5, filter_str=detector.filter_str) except GraylogError: messages = [] return [(None, messages)] def fetch(job): rule_name, rule_query, rule_window = job try: messages = client.search_views_messages( query="(%s) AND %s" % (rule_query, query), range_seconds=rule_window, limit=3, filter_str=detector.filter_str) except GraylogError: messages = [] return rule_name, messages results = [] with ThreadPoolExecutor(max_workers=6) as pool: for item in pool.map(fetch, fired): results.append(item) return results def timeline_call(): interval = "5m" if window <= 3600 else "1h" history = client.histogram(query=query, range_seconds=window, interval=interval, filter_str=detector.filter_str) return interval, normalize_histogram(history) dimensions = (("uris", "uri", 10), ("uas", "ua", 5), ("statuses", "status", 6), ("methods", "method", 6), ("hosts", "host", 6), ("server_ips", "server_ip", 6), ("vips", "bigip", 6)) with ThreadPoolExecutor(max_workers=8) as pool: futures = {} for name, field_key, size in dimensions: field = fields.get(field_key) if field: futures[name] = pool.submit(terms_call, field, size) futures["samples"] = pool.submit(samples_call) futures["timeline"] = pool.submit(timeline_call) for name, future in futures.items(): try: value = future.result() except GraylogError as exc: result[name + "_error"] = str(exc) continue if name == "samples": merged = [] seen = {} for rule_name, messages in value: for message in messages: # second precision: the same request is often logged # twice (two client ports) within the same second. fingerprint = ((message.get("timestamp") or "")[:19], message.get("uri"), message.get("status"), message.get("method")) if fingerprint in seen: entry = seen[fingerprint] if rule_name and rule_name not in entry["rules"]: entry["rules"].append(rule_name) continue payload = _sample(message) payload["rules"] = [rule_name] if rule_name else [] seen[fingerprint] = payload merged.append(payload) merged.sort(key=lambda entry: entry.get("timestamp") or "", reverse=True) result["samples"] = merged[:24] elif name == "timeline": interval, timeline = value result["timeline"] = timeline result["interval"] = interval if timeline: peak = max(timeline, key=lambda row: row["count"]) result["peak"] = {"timestamp": peak["timestamp"], "count": peak["count"]} result["active_buckets"] = sum(1 for row in timeline if row["count"]) else: result[name] = value result["distinct_uris_shown"] = len(result.get("uris") or []) if not result.get("samples") and not result.get("samples_error"): result["samples_error"] = "该窗口内没有可抽取的样本" if not result.get("uris") and not result.get("samples"): result["available"] = False if not result.get("error") and result.get("uris_error"): result["error"] = result["uris_error"] EVIDENCE_CACHE[cache_key] = (time.time(), result) return result DASHBOARD = r""" 恶意 IP 清单

恶意 IP 清单

特征 + 行为融合检测 · Graylog · F5 访问日志
加载中…
IP 地址位置 / 归属来源评分请求数频率 发现时间最近活动判定理由
""" def make_handler(store, window, interval, detector=None, path_prefix=""): class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def _send(self, code, payload, content_type="application/json; charset=utf-8"): if isinstance(payload, (dict, list)): payload = json.dumps(payload, ensure_ascii=False) raw = payload.encode("utf-8") self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(raw))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(raw) def _send_raw(self, code, text, content_type, filename=None): raw = text.encode("utf-8") self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(raw))) if filename: self.send_header("Content-Disposition", 'attachment; filename="%s"' % filename) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(raw) def do_GET(self): parsed = urlparse(self.path) path = parsed.path query = {key: values[0] for key, values in parse_qs(parsed.query).items()} if path_prefix: if path == path_prefix: self.send_response(302) self.send_header("Location", path_prefix + "/") self.end_headers() return if path.startswith(path_prefix + "/"): path = path[len(path_prefix):] or "/" elif path != "/healthz": return self._send_raw(404, "Not Found", "text/plain; charset=utf-8") if path == "/": return self._send(200, DASHBOARD.replace("__BASE__", path_prefix), "text/html; charset=utf-8") if path == "/healthz": return self._send(200, {"ok": True}) if path == "/api/stats": return self._send(200, store.stats()) if path == "/api/status": status = {"graylog": "unknown", "last_run": None, "last_error": None, "age_seconds": None} if detector is not None: age = (time.time() - detector.last_success if getattr(detector, "last_success", None) else None) status["last_run"] = detector.last_run status["last_error"] = detector.last_error status["age_seconds"] = round(age, 1) if age is not None else None if detector.last_error and (age is None or age > interval * 2): status["graylog"] = "error" elif age is not None and age <= max(120, interval * 2.5): status["graylog"] = "ok" elif age is not None: status["graylog"] = "stale" return self._send(200, status) if path == "/api/ips": limit = min(2000, int(query.get("limit", 500))) fused = store.fused_items(limit) window = detector.window if detector is not None else 3600 items = [] for row in fused: try: rules = json.loads(row.get("s_rules") or "{}") except ValueError: rules = {} items.append({ "ip": row["ip"], "sources": row["sources"], "tier": row.get("tier") or "behavior", "score": max(float(row.get("feature_score") or 0), float(row.get("behavior_score") or 0)), "feature_score": row.get("feature_score"), "behavior_score": row.get("behavior_score"), "total": row.get("total") or 0, "rules": rules, "reasons": [row.get("fused_reason") or ""], "first_seen": row.get("first_banned_at"), "last_seen": row.get("last_activity_at"), "last_malicious_at": row.get("last_activity_at"), "window": window, "country_code": row.get("country_code") or "", "country_name": row.get("country_name") or "", "region": row.get("region") or "", "city": row.get("city") or "", "asn": row.get("asn") or "", "asn_org": row.get("asn_org") or "", "asn_brand": row.get("asn_brand") or "", "asn_type": row.get("asn_type") or "", "asn_icon": row.get("asn_icon") or "", }) return self._send(200, {"count": len(items), "updated_at": store.stats().get("updated_at"), "items": items}) if path in ("/api/export.csv", "/api/export.txt"): scope = query.get("scope", "core") if scope not in ("core", "all"): scope = "core" sort = query.get("sort", "first_seen") if sort not in Store.SORT_COLUMNS: sort = "first_seen" order = query.get("order", "desc") if order not in ("asc", "desc"): order = "desc" items = store.list(limit=20000, scope=None if scope == "all" else scope, sort=sort, order=order) if path.endswith(".csv"): body = _csv_text(items) ctype = "text/csv; charset=utf-8" filename = "malicious-ips.csv" else: body = "\n".join(item["ip"] for item in items) + ("\n" if items else "") ctype = "text/plain; charset=utf-8" filename = "malicious-ips.txt" return self._send_raw(200, body, ctype, filename) if path.startswith("/api/ip/"): ip = path[len("/api/ip/"):] item = store.get(ip) fused = store.fused_row(ip) behavior = store.behavior_profile(ip) if item is None and fused is None: return self._send(404, {"error": "not found", "ip": ip}) if item is None: item = { "ip": ip, "tier": "behavior", "score": (behavior or {}).get("score") or 0, "total": (behavior or {}).get("total") or 0, "rules": {}, "reasons": [(fused or {}).get("fused_reason") or (behavior or {}).get("reason_text") or ""], "first_seen": (fused or {}).get("first_banned_at"), "last_seen": (fused or {}).get("last_activity_at"), "last_malicious_at": (fused or {}).get("last_activity_at"), "window": 86400, "country_code": (behavior or {}).get("country_code", ""), "country_name": (behavior or {}).get("country_name", ""), "region": (behavior or {}).get("region", ""), "city": (behavior or {}).get("city", ""), "asn": (behavior or {}).get("asn", ""), "asn_org": (behavior or {}).get("asn_org", ""), "asn_brand": (behavior or {}).get("asn_brand", ""), "asn_type": (behavior or {}).get("asn_type", ""), "asn_icon": (behavior or {}).get("asn_icon", ""), } item["sources"] = (fused or {}).get("sources") or "feature" item["behavior"] = behavior item["fused"] = fused if item.get("window") is None and detector is not None: item["window"] = detector.window # Evidence is fetched live from Graylog on click (memory # cached) so the database stays small; a compact snapshot is # kept as a fallback once the index has rotated away. stored_raw = item.pop("evidence", None) try: evidence = collect_evidence( detector, ip, item.get("rules"), item.get("window") or (detector.window if detector else None)) except Exception as exc: # never kill the connection evidence = {"available": False, "error": str(exc)} live_ok = bool(evidence.get("available") and (evidence.get("uris") or evidence.get("samples"))) if live_ok: try: stamp = now_iso() store.set_evidence(ip, _compact_evidence(evidence), stamp) item["evidence_at"] = stamp except Exception: pass evidence["stale"] = False else: snapshot = None if stored_raw: try: snapshot = json.loads(stored_raw) except ValueError: snapshot = None if snapshot: snapshot["available"] = True snapshot["stale"] = True snapshot["samples"] = [] snapshot["reason"] = "Graylog 中该时段日志已过期,以下为判定时的快照" snapshot.setdefault("window", item.get("window")) evidence = snapshot return self._send(200, {"item": item, "evidence": evidence}) return self._send(404, {"error": "not found", "path": path}) return Handler # --------------------------------------------------------------------- entry def run(ctx, args): fields = dict(DEFAULT_FIELDS) profile_path = getattr(args, "profile", None) if profile_path: with open(profile_path, "r", encoding="utf-8") as handle: profile = json.load(handle) for key, value in (profile.get("fields") or {}).items(): if value and key in fields: fields[key] = value for attribute, key in (("field", "client_ip"), ("status_field", "status"), ("uri_field", "uri"), ("ua_field", "ua")): value = getattr(args, attribute, None) if value: fields[key] = value allow_entries = [] for path in (getattr(args, "allow", None) or []): with open(path, "r", encoding="utf-8") as handle: text = handle.read() for line in text.splitlines(): line = line.split("#", 1)[0].strip() if not line: continue try: allow_entries.append(ipaddress.ip_network(line, strict=False)) except ValueError: raise SystemExit("白名单 %s 不是合法 IP/CIDR: %r" % (path, line)) def allowlisted(ip): try: address = ipaddress.ip_address(ip) except ValueError: return False return any(address.version == net.version and address in net for net in allow_entries) notifier = None notify_path = getattr(args, "notify_config", "") or "" if notify_path and os.path.exists(notify_path): try: notifier = load_notifier(notify_path) print("推送已启用: %s" % notifier.kind, flush=True) except Exception as exc: print("推送配置无效: %s" % exc, flush=True) if notifier is None: try: notifier = notifier_from_env() if notifier is not None: print("推送已启用(环境变量): %s" % notifier.kind, flush=True) except Exception as exc: print("推送环境变量无效: %s" % exc, flush=True) if getattr(args, "notify_test", False): if notifier is None: print("未配置推送:请创建 %s(参考 notify.example.json)" % (notify_path or "notify.json"), flush=True) return 1 try: reply = notifier.send_test() except Exception as exc: print("测试推送失败: %s" % exc, flush=True) return 1 print("测试推送成功: %s" % str(reply)[:300], flush=True) return 0 store = Store(args.db) detector = Detector( ctx.client, store, fields, window=int(args.window), size=int(args.size), min_requests=int(args.min_requests), verify_limit=int(args.verify), forget=int(args.forget), allowlist=allowlisted, min_observe_score=float(args.min_observe_score), warm_limit=int(getattr(args, "warm_limit", 5) or 0), filter_str=("streams:" + ctx.stream_id) if ctx.stream_id else None, geo_db=getattr(args, "geo_db", "") or None, asn_db=getattr(args, "asn_db", "") or None, ) if getattr(detector, "geo", None) is not None: filled = detector.backfill_geo(3000) if filled: print("补充地理信息 %d 个 IP" % filled, flush=True) if getattr(detector, "asn", None) is not None: filled = detector.backfill_asn(3000) if filled: print("补充 ASN 信息 %d 个 IP" % filled, flush=True) # Fused block list: existing feature detections first (historical rows keep # their original discovery time), then the behaviour thread keeps adding. store.sync_fused() behavior_analyzer = None if not getattr(args, "no_behavior", False): behavior_analyzer = BehaviorAnalyzer( ctx.client, store, fields, filter_str=("streams:" + ctx.stream_id) if ctx.stream_id else None, root=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), interval=int(getattr(args, "behavior_interval", 900) or 900), notifier=notifier, ) threading.Thread(target=behavior_analyzer.run_forever, daemon=True).start() print("行为检测线程已启动(每 %d 秒一次)" % behavior_analyzer.interval, flush=True) def report(result): line = ("[%s] 窗口 %ds: %d 个候选(恶意 %d / 可疑 %d / 自动化 %d),耗时 %.1fs" % (now_iso(), result["window"], result["candidates"], result["malicious"], result["suspicious"], result["automation"], result["request_seconds"])) print(line, flush=True) if getattr(args, "fix_first_seen", False): count = detector.backfill_first_seen(int(getattr(args, "warm_limit", 500) or 500)) print("已修正 %d 个 IP 的首次发现时间" % count, flush=True) store.close() return 0 if getattr(args, "warm_only", False): count = detector.warm_snapshots(int(getattr(args, "warm_limit", 50) or 50)) print("已预热 %d 个 IP 的证据快照" % count, flush=True) store.close() return 0 if getattr(args, "once", False): report(detector.run_once()) if getattr(args, "export", None): chosen = export_blocklist(store, args.export) print("已导出 %d 个恶意 IP -> %s" % (len(chosen), args.export)) for row in store.list(limit=int(args.top)): print("%-16s %-11s %5.1f %6d %s" % ( row["ip"], TIER_LABELS.get(row["tier"], row["tier"]), row["score"], row["total"], ";".join(row["reasons"][:3]))) store.close() return 0 prefix = (getattr(args, "path_prefix", "") or "").strip() if prefix: prefix = "/" + prefix.strip("/") server = ThreadingHTTPServer((args.host, int(args.port)), make_handler(store, int(args.window), int(args.interval), detector, prefix)) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() print("面板: http://%s:%s%s/ (Ctrl+C 退出)" % (args.host, args.port, prefix), flush=True) if getattr(args, "serve_only", False): try: thread.join() except KeyboardInterrupt: print("", flush=True) finally: server.shutdown() store.close() return 0 try: while True: try: report(detector.run_once()) store.sync_fused() if notifier is not None: notify_fused(store, notifier) if getattr(args, "export", None): chosen = export_blocklist(store, args.export) print(" 封禁名单: %d 个 -> %s" % (len(chosen), args.export), flush=True) except GraylogError as exc: detector.last_error = str(exc) print("[%s] 检测失败: %s" % (now_iso(), exc), flush=True) except Exception as exc: detector.last_error = str(exc) print("[%s] 检测异常: %s" % (now_iso(), exc), flush=True) time.sleep(max(5, int(args.interval))) except KeyboardInterrupt: print("\n停止。数据库: %s" % os.path.abspath(args.db), flush=True) finally: server.shutdown() store.close() return 0