初始提交:恶意 IP 特征+行为融合检测系统

- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行
- 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN)
- 钉钉融合推送(加签、24h 节流、去重)
- 页面状态灯、CSV 导出、Docker 镜像构建文件
This commit is contained in:
DeepFal committed 2026-10-18 05:03:05 +08:00
commit 2d76ed6672
42 files changed
+10645

No files matched your search

+951
View File
@@ -0,0 +1,951 @@
#!/usr/bin/env python3
"""Behaviour analysis prototype v2 (read-only).
Computes 24h traffic features per client IP from Graylog pivot aggregations,
derives a behaviour profile, combines it with the existing rule score into a
single risk score with an action recommendation, and serves the result on a
separate port. It never writes to the production sqlite database.
"""
import argparse
import csv
import ipaddress
import json
import os
import re
import statistics
import sys
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from graylog_ops.http import HttpClient # noqa: E402
from graylog_ops.client import GraylogClient, normalize_histogram # noqa: E402
from graylog_ops import watch # noqa: E402
STREAM = "streams:6ac83059a6878e26256fcacc"
WINDOW = 86400
DATA_FILE = os.path.join(ROOT, "data", "behavior_prototype.json")
DB_FILE = os.path.join(ROOT, "data", "ips.db")
INTERNAL = ("10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.2", "172.3")
UA_SDK = ("curl", "wget", "python-requests", "python-urllib", "go-http-client",
"okhttp", "java/", "apache-httpclient", "httpcomponents",
"libredtail", "axios", "node-fetch", "aiohttp", "scrapy",
"postmanruntime", "insomnia", "unityplayer", "dart/", "httpie",
"guzzle", "faraday", "restsharp", "libwww", "eeo/", "academy_widget")
UA_BROWSER = ("chrome", "firefox", "safari", "edg/", "opr/", "gecko/20100101")
ASN_CSV = os.path.join(ROOT, "data", "geo", "dbip-asn-lite-2026-10.csv")
_ASN_CACHE = {}
def _load_asn():
if _ASN_CACHE:
return _ASN_CACHE
import bisect
starts, ranges = [], []
try:
with open(ASN_CSV, encoding="utf-8", errors="replace") as fh:
for row in csv.reader(fh):
if len(row) < 4 or ":" in row[0]:
continue
try:
first = int(ipaddress.IPv4Address(row[0]))
last = int(ipaddress.IPv4Address(row[1]))
except ValueError:
continue
starts.append(first)
ranges.append((last, row[2], row[3]))
except FileNotFoundError:
pass
order = sorted(range(len(starts)), key=lambda i: starts[i])
starts = [starts[i] for i in order]
ranges = [ranges[i] for i in order]
_ASN_CACHE.update({"starts": starts, "ranges": ranges, "bisect": bisect})
return _ASN_CACHE
def asn_info(ip):
cache = _load_asn()
starts = cache.get("starts") or []
if not starts:
return None
try:
value = int(ipaddress.IPv4Address(ip))
except ValueError:
return None
index = cache["bisect"].bisect_right(starts, value) - 1
if index >= 0:
last, asn, org = cache["ranges"][index]
if last >= value:
return {"asn": asn, "org": org, "shared": shared_type(org)}
return None
def shared_type(org):
text = (org or "").lower()
cloud = ("alibaba", "aliyun", "tencent", "huawei cloud", "aws", "amazon",
"google", "microsoft", "digitalocean", "vultr", "linode", "hetzner",
"ovh", "wowrack", "idc", "data center", "datacenter", "cloud",
"hosting", "colo")
if any(token in text for token in cloud):
return "hosting"
if "china mobile" in text or "中国移动" in text or "cmnet" in text:
return "mobile"
if ("china unicom" in text or "china telecom" in text
or "chinanet" in text or "联通" in text or "电信" in text
or "broadband" in text):
return "broadband"
if "education" in text or "cernet" in text or "教育" in text:
return "education"
return "unknown"
ORG_CN = (("china mobile", "中国移动"), ("china unicom", "中国联通"),
("china telecom", "中国电信"), ("chinanet", "中国电信"),
("cernet", "中国教育网"), ("education", "教育网"),
("alibaba", "阿里云"), ("aliyun", "阿里云"),
("huawei cloud", "华为云"), ("tencent", "腾讯云"),
("wowrack", "Wowrack 机房"), ("vmiss", "VMISS 机房"),
("idc", "IDC 机房"))
def org_cn(org):
text = (org or "").lower()
for token, name in ORG_CN:
if token in text:
return name
return org or "未知"
SHARE_LABEL = {"mobile": "移动网络", "broadband": "宽带 ISP",
"education": "教育网", "hosting": "云主机/机房",
"unknown": "未知网络"}
def humanize(item):
total = item["total"]
parts = []
if item["uri_card"] >= 20:
parts.append("访问了 %s 个不同路径" % format(item["uri_card"], ","))
if item["four_xx"] >= 20:
parts.append("产生 %s 次失败请求(失败率 %.0f%%)"
% (format(int(item["four_xx"]), ","), item["pct4"]))
elif item["pct4"] >= 20:
parts.append("失败率 %.0f%%" % item["pct4"])
if item["coverage"] <= 30 and item["peak_share"] >= 25:
parts.append("流量集中在很短的时间内")
if item["login_ratio"] >= 20:
parts.append("大量请求集中在登录接口")
if item["host_card"] >= 2:
parts.append("同时访问了 %d 个站点" % item["host_card"])
if item["ua_family"] in ("sdk", "fake-browser", "other"):
parts.append("使用脚本/非浏览器客户端")
behavior = item.get("behavior_type") or "异常自动化"
tail = {
"目录扫描": "符合目录扫描特征",
"短时扫描": "符合短时扫描特征",
"登录爆破": "符合登录爆破特征",
"伪造爬虫 UA": "伪装成搜索引擎爬虫,且请求大量失败",
"采集爬虫": "是采集爬虫",
"异常自动化": "行为高度自动化,不像正常用户",
}.get(behavior, "行为异常")
if parts:
return "24 小时内" + ",".join(parts) + "——" + tail + "。"
return "24 小时内请求 %d 次,%s。" % (total, tail)
def load_client():
cfg = json.load(open(os.path.join(ROOT, "graylog-ops.json")))
return GraylogClient(HttpClient(cfg["url"], cfg["token"], "token"))
def pivot(client, series, row_groups, column_groups=None, query="*",
limit=300, timeout=180000):
body = {"queries": [{
"id": "q1",
"timerange": {"type": "relative", "range": WINDOW},
"query": {"type": "elasticsearch", "query_string": query},
"filter": {"type": "or", "filters": [{"type": "stream",
"id": STREAM.split(":")[-1]}]},
"search_types": [{
"type": "pivot", "id": "st1", "series": series,
"row_groups": [{"type": "values", "field": f, "limit": limit}
for f in row_groups],
"column_groups": column_groups or [], "sort": [], "rollup": True,
}],
}], "parameters": []}
data = client.http.request("POST", "/api/views/search/sync",
params={"timeout": timeout}, body=body)
return data["results"]["q1"]["search_types"]["st1"]
def rows_to_dict(search_type, series_id):
out = {}
for row in search_type.get("rows") or []:
key = row.get("key") or []
if not key:
continue
ip = str(key[0])
for value in row.get("values") or []:
if (value.get("key") or [None])[0] == series_id:
try:
out[ip] = float(value.get("value") or 0)
except (TypeError, ValueError):
out[ip] = 0.0
return out
def hourly_matrix(search_type):
out = {}
for row in search_type.get("rows") or []:
key = row.get("key") or []
if not key:
continue
counts = []
for value in row.get("values") or []:
stamp = (value.get("key") or [None])[0]
if not stamp or "cnt" not in (value.get("key") or []):
continue
try:
counts.append((stamp, float(value.get("value") or 0)))
except (TypeError, ValueError):
pass
out[str(key[0])] = counts
return out
def bucket_stats(counts):
active = [c for c in counts if c > 0]
if not counts:
return {"coverage": 0.0, "cv": None, "peak_share": 0.0}
cv = (statistics.pstdev(active) / statistics.fmean(active)
if len(active) > 2 and statistics.fmean(active) else None)
peak = max(counts) if counts else 0
return {"coverage": len(active) / len(counts) * 100,
"cv": cv,
"peak_share": (peak / sum(counts) * 100) if sum(counts) else 0.0}
def uri_template(uri):
path = str(uri or "").split("?")[0]
path = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "{uuid}", path)
path = re.sub(r"[0-9a-fA-F]{16,}", "{hex}", path)
path = re.sub(r"\d+", "{n}", path)
return path
def ua_family(ua):
text = str(ua or "").strip()
if not text:
return "empty"
lower = text.lower()
if any(token in lower for token in UA_SDK):
return "sdk"
if lower.startswith("mozilla/") and any(t in lower for t in UA_BROWSER):
return "browser"
if lower.startswith("mozilla/5.0"):
return "fake-browser"
return "other"
def ua_garbage(ua_card, top_ua):
"""The F5 UA field carries binary garbage on some VS: detect it."""
if ua_card > 50:
return True
text = str(top_ua or "")
if not text:
return False
printable = sum(1 for char in text if 32 <= ord(char) < 127 or ord(char) > 127)
return printable / max(1, len(text)) < 0.85
def rule_data(client, fields):
rules = {r["name"]: {"title": r["title"], "query": r["query"](fields),
"weight": r["weight"]}
for r in watch.RULE_DEFS}
hits = {}
def run(item):
name, meta = item
try:
st = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query=meta["query"], limit=300)
return name, rows_to_dict(st, "cnt")
except Exception:
return name, {}
with ThreadPoolExecutor(max_workers=6) as pool:
for name, counts in pool.map(run, rules.items()):
for ip, count in counts.items():
if count > 0:
hits.setdefault(ip, {})[name] = int(count)
return rules, hits
def detail_for(client, ip):
q = 'client_ip:"%s"' % ip
detail = {"uri": [], "ua": [], "host": []}
try:
st = pivot(client, [{"type": "count", "id": "cnt"}], ["uri"],
query=q, limit=40)
for row in st.get("rows") or []:
key = row.get("key") or []
value = (row.get("values") or [{}])[0].get("value")
if key:
detail["uri"].append((str(key[0]), int(float(value or 0))))
except Exception:
pass
for label, field, size in (("ua", "ua", 3), ("host", "host", 2)):
try:
st = pivot(client, [{"type": "count", "id": "cnt"}], [field],
query=q, limit=size)
for row in st.get("rows") or []:
key = row.get("key") or []
value = (row.get("values") or [{}])[0].get("value")
if key:
detail[label].append((str(key[0]), int(float(value or 0))))
except Exception:
pass
try:
hist = client.histogram(query=q, range_seconds=WINDOW, interval="5m",
filter_str=STREAM)
detail["buckets5m"] = [b["count"] for b in normalize_histogram(hist)]
except Exception:
detail["buckets5m"] = []
return ip, detail
def gather_details(client, ips):
out = {}
with ThreadPoolExecutor(max_workers=10) as pool:
for ip, detail in pool.map(lambda value: detail_for(client, value), ips):
out[ip] = detail
return out
def rule_score_for(total, hits):
tier, score, reasons = watch.classify(total, hits)
return tier, score, reasons
def behavior_profile(feat):
"""Return (category, adjustment, confidence, notes) from behaviour only."""
notes = []
total = feat["total"]
coverage = feat["coverage"]
cv = feat["cv"]
pct2, pct4 = feat["pct2"], feat["pct4"]
status_ok = feat["status_known"] >= 10
ua_ok = not feat["ua_garbage"]
ua_card = feat["ua_card"]
if not ua_ok:
notes.append("UA 字段异常(%d 种)" % ua_card)
if not status_ok:
notes.append("status 字段缺失(仅 %.0f%%)" % feat["status_known"])
if total < 100:
notes.append("24h 仅 %d 次请求,行为样本不足" % total)
return "insufficient", -10, "低", notes
if coverage >= 80 and cv is not None and cv <= 1.2 and ua_ok and ua_card <= 2:
if status_ok and pct4 >= 50:
notes.append("长期稳定轮询、%.0f%% 失败" % pct4)
return "integration_error", -35, "中", notes
notes.append("覆盖率 %.0f%%、小时CV %.2f、UA %d 个%s"
% (coverage, cv, ua_card,
("、2xx %.0f%%" % pct2) if status_ok else ""))
return "server", -35, "高" if status_ok else "中", notes
if coverage >= 80 and cv is not None and cv > 1.2 and ua_ok and ua_card <= 2:
if (status_ok and pct2 >= 85) or not status_ok:
notes.append("覆盖率 %.0f%%、小时CV %.2f,周期性批量任务" % (coverage, cv))
return "batch_job", -30, "中", notes
if ua_ok and ua_card >= 5 and feat["total"] >= 300:
notes.append("%d 种 UA、请求 %d 次,疑似共享出口/NAT"
% (ua_card, total))
return "shared_nat", -40, "中", notes
if ((feat["uri_templates"] >= 200 and total >= 500 and ua_ok
and ua_card <= 3 and (not status_ok or pct2 >= 80))
or (feat["get_ratio"] >= 85 and feat["uri_templates"] >= 100
and total >= 1000 and ua_ok)):
notes.append("访问 %d 个 URI 模板,UA 稳定%s"
% (feat["uri_templates"],
("、2xx %.0f%%" % pct2) if status_ok else ""))
return "crawler", -15, "中", notes
if status_ok and pct4 >= 50 and total >= 1000 and ua_ok and ua_card <= 3:
notes.append("请求 %d 次、%.0f%% 失败,疑似鉴权/配置异常" % (total, pct4))
return "integration_error", -35, "中", notes
if coverage <= 30 and ua_card >= 3 and (not status_ok or pct4 >= 20):
notes.append("短时活跃、%d 种 UA、%.0f%% 4xx" % (ua_card, pct4))
return "script_candidate", 10, "低", notes
if coverage <= 30 and (not status_ok or pct2 >= 90):
notes.append("短时活跃、成功率 %.0f%%" % pct2)
return "human", -25, "低", notes
notes.append("覆盖率 %.0f%%、CV %s、4xx %.0f%%"
% (coverage, ("%.2f" % cv) if cv is not None else "-", pct4))
return "uncertain", 0, "低", notes
def combine(rule_tier, rule_score, behavior_cat, behavior_adj):
"""Hybrid risk score + action recommendation."""
score = rule_score + behavior_adj
server_like = behavior_cat in ("server", "batch_job", "shared_nat",
"integration_error")
if behavior_cat == "insufficient":
server_like = False
risk = max(0.0, min(100.0, score))
if rule_score >= 60:
action = "人工确认" if server_like else "封禁"
elif risk >= 45:
action = "人工确认"
elif risk >= 25:
action = "观察"
else:
action = "正常"
if server_like and action == "封禁":
action = "人工确认"
return round(risk, 1), action, server_like
def behavior_score(feat):
"""Behaviour-only risk score. Uses no rule hits and no known list."""
total = feat["total"]
if total < 10:
return 0, ["请求过少"]
score = 0
signals = []
pct4 = feat["pct4"]
if pct4 >= 50:
score += 30
signals.append("高失败率 %.0f%%" % pct4)
elif pct4 >= 30:
score += 15
signals.append("失败率 %.0f%%" % pct4)
if feat["uri_card"] >= 20:
score += 20
signals.append("路径分散(%d 个 URI)" % feat["uri_card"])
elif feat["uri_card"] >= 8:
score += 8
if feat["coverage"] <= 30 and feat["peak_share"] >= 25:
score += 20
signals.append("短时爆发(覆盖 %.0f%%、峰值 %.0f%%)"
% (feat["coverage"], feat["peak_share"]))
if feat["login_ratio"] >= 20:
score += 15
signals.append("登录接口集中 %.0f%%" % feat["login_ratio"])
if feat["host_card"] >= 2:
score += 5
signals.append("多站点(%d 个 Host)" % feat["host_card"])
if feat["ua_card"] == 1:
score += 5
if feat["ua_family"] in ("sdk", "fake-browser", "other"):
score += 10
signals.append("自动化 UA(%s)" % feat["ua_family"])
elif feat["ua_family"] == "browser":
score -= 5
if feat.get("four_xx", 0) >= 100 and pct4 >= 20:
score += 10
signals.append("失败请求 %d 次" % feat["four_xx"])
# business/integration client: mostly POST or a few steady endpoints
if feat["get_ratio"] < 30:
score -= 25
signals.append("非 GET 为主(业务接口)")
if pct4 < 15 and feat["pct2"] >= 80:
score -= 20
signals.append("低失败率(采集/浏览)")
if (feat["coverage"] >= 80 and feat["cv"] is not None
and feat["cv"] <= 1.2 and feat["uri_templates"] <= 8):
score -= 20
signals.append("长期稳定单接口")
if feat["uri_templates"] >= 15:
score += 10
signals.append("路径模板分散(%d 个)" % feat["uri_templates"])
if feat["status_known"] < 10:
score -= 10
signals.append("状态字段缺失")
return max(0, min(100, score)), signals
def empty_feat(ip):
return {
"ip": ip, "total": 0, "uri_card": 0, "ua_card": 0, "host_card": 0,
"method_card": 0, "get_ratio": 0.0, "login_ratio": 0.0,
"pct2": 0.0, "pct3": 0.0, "pct4": 0.0, "pct5": 0.0,
"status_known": 0.0, "coverage": 0.0, "cv": None, "peak_share": 0.0,
"coverage5m": 0.0, "cv5m": None, "peak_share5m": 0.0,
"uri_templates": 0, "ua_family": "", "ua_garbage": False,
"top_uri": "", "top_ua": "",
}
def finalize_feat(feat, status_map, get_map, login_map, hourly_map):
ip = feat["ip"]
buckets = [c for _, c in hourly_map.get(ip, [])]
hour = bucket_stats(buckets)
sum_status = sum(status_map[cls].get(ip, 0) for cls in status_map)
total = feat["total"]
feat.update({
"get_ratio": get_map.get(ip, 0) / total * 100 if total else 0.0,
"login_ratio": login_map.get(ip, 0) / total * 100 if total else 0.0,
"pct2": status_map["2*"].get(ip, 0) / max(1, sum_status) * 100,
"pct3": status_map["3*"].get(ip, 0) / max(1, sum_status) * 100,
"pct4": status_map["4*"].get(ip, 0) / max(1, sum_status) * 100,
"pct5": status_map["5*"].get(ip, 0) / max(1, sum_status) * 100,
"status_known": (sum_status / total * 100) if total else 0.0,
"coverage": hour["coverage"],
"cv": hour["cv"],
"peak_share": hour["peak_share"],
"four_xx": status_map["4*"].get(ip, 0),
})
return feat
def analyze():
client = load_client()
profile = json.load(open(os.path.join(ROOT, "profiles", "f5.json")))
fields = profile.get("fields", profile)
known = {}
try:
db = sqlite3.connect(DB_FILE)
db.row_factory = sqlite3.Row
for row in db.execute("select ip, ever_malicious from ip_stats"):
known[row["ip"]] = bool(row["ever_malicious"])
except sqlite3.Error:
pass
print("拉取 24h 全量聚合 ...", flush=True)
st = pivot(client,
[{"type": "count", "id": "cnt"},
{"type": "card", "field": "uri", "id": "uri_card"},
{"type": "card", "field": "ua", "id": "ua_card"},
{"type": "card", "field": "host", "id": "host_card"},
{"type": "card", "field": "method", "id": "method_card"}],
["client_ip"], limit=600)
total = rows_to_dict(st, "cnt")
uri_card = rows_to_dict(st, "uri_card")
ua_card = rows_to_dict(st, "ua_card")
host_card = rows_to_dict(st, "host_card")
method_card = rows_to_dict(st, "method_card")
status = {cls: {} for cls in ("2*", "3*", "4*", "5*")}
for cls in status:
st_cls = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query="status:%s" % cls, limit=600)
status[cls] = rows_to_dict(st_cls, "cnt")
get_count = rows_to_dict(
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query="method:GET", limit=600), "cnt")
LOGIN_QUERY = ("uri:(\\/login* OR \\/passport* OR \\/captcha* "
"OR \\/jsxy\\/api\\/login*)")
login_count = rows_to_dict(
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query=LOGIN_QUERY, limit=600), "cnt")
st_hour = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
column_groups=[{"type": "time", "field": "timestamp",
"interval": "1h"}], limit=600)
hourly = hourly_matrix(st_hour)
# IPs with the most 4xx are the ones a behaviour model should look at;
# they are usually outside the volume top-600.
st_4xx = pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query="status:4*", limit=800)
four_xx = rows_to_dict(st_4xx, "cnt")
base = {}
for ip, count in total.items():
if count <= 0:
continue
feat = empty_feat(ip)
feat["total"] = int(count)
feat["uri_card"] = int(uri_card.get(ip, 0))
feat["ua_card"] = int(ua_card.get(ip, 0))
feat["host_card"] = int(host_card.get(ip, 0))
feat["method_card"] = int(method_card.get(ip, 0))
base[ip] = finalize_feat(feat, status, get_count, login_count, hourly)
extra = sorted(set(four_xx) - set(total), key=lambda ip: -four_xx[ip])
extra = [ip for ip in extra if not ip.startswith(INTERNAL)][:600]
print("补充 %d 个高 4xx IP 的计数 ..." % len(extra), flush=True)
for offset in range(0, len(extra), 80):
chunk = extra[offset:offset + 80]
chunk_query = " OR ".join('client_ip:"%s"' % ip for ip in chunk)
st = pivot(client,
[{"type": "count", "id": "cnt"},
{"type": "card", "field": "uri", "id": "uri_card"},
{"type": "card", "field": "ua", "id": "ua_card"},
{"type": "card", "field": "host", "id": "host_card"},
{"type": "card", "field": "method", "id": "method_card"}],
["client_ip"], query=chunk_query, limit=200)
totals = rows_to_dict(st, "cnt")
uris = rows_to_dict(st, "uri_card")
uas = rows_to_dict(st, "ua_card")
hosts = rows_to_dict(st, "host_card")
methods = rows_to_dict(st, "method_card")
chunk_status = {}
for cls in ("2*", "3*", "4*", "5*"):
chunk_status[cls] = rows_to_dict(
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query='status:%s AND (%s)' % (cls, chunk_query), limit=200),
"cnt")
chunk_get = rows_to_dict(
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query='method:GET AND (%s)' % chunk_query, limit=200), "cnt")
chunk_login = rows_to_dict(
pivot(client, [{"type": "count", "id": "cnt"}], ["client_ip"],
query='%s AND (%s)' % (LOGIN_QUERY, chunk_query), limit=200),
"cnt")
chunk_hour = hourly_matrix(pivot(
client, [{"type": "count", "id": "cnt"}], ["client_ip"],
column_groups=[{"type": "time", "field": "timestamp",
"interval": "1h"}], query=chunk_query, limit=200))
for ip in chunk:
if ip not in totals or totals[ip] <= 0:
continue
for cls in chunk_status:
status[cls].update(chunk_status[cls])
ctotal = totals[ip]
feat = empty_feat(ip)
feat["total"] = int(ctotal)
feat["uri_card"] = int(uris.get(ip, 0))
feat["ua_card"] = int(uas.get(ip, 0))
feat["host_card"] = int(hosts.get(ip, 0))
feat["method_card"] = int(methods.get(ip, 0))
base[ip] = finalize_feat(feat, status, chunk_get, chunk_login, chunk_hour)
get_count.update(chunk_get)
login_count.update(chunk_login)
print("候选 %d 个,预筛细节 ..." % len(base), flush=True)
shortlist = []
for ip, feat in base.items():
if ip.startswith(INTERNAL):
continue
if feat["total"] >= 10 and (feat["pct4"] >= 20 or feat["four_xx"] >= 20
or feat["uri_card"] >= 12):
shortlist.append(ip)
shortlist.sort(key=lambda ip: -base[ip]["four_xx"])
shortlist = shortlist[:260]
details = gather_details(client, shortlist)
geo = None
try:
from graylog_ops.geo import GeoIP
geo = GeoIP(os.path.join(ROOT, "data", "geo", "geo.db"))
except (FileNotFoundError, sqlite3.Error):
geo = None
flagged = []
for ip, feat in base.items():
if ip.startswith(INTERNAL):
continue
detail = details.get(ip) or {}
five = bucket_stats(detail.get("buckets5m") or [])
uris = detail.get("uri") or []
uas = detail.get("ua") or []
feat["coverage5m"] = five["coverage"]
feat["cv5m"] = five["cv"]
feat["peak_share5m"] = five["peak_share"]
feat["uri_templates"] = len({uri_template(uri) for uri, _ in uris})
feat["top_uri"] = uris[0][0] if uris else ""
feat["top_ua"] = uas[0][0] if uas else ""
feat["ua_family"] = ua_family(feat["top_ua"])
feat["ua_garbage"] = ua_garbage(feat["ua_card"], feat["top_ua"])
score, signals = behavior_score(feat)
if score < 60 or len(signals) < 2:
continue
crawler_tokens = ("sogou", "baiduspider", "googlebot", "bingbot",
"yandex", "bytespider", "ahrefs", "semrush", "mj12",
"dotbot", "petalbot")
is_crawler_ua = any(token in feat["top_ua"].lower()
for token in crawler_tokens)
if is_crawler_ua:
behavior_type = ("伪造爬虫 UA" if feat["pct4"] >= 50
else "采集爬虫")
elif feat["login_ratio"] >= 20:
behavior_type = "登录爆破"
elif feat["pct4"] >= 50 and feat["uri_card"] >= 20:
behavior_type = "目录扫描"
elif feat["coverage"] <= 30 and feat["peak_share"] >= 25:
behavior_type = "短时扫描"
elif feat["uri_templates"] >= 100 and feat["pct2"] >= 70:
behavior_type = "内容抓取"
else:
behavior_type = "异常自动化"
geo_info = geo.lookup(ip) if geo else None
feat.update({
"score": score,
"signals": signals,
"behavior_type": behavior_type,
"in_list": known.get(ip, False),
"country_code": (geo_info or {}).get("country_code", ""),
"country_name": (geo_info or {}).get("country_name", ""),
"region": (geo_info or {}).get("region", ""),
"city": (geo_info or {}).get("city", ""),
})
info = asn_info(ip) or {"asn": "", "org": "未知", "shared": "unknown"}
shared = info["shared"]
org = info["org"] or "未知"
multi_device = feat["ua_card"] >= 5 or feat["host_card"] >= 10
if shared in ("mobile", "education"):
verdict, vclass = "仅告警(共享出口)", "v-watch"
share_note = ("出口属于%s(%s),大概率有大量用户共享;"
"整体封禁会误伤,建议只告警或按 UA/路径单独拦截。"
% (org_cn(org), SHARE_LABEL[shared]))
elif shared == "broadband" and multi_device:
verdict, vclass = "仅告警(疑似多用户)", "v-watch"
share_note = ("出口属于%s(宽带 ISP),且该 IP 出现 %d 种 UA / %d 个站点,"
"疑似多用户共享,整体封禁可能误伤。"
% (org_cn(org), feat["ua_card"], feat["host_card"]))
elif shared == "broadband":
if feat["score"] >= 75:
verdict, vclass = "建议封禁", "v-ban"
else:
verdict, vclass = "建议人工确认", "v-review"
share_note = ("出口属于%s(宽带 ISP),但该 IP 只有 %d 种 UA,"
"更像单台设备/家庭网络,封禁影响面小。"
% (org_cn(org), feat["ua_card"]))
elif shared == "hosting":
verdict, vclass = "建议封禁", "v-ban"
share_note = "出口属于%s(云主机/机房),不是家庭或移动网络,封禁影响面小。" % org_cn(org)
else:
verdict, vclass = "建议人工确认", "v-review"
share_note = "出口属于%s,归属不明确,建议人工核实后再决定。" % org_cn(org)
feat.update({
"verdict": verdict, "vclass": vclass,
"reason_text": humanize(feat),
"share_note": share_note,
"asn": info["asn"], "asn_org": org, "shared_type": shared,
})
flagged.append(feat)
flagged.sort(key=lambda item: (-item["score"], -item["total"]))
metrics = {
"flagged": len(flagged),
"new": sum(1 for item in flagged if not item["in_list"]),
"known": sum(1 for item in flagged if item["in_list"]),
"high": sum(1 for item in flagged if item["score"] >= 80),
"ban": sum(1 for item in flagged if item["verdict"] == "建议封禁"),
"watch": sum(1 for item in flagged if item["verdict"].startswith("仅告警")),
"review": sum(1 for item in flagged if item["verdict"] == "建议人工确认"),
"candidates": len(base),
}
payload = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"window_seconds": WINDOW,
"items": flagged,
"metrics": metrics,
}
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
with open(DATA_FILE, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False)
if geo is not None:
geo.close()
return payload
def serve(port):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, code, body, ctype="application/json; charset=utf-8"):
raw = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
if self.path.startswith("/data.json"):
try:
with open(DATA_FILE, encoding="utf-8") as fh:
return self._send(200, fh.read())
except FileNotFoundError:
return self._send(404, '{"error":"no data"}')
return self._send(200, PAGE, "text/html; charset=utf-8")
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print("行为分析原型: http://0.0.0.0:%d/ (Ctrl+C 退出)" % port, flush=True)
server.serve_forever()
PAGE = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>行为检测 · 独立判定</title>
<style>
:root{--bg:#F6F4EE;--surface:#FFF;--surface-2:#FBFAF6;--ink:#242119;--ink-2:#4B463B;
--muted:#8B8577;--line:#E8E3D9;--accent:#C15F3C;--accent-soft:#F4E6DE;--green:#2E6B4F;
--green-soft:#E3EEE7;--amber:#8F6418;--amber-soft:#F6EBD2;--red:#A63A28;--red-soft:#F6E1DB;
--blue:#34557F;--blue-soft:#E6ECF5;--radius:14px;--shadow:0 1px 2px rgba(36,33,25,.05),0 10px 30px rgba(36,33,25,.045)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
header{padding:18px 28px;border-bottom:1px solid var(--line);background:rgba(246,244,238,.9);
backdrop-filter:blur(10px);position:sticky;top:0;z-index:20;display:flex;justify-content:space-between;align-items:center}
h1{font-family:Georgia,"Songti SC",serif;font-size:19px;margin:0;font-weight:600}
.sub{color:var(--muted);font-size:12px;margin-top:2px}
main{padding:24px 28px 80px;max-width:1200px;margin:0 auto}
.summary{font-size:15px;line-height:1.9;margin-bottom:16px}
.summary b{color:var(--accent-deep)}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;box-shadow:var(--shadow)}
.card .n{font-family:Georgia,serif;font-size:25px;font-weight:600}
.card .k{color:var(--muted);font-size:12px;margin-top:2px}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
input[type=search]{flex:1;min-width:240px;padding:10px 14px;border:1px solid var(--line);
border-radius:10px;background:var(--surface);font-size:13px;outline:none}
.chips{display:flex;gap:2px;background:#EFEAE0;border:1px solid var(--line);padding:3px;border-radius:10px;flex-wrap:wrap}
.chip{border:0;background:transparent;padding:6px 11px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--ink-2)}
.chip.active{background:var(--surface);color:var(--accent);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.08)}
.item{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
padding:16px 20px;margin-bottom:10px;box-shadow:var(--shadow)}
.line1{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.ip{font-family:ui-monospace,Menlo,monospace;font-size:15px;font-weight:600}
.geo{color:var(--ink-2);font-size:13px}
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:12px;white-space:nowrap;font-weight:600}
.v-ban{background:var(--red-soft);color:var(--red)}
.v-watch{background:var(--blue-soft);color:var(--blue)}
.v-review{background:var(--amber-soft);color:var(--amber)}
.b-new{background:var(--red-soft);color:var(--red);font-weight:500}
.b-known{background:#EFEBE2;color:var(--muted);font-weight:500}
.score{margin-left:auto;color:var(--muted);font-size:12px}
.reason{font-size:14px;color:var(--ink);margin:9px 0 4px;line-height:1.75}
.note{font-size:13px;color:var(--muted);margin-bottom:2px}
details{margin-top:8px}
summary{cursor:pointer;color:var(--accent-deep);font-size:12.5px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
gap:6px 18px;margin-top:8px;font-size:12.5px;color:var(--ink-2)}
.grid b{color:var(--muted);font-weight:500;margin-right:6px}
.grid .wide{grid-column:1/-1;word-break:break-all}
.empty{padding:48px;text-align:center;color:var(--muted)}
.algo{margin-top:22px;background:var(--surface);border:1px solid var(--line);
border-radius:var(--radius);padding:14px 18px;box-shadow:var(--shadow)}
.algo summary{cursor:pointer;color:var(--accent-deep);font-weight:600;font-size:13px}
.algo ul{margin:10px 0 0;padding-left:18px}
.algo li{font-size:12.5px;color:var(--ink-2);margin:5px 0;line-height:1.6}
@media (max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}}
</style></head><body>
<header><div><h1>行为检测 · 独立判定</h1>
<div class="sub" id="sub">不用规则、不看名单,纯行为特征算出的恶意 IP · 只读</div></div>
<div class="muted" id="meta">加载中…</div></header>
<main>
<div class="summary" id="summary"></div>
<div class="cards" id="cards"></div>
<div class="toolbar">
<input type="search" id="q" placeholder="搜索 IP / 国家 / 原因">
<div class="chips" id="chips"></div>
</div>
<div id="list"></div>
<details class="algo">
<summary>算法说明(纯行为评分,不依赖规则和名单)</summary>
<ul>
<li><b>范围</b>:24h 内全部公网 IP(约 1000+ 个候选),先按失败率/路径分散预筛,再抽样细节。</li>
<li><b>评分信号</b>:高失败率、路径分散、短时爆发、登录接口集中、多站点、自动化 UA、
路径模板分散、失败请求量大;业务接口(非 GET 为主)和长期稳定单接口会降权。</li>
<li><b>判定</b>:行为分 ≥60 且至少 2 个信号 → 行为判定恶意。</li>
<li><b>建议动作</b>:出口是移动/宽带/教育网(共享网络)→ 仅告警,避免误伤;
出口是云主机/机房 → 建议封禁;其余 → 人工确认。</li>
<li><b>名单</b>列只用于事后对比:红色「新发现」= 不在现有规则名单。</li>
</ul>
</details>
</main>
<script>
const state = {flt:'all', q:'', items:[], metrics:null};
const $ = s => document.querySelector(s);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const flagOf = code => (!code || code.length !== 2 || code === 'ZZ') ? ''
: String.fromCodePoint(...[...code.toUpperCase()].map(ch => 0x1F1E6 + ch.charCodeAt(0) - 65));
const fmt = n => n >= 10000 ? (n/10000).toFixed(1)+'万' : (n||0).toLocaleString();
function filtered(){
const q = state.q.trim().toLowerCase();
return state.items.filter(it => {
if (state.flt === 'ban' && it.verdict !== '建议封禁') return false;
if (state.flt === 'watch' && !it.verdict.startsWith('仅告警')) return false;
if (state.flt === 'review' && it.verdict !== '建议人工确认') return false;
if (state.flt === 'new' && it.in_list) return false;
if (!q) return true;
const hay = [it.ip, it.country_name, it.region, it.reason_text,
it.share_note, it.asn_org, ...(it.signals||[])].join(' ').toLowerCase();
return hay.includes(q);
});
}
function render(){
const rows = filtered();
$('#list').innerHTML = rows.map(it => `<div class="item">
<div class="line1">
<span class="ip">${esc(it.ip)}</span>
<span class="geo">${flagOf(it.country_code)} ${esc([it.country_name, it.region].filter(Boolean).join(' · ') || '未知地区')}</span>
<span class="badge ${it.vclass}">${esc(it.verdict)}</span>
${it.in_list ? '<span class="badge b-known">名单内</span>' : '<span class="badge b-new">新发现</span>'}
<span class="score">行为分 ${it.score}</span>
</div>
<div class="reason">${esc(it.reason_text)}</div>
<div class="note">${esc(it.share_note)}</div>
<details><summary>查看技术细节</summary>
<div class="grid">
<div><b>请求量</b>${fmt(it.total)} 次</div>
<div><b>失败率</b>${it.pct4.toFixed(0)}%</div>
<div><b>不同路径</b>${it.uri_card}</div>
<div><b>路径模板</b>${it.uri_templates}</div>
<div><b>活跃时段</b>${it.coverage.toFixed(0)}%</div>
<div><b>峰值占比</b>${it.peak_share.toFixed(0)}%</div>
<div><b>站点数</b>${it.host_card}</div>
<div><b>UA 数</b>${it.ua_card}(${esc(it.ua_family || '-')})</div>
<div><b>ASN</b>${esc(it.asn_org)}</div>
<div class="wide"><b>Top URI</b>${esc(it.top_uri || '-')}</div>
<div class="wide"><b>Top UA</b>${esc(it.top_ua || '-')}</div>
<div class="wide"><b>信号</b>${(it.signals||[]).map(esc).join(';')}</div>
</div>
</details>
</div>`).join('') || '<div class="empty">没有匹配的记录</div>';
$('#meta').textContent = `共 ${state.items.length} 个(显示 ${rows.length})`;
}
function cards(){
const m = state.metrics || {};
const items = [
['判出恶意', m.flagged || 0], ['建议封禁', m.ban || 0],
['仅告警(共享出口)', m.watch || 0], ['建议人工确认', m.review || 0],
['新发现', m.new || 0],
];
$('#cards').innerHTML = items.map(([label, value]) =>
`<div class="card"><div class="n">${value}</div><div class="k">${label}</div></div>`).join('');
$('#summary').innerHTML = `行为分析在 <b>${m.candidates||0}</b> 个候选 IP 中判出 <b>${m.flagged||0}</b> 个恶意,`
+ `其中 <b>${m.new||0}</b> 个不在现有规则名单里;建议封禁 <b>${m.ban||0}</b> 个,`
+ `共享出口仅告警 <b>${m.watch||0}</b> 个,人工确认 <b>${m.review||0}</b> 个。`;
const chips = [['all','全部'],['ban','建议封禁'],['watch','仅告警'],['review','人工确认'],['new','新发现']];
$('#chips').innerHTML = chips.map(([key,label]) =>
`<button class="chip ${state.flt===key?'active':''}" data-flt="${key}">${label}</button>`).join('');
$('#chips').querySelectorAll('.chip').forEach(el => el.addEventListener('click', () => {
state.flt = el.dataset.flt; cards(); render();
}));
}
fetch('/data.json').then(r=>r.json()).then(data => {
state.items = data.items || []; state.metrics = data.metrics || {};
$('#sub').textContent = `不用规则、不看名单,纯行为特征算出的恶意 IP · 生成于 ${data.generated_at} · 只读`;
cards(); render();
}).catch(err => { $('#meta').textContent = '加载失败: ' + err; });
$('#q').addEventListener('input', e => { state.q = e.target.value; render(); });
</script></body></html>
"""
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--analyze", action="store_true")
parser.add_argument("--serve", action="store_true")
parser.add_argument("--port", type=int, default=8090)
args = parser.parse_args()
if args.analyze or not os.path.exists(DATA_FILE):
payload = analyze()
print("分析完成: %d 个 IP" % len(payload["items"]), flush=True)
if args.serve:
serve(args.port)