admin / Syanpse-Vanguard
public
Syanpse-Vanguard / synapse-vanguard-v3 / sv / alerts.py
6225 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """Keyword alert rules. Admins define named rules — an *Alert type name* plus one or more keywords. When an ingested event's message matches, the normalizer re-tags the event as that named Alert at the rule's configured severity (default ALERT). This lets you have several distinct Alert types ("Ransomware", "Data Exfil", …) each keyed off its own keywords. Rules are cached in-process with a short TTL so the per-event match check on the ingest path stays cheap.""" from __future__ import annotations import json import time import uuid from datetime import datetime, timezone from sv.config import DEFAULT_TENANT_ID from sv.models.event import EventModel, Severity from sv.storage.base import Query, StorageEngine _COLUMNS = ("id, tenant_id, name, keywords, match_mode, severity, enabled, " "webhook_id, created_ts, last_hit_ts, hits") # tiny in-process cache: {tenant_id: (expires_at, [compiled_rules])} _CACHE: dict[str, tuple[float, list[dict]]] = {} _TTL = 5.0 def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _coerce_keywords(raw) -> list[str]: if isinstance(raw, str): try: raw = json.loads(raw) except json.JSONDecodeError: raw = [raw] return [str(k) for k in (raw or []) if str(k).strip()] # --- CRUD ------------------------------------------------------------------- async def list_rules(engine: StorageEngine, tenant_id=DEFAULT_TENANT_ID) -> list[dict]: rows = await engine.fetchall(Query( f"SELECT {_COLUMNS} FROM alert_rules WHERE tenant_id = ? ORDER BY name", [str(tenant_id)], )) for r in rows: r["keywords"] = _coerce_keywords(r.get("keywords")) return rows async def create_rule(engine: StorageEngine, name: str, keywords: list[str], match_mode: str, severity: int, webhook_id: str | None = None, tenant_id=DEFAULT_TENANT_ID) -> str: rid = str(uuid.uuid4()) await engine.run(Query( "INSERT INTO alert_rules (id, tenant_id, name, keywords, match_mode, " "severity, enabled, webhook_id, created_ts, hits) " "VALUES (?, ?, ?, ?, ?, ?, TRUE, ?, ?, 0)", [rid, str(tenant_id), name, json.dumps(keywords), "all" if match_mode == "all" else "any", max(0, min(7, severity)), webhook_id or None, _now()], )) _CACHE.pop(str(tenant_id), None) return rid async def set_enabled(engine: StorageEngine, rid: str, enabled: bool, tenant_id=DEFAULT_TENANT_ID) -> None: await engine.run(Query( "UPDATE alert_rules SET enabled = ? WHERE id = ? AND tenant_id = ?", [enabled, rid, str(tenant_id)], )) _CACHE.pop(str(tenant_id), None) async def delete_rules(engine: StorageEngine, ids: list[str], tenant_id=DEFAULT_TENANT_ID) -> int: if not ids: return 0 placeholders = ", ".join("?" for _ in ids) await engine.run(Query( f"DELETE FROM alert_rules WHERE tenant_id = ? AND id IN ({placeholders})", [str(tenant_id), *ids], )) _CACHE.pop(str(tenant_id), None) return len(ids) # --- matching (hot path) ---------------------------------------------------- async def _enabled_rules(engine: StorageEngine, tenant_id: str) -> list[dict]: cached = _CACHE.get(tenant_id) if cached and cached[0] > time.monotonic(): return cached[1] rows = await engine.fetchall(Query( f"SELECT {_COLUMNS} FROM alert_rules WHERE tenant_id = ? AND enabled = TRUE", [tenant_id], )) compiled = [] for r in rows: kws = [k.lower() for k in _coerce_keywords(r.get("keywords"))] if not kws: continue compiled.append({"id": r["id"], "name": r["name"], "keywords": kws, "match_mode": r.get("match_mode", "any"), "severity": int(r["severity"]), "webhook_id": r.get("webhook_id")}) _CACHE[tenant_id] = (time.monotonic() + _TTL, compiled) return compiled def _matches(rule: dict, message: str) -> bool: kws = rule["keywords"] if rule["match_mode"] == "all": return all(k in message for k in kws) return any(k in message for k in kws) async def apply_rules(engine: StorageEngine, events: list[EventModel], tenant_id=DEFAULT_TENANT_ID) -> list[tuple[EventModel, str]]: """Mutate matching events in place: set severity + tag them as the named Alert (category='alert', action=<rule name>). First matching rule wins. Returns a list of (event, webhook_id) for rules that have a target webhook, so the caller can fire those directly. No-op when no rules exist.""" rules = await _enabled_rules(engine, str(tenant_id)) if not rules: return [] from sv import suppressions supps = await suppressions.load_enabled(engine, str(tenant_id)) hit_counts: dict[str, int] = {} supp_hits: dict[str, int] = {} targets: list[tuple[EventModel, str]] = [] for ev in events: msg = (ev.message or "").lower() if not msg: continue for rule in rules: if _matches(rule, msg): # False-positive tuning: skip this alert if a suppression matches. sid = suppressions.match_any(supps, ev, rule["name"]) if supps else None if sid: supp_hits[sid] = supp_hits.get(sid, 0) + 1 continue ev.severity = Severity(rule["severity"]) ev.category = "alert" ev.action = rule["name"] ev.labels = {**ev.labels, "alert_rule": rule["name"]} hit_counts[rule["id"]] = hit_counts.get(rule["id"], 0) + 1 if rule.get("webhook_id"): targets.append((ev, rule["webhook_id"])) break for rid, n in hit_counts.items(): await engine.run(Query( "UPDATE alert_rules SET hits = hits + ?, last_hit_ts = ? WHERE id = ?", [n, _now(), rid], )) if supp_hits: await suppressions.record_hits(engine, supp_hits) return targets |