Catalyst / admin/Syanpse-Vanguard 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Syanpse-Vanguard

public
Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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