初始提交:恶意 IP 特征+行为融合检测系统
- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行 - 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN) - 钉钉融合推送(加签、24h 节流、去重) - 页面状态灯、CSV 导出、Docker 镜像构建文件
This commit is contained in:
commit
2d76ed6672
42 files changed
+10645
No files matched your search
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)
|
||||
Reference in new issue
Block a user