#!/usr/bin/env python3 """Backfill feature detection for a historical time range. Scans the Graylog F5 stream for every signature rule over [--from, --to], scores the IPs exactly like the live detector and writes them into the local SQLite database. Backfilled rows are marked as already pushed so they do not flood the DingTalk webhook. """ import argparse import datetime import ipaddress import json import os import sys import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) from graylog_ops import watch # noqa: E402 from graylog_ops.asn import AsnDB # noqa: E402 from graylog_ops.client import GraylogClient # noqa: E402 from graylog_ops.geo import GeoIP # 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 normalise_timestamp(value): text = str(value or "") if not text: return None try: stamp = datetime.datetime.fromisoformat(text.replace("Z", "+00:00")) except ValueError: return None return utc_iso(stamp.astimezone(datetime.timezone.utc)) def main(): parser = argparse.ArgumentParser() parser.add_argument("--from", dest="from_ts", required=True, help='开始时间,例如 "2026-10-18 04:00"') parser.add_argument("--to", dest="to_ts", required=True, help='结束时间,例如 "2026-10-19 03:55"') parser.add_argument("--db", default=os.path.join(ROOT, "data", "ips.db")) parser.add_argument("--geo-db", default=os.path.join(ROOT, "data", "geo", "geo.db")) parser.add_argument("--asn-db", default=os.path.join(ROOT, "data", "geo", "asn.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("结束时间必须晚于开始时间") span = int((to_dt - from_dt).total_seconds()) cfg = json.load(open(args.config, encoding="utf-8")) client = GraylogClient(HttpClient(cfg["url"], username=cfg["token"], password="token", timeout=60, retries=2)) fields = build_fields(load_profile(args.profile)) ip_field = fields["client_ip"] filter_str = "streams:" + args.stream counts = {} rule_totals = {} for rule in watch.RULE_DEFS: if rule.get("weight", 0) <= 0 and rule["name"] != "not_found": continue try: query = rule["query"](fields) result = client.terms(ip_field, query=query, size=2000, 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 print("规则 %s 查询失败: %s" % (rule["name"], exc), file=sys.stderr) continue terms = result.get("terms") or [] rule_totals[rule["name"]] = sum(int(item.get("count") or 0) for item in terms) for item in terms: ip = str(item.get("term") or "") if not ip: continue counts.setdefault(ip, {})[rule["name"]] = int(item.get("count") or 0) print("规则 %-14s %6d 个 IP" % (rule["name"], len(terms)), file=sys.stderr) now_iso = watch.now_iso() store = watch.Store(args.db) geo = GeoIP(args.geo_db) if args.geo_db and os.path.exists(args.geo_db) else None asn = AsnDB(args.asn_db) if args.asn_db and os.path.exists(args.asn_db) else None candidates = [] for ip, hits in counts.items(): try: addr = ipaddress.ip_address(ip) except ValueError: continue if not addr.is_global: continue total = sum(hits.values()) tier, score, reasons = watch.classify(total, hits, window_scale=span / 900.0) if tier != watch.TIER_MALICIOUS: continue candidates.append({"ip": ip, "hits": hits, "total": total, "tier": tier, "score": score, "reasons": reasons}) candidates.sort(key=lambda item: (-item["score"], -item["total"], item["ip"])) created = upgraded = extended = 0 touched = [] for item in candidates: ip = item["ip"] first = last = None try: asc = client.search_absolute(query='%s:"%s"' % (ip_field, ip), from_ts=utc_iso(from_dt), to_ts=utc_iso(to_dt), limit=1, sort="timestamp:asc", fields=["timestamp"], filter_str=filter_str) desc = client.search_absolute(query='%s:"%s"' % (ip_field, ip), from_ts=utc_iso(from_dt), to_ts=utc_iso(to_dt), limit=1, sort="timestamp:desc", fields=["timestamp"], filter_str=filter_str) first = normalise_timestamp((asc or [{}])[0].get("timestamp")) last = normalise_timestamp((desc or [{}])[0].get("timestamp")) except Exception as exc: print("时间查询 %s 失败: %s" % (ip, exc), file=sys.stderr) first = first or utc_iso(from_dt) last = last or utc_iso(to_dt) record = { "ip": ip, "tier": item["tier"], "score": item["score"], "total": item["total"], "rules": item["hits"], "reasons": item["reasons"], "first_seen": first, "last_seen": last, "last_malicious_at": last, "risk": "high" if item["score"] >= 85 else "medium", "window": span, "geo": geo.lookup(ip) if geo else {}, "asn": asn.lookup(ip) if asn else {}, } if args.dry_run: candidates[0] = candidates[0] # no-op print("[dry-run] %-16s 评分 %5.1f 命中 %d 次 规则 %s 首次 %s" % (ip, item["score"], item["total"], ",".join(item["hits"]), first), file=sys.stderr) continue existing = store.get(ip) if existing is None: store.upsert(record, now_iso) created += 1 touched.append(ip) elif existing.get("tier") == watch.TIER_MALICIOUS: with store.lock, store.conn: store.conn.execute( "UPDATE ip_stats SET first_seen=?, last_seen=?, last_malicious_at=?, " "ever_malicious=1 WHERE ip=?", (min(first, existing.get("first_seen") or first), max(last, existing.get("last_seen") or last), max(last, existing.get("last_malicious_at") or last), ip)) extended += 1 touched.append(ip) elif float(existing.get("score") or 0) < item["score"]: store.upsert(record, now_iso) upgraded += 1 touched.append(ip) else: extended += 1 touched.append(ip) if not args.dry_run and touched: store.sync_fused() stamp = watch.now_iso() with store.lock, store.conn: store.conn.executemany( "UPDATE ip_fused SET last_pushed_at=? WHERE ip=?", [(stamp, ip) for ip in touched]) print(json.dumps({ "range": [utc_iso(from_dt), utc_iso(to_dt)], "span_hours": round(span / 3600.0, 1), "candidates": len(candidates), "created": created, "upgraded": upgraded, "extended": extended, "rule_totals": rule_totals, "top": [{"ip": c["ip"], "score": c["score"], "total": c["total"], "rules": c["hits"]} for c in candidates[:15]], }, ensure_ascii=False, indent=2)) store.close() return 0 if __name__ == "__main__": raise SystemExit(main())