commit d3f7c958d0a51617683981078211fa575dbb5b68 Author: arthur Date: Wed Feb 11 10:59:55 2026 +0100 Initial commit - Wazuh to Teams workflow integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d41b91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# secrets / configs locales +config.json +.env + +# python +__pycache__/ +*.pyc +*.pyo + +# editor +.vscode/ +.idea/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a95280c --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Wazuh → Microsoft Teams (Workflows) Integration + +Intégration Wazuh qui envoie des alertes vers Microsoft Teams via **Workflows** (webhook) en **Adaptive Card**. + +Basé sur le template : https://github.com/jayzielinski/wazuh-teams-workflows/tree/main + +## Fonctionnalités + +- Envoi d'une Adaptive Card à Teams (Workflows webhook) +- Ajout d'un champ **IP Source** (extrait depuis le JSON d’alerte ou `full_log`) +- Formatage des infos utiles (agent, rule, level, timestamp, etc.) +- **Ne transmet pas `full_log`** volontairement (privacy / bruit) + +--- + +## Contenu + +- custom-teams : Wrapper (Execute le script) +- custom-teams.py : Script workflow + +--- + + +## Prérequis + +- Wazuh Manager (intégrations dans `/var/ossec/integrations/`) +- Python fourni par Wazuh (framework) +- Accès à un webhook Teams (Workflows) + +Pour vérifier la version de Python Wazuh : + +```bash +/var/ossec/framework/python/bin/python3 --version + +--- + +## Installation + +1) Installer les dépendances Python dans l’environnement Wazuh + +```bash +/var/ossec/framework/python/bin/python3 -m pip install --upgrade pip +/var/ossec/framework/python/bin/python3 -m pip install -r requirements.txt``` + + +2) Déployer les fichiers d’intégration + +Copier les fichiers +```bash +sudo cp custom-teams custom-teams.py /var/ossec/integrations/``` + +Mettre les droits +```bash +sudo chown root:wazuh /var/ossec/integrations/custom-teams /var/ossec/integrations/custom-teams.py +sudo chmod 750 /var/ossec/integrations/custom-teams +sudo chmod 750 /var/ossec/integrations/custom-teams.py``` + + +--- + +##Test + +Créer un log test +```bash +cat > /tmp/test.alert <<'EOF' +{"rule":{"level":15,"id":"999","description":"TEST"},"agent":{"name":"wazuh-manager"},"timestamp":"2026-01-22T16:30:00+0100","full_log":"hello"} +EOF``` + +Lancer le test +```bash +/var/ossec/framework/python/bin/python3 /var/ossec/integrations/custom-teams.py /tmp/test.alert "" 15``` + diff --git a/custom-teams b/custom-teams new file mode 100755 index 0000000..f371fc1 --- /dev/null +++ b/custom-teams @@ -0,0 +1,9 @@ +#!/bin/bash +# Wazuh → Teams wrapper; passes all arguments to the Python script + +SCRIPT="/var/ossec/integrations/custom-teams.py" + +[ ! -f "$SCRIPT" ] && echo "Error: $SCRIPT not found" && exit 1 +[ ! -x "$SCRIPT" ] && echo "Error: $SCRIPT not executable" && exit 1 + +exec python3 "$SCRIPT" "$@" diff --git a/custom-teams.py b/custom-teams.py new file mode 100755 index 0000000..ab925ce --- /dev/null +++ b/custom-teams.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Wazuh → Microsoft Teams Integration (Workflows) +- Sends Adaptive Card to Teams Workflows webhook +- Adds "IP Source" extracted from alert JSON or from full_log text +- Does NOT send full_log in Teams (privacy / noise) +""" + +import json +import logging +import re +import sys +from datetime import datetime +from urllib.parse import urlparse + +import requests + +LOG_FILE = "/var/ossec/logs/integrations.log" +USER_AGENT = "Wazuh-Teams-Integration/2.1" + +ALLOWED_SUFFIXES = ("logic.azure.com", "powerplatform.com") + + +class Integration: + def __init__(self, alert_file, webhook_url, level): + self.alert_file = alert_file + self.webhook_url = webhook_url + self.level = level + self._setup_logging() + + def _setup_logging(self): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout), + ], + ) + self.logger = logging.getLogger("wazuh-teams") + + def _validate(self): + try: + if not isinstance(self.alert_file, str) or not self.alert_file.endswith(".alert"): + self.logger.error(f"Invalid alert file: {self.alert_file!r} (must end with .alert)") + return False + + if not isinstance(self.webhook_url, str) or not self.webhook_url.startswith(("http://", "https://")): + self.logger.error(f"Invalid webhook URL: {self.webhook_url!r} (must start with http/https)") + return False + + parsed = urlparse(self.webhook_url) + host = (parsed.netloc or "").lower() + host_no_port = host.split(":")[0] if host else "" + + if not any(host_no_port.endswith(sfx) for sfx in ALLOWED_SUFFIXES): + self.logger.error( + f"Invalid webhook host: {host_no_port!r} (expected domain ending with: {ALLOWED_SUFFIXES})" + ) + return False + + if not isinstance(self.level, int): + self.logger.error(f"Invalid level: {self.level!r} (must be int)") + return False + + return True + + except Exception as e: + self.logger.exception(f"Validation exception: {e}") + return False + + def _load_alert(self): + try: + with open(self.alert_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + self.logger.error(f"Cannot load alert JSON: {e}") + return {} + + def _priority(self, alert): + l = int(alert.get("rule", {}).get("level", 0) or 0) + if l >= 12: + return {"txt": "CRITICAL", "clr": "Attention", "lvl": l} + if l >= 7: + return {"txt": "HIGH", "clr": "Warning", "lvl": l} + if l >= 4: + return {"txt": "MEDIUM", "clr": "Good", "lvl": l} + return {"txt": "LOW", "clr": "Accent", "lvl": l} + + def _format_time(self, ts): + try: + # Convert "+0100" → "+01:00" for fromisoformat + ts_fixed = ts[:-2] + ":" + ts[-2:] if ts and len(ts) > 5 and (ts[-5] in ["+", "-"]) else ts + dt = datetime.fromisoformat(ts_fixed) + local_dt = dt.astimezone() + return local_dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return ts + + def _extract_ip_source(self, alert): + # 1) Common fields in Wazuh alerts + candidates = [ + ("data", "ipAddress"), + ("data", "srcip"), + ("data", "src_ip"), + ("data", "source_ip"), + ("srcip",), + ("src_ip",), + ] + + for path in candidates: + cur = alert + ok = True + for k in path: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + else: + ok = False + break + if ok and isinstance(cur, str) and cur.strip(): + return cur.strip() + + # 2) Extract from full_log string (if present) + full_log = alert.get("full_log", "") + if isinstance(full_log, str) and full_log: + m = re.search(r'"ipAddress"\s*:\s*"([^"]+)"', full_log) + if m: + return m.group(1).strip() + + return None + + def _make_card(self, alert): + pr = self._priority(alert) + rule = alert.get("rule", {}) or {} + agent = alert.get("agent", {}) or {} + + + ip_source = self._extract_ip_source(alert) + + facts = [] + if ip_source: + facts.append({"title": "IP Source", "value": ip_source}) + + facts.extend( + [ + {"title": "Utilisateur", "value": str(alert.get("data",{}).get("win",{}).get("eventdata",{}).get("targetUserName", "N/A3"))}, + {"title": "Ordinateur", "value": str(alert.get("data",{}).get("win",{}).get("eventdata",{}).get("workstationName", "N/A3"))}, + {"title": "Level", "value": f"{pr['txt']} ({pr['lvl']})"}, + {"title": "Rule ID", "value": str(rule.get("id", "N/A"))}, + {"title": "Description", "value": str(rule.get("description", "N/A"))}, + {"title": "Agent", "value": f"{agent.get('name','?')} ({agent.get('ip','?')})"}, + {"title": "Timestamp", "value": self._format_time(str(alert.get("timestamp", "")))}, + ] + ) + + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { + "type": "TextBlock", + "text": f"{pr['txt']} WAZUH ALERT - {rule.get('description', 'N/A')}", + "weight": "Bolder", + "size": "Large", + "color": pr["clr"], + }, + {"type": "FactSet", "facts": facts}, + ], + }, + } + ], + } + + # ✅ full_log intentionally NOT included + + return payload + + def _send(self, card): + headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} + try: + resp = requests.post(self.webhook_url, json=card, headers=headers, timeout=30) + if resp.status_code in (200, 202): + self.logger.info(f"Sent ok (status {resp.status_code})") + return True + self.logger.error(f"Send failed: {resp.status_code} {resp.text}") + except Exception as e: + self.logger.error(f"Exception while sending: {e}") + return False + + def run(self): + if not self._validate(): + self.logger.error("Validation failed") + sys.exit(1) + + alert = self._load_alert() + card = self._make_card(alert) + + if not self._send(card): + sys.exit(1) + + sys.exit(0) + + +def parse_args(argv): + alert_file = None + webhook = None + level = None + + for arg in argv[1:]: + if arg.startswith("/tmp/") and arg.endswith(".alert"): + alert_file = arg + elif arg.startswith("http"): + webhook = arg + else: + try: + level = int(arg) + except Exception: + pass + + return alert_file, webhook, level + + +def main(): + af, wh, lv = parse_args(sys.argv) + if not (af and wh and lv is not None): + print("Usage: custom-teams.py ") + sys.exit(1) + + Integration(af, wh, lv).run() + + +if __name__ == "__main__": + main()