补测:行为检测支持历史区间;特征扫描自动时间切片;历史画像保留

This commit is contained in:
DeepFal committed 2026-10-19 12:51:04 +08:00
1 parent d78eabc458
commit 897828f506
5 files changed
+229 -18

No files matched your search

+15 -2
View File
@@ -134,11 +134,20 @@ def asn_info(ip, root):
return {"asn": "", "org": "未知", "shared": "unknown"} return {"asn": "", "org": "未知", "shared": "unknown"}
RANGE_OVERRIDE = None
def pivot(client, series, row_groups, column_groups=None, query="*", def pivot(client, series, row_groups, column_groups=None, query="*",
limit=300, timeout=180000): limit=300, timeout=180000):
if RANGE_OVERRIDE:
timerange = {"type": "absolute",
"from": RANGE_OVERRIDE["from"],
"to": RANGE_OVERRIDE["to"]}
else:
timerange = {"type": "relative", "range": WINDOW}
body = {"queries": [{ body = {"queries": [{
"id": "q1", "id": "q1",
"timerange": {"type": "relative", "range": WINDOW}, "timerange": timerange,
"query": {"type": "elasticsearch", "query_string": query}, "query": {"type": "elasticsearch", "query_string": query},
"filter": {"type": "or", "filters": [{"type": "stream", "filter": {"type": "or", "filters": [{"type": "stream",
"id": "6ac83059a6878e26256fcacc"}]}, "id": "6ac83059a6878e26256fcacc"}]},
@@ -401,9 +410,13 @@ class BehaviorAnalyzer:
except GraylogError: except GraylogError:
pass pass
try: try:
extra = {}
if RANGE_OVERRIDE:
extra = {"from_ts": RANGE_OVERRIDE["from"],
"to_ts": RANGE_OVERRIDE["to"]}
hist = self.client.histogram(query=q, range_seconds=self.window, hist = self.client.histogram(query=q, range_seconds=self.window,
interval="5m", interval="5m",
filter_str=self.filter_str) filter_str=self.filter_str, **extra)
from .client import normalize_histogram from .client import normalize_histogram
detail["buckets5m"] = [b["count"] detail["buckets5m"] = [b["count"]
for b in normalize_histogram(hist)] for b in normalize_histogram(hist)]
+38 -5
View File
@@ -345,7 +345,8 @@ class Store:
country_code TEXT, country_name TEXT, region TEXT, city TEXT country_code TEXT, country_name TEXT, region TEXT, city TEXT
)""" )"""
) )
for column in ("asn_brand TEXT", "asn_type TEXT", "asn_icon TEXT"): for column in ("asn_brand TEXT", "asn_type TEXT", "asn_icon TEXT",
"historical INTEGER DEFAULT 0"):
try: try:
self.conn.execute( self.conn.execute(
"ALTER TABLE behavior_profiles ADD COLUMN %s" % column) "ALTER TABLE behavior_profiles ADD COLUMN %s" % column)
@@ -497,10 +498,40 @@ class Store:
return item return item
# ---------------------------------------------------------- behaviour # ---------------------------------------------------------- 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): def replace_behavior_profiles(self, items):
stamp = now_iso() stamp = now_iso()
with self.lock, self.conn: with self.lock, self.conn:
self.conn.execute("DELETE FROM behavior_profiles") self.conn.execute(
"DELETE FROM behavior_profiles WHERE COALESCE(historical,0)=0")
for item in items: for item in items:
self.conn.execute( self.conn.execute(
"""INSERT OR REPLACE INTO behavior_profiles """INSERT OR REPLACE INTO behavior_profiles
@@ -509,8 +540,8 @@ class Store:
asn_type, asn_icon, total, pct4, uri_card, uri_templates, asn_type, asn_icon, total, pct4, uri_card, uri_templates,
ua_card, host_card, coverage, peak_share, top_uri, top_ua, ua_card, host_card, coverage, peak_share, top_uri, top_ua,
last_ts, updated_at, country_code, country_name, region, last_ts, updated_at, country_code, country_name, region,
city) city, historical)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)""",
(item["ip"], item.get("score"), item.get("verdict"), (item["ip"], item.get("score"), item.get("verdict"),
json.dumps(item.get("signals") or [], ensure_ascii=False), json.dumps(item.get("signals") or [], ensure_ascii=False),
item.get("reason_text"), item.get("share_note"), item.get("reason_text"), item.get("share_note"),
@@ -649,11 +680,13 @@ class Store:
if beh["verdict"] != "建议封禁" or ip in wanted: if beh["verdict"] != "建议封禁" or ip in wanted:
continue continue
prev = existing.get(ip) 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] = { wanted[ip] = {
"sources": "behavior", "sources": "behavior",
"feature_score": None, "feature_score": None,
"behavior_score": beh["score"], "behavior_score": beh["score"],
"first_banned_at": prev["first_banned_at"] if prev else now, "first_banned_at": prev["first_banned_at"] if prev else fallback,
"last_activity_at": iso(beh["last_ts"]), "last_activity_at": iso(beh["last_ts"]),
"fused_reason": "行为表现:" + (beh["reason_text"] or ""), "fused_reason": "行为表现:" + (beh["reason_text"] or ""),
} }
+27
View File
@@ -99,5 +99,32 @@ class FusedTests(unittest.TestCase):
store.close() store.close()
class HistoricalBehaviourTests(unittest.TestCase):
def test_historical_profile_survives_live_refresh(self):
with tempfile.TemporaryDirectory() as directory:
store = watch.Store(os.path.join(directory, "ips.db"))
try:
store.upsert_historical_profiles([{
"ip": "198.51.100.9", "score": 80,
"verdict": "建议封禁", "signals": ["历史补测"],
"last_ts": "2026-10-18T10:00:00Z", "total": 1000,
}])
store.replace_behavior_profiles([{
"ip": "203.0.113.9", "score": 70,
"verdict": "仅告警(共享出口)"}])
rows = {r["ip"]: dict(r) for r in store.conn.execute(
"SELECT * FROM behavior_profiles")}
self.assertEqual(rows["198.51.100.9"]["historical"], 1)
self.assertEqual(rows["203.0.113.9"]["historical"], 0)
store.sync_fused()
fused = {r["ip"]: dict(r) for r in store.conn.execute(
"SELECT * FROM ip_fused")}
self.assertEqual(fused["198.51.100.9"]["first_banned_at"],
"2026-10-18T10:00:00Z")
finally:
store.close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Backfill behaviour detection for a historical range (service-off gap).
Runs the independent behaviour analyzer against an absolute time range and
stores flagged profiles with historical=1 so later live runs keep them.
"""
import argparse
import datetime
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from graylog_ops import behavior, watch # noqa: E402
from graylog_ops.client import GraylogClient # noqa: E402
from graylog_ops.http import HttpClient # noqa: E402
from graylog_ops.judge import build_fields, load_profile # noqa: E402
STREAM_ID = "6ac83059a6878e26256fcacc"
def parse_local(value):
text = value.strip()
if text.endswith("Z"):
stamp = datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
elif "T" in text and ("+" in text[10:] or text.endswith("00")):
stamp = datetime.datetime.fromisoformat(text)
else:
stamp = datetime.datetime.strptime(text, "%Y-%m-%d %H:%M").astimezone()
if stamp.tzinfo is None:
stamp = stamp.astimezone()
return stamp.astimezone(datetime.timezone.utc)
def utc_iso(stamp):
return stamp.strftime("%Y-%m-%dT%H:%M:%S.000Z")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--from", dest="from_ts", required=True)
parser.add_argument("--to", dest="to_ts", required=True)
parser.add_argument("--db", default=os.path.join(ROOT, "data", "ips.db"))
parser.add_argument("--profile", default=os.path.join(ROOT, "profiles", "f5.json"))
parser.add_argument("--config", default=os.path.join(ROOT, "graylog-ops.json"))
parser.add_argument("--stream", default=STREAM_ID)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
from_dt, to_dt = parse_local(args.from_ts), parse_local(args.to_ts)
if to_dt <= from_dt:
raise SystemExit("结束时间必须晚于开始时间")
cfg = json.load(open(args.config, encoding="utf-8"))
client = GraylogClient(HttpClient(cfg["url"], username=cfg["token"],
password="token", timeout=120, retries=2))
fields = build_fields(load_profile(args.profile))
filter_str = "streams:" + args.stream
behavior.RANGE_OVERRIDE = {"from": utc_iso(from_dt), "to": utc_iso(to_dt)}
store = watch.Store(args.db)
analyzer = behavior.BehaviorAnalyzer(client, store, fields, filter_str,
root=ROOT, notifier=None)
try:
items = analyzer.analyze()
finally:
behavior.RANGE_OVERRIDE = None
flagged = [item for item in items if item.get("verdict") == "建议封禁"]
for item in items:
try:
last = client.search_absolute(
query='%s:"%s"' % (fields["client_ip"], item["ip"]),
from_ts=utc_iso(from_dt), to_ts=utc_iso(to_dt),
limit=1, sort="timestamp:desc", fields=["timestamp"],
filter_str=filter_str)
stamp = (last or [{}])[0].get("timestamp")
if stamp:
item["last_ts"] = datetime.datetime.fromisoformat(
str(stamp).replace("Z", "+00:00")).astimezone(
datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception as exc:
print("时间查询 %s 失败: %s" % (item["ip"], exc), file=sys.stderr)
print("行为违规 %d 个,建议封禁 %d 个" % (len(items), len(flagged)),
file=sys.stderr)
for item in sorted(flagged, key=lambda x: -x["score"])[:20]:
print(" %-16s 评分 %-5s %-10s %s" % (
item["ip"], item["score"], item.get("asn_brand") or "",
item.get("reason_text") or ""), file=sys.stderr)
if args.dry_run:
print(json.dumps({"violations": len(items),
"flagged": len(flagged),
"ips": [i["ip"] for i in flagged]},
ensure_ascii=False, indent=2))
store.close()
return 0
store.upsert_historical_profiles(items)
before = {row["ip"] for row in store.conn.execute(
"SELECT ip FROM ip_fused")}
store.sync_fused()
added = [i["ip"] for i in flagged
if i["ip"] not in before]
stamp = watch.now_iso()
if added:
with store.lock, store.conn:
store.conn.executemany(
"UPDATE ip_fused SET last_pushed_at=? WHERE ip=?",
[(stamp, ip) for ip in added])
print(json.dumps({
"range": [utc_iso(from_dt), utc_iso(to_dt)],
"violations": len(items),
"flagged": len(flagged),
"newly_fused": len(added),
"new_ips": sorted(added),
}, ensure_ascii=False, indent=2))
store.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+23 -11
View File
@@ -67,6 +67,8 @@ def main():
parser.add_argument("--profile", default=os.path.join(ROOT, "profiles", "f5.json")) parser.add_argument("--profile", default=os.path.join(ROOT, "profiles", "f5.json"))
parser.add_argument("--config", default=os.path.join(ROOT, "graylog-ops.json")) parser.add_argument("--config", default=os.path.join(ROOT, "graylog-ops.json"))
parser.add_argument("--stream", default=STREAM_ID) parser.add_argument("--stream", default=STREAM_ID)
parser.add_argument("--size", type=int, default=10000,
help="单次聚合最多取多少个 IP(超过会自动按时间切片)")
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -84,25 +86,35 @@ def main():
counts = {} counts = {}
rule_totals = {} rule_totals = {}
def collect(rule, query, start, end):
"""Query one time slice; split recursively when the terms cap is hit."""
result = client.terms(ip_field, query=query, size=args.size,
from_ts=utc_iso(start), to_ts=utc_iso(end),
filter_str=filter_str)
terms = result.get("terms") or []
if len(terms) >= args.size and (end - start).total_seconds() > 300:
middle = start + (end - start) / 2
return collect(rule, query, start, middle) + collect(rule, query, middle, end)
return [(str(item.get("term") or ""), int(item.get("count") or 0))
for item in terms if item.get("term")]
for rule in watch.RULE_DEFS: for rule in watch.RULE_DEFS:
if rule.get("weight", 0) <= 0 and rule["name"] != "not_found": if rule.get("weight", 0) <= 0 and rule["name"] != "not_found":
continue continue
try: try:
query = rule["query"](fields) query = rule["query"](fields)
result = client.terms(ip_field, query=query, size=2000, pairs = collect(rule, query, from_dt, to_dt)
from_ts=utc_iso(from_dt), to_ts=utc_iso(to_dt),
filter_str=filter_str)
except Exception as exc: # keep going; one broken rule must not stop us except Exception as exc: # keep going; one broken rule must not stop us
print("规则 %s 查询失败: %s" % (rule["name"], exc), file=sys.stderr) print("规则 %s 查询失败: %s" % (rule["name"], exc), file=sys.stderr)
continue continue
terms = result.get("terms") or [] merged = {}
rule_totals[rule["name"]] = sum(int(item.get("count") or 0) for item in terms) for ip, count in pairs:
for item in terms: merged[ip] = merged.get(ip, 0) + count
ip = str(item.get("term") or "") rule_totals[rule["name"]] = sum(merged.values())
if not ip: for ip, count in merged.items():
continue counts.setdefault(ip, {})[rule["name"]] = count
counts.setdefault(ip, {})[rule["name"]] = int(item.get("count") or 0) print("规则 %-14s %6d 个 IP" % (rule["name"], len(merged)), file=sys.stderr)
print("规则 %-14s %6d 个 IP" % (rule["name"], len(terms)), file=sys.stderr)
now_iso = watch.now_iso() now_iso = watch.now_iso()
store = watch.Store(args.db) store = watch.Store(args.db)