feat(dlp): implement SIEM/SOAR integrations (CEF, webhook, syslog, systemd timers)

This commit is contained in:
igor04091968
2026-05-13 02:30:52 +03:00
parent 067457198d
commit fd2e9ac59d
15 changed files with 563 additions and 33 deletions
+139 -14
View File
@@ -2,28 +2,153 @@
from __future__ import annotations
import json
import os
import logging
import time
from urllib import request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib import error, request
import yaml
LOG = logging.getLogger("aw.dlp.webhook_sender")
def post(url: str, payload: dict, retries: int = 3) -> bool:
body = json.dumps(payload).encode("utf-8")
for i in range(retries):
def setup_logging() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return {}
return data
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except Exception:
return {}
return {}
def save_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def http_json(url: str, timeout: int = 15) -> Any:
req = request.Request(url, method="GET")
with request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", errors="ignore"))
def post_with_retry(url: str, payload: dict[str, Any], retries: int, timeout: int, backoff_base: float) -> bool:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json; charset=utf-8"}
for attempt in range(1, retries + 1):
try:
req = request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
with request.urlopen(req, timeout=10):
return True
except Exception:
time.sleep(2 ** i)
req = request.Request(url, data=body, headers=headers, method="POST")
with request.urlopen(req, timeout=timeout) as resp:
code = getattr(resp, "status", 200)
if 200 <= code < 300:
return True
except error.HTTPError as exc:
LOG.warning("webhook http error url=%s code=%s attempt=%d/%d", url, exc.code, attempt, retries)
except Exception as exc:
LOG.warning("webhook transport error url=%s err=%s attempt=%d/%d", url, exc, attempt, retries)
if attempt < retries:
time.sleep(backoff_base ** (attempt - 1))
return False
def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]:
buckets = http_json(f"{aw_base}/buckets/")
bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")])
last_ids = state.get("last_ids", {})
if not isinstance(last_ids, dict):
last_ids = {}
max_ids: dict[str, int] = {}
out: list[dict[str, Any]] = []
for bid in bucket_ids:
events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}")
prev = int(last_ids.get(bid, 0))
bucket_max = prev
for ev in events:
eid = int(ev.get("id") or 0)
if eid <= prev:
continue
out.append(ev)
if eid > bucket_max:
bucket_max = eid
max_ids[bid] = bucket_max
out.sort(key=lambda x: int(x.get("id") or 0))
return out, max_ids
def should_send(severity: str, allowed: list[str]) -> bool:
return severity.lower() in {s.lower() for s in allowed}
def main() -> None:
hooks = [h.strip() for h in os.environ.get("AW_DLP_CRITICAL_WEBHOOKS", "").split(",") if h.strip()]
payload = {"text": "AWatch DLP critical incident", "severity": "high"}
for hook in hooks:
post(hook, payload)
setup_logging()
cfg_path = Path("/opt/activitywatch/dlp-integrations/webhook-config.yaml")
cfg = load_yaml(cfg_path)
aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/")
state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/webhook-state.json")))
retries = int(cfg.get("retries", 4))
timeout = int(cfg.get("timeout_sec", 15))
backoff_base = float(cfg.get("backoff_base", 2.0))
per_bucket_limit = int(cfg.get("per_bucket_limit", 300))
hooks = cfg.get("critical_webhooks", [])
if not isinstance(hooks, list):
hooks = []
state = load_json(state_path)
incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit)
sent = 0
for ev in incidents:
data = ev.get("data") or {}
severity = str(data.get("severity") or "low")
for hook in hooks:
if not isinstance(hook, dict):
continue
url = str(hook.get("url") or "").strip()
if not url:
continue
allowed = hook.get("severity", ["high"])
if isinstance(allowed, str):
allowed = [allowed]
if not should_send(severity, [str(x) for x in allowed]):
continue
payload = {
"source": "AWatch-rus DLP",
"timestamp": ev.get("timestamp"),
"event_id": ev.get("id"),
"severity": severity,
"message": data.get("message"),
"ruleId": data.get("ruleId"),
"signalType": data.get("signalType"),
"hostname": data.get("hostname"),
"username": data.get("username"),
"action": data.get("action"),
"raw": data,
}
if post_with_retry(url=url, payload=payload, retries=retries, timeout=timeout, backoff_base=backoff_base):
sent += 1
state["last_ids"] = max_ids
state["updated_at"] = datetime.now(timezone.utc).isoformat()
save_json(state_path, state)
LOG.info("Webhook sender done: delivered=%d incidents_seen=%d", sent, len(incidents))
if __name__ == "__main__":