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 / webhooks.py 8367 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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""Outbound webhooks.

Vanguard forwards qualifying (high-severity) events to configured endpoints —
typically an IronNode tenant webhook that triggers a response playbook. Each
webhook has a severity threshold, an on/off toggle, and an optional HMAC secret
used to sign the request body so the receiver can verify authenticity.

Delivery is best-effort and fire-and-forget: it never blocks or fails the
ingest/normalize pipeline."""
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import logging
import uuid
from datetime import datetime, timezone

from sv.config import DEFAULT_TENANT_ID
from sv.models.event import EventModel
from sv.storage.base import Query, StorageEngine

log = logging.getLogger("sv.webhooks")

_COLUMNS = ("id, tenant_id, name, url, secret, min_severity, enabled, "
           "created_ts, last_status, last_fired_ts")


def _now() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


# --- CRUD -------------------------------------------------------------------
async def list_webhooks(engine: StorageEngine, tenant_id=DEFAULT_TENANT_ID) -> list[dict]:
    return await engine.fetchall(Query(
        f"SELECT {_COLUMNS} FROM webhooks WHERE tenant_id = ? ORDER BY name",
        [str(tenant_id)],
    ))


async def create_webhook(engine: StorageEngine, name: str, url: str,
                         secret: str | None, min_severity: int,
                         tenant_id=DEFAULT_TENANT_ID) -> str:
    wid = str(uuid.uuid4())
    await engine.run(Query(
        "INSERT INTO webhooks (id, tenant_id, name, url, secret, min_severity, "
        "enabled, created_ts) VALUES (?, ?, ?, ?, ?, ?, TRUE, ?)",
        [wid, str(tenant_id), name, url, secret or None,
         max(0, min(7, min_severity)), _now()],
    ))
    return wid


async def set_enabled(engine: StorageEngine, wid: str, enabled: bool,
                      tenant_id=DEFAULT_TENANT_ID) -> None:
    await engine.run(Query(
        "UPDATE webhooks SET enabled = ? WHERE id = ? AND tenant_id = ?",
        [enabled, wid, str(tenant_id)],
    ))


async def delete_webhooks(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 webhooks WHERE tenant_id = ? AND id IN ({placeholders})",
        [str(tenant_id), *ids],
    ))
    return len(ids)


async def get_webhook(engine: StorageEngine, wid: str,
                      tenant_id=DEFAULT_TENANT_ID) -> dict | None:
    return await engine.fetchone(Query(
        f"SELECT {_COLUMNS} FROM webhooks WHERE id = ? AND tenant_id = ?",
        [wid, str(tenant_id)],
    ))


async def _update_status(engine: StorageEngine, wid: str, status: str) -> None:
    await engine.run(Query(
        "UPDATE webhooks SET last_status = ?, last_fired_ts = ? WHERE id = ?",
        [status[:80], _now(), wid],
    ))


# --- delivery ---------------------------------------------------------------
def event_payload(ev: EventModel | dict) -> dict:
    """Shape sent to the receiver. For an IronNode webhook this becomes the
    playbook trigger payload."""
    d = ev.to_storage_row() if isinstance(ev, EventModel) else dict(ev)
    return {
        "source": "synapse-vanguard",
        "kind": "security_event",
        "event": {
            "ts": str(d.get("ts")),
            "host": d.get("host"),
            "host_ip": d.get("host_ip"),
            "source_type": d.get("source_type"),
            "severity": d.get("severity"),
            "category": d.get("category"),
            "action": d.get("action"),
            "mitre_technique": d.get("mitre_technique"),
            "message": d.get("message"),
        },
    }


def _sign(secret: str, body: bytes) -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


async def _deliver(client, engine: StorageEngine, hook: dict, payload: dict) -> None:
    body = json.dumps(payload, default=str).encode()
    headers = {"Content-Type": "application/json", "X-SV-Event": payload.get("kind", "event")}
    if hook.get("secret"):
        headers["X-SV-Signature"] = _sign(hook["secret"], body)
    try:
        resp = await client.post(hook["url"], content=body, headers=headers, timeout=8.0)
        await _update_status(engine, hook["id"], str(resp.status_code))
        log.debug("webhook %s -> %s (%s)", hook["name"], hook["url"], resp.status_code)
    except Exception as exc:  # noqa: BLE001 — best-effort
        await _update_status(engine, hook["id"], f"error: {type(exc).__name__}")
        log.warning("webhook %s delivery failed: %s", hook["name"], exc)


async def dispatch(engine: StorageEngine, events: list[EventModel],
                   tenant_id=DEFAULT_TENANT_ID,
                   direct: list[tuple[EventModel, str]] | None = None) -> None:
    """Deliver events to webhooks via two paths, deduped so no (webhook, event)
    pair fires twice:
      1. `direct` — (event, webhook_id) pairs from matched alert rules; fired
         regardless of the webhook's severity threshold.
      2. severity threshold — any enabled webhook whose min_severity is met.
    Loads webhooks once; a no-op when nothing qualifies."""
    hooks = await engine.fetchall(Query(
        f"SELECT {_COLUMNS} FROM webhooks WHERE tenant_id = ? AND enabled = TRUE",
        [str(tenant_id)],
    ))
    if not hooks:
        return
    by_id = {h["id"]: h for h in hooks}
    sent: set[tuple[str, int]] = set()   # (webhook_id, id(event))

    # False-positive tuning also mutes the severity-threshold path: a suppressed
    # event won't fire a webhook even if it's natively high severity.
    from sv import suppressions
    supps = await suppressions.load_enabled(engine, str(tenant_id))

    import httpx
    async with httpx.AsyncClient() as client:
        tasks = []
        # 1) alert-rule direct targets (already suppression-filtered upstream)
        for ev, wid in (direct or []):
            hook = by_id.get(wid)
            if hook and (wid, id(ev)) not in sent:
                sent.add((wid, id(ev)))
                tasks.append(_deliver(client, engine, hook, event_payload(ev)))
        # 2) severity-threshold matches (skip suppressed events)
        for ev in events:
            if supps:
                alert_name = ev.action if ev.category == "alert" else None
                if suppressions.match_any(supps, ev, alert_name):
                    continue
            sev = int(ev.severity)
            for hook in hooks:
                key = (hook["id"], id(ev))
                if sev <= int(hook["min_severity"]) and key not in sent:
                    sent.add(key)
                    tasks.append(_deliver(client, engine, hook, event_payload(ev)))
        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)


async def send_test(engine: StorageEngine, wid: str,
                    tenant_id=DEFAULT_TENANT_ID) -> dict:
    """Deliver a synthetic test event to one webhook (from the admin UI)."""
    hook = await get_webhook(engine, wid, tenant_id)
    if not hook:
        return {"ok": False, "message": "webhook not found"}
    payload = {
        "source": "synapse-vanguard", "kind": "test",
        "event": {"ts": _now().isoformat(), "host": "synapse-vanguard",
                  "source_type": "test", "severity": 2, "category": "test",
                  "message": "Synapse-Vanguard webhook test event"},
    }
    import httpx
    body = json.dumps(payload).encode()
    headers = {"Content-Type": "application/json", "X-SV-Event": "test"}
    if hook.get("secret"):
        headers["X-SV-Signature"] = _sign(hook["secret"], body)
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.post(hook["url"], content=body, headers=headers, timeout=8.0)
        await _update_status(engine, wid, str(resp.status_code))
        ok = 200 <= resp.status_code < 300
        return {"ok": ok, "status": resp.status_code,
                "message": "Delivered" if ok else f"Receiver returned HTTP {resp.status_code}"}
    except Exception as exc:  # noqa: BLE001
        await _update_status(engine, wid, f"error: {type(exc).__name__}")
        return {"ok": False, "message": f"Delivery failed: {exc}"}