Wazuh — мощная открытая платформа для мониторинга безопасности, обнаружения угроз и соответствия нормативным требованиям. Но по умолчанию она отправляет алерты только в логи или SIEM-систему. Что делать, если вы хотите оперативно получать уведомления в Telegram и автоматически создавать задачи в Bitrix24 при критических событиях — например, при изменении членства в группе администраторов или блокировке учётной записи?

В этой статье мы рассмотрим, как:

  • Настроить кастомные правила Wazuh для отслеживания событий Active Directory и Kaspersky Security Center

  • Написать универсальный скрипт интеграции на Python

  • Отправлять уведомления в Telegram с маршрутизацией по темам

  • Создавать задачи в Bitrix24 с разными исполнителями и без дублирования

  • Избежать типичных ошибок при работе с <integration> и active-response

Подготовка: что нам понадобится

  • Wazuh Manager (версия 4.x)

  • Аккаунт Telegram Bot (получите у @BotFather)

  • Вебхук Bitrix24 с правами на создание задач

  • Доступ к файловой системе сервера Wazuh

Создание кастомных правил

Wazuh использует XML-правила для сопоставления событий. Мы создадим правила для:

  • Изменения в группах Domain Admins, Enterprise Admins

  • Работы с кастомными группами вроде Enable-access

  • Блокировки учётных записей (EventID 4740)

Пример: правило для добавления в кастомную группу

Файл: /var/ossec/etc/rules/local_group_security_changed.xml

<group name="windows,ad_security,critical_group">
  <rule id="100114" level="14">
    <if_sid>60141</if_sid> <!-- EventID 4728 -->
     <field name="win.eventdata.targetUserName">^Enable-access$</field>
    <description>CRITICAL: Добавление в группу 'Enable-access'</description>
   <group>pci_dss_10.2.5,gdpr_IV_35.7.d</group>
  </rule>
</group>

Важно: используйте <if_sid>60141</if_sid> для добавления (4728) и <if_sid>60148</if_sid> для изменения/удаления (4737).

Интеграция через

Wazuh поддерживает отправку алертов в сторонние системы через механизм <integration>, который вызывает скрипт при срабатывании указанных правил.

Добавим в /var/ossec/etc/ossec.conf:

<integration>
<name>custom-telegram</name>
<level>9</level>
<hook_url>https://api.telegram.org/bot{TOKEN_TELEGRAM}/sendMessage<</hook_url>
<rule_id>
60115, 100110 ,100111 ,100112, 100150
</rule_id>
<alert_format>json</alert_format>
</integration>

создадим два файла и поменяйте права

touch /var/ossec/integrations/custom_telegram  
touch /var/ossec/integrations/custom_telegram.py
sudo chmod 750 /var/ossec/integrations/custom_telegram.py 
sudo chmod 750 /var/ossec/integrations/custom_telegram 
sudo chown root:wazuh /var/ossec/integrations/custom_telegram.py 
sudo chown root:wazuh /var/ossec/integrations/custom_telegram

Так же давайте создадим пару кастомных правил, для этого создадим два файла

touch /var/ossec/etc/rules/ksc_rules.xml

со следующим содержимым

<!-- /var/ossec/etc/rules/ksc_rules.xml -->
<group name="kaspersky,">
  <rule id="100150" level="9">
    <decoded_as>ksc-cef</decoded_as>
    <description>KSC Allert</description>
  </rule>

</group>

и второй

touch /var/ossec/etc/rules/local_group_security_changed.xml
<group name="windows,ad_security,critical_group,">
 <rule id="100111" level="14">
  <if_sid>60141</if_sid>
   <field name="win.eventdata.targetUserName">^GRRS$</field>
    <description>CRITICAL: Пользователь добавлен в группу 'GRRS'</description>
   <group>ad_security_monitoring,pci_dss_10.2.5,gdpr_IV_35.7.d,</group>
 </rule>
 <rule id="100112" level="14">
  <if_sid>60142</if_sid>
   <field name="win.eventdata.targetUserName">^GRRS2$</field>
    <description>CRITICAL: Изменение состава группы 'GRRS2' (удаление/модификация)</description>
   <group>ad_security_monitoring,pci_dss_10.2.5,gdpr_IV_35.7.d,</group>
 </rule>
 <rule id="100127" level="14">
  <if_sid>60142</if_sid>
   <field name="win.eventdata.targetUserName">^GRRS3$</field>
    <description>CRITICAL: Изменение состава группы 'GRRS3' (удаление/модификация)</description>
  <group>ad_security_monitoring,pci_dss_10.2.5,gdpr_IV_35.7.d,</group>
 </rule>
</group>

В ранее созданный файл custom_telegram, добавим следующий код

!/bin/sh

WPYTHON_BIN="framework/python/bin/python3"

SCRIPT_PATH_NAME="$0"

DIR_NAME="$(cd $(dirname ${SCRIPT_PATH_NAME}); pwd -P)"
SCRIPT_NAME="$(basename ${SCRIPT_PATH_NAME})"

case ${DIR_NAME} in
    */active-response/bin | */wodles*)
        if [ -z "${WAZUH_PATH}" ]; then
            WAZUH_PATH="$(cd ${DIR_NAME}/../..; pwd)"
        fi

        PYTHON_SCRIPT="${DIR_NAME}/${SCRIPT_NAME}.py"
    ;;
    */bin)
        if [ -z "${WAZUH_PATH}" ]; then
            WAZUH_PATH="$(cd ${DIR_NAME}/..; pwd)"
        fi

        PYTHON_SCRIPT="${WAZUH_PATH}/framework/scripts/${SCRIPT_NAME}.py"
    ;;
     */integrations)
        if [ -z "${WAZUH_PATH}" ]; then
            WAZUH_PATH="$(cd ${DIR_NAME}/..; pwd)"
        fi

        PYTHON_SCRIPT="${DIR_NAME}/${SCRIPT_NAME}.py"
    ;;
esac


${WAZUH_PATH}/${WPYTHON_BIN} ${PYTHON_SCRIPT} "$@"

Ну и собственно сам скрипт интеграции на Python

Скрипт должен:

  • Парсить JSON-алерт

  • Обогащать данные (например, извлекать targetUserName)

  • Отправлять сообщение в Telegram

  • Создавать задачу в Bitrix24 (кроме событий вроде 60115, где задача не нужна)

#!/usr/bin/env python3

import sys
import json
import requests
import re
import os

# === Bitrix24 Webhook ===
BITRIX_WEBHOOK = "https://{COMPANY_NAME}.bitrix24.ru/rest/{USER_ID}/{BITRIX_WEBHOOK_SECRET}/tasks.task.add"

BITRIX_FLOW_MAP = {
    "100110": "{FLOW_ID_BITRIX}",
    "100112": "{FLOW_ID_BITRIX}",
    "100150": "{FLOW_ID_BITRIX}",
    "60115": "{FLOW_ID_BITRIX}",  # Нужно для прохождения фильтра (задача не создаётся)
}
GROUP_ID = "GROUP_ID_BITRIX"

TASK_TITLE_MAP = {
    "100150": "Kaspersky Security Center",
    "100110": "Windows AD: Изменение группы 'Администраторы домена'",
    "100112": "Windows AD: Изменение группы 'Администраторы предприятия'",
    "60115": "Windows AD: Учётная запись заблокирована (брутфорс)",
}
  RESPONSIBLE_MAP = {
    "100150": {USER_ID_BITRIX},
    "100110": {USER_ID_BITRIX},
    "100112": {USER_ID_BITRIX},
    "60115": {USER_ID_BITRIX},
}

KSC_PRODUCT_NAMES = {
    "1093": "Kaspersky Security Center",
    "1102": "Kaspersky Endpoint Security",
    "1106": "Kaspersky Security for Linux",
    "1112": "Kaspersky Small Office Security",
}

MAIN_CHAT_ID = "-{CHAT_ID_TELEGRAMM}"

def escape_markdown_basic(text):
    if not isinstance(text, str):
        return str(text)
    for char in r'_*[]()~`>#+-=|{}.!':
        text = text.replace(char, '\\' + char)
    return text

def parse_ksc_cef(cef_line):
    fields = {}
    if not cef_line or not isinstance(cef_line, str):
        return fields
    parts = cef_line.split('|', 6)
    if len(parts) < 7:
        return fields
    ext_part = parts[6]
    pairs = re.findall(r'(\w+)=([^=]+?)(?=\s+\w+=|$)', ext_part)
    for key, value in pairs:
        fields[key] = value.strip()
    return fields

def safe_get(d, *keys, default='N/A'):
    for key in keys:
        if isinstance(d, dict) and key in d:
            d = d[key]
        else:
            return default
    return d if d not in (None, '') else default

def build_bitrix_description(alert_json, rule_id, is_windows, data, event_id, host_for_check):
    lines = []

    descriptions = {
        "100110": "🚨 CRITICAL ALERT: Domain Admins group modified!",
        "100112": "🚨 CRITICAL ALERT: Enterprise Admins group modified!",
        "100150": "🚨 Событие Kaspersky Security Center",
        "60115": "🚨 Учётная запись заблокирована из-за множества неудачных попыток входа",
    }
    lines.append(descriptions.get(rule_id, "🚨 Событие безопасности Wazuh"))

    timestamp = alert_json.get('timestamp', 'N/A')
    if timestamp != 'N/A':
        lines.append(f"🕗 Время: {timestamp}")

    alert_level = safe_get(alert_json, 'rule', 'level')
    if alert_level != 'N/A':
        lines.append(f"🚨 Уровень: {alert_level}")

    description = safe_get(alert_json, 'rule', 'description')
    if description != 'N/A':
        lines.append(f"📝 Описание: {description}")

    agent_name = safe_get(alert_json, 'agent', 'name')
    if agent_name != 'N/A':
        lines.append(f"🖥️ Агент: {agent_name}")

    if is_windows:
        target_host = safe_get(data, 'win', 'system', 'computer')
        if target_host != 'N/A':
            lines.append(f"🎯 Целевой хост: {target_host}")

        workstation = safe_get(data, 'win', 'eventdata', 'workstationName')
        if workstation != 'N/A':
            lines.append(f"📍 Источник: {workstation}")

        src_ip = alert_json.get('srcip', 'N/A')
        if src_ip == 'N/A':
            src_ip = safe_get(data, 'win', 'eventdata', 'ipAddress')
        if src_ip != 'N/A':
            lines.append(f"🌐 IP источника: {src_ip}")

        if str(event_id).strip() in ('4728', '4729', '4732', '4733', '4737', '4738', '4746', '4747', '4757'):
            if str(event_id).strip() == "4737":
                lines.append("👤 Состав группы изменён (возможно, удаление участника)")
                subject_user = safe_get(data, 'win', 'eventdata', 'subjectUserName')
                subject_domain = safe_get(data, 'win', 'eventdata', 'subjectDomainName')
                if subject_user != 'N/A':
                    modifier = f"{subject_domain}\\{subject_user}" if subject_domain != 'N/A' else subject_user
                    lines.append(f"✏️ Изменил: {modifier}")
            else:
                member_name = safe_get(data, 'win', 'eventdata', 'memberName')
                affected_user = 'N/A'
                if member_name != 'N/A' and isinstance(member_name, str):
                    if member_name.startswith('CN='):
                        affected_user = member_name[3:].split(',')[0]
                    else:
                        affected_user = member_name
                if affected_user == 'N/A':
                    target_user_fallback = safe_get(data, 'win', 'eventdata', 'targetUserName')
                    if target_user_fallback != 'N/A':
                        affected_user = target_user_fallback
                if affected_user != 'N/A':
                    lines.append(f"👤 Добавлен/удалён: {affected_user}")

                subject_user = safe_get(data, 'win', 'eventdata', 'subjectUserName')
                subject_domain = safe_get(data, 'win', 'eventdata', 'subjectDomainName')
                if subject_user != 'N/A':
                    modifier = f"{subject_domain}\\{subject_user}" if subject_domain != 'N/A' else subject_user
                    lines.append(f"✏️ Изменил: {modifier}")
        else:
            # Обработка остальных событий (включая 4740)
            target_user = safe_get(data, 'win', 'eventdata', 'targetUserName')
            target_domain = safe_get(data, 'win', 'eventdata', 'targetDomainName')
            full_user = None
            if target_user != 'N/A' and target_domain != 'N/A':
                full_user = f"{target_domain}\\{target_user}"
            elif target_user != 'N/A':
                full_user = target_user
            elif target_domain != 'N/A':
                full_user = target_domain
            if full_user:
                lines.append(f"👤 Пользователь: {full_user}")

    if re.search(r'utm', host_for_check, re.IGNORECASE):
        lines.append("⚠️ Примечание: Неверная попытка подключения по VPN")
    if re.search(r'mail1', host_for_check, re.IGNORECASE):
        lines.append("📧 Примечание: Попытка подключения к почтовому серверу с неверными данными")

    full_log = alert_json.get('full_log', '')
    if full_log.strip():
        lines.append("\n--- Полный лог ---")
        lines.append(full_log[:10000])

    return "\n".join(lines)

# === Основной код ===
try:
    alert_json = json.load(sys.stdin)
except Exception:
    if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
        with open(sys.argv[1]) as f:
            alert_json = json.load(f)
    else:
        sys.exit(1)

rule_id = safe_get(alert_json, 'rule', 'id')
if rule_id not in RESPONSIBLE_MAP:
    sys.exit(0)

alert_level = safe_get(alert_json, 'rule', 'level')
description = safe_get(alert_json, 'rule', 'description')
agent_name = safe_get(alert_json, 'agent', 'name')
timestamp = alert_json.get('timestamp', 'N/A')
full_log = alert_json.get('full_log', '')

data = alert_json.get('data', {})
is_windows = isinstance(data, dict) and 'win' in data
event_id = 'N/A'
if is_windows:
    event_id = safe_get(data, 'win', 'system', 'eventID')
    if event_id == 'N/A':
        event_id = safe_get(data, 'win', 'system', 'EventID')

if str(event_id).strip() == "4740":
    description = "Попытка ввода трёх неверных паролей за 2 минуты"

src_ip = alert_json.get('srcip', 'N/A')
if src_ip == 'N/A' and isinstance(data, dict):
    src_ip = safe_get(data, 'win', 'eventdata', 'ipAddress')
    if src_ip == 'N/A':
        src_ip = data.get('srcip', 'N/A')

target_host = 'N/A'
if is_windows:
    target_host = safe_get(data, 'win', 'system', 'computer')
host_for_check = target_host if target_host != 'N/A' else agent_name

is_group_change = False
if is_windows and str(event_id).strip() in ('4728', '4729', '4732', '4733', '4737', '4738', '4746', '4747', '4757'):
    is_group_change = True

# === Формирование Telegram-сообщения ===
lines = []
lines.append("🪟 *Windows Allert*" if is_windows else "🐧 *Non-Windows Allert*")

if timestamp != 'N/A':
    lines.append(f"🕗 *Время:* {timestamp}")
if rule_id != 'N/A':
    lines.append(f"🆔 *Rule ID:* {rule_id}")
if alert_level != 'N/A':
    lines.append(f"🚨 *Уровень:* {alert_level}")
if description != 'N/A':
    lines.append(f"📝 *Description:* {description}")
if agent_name != 'N/A':
    lines.append(f"🖥️ *Agent:* {agent_name}")

if rule_id == "100110":
    lines.insert(1, "🚨 *CRITICAL ALERT: Domain Admins group modified!*")
elif rule_id == "100112":
    lines.insert(1, "🚨 *CRITICAL ALERT: Enterprise Admins group modified!*")
elif rule_id in ("100111", "100112"):
    lines.insert(1, "🚨 *CRITICAL: Изменение членства в группах доступа*")
elif rule_id == "60115":
    lines.insert(1, "🔒 *ALERT: Учётная запись заблокирована!*")

if is_windows:
    if target_host != 'N/A':
        lines.append(f"🎯 *Целевой Хост:* {target_host}")
    workstation = safe_get(data, 'win', 'eventdata', 'workstationName')
    if workstation != 'N/A':
        lines.append(f"📍 *Источник:* {workstation}")
    if src_ip != 'N/A':
        lines.append(f"🌐 *IP Источника:* {src_ip}")

    if is_group_change:
        event_id_str = str(event_id).strip()
        if event_id_str == "4737":
            lines.append("👤 Состав группы изменён (возможно, удаление участника)")
            subject_user = safe_get(data, 'win', 'eventdata', 'subjectUserName')
            subject_domain = safe_get(data, 'win', 'eventdata', 'subjectDomainName')
            if subject_user != 'N/A':
                modifier = f"{subject_domain}\\{subject_user}" if subject_domain != 'N/A' else subject_user
                lines.append(f"✏️ Изменил: {escape_markdown_basic(modifier)}")
        else:
            member_name = safe_get(data, 'win', 'eventdata', 'memberName')
            affected_user = 'N/A'
            if member_name != 'N/A' and isinstance(member_name, str):
                if member_name.startswith('CN='):
                    affected_user = member_name[3:].split(',')[0]
                else:
                    affected_user = member_name
            if affected_user == 'N/A':
                target_user_fallback = safe_get(data, 'win', 'eventdata', 'targetUserName')
                if target_user_fallback != 'N/A':
                    affected_user = target_user_fallback
            if affected_user != 'N/A':
                lines.append(f"👤 Добавлен/удалён: {escape_markdown_basic(affected_user)}")

            subject_user = safe_get(data, 'win', 'eventdata', 'subjectUserName')
            subject_domain = safe_get(data, 'win', 'eventdata', 'subjectDomainName')
            if subject_user != 'N/A':
                modifier = f"{subject_domain}\\{subject_user}" if subject_domain != 'N/A' else subject_user
                lines.append(f"✏️ Изменил: {escape_markdown_basic(modifier)}")
    else:
        # Обработка остальных событий (включая 4740)
        target_user = safe_get(data, 'win', 'eventdata', 'targetUserName')
        target_domain = safe_get(data, 'win', 'eventdata', 'targetDomainName')
        full_user = None
        if target_user != 'N/A' and target_domain != 'N/A':
            full_user = f"{target_domain}\\{target_user}"
        elif target_user != 'N/A':
            full_user = target_user
        elif target_domain != 'N/A':
            full_user = target_domain
        if full_user:
            lines.append(f"👤 Пользователь: {escape_markdown_basic(full_user)}")

telegram_msg = "\n".join(lines)
bitrix_description = build_bitrix_description(alert_json, rule_id, is_windows, data, event_id, host_for_check)

# === Определение чата Telegram ===
if rule_id in ("100110", "100111"):
    telegram_chat_id = "-{CHAT_ID_TELEGRAMM}"
    telegram_thread_id = 5
elif rule_id == "100150":
    log_lower = full_log.lower()
    noise_keywords = [
        "облегченный поиск", "обновление баз", "проверка лицензии", "авто установка",
        "загрузка обновлений", "test_siem_connection", "автоматическая установка",
        "базы обновлены", "устройство давно не подключалось", "установка kaspersky endpoint", "отчет",
        "поиск вредоносного по", "обнаружено новое устройство", "было недоступно",
        "облегченный еженедельный", "добавление компонентов bitlocker",
        "поиск и удаление", "предупреждение", "добавлено", "поиск уязвимостей",
        "аудит (модификация объектов)", "устройство удалено", "операция с устройством запрещена",
        "статус шифрования данных", "автоматически перемещено", "обновление для пк",
        "устройство стало неуправляемым", "базы устарели"
    ]
  #=== Выше фильтрация спама который нам не нужно получать от KSC, можно реализовать через xml правила, но так проще и быстрее
    if any(kw in log_lower for kw in noise_keywords):
        sys.exit(0)

    cef = parse_ksc_cef(full_log)
    host = cef.get('dhost', 'N/A')
    ip = cef.get('dst', 'N/A')
    group = cef.get('cs9', 'N/A')
    product = KSC_PRODUCT_NAMES.get(cef.get('cs2', 'N/A'), cef.get('cs2', 'N/A'))
    task_name = cef.get('cs10', 'N/A')
    task_id = cef.get('cs4', 'N/A')
    task_state = cef.get('cn2', 'N/A')
    state_desc = ""
    if task_state != 'N/A':
        states = {"0": "Неизвестно", "1": "Выполняется", "2": "Успешно", "3": "Ошибка", "4": "Отменено"}
        state_desc = f" → {states.get(task_state, task_state)}"

    event_msg = cef.get('msg', '').strip()
    if task_name != 'N/A':
        task_line = f"📋 Задача: {task_name} (ID: {task_id}){state_desc}"
    elif event_msg:
        task_line = f"💬 Сообщение: {event_msg}"
    else:
        task_line = f"📝 Тип: {description or 'Событие KSC'}"

    telegram_msg = (
        f"🚨 *KSC Событие*\n"
        f"🆔 Rule ID: {rule_id}\n"
        f"📊 Уровень: {alert_level}\n"
        f"🖥️ Хост: {host} ({ip})\n"
        f"📂 Группа: {group}\n"
        f"📦 Продукт: {product}\n"
        f"{task_line}"
    )
    telegram_msg = escape_markdown_basic(telegram_msg)
    telegram_chat_id = "-{CHAT_ID_TELEGRAMM}"
    telegram_thread_id = 20
elif "Попытка подключения к почтовому серверу с неверными данными" in telegram_msg:
    telegram_chat_id = "-{CHAT_ID_TELEGRAMM}"
    telegram_thread_id = 14
elif "Попытка ввода трёх неверных паролей за 2 минуты" in telegram_msg:
    telegram_chat_id = "-{CHAT_ID_TELEGRAMM}"
    telegram_thread_id = 3
elif "Неверная попытка подключения по VPN" in telegram_msg:
    telegram_chat_id = "-{CHAT_ID_TELEGRAMM}"
    telegram_thread_id = 18
else:
    telegram_chat_id = MAIN_CHAT_ID
    telegram_thread_id = None

# === Отправка в Telegram ===
telegram_payload = {
    'chat_id': telegram_chat_id,
    'text': telegram_msg,
    'parse_mode': 'Markdown'
}
if telegram_thread_id:
    telegram_payload['message_thread_id'] = telegram_thread_id

try:
    HOOK_URL = "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    requests.post(HOOK_URL, json=telegram_payload, timeout=10)
except Exception:
    pass

# === Отправка в Bitrix24 (кроме 60115) ===
if rule_id != "60115":
    flow_id = BITRIX_FLOW_MAP[rule_id]
    title = TASK_TITLE_MAP.get(rule_id, f"Wazuh Alert: {rule_id}")
    if rule_id == "100150":
        cef = parse_ksc_cef(full_log)
        task_name = cef.get('cs10', 'N/A')
        if task_name != 'N/A':
            title = f"KSC: {task_name}"

    bitrix_payload = {
        "fields": {
            "TITLE": title,
            "DESCRIPTION": bitrix_description,
            "RESPONSIBLE_ID": RESPONSIBLE_MAP[rule_id],
            "AUDITORS": [{USER_ID_BITRIX}, {USER_ID_BITRIX}, {USER_ID_BITRIX}],
            "STATUS": "2",
            "PRIORITY": "1",
            "GROUP_ID": GROUP_ID,
            "FLOW_ID": flow_id
        }
    }

    try:
        requests.post(BITRIX_WEBHOOK, json=bitrix_payload, timeout=10)
    except Exception:
        pass