Как встроить ИИ-ассистент в Wazuh - Академия Selectel

Как встроить ИИ-ассистент в Wazuh

Андрей Ефремов
Андрей Ефремов Руководитель направления безопасности облака
2 октября 2026

Подключаем к Wazuh свою LLM через OpenSearch Assistant: вопросы к данным словами, генерация запросов PPL и объяснение алертов в один клик.

Изображение записи

Wazuh хорошо собирает события безопасности, но чтобы найти в них ответ, нужно знать язык запросов и сотни полей индекса. Опытный инженер SOC напишет запрос за минуту, но вот коллегам, которые не работали в Wazuh или с SIEM-системами, это сделать не так просто.

Дашборд Wazuh построен на OpenSearch Dashboards, а у OpenSearch есть готовый ассистент — OpenSearch Assistant. В поставку Wazuh он не входит, но ставится штатными плагинами той же версии, без переделки самого Wazuh. В статье мы установим его, подключим собственную модель и добавим в интерфейс кнопку, которая объясняет любой документ.

Инструкцию мы проверили на Wazuh 4.14.7, в основе которого — OpenSearch 2.19.5. Модель подойдет любая с OpenAI-совместимым API. На всю настройку уйдет около двух часов, из них 30–60 минут займет сборка плагина для кнопки Explain Document.

После настройки в дашборде появятся три инструмента.

  • Окно чата. Аналитик задает вопрос своими словами и получает в ответ от LLM.
  • Query Assist в Discover. «Словесный» вопрос превращается в запрос PPL (Piped Processing Language, язык запросов OpenSearch), а результат открывается обычной таблицей.
  • Кнопка Explain Document в панели просмотра документа. Модель разбирает алерт или запись об уязвимости: что произошло, насколько это серьезно и что проверить.

Как это работает

Вопрос аналитика проходит следующий путь:

Путь вопроса аналитика. Аналитик → Дашборд → Индексатор → LLM.
  • Плагины дашборда рисуют окно чата, строку Query Assist и кнопку. Сами они ничего не вычисляют, а передают вопрос в индексатор.
  • Плагин ml-commons в индексаторе хранит подключение к модели и агентов. Агент — это сценарий работы: какой промпт отправить модели и какие инструменты ей дать. Главный инструмент здесь — PPLTool. Он просит модель составить запрос PPL и сам его выполняет.
  • Модель работает отдельно. Индексатор обращается к ней по HTTPS через коннектор — объект с адресом, форматом запроса и правилом разбора ответа.

Настраивать будем в том же порядке: плагины, модель, агенты, а на конец оставим кнопку в интерфейсе.

Требования

Инструкция проверена на следующей конфигурации:

  • Wazuh 4.14.7 в Docker (репозиторий wazuh-docker, вариант single-node). Внутри Wazuh 4.14.7 работает OpenSearch 2.19.5;
  • модель qwen3-next-80b за OpenWebUI. Подойдет любая модель с OpenAI-совместимым API, которая уверенно следует инструкциям.

Подготовьте заранее:

  • адрес API модели, имя модели и токен;
  • root-доступ к серверу, где работают индексатор и дашборд Wazuh;
  • для кнопки Explain Document — машину с Linux и Docker, 8 ГБ оперативной памяти и доступом к GitHub и npm.

Версии плагинов должны совпадать с OpenSearch. Плагин другой версии просто не загрузится. Узнать версию OpenSearch можно в Dev Tools запросом GET /, поле version.number. Для Wazuh 4.14.7 это 2.19.5. Если у вас другая версия Wazuh, замените 2.19.5 во всех командах на свою.

Большая часть команд выполняется в меню консоли дашборда: Indexer management → Dev Tools. Консоль сама подставляет адрес индексатора и авторизацию. Не перепутайте ее с пунктом Server management → Dev Tools: там консоль API менеджера Wazuh, запросы из статьи в ней не сработают. Там, где нужен терминал сервера, это указано над блоком кода.

При этом в командах встречаются значения, которые нужно заменить на свои:

ЗначениеНа что заменить
<LLM_URL>Адрес API модели — например, https://llm.example.com.
<LLM_PATH>Путь к методу чата: /v1/chat/completions. У OpenWebUI — это /api/chat/completions.
<LLM_MODEL>Имя модели так, как его понимает API.
<LLM_API_KEY>Токен доступа к модели.
<CONNECTOR_ID>, <MODEL_ID>Идентификаторы, которые вернут команды шага 3.
<CHAT_AGENT_ID>, <PPL_AGENT_ID>, <SUMMARY_AGENT_ID>Идентификаторы агентов из шагов 4–6.
<INDEXER_CONTAINER>, <DASHBOARD_CONTAINER>Имена контейнеров индексатора и дашборда. Посмотреть их можно командой docker ps.

Шаг 1. Установка плагинов

Ассистенту нужны два плагина индексатора (opensearch-skills и opensearch-flow-framework) и три плагина дашборда (assistantDashboards, mlCommonsDashboards, observabilityDashboards). Отдельных архивов у них нет, поэтому возьмем их из официальной сборки OpenSearch той же версии. Архивы весят около 1,3 ГБ.

Выполните на сервере Wazuh следующие шаги.

1. Скачайте сборки OpenSearch и OpenSearch Dashboards и распакуйте из них только нужные плагины:


      mkdir -p /opt/wazuh-llm && cd /opt/wazuh-llm
URL=https://artifacts.opensearch.org/releases/bundle
curl -LO $URL/opensearch/2.19.5/opensearch-2.19.5-linux-x64.tar.gz
curl -LO $URL/opensearch-dashboards/2.19.5/opensearch-dashboards-2.19.5-linux-x64.tar.gz
mkdir -p indexer dashboard
tar -xzf opensearch-2.19.5-linux-x64.tar.gz -C indexer --strip-components=2 \
  opensearch-2.19.5/plugins/opensearch-skills \
  opensearch-2.19.5/plugins/opensearch-flow-framework
tar -xzf opensearch-dashboards-2.19.5-linux-x64.tar.gz -C dashboard \
  --strip-components=2 \
  opensearch-dashboards-2.19.5/plugins/assistantDashboards \
  opensearch-dashboards-2.19.5/plugins/mlCommonsDashboards \
  opensearch-dashboards-2.19.5/plugins/observabilityDashboards
ls indexer dashboard

2. Подключите плагины.

Если Wazuh работает в Docker, назначьте владельцем файлов пользователя, имеющего UID 1000 — под ним работают процессы в контейнерах:


      chown -R 1000:1000 /opt/wazuh-llm/indexer /opt/wazuh-llm/dashboard

Затем добавьте тома в docker-compose.yml: первые две строки в раздел volumes сервиса wazuh.indexer, остальные три — в раздел volumes сервиса wazuh.dashboard.


      wazuh.indexer:
    volumes:
      - /opt/wazuh-llm/indexer/opensearch-skills:/usr/share/wazuh-indexer/plugins/opensearch-skills
      - /opt/wazuh-llm/indexer/opensearch-flow-framework:/usr/share/wazuh-indexer/plugins/opensearch-flow-framework
 
  wazuh.dashboard:
    volumes:
      - /opt/wazuh-llm/dashboard/assistantDashboards:/usr/share/wazuh-dashboard/plugins/assistantDashboards
      - /opt/wazuh-llm/dashboard/mlCommonsDashboards:/usr/share/wazuh-dashboard/plugins/mlCommonsDashboards
      - /opt/wazuh-llm/dashboard/observabilityDashboards:/usr/share/wazuh-dashboard/plugins/observabilityDashboards

      docker compose up -d

Если Wazuh установлен из пакетов, скопируйте плагины в каталоги сервисов и перезапустите их:


      cp -r /opt/wazuh-llm/indexer/* /usr/share/wazuh-indexer/plugins/
cd /usr/share/wazuh-indexer/plugins
chown -R wazuh-indexer:wazuh-indexer opensearch-skills opensearch-flow-framework
cp -r /opt/wazuh-llm/dashboard/* /usr/share/wazuh-dashboard/plugins/
cd /usr/share/wazuh-dashboard/plugins
chown -R wazuh-dashboard:wazuh-dashboard \
  assistantDashboards mlCommonsDashboards observabilityDashboards
systemctl restart wazuh-indexer wazuh-dashboard

3. Проверьте, что индексатор загрузил плагины. В ответе должны быть строки opensearch-skills 2.19.5.0 и opensearch-flow-framework 2.19.5.0:


      GET _cat/plugins?v&h=component,version

Шаг 2. Включение ассистента

1. Включите агентов в ml-commons и разрешите подключение к вашей модели. В последнем параметре замените адрес llm.example.com на хост своей модели:


      PUT _cluster/settings
{
  "persistent": {
    "plugins.ml_commons.agent_framework_enabled": true,
    "plugins.ml_commons.memory_feature_enabled": true,
    "plugins.ml_commons.only_run_on_ml_node": false,
    "plugins.ml_commons.connector.private_ip_enabled": true,
    "plugins.ml_commons.trusted_connector_endpoints_regex": [
      "^https://llm\\.example\\.com/.*$"
    ]
  }
}

Рассмотрим подробнее, что делают эти настройки.

  • agent_framework_enabled и memory_feature_enabled включают агентов и память диалога. Без памяти не будут работать уточняющие вопросы вроде «а сколько событий из них критичны?».
  • only_run_on_ml_node: false разрешает работу моделей на обычных узлах. По умолчанию ml-commons ждет отдельных ML-узлов, которых в Wazuh нет.
  • connector.private_ip_enabled нужен, если модель находится во внутренней сети.
  • trusted_connector_endpoints_regex — список разрешенных адресов моделей. По умолчанию там только облачные сервисы вроде OpenAI и Bedrock. Это регулярное выражение: каждая точка в имени хоста записывается как \\., иначе она совпадет с любым символом.

В ответе должно прийти "acknowledged": true. Перезапуск индексатора не нужен.

2. Включите интерфейс ассистента. Добавьте в конец opensearch_dashboards.yml пять строк. В Docker этот файл лежит на сервере в config/wazuh_dashboard/opensearch_dashboards.yml рядом с docker-compose.yml, в пакетной установке — в /etc/wazuh-dashboard/opensearch_dashboards.yml:


      assistant.chat.enabled: true
assistant.incontextInsight.enabled: true
assistant.alertInsight.enabled: true
observability.query_assist.enabled: true
observability.summarize.enabled: true

3. Перезапустите дашборд:


      docker restart <DASHBOARD_CONTAINER>
# или в пакетной установке:
systemctl restart wazuh-dashboard

После перезапуска в шапке дашборда появится строка Ask a question, а по нажатию на нее откроется окно OpenSearch Assistant. Отвечать оно пока не сможет, так как модель еще не подключена.

Скриншот окна ассистента после шага 2.
Окно ассистента после шага 2.

Шаг 3. Подключение модели

Индексатор не запускает модель у себя, а обращается к ней по API. Для этого нужны коннектор и зарегистрированная поверх него модель.

1. Создайте коннектор. Замените значения в угловых скобках на свои и выполните запрос целиком:


      POST _plugins/_ml/connectors/_create
{
  "name": "LLM chat",
  "description": "OpenAI-compatible chat endpoint",
  "version": "1",
  "protocol": "http",
  "parameters": {
    "endpoint": "<LLM_URL>",
    "path": "<LLM_PATH>",
    "model": "<LLM_MODEL>",
    "temperature": 0,
    "max_tokens": 2048
  },
  "credential": {
    "api_key": "<LLM_API_KEY>"
  },
  "actions": [
    {
      "action_type": "predict",
      "method": "POST",
      "url": "${parameters.endpoint}${parameters.path}",
      "headers": {
        "Authorization": "Bearer ${credential.api_key}",
        "Content-Type": "application/json"
      },
      "request_body": """{ "model": "${parameters.model}", "messages": [{"role": "user", "content": "${parameters.prompt}"}], "temperature": ${parameters.temperature}, "max_tokens": ${parameters.max_tokens}, "stream": false }""",
      "post_process_function": """
def text = params['choices'][0]['message']['content'];
if (text == null) { text = ""; }
String t = text.toString().trim();
int te = t.indexOf("</think>");
if (te >= 0) { t = t.substring(te + 8).trim(); }
if (t.startsWith("```")) {
  t = t.replace("```json", " ").replace("```JSON", " ").replace("```ppl", " ").replace("```", " ").trim();
}
t = t.replace("<cppl>", "<ppl>").replace("</cppl>", "</ppl>");
return '{"name":"response","dataAsMap":{"response":"' + escape(t) + '"}}';
"""
    }
  ]
}

Тройные кавычки """ — это возможность консоли Dev Tools: текст между ними можно писать как есть, а консоль сама превратит его в JSON-строку. В двух местах коннектор отличается от стандартного шаблона OpenAI, и оба отличия обязательны:

  • request_body подставляет в запрос параметр ${parameters.prompt}. Именно его передают агенты ассистента. Стандартный шаблон с ${parameters.messages} для агентов не подходит: модель получает сломанный запрос, а ml-commons отвечает Invalid payload.
  • post_process_function достает текст из ответа модели и возвращает его в виде {"response": "..."}, как этого ждут инструменты. Заодно функция убирает блок рассуждений <think> и обертку Markdown, которые добавляют некоторые модели.

В ответе придет connector_id. Запишите его, это <CONNECTOR_ID>.

2. Зарегистрируйте и запустите модель:


      POST _plugins/_ml/models/_register?deploy=true
{
  "name": "llm-chat",
  "function_name": "remote",
  "description": "LLM for OpenSearch Assistant",
  "connector_id": "<CONNECTOR_ID>"
}

В ответе придет model_id — это <MODEL_ID>. Модель работает на стороне API OpenWebUI, поэтому запускается за несколько секунд. Проверить состояние можно запросом GET _plugins/_ml/models/<MODEL_ID>: поле model_state должно быть DEPLOYED.

3. Проверьте, что модель отвечает:


      POST _plugins/_ml/models/<MODEL_ID>/_predict
{
  "parameters": {
    "prompt": "Ответь одним словом: работает?"
  }
}

В ответе должно быть поле dataAsMap.response с коротким ответом модели. Если пришла ошибка, проверьте адрес, путь, токен и регулярное выражение из шага 2.

Шаг 4. Агент чата

В окне чата общение происходит не с моделью, а с агентом. Агент принимает вопрос, при необходимости вызывает PPLTool, получает результат запроса и формулирует ответ. Поведение агента задают три текста, ознакомиться с ними можно под спойлерами ниже.

Приложение А — системный промпт: отвечать по-русски, не писать запросы самому, показывать выполненный запрос.

Вставляется в параметр prompt.prefix в шаге 4.

Ты помощник дежурного аналитика SOC. Данные — алерты Wazuh в индексе wazuh-alerts-*.

Отвечай по-русски, коротко, без пересказа своих шагов.

 

ГЛАВНОЕ ПРАВИЛО. Аналитик должен иметь возможность перепроверить каждое число.

Поэтому КАЖДЫЙ ответ, даже на простой вопрос «сколько», содержит выполненный запрос

PPL в блоке кода. Ответ без блока с запросом недопустим. Вид ответа:

 

**Ответ.** <числа и суть, 1-2 фразы>

 

**Запрос.**

“`sql

<точный PPL, который выполнил инструмент>

“`

 

КАК ИСКАТЬ ДАННЫЕ

Запросы PPL ты не пишешь сам. Чтобы получить число или список событий, вызови

инструмент TransferQuestionToPPLAndExecuteTool и передай в поле question вопрос

аналитика словами. Инструмент сам построит запрос по схеме индекса и выполнит его.

  Правильно:   {“question”: “Сколько алертов уровня 10 и выше за сегодня?”}

  Неправильно: {“question”: “source=wazuh-alerts-* | where …”}

Если аналитик назвал номер правила, группу, адрес или имя пользователя, перенеси их

в вопрос дословно. Номера правил по памяти не подставляй: в каждом кластере они свои.

Число в ответе должно прийти из результата инструмента. Если инструмент еще не

вызывался, ответа у тебя нет.

 

СОСТАВНЫЕ ВОПРОСЫ

Если в вопросе две части, которым нужны разные подсчеты (например «сколько всего» и

«с какого адреса больше всего»), вызови инструмент отдельно для каждой части.

Финальный ответ дай только после того, как получил результаты по всем частям.

 

УТОЧНЯЮЩИЕ ВОПРОСЫ

Если вопрос опирается на предыдущий, сделай из него самостоятельный вопрос: повтори

период и условия.

  «А сколько из них уровня 13 и выше?» после вопроса про сегодня

  превращается в «Сколько алертов уровня 13 и выше за сегодня?»

Если аналитик сменил тему, условия прошлых вопросов не переноси.

 

ЧЕСТНЫЙ ОТВЕТ

Период в ответе должен совпадать с периодом в выполненном запросе. Если фильтра по

времени нет, пиши «за все время».

Если события есть, но нужное поле в них пустое, так и скажи. Это не то же самое,

что «событий нет».

Если результат пустой, а период аналитик не называл, повтори вопрос инструменту,

добавив «за все время», и отвечай по второму результату.

Никогда не отвечай пустым сообщением. Если запрос не удался, напиши об этом и

покажи последний запрос, который пытался выполнить.

 

ФОРМАТ ОТВЕТА, СОБЛЮДАТЬ СТРОГО

Отвечай в Markdown. Эта структура обязательна для КАЖДОГО ответа без исключений,

включая простые счетные вопросы и объяснение правила по номеру:

 

**Ответ.** Одно-два предложения по существу вопроса, с числами.

 

**Запрос.**

“`sql

<сюда точный PPL, который выполнил инструмент>

“`

 

Если есть важная оговорка (период, пустое поле), добавь строку:

 

**Замечание.** Одна фраза.

 

Правила формата:

– В блок кода вставляй точный запрос из поля ppl ответа инструмента, не переписывай

  его по памяти.

– Если инструмент вызывался несколько раз, покажи в блоке **Запрос.** все выполненные

  запросы, каждый в своем блоке кода.

– Служебный JSON при вызове инструмента в тройные кавычки не оборачивай.

– Не пересказывай свои шаги и не используй теги think.



Приложение Б — шаблон, по которому PPLTool просит модель составить запрос. В нем правила языка PPL для индексов Wazuh и примеры.

Вставляется в параметр prompt инструмента PPLTool в шагах 4 и 5. Перед вставкой замените <ВСТАВЬТЕ_СПИСОК_ГРУПП> на список групп вашего кластера. Строки ${indexInfo.indexName}, ${indexInfo.mappingInfo} и ${indexInfo.question} не меняйте: вместо них PPLTool подставит имя индекса, список его полей и вопрос.

Ты переводишь вопрос аналитика в один запрос PPL (Piped Processing Language) для OpenSearch.

Используй только поля из списка полей индекса в конце этого текста.

ПРАВИЛА ДЛЯ ИНДЕКСОВ WAZUH

 

  1. ИМЕНА С ТОЧКАМИ

Имя индекса и все поля с точками заключай в обратные кавычки:

  source=`wazuh-alerts-*` | where `rule.id` = ‘5710’

 

  1. ТИПЫ ОСНОВНЫХ ПОЛЕЙ

`timestamp` — date. `rule.id` — keyword, сравнивай со строкой в кавычках: ‘5710’.

`rule.level` — число, сравнивай с числом: `rule.level` >= 10.

`rule.groups`, `agent.name`, `agent.id`, `data.srcip`, `data.dstuser` — keyword.

`rule.description` — keyword, текст описания правила на английском.

 

  1. ФИЛЬТР ПО ВРЕМЕНИ

Ставь фильтр по `timestamp` в каждом запросе. Используй только эти формы:

  последние N минут   `timestamp` >= adddate(now(), interval -N minute)

  последние N часов   `timestamp` >= adddate(now(), interval -N hour)

  сегодня             `timestamp` >= curdate()

  за сутки            `timestamp` >= adddate(now(), interval -24 hour)

  вчера               `timestamp` >= adddate(curdate(), interval -1 day) and `timestamp` < curdate()

  за N дней           `timestamp` >= adddate(now(), interval -N day)

Выражение now() – interval 1 day эта версия PPL не принимает.

«Вчера» — это всегда два условия: нижняя и верхняя граница.

Если период не назван, бери последние 7 дней.

Если аналитик просит «за все время» или объяснить правило по номеру, фильтр по

времени не ставь.

 

  1. КАК ОТБИРАТЬ СОБЫТИЯ

Назван номер правила — фильтруй по нему: `rule.id` = ‘5710’.

Если номер правила в вопросе не назван, `rule.id` в запросе не используй вообще:

номера правил в каждом кластере свои, а угаданный номер дает неверный ответ.

Несколько номеров — через or: `rule.id` = ‘5710’ or `rule.id` = ‘5712’.

Названа тема — фильтруй по группе. Штатные группы Wazuh:

  authentication_failed   неудачные входы (SSH, PAM и другие)

  authentication_success  успешные входы

  sshd                    все события SSH, и успешные, и неудачные

  syscheck                изменения файлов (контроль целостности)

  `rule.groups` = ‘authentication_failed’

Группы этого кластера перечислены в списке ниже. Бери группу только из списка.

Если подходящей группы нет, ищи по описанию правила:

  like(`rule.description`, ‘%brute force%’)

 

  1. ПОДСЧЕТЫ И СОРТИРОВКА

Уникальные значения: distinct_count(`поле`). count(distinct …) не работает.

Непустое поле: isnotnull(`поле`). Конструкция is not null не работает.

Сортируй по имени агрегата: stats count() as `cnt` by `data.srcip` | sort – `cnt`.

 

  1. ФОРМАТ ВЫВОДА

Верни ровно одну строку <ppl>ЗАПРОС</ppl> и больше ничего: без пояснений, без

других тегов и без тройных кавычек.

 

ГРУППЫ ПРАВИЛ ЭТОГО КЛАСТЕРА

<ВСТАВЬТЕ_СПИСОК_ГРУПП>

 

ПРИМЕРЫ

Question: Сколько алертов за сегодня?

PPL: <ppl>source=`wazuh-alerts-*` | where `timestamp` >= curdate() | stats count()</ppl>

 

Question: Сколько алертов уровня 10 и выше было вчера?

PPL: <ppl>source=`wazuh-alerts-*` | where `timestamp` >= adddate(curdate(), interval -1 day) and `timestamp` < curdate() and `rule.level` >= 10 | stats count()</ppl>

 

Question: С каких адресов больше всего неудачных входов за неделю?

PPL: <ppl>source=`wazuh-alerts-*` | where `timestamp` >= adddate(now(), interval -7 day) and `rule.groups` = ‘authentication_failed’ | stats count() as `cnt` by `data.srcip` | sort – `cnt` | head 5</ppl>

 

Question: Сколько уникальных агентов прислали алерты за сутки?

PPL: <ppl>source=`wazuh-alerts-*` | where `timestamp` >= adddate(now(), interval -24 hour) | stats distinct_count(`agent.id`) as `agents`</ppl>

 

Question: Покажи последние события по правилу 5712

PPL: <ppl>source=`wazuh-alerts-*` | where `rule.id` = ‘5712’ and `timestamp` >= adddate(now(), interval -7 day) | fields `timestamp`, `agent.name`, `rule.description`, `data.srcip` | head 5</ppl>

 

Question: Объясни правило 5710

PPL: <ppl>source=`wazuh-alerts-*` | where `rule.id` = ‘5710’ | fields `rule.id`, `rule.level`, `rule.description`, `rule.groups` | head 1</ppl>

 

—————-

Индекс: `${indexInfo.indexName}`

Поля индекса в формате «имя: тип (пример значения)»:

${indexInfo.mappingInfo}

 

Question: ${indexInfo.question}

PPL:

Приложение В — инструкция формата ответа. Она заменяет стандартную инструкцию ml-commons, которая просит модель ответить одним предложением. Без замены блок с запросом пропадает из ответов на простые вопросы.

Вставляется в параметр prompt.format_instruction в шаге 4. Это стандартная инструкция ml-commons 2.19.5, в которой изменено только описание поля final_answer. Остальной текст должен совпадать дословно: агент разбирает ответы модели по этой схеме.

Human:RESPONSE FORMAT INSTRUCTIONS

—————————-

Output a JSON markdown code snippet containing a valid JSON object in one of two formats:

 

**Option 1:**

Use this if you want the human to use a tool.

Markdown code snippet formatted in the following schema:

 

“`json

{

    “thought”: string, // think about what to do next: if you know the final answer just return “Now I know the final answer”, otherwise suggest which tool to use.

    “action”: string, // The action to take. Must be one of these tool names: [${parameters.tool_names}], do NOT use any other name for action except the tool names.

    “action_input”: string // The input to the action. May be a stringified object.

}

“`

 

**Option #2:**

Use this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:

 

“`json

{

    “thought”: “Now I know the final answer”,

    “final_answer”: string // Markdown in exactly the answer format from the instructions above: “**Ответ.** …” then “**Запрос.**” with every executed PPL query in a sql code block. Never a bare sentence.

}

“`

Приложение Г — создается в шаге 6.2 по пути plugins/main/public/components/common/wazuh-discover/components/incontext-insight.tsx.

      import React, { useState } from 'react';
import {
  EuiButtonEmpty,
  EuiCallOut,
  EuiFlexGroup,
  EuiFlexItem,
  EuiLoadingSpinner,
  EuiMarkdownFormat,
  EuiSpacer,
  EuiText,
} from '@elastic/eui';
 
// Кнопка Explain Document в панели просмотра документа.
// Отправляет документ агенту os_summary через маршрут /api/assistant/summary.
 
// Документ передается строкой с текстовым префиксом, а не чистым JSON:
// чистый JSON ml-commons не экранирует, и запрос к модели ломается (Invalid payload).
export const buildDocContext = (source: any) => {
  if (!source) {
    return '';
  }
 
  try {
    return `Документ Wazuh (JSON):\n${JSON.stringify(source, null, 2)}`;
  } catch {
    return '';
  }
};
 
const ExplainDocument = ({ context }: { context: string }) => {
  const [state, setState] = useState<{
    loading: boolean;
    answer?: string;
    error?: string;
  }>({ loading: false });
 
  const ask = async () => {
    setState({ loading: true });
 
    try {
      const response = await fetch('/api/assistant/summary', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'osd-xsrf': 'true' },
        body: JSON.stringify({
          summaryType: 'alerts',
          question: 'Explain this document',
          context,
        }),
      });
 
      if (!response.ok) {
        throw new Error(`server responded ${response.status}`);
      }
 
      const body = await response.json();
 
      setState({
        loading: false,
        answer: body?.summary ?? JSON.stringify(body).slice(0, 800),
      });
    } catch (error) {
      setState({ loading: false, error: String(error) });
    }
  };
 
  return (
    <>
      <EuiFlexGroup alignItems='center' gutterSize='s' responsive={false}>
        <EuiFlexItem grow={false}>
          <EuiButtonEmpty
            aria-label='explain-document'
            data-test-subj='wzExplainDocument'
            iconType='generate'
            size='s'
            isDisabled={state.loading}
            onClick={ask}
          >
            Explain Document
          </EuiButtonEmpty>
        </EuiFlexItem>
        {state.loading && (
          <EuiFlexItem grow={false}>
            <EuiLoadingSpinner size='m' />
          </EuiFlexItem>
        )}
      </EuiFlexGroup>
      {(state.answer || state.error) && (
        <>
          <EuiSpacer size='s' />
          <EuiCallOut
            color={state.error ? 'danger' : 'primary'}
            iconType={state.error ? 'alert' : 'generate'}
            size='s'
            title={state.error ? 'Failed to explain' : 'Assistant explanation'}
          >
            {state.error ? (
              <EuiText size='s'>
                <p style={{ whiteSpace: 'pre-wrap' }}>{state.error}</p>
              </EuiText>
            ) : (
              <EuiMarkdownFormat>{state.answer ?? ''}</EuiMarkdownFormat>
            )}
          </EuiCallOut>
        </>
      )}
      <EuiSpacer size='s' />
    </>
  );
};
 
export const withIncontextInsight = (
  item: any,
  _docId: string,
  component: JSX.Element,
) => {
  try {
    const context = buildDocContext(item?._source ?? item);
 
    if (!context) {
      return component;
    }
 
    return (
      <>
        <ExplainDocument context={context} />
        {component}
      </>
    );
  } catch (error) {
    // при любой ошибке показываем документ без кнопки
    return component;
  }
};

1. Соберите список групп правил вашего кластера. Модель должна искать события по группам (rule.groups), которые реально есть в ваших данных, а не угадывать номера правил: номера у каждого кластера свои.


      GET wazuh-alerts-*/_search
{
  "size": 0,
  "query": { "range": { "timestamp": { "gte": "now-30d" } } },
  "aggs": {
    "groups": { "terms": { "field": "rule.groups", "size": 50 } }
  }
}

Из ответа выпишите значения key через запятую. Группы стандартов соответствия (pci_dss_*, gdpr_*, hipaa_*, nist_800_53_*, tsc_*, gpg13_*) пропустите: они есть почти в каждом событии и только мешают. Получившейся строкой замените <ВСТАВЬТЕ_СПИСОК_ГРУПП> в тексте приложения Б.

2. Зарегистрируйте агента. Вместо строк <ТЕКСТ ПРИЛОЖЕНИЯ …> вставьте тексты приложений целиком, тройные кавычки оставьте на месте:


      POST _plugins/_ml/agents/_register
{
  "name": "Wazuh chat agent",
  "type": "conversational",
  "description": "SOC assistant over Wazuh alerts",
  "app_type": "os_chat",
  "llm": {
    "model_id": "<MODEL_ID>",
    "parameters": {
      "max_iteration": 10,
      "stop_when_no_tool_found": true,
      "prompt.prefix": """
<ТЕКСТ ПРИЛОЖЕНИЯ А>
""",
      "prompt.format_instruction": """
<ТЕКСТ ПРИЛОЖЕНИЯ В>
"""
    }
  },
  "memory": { "type": "conversation_index" },
  "parameters": { "message_history_limit": "3" },
  "tools": [
    {
      "type": "PPLTool",
      "name": "TransferQuestionToPPLAndExecuteTool",
      "description": "Переводит вопрос аналитика в запрос PPL по индексу алертов Wazuh и выполняет его. Вход: {index: имя индекса, question: вопрос словами}. Индекс по умолчанию wazuh-alerts-*.",
      "parameters": {
        "model_id": "<MODEL_ID>",
        "model_type": "FINETUNE",
        "execute": true,
        "head": 5,
        "prompt": """
<ТЕКСТ ПРИЛОЖЕНИЯ Б>
"""
      }
    }
  ]
}

Значения параметров подобраны на стенде, менять их без причины не стоит:

ПараметрЗачем такое значение
model_type: "FINETUNE"Со значением OPENAI инструмент удаляет из запроса обратные кавычки. Без них имена полей с точками (rule.id, wazuh-alerts-*) ломают разбор запроса.
head: 5Ограничивает число строк в результате. Каждый алерт — это документ на сотни полей, и большая выдача переполняет контекст модели.
message_history_limit: "3"Сколько прошлых реплик диалога передавать модели. При значении по умолчанию контекст переполняется уже на третьем вопросе, и ответ приходит пустым.
max_iteration: 10Сколько шагов может сделать агент. Составному вопросу нужно два вызова инструмента и еще шаг на ответ.

В ответе придет agent_id — это <CHAT_AGENT_ID>.

3. Привяжите агента к окну чата. Окно ищет агента в системном индексе .plugins-ml-config по ключу os_chat. В системные индексы OpenSearch пускает только с административным сертификатом, поэтому этот запрос выполняется не в Dev Tools, а в терминале сервера.

Wazuh в Docker:


      docker exec <INDEXER_CONTAINER> curl -sk \
  --cert /usr/share/wazuh-indexer/config/certs/admin.pem \
  --key /usr/share/wazuh-indexer/config/certs/admin-key.pem \
  -X PUT "https://localhost:9200/.plugins-ml-config/_doc/os_chat?refresh=true" \
  -H "Content-Type: application/json" \
  -d '{"type": "os_chat_root_agent", "configuration": {"agent_id": "<CHAT_AGENT_ID>"}}'

Пакетная установка: та же команда без docker exec <INDEXER_CONTAINER>, сертификаты лежат в /etc/wazuh-indexer/certs/admin.pem и /etc/wazuh-indexer/certs/admin-key.pem.

В ответе должно быть "result": "created". Если ключ уже был привязан к другому агенту, придет "result": "updated".

4. Откройте окно ассистента и задайте несколько вопросов. Вот что мы спрашивали на стенде ( все числа в ответах совпали с проверочными запросами):

  • «Сколько алертов за сегодня?», а следом «А сколько из них уровня 10 и выше?» — второй вопрос должен учитывать период из первого;
  • «С каких адресов больше всего неудачных входов за неделю и сколько их всего?» — агент должен выполнить два запроса и объединить результаты;
  • «Объясни правило 5712» — агент найдет правило по номеру и расскажет, что оно означает.

Каждый ответ состоит из блока Ответ с числами и блока Запрос с выполненным PPL. Запрос можно скопировать в Discover и перепроверить число.

Шаг 5. Query Assist в Discover

Query Assist — строка в Discover, в которой вопрос словами превращается в запрос PPL. Для нее нужен отдельный агент: у него нет памяти диалога, только PPLTool.

1. Зарегистрируйте агента. Вместо <ТЕКСТ ПРИЛОЖЕНИЯ Б> вставьте тот же текст, что и в шаге 4, со списком групп:


      POST _plugins/_ml/agents/_register
{
  "name": "Wazuh PPL agent",
  "type": "flow",
  "description": "Natural language to PPL over Wazuh alerts",
  "memory": { "type": "demo" },
  "tools": [
    {
      "type": "PPLTool",
      "name": "TransferQuestionToPPLAndExecuteTool",
      "description": "Переводит вопрос аналитика в запрос PPL по индексу алертов Wazuh и выполняет его.",
      "parameters": {
        "model_id": "<MODEL_ID>",
        "model_type": "FINETUNE",
        "execute": true,
        "head": 5,
        "prompt": """
<ТЕКСТ ПРИЛОЖЕНИЯ Б>
"""
      }
    }
  ]
}

В ответе придет agent_id — это <PPL_AGENT_ID>.

2. Привяжите его под ключом os_query_assist_ppl. Команда такая же, как в шаге 4, отличаются ключ в адресе и идентификатор агента:


      docker exec <INDEXER_CONTAINER> curl -sk \
  --cert /usr/share/wazuh-indexer/config/certs/admin.pem \
  --key /usr/share/wazuh-indexer/config/certs/admin-key.pem \
  -X PUT "https://localhost:9200/.plugins-ml-config/_doc/os_query_assist_ppl?refresh=true" \
  -H "Content-Type: application/json" \
  -d '{"type": "os_chat_root_agent", "configuration": {"agent_id": "<PPL_AGENT_ID>"}}'

3. Откройте Discover, выберите шаблон индекса wazuh-alerts-* и переключите язык запросов на PPL. Введите вопрос, например «покажи неудачные входы по SSH за 3 дня». Над таблицей появится сгенерированный запрос, который можно поправить вручную.

Вопрос словами и сгенерированный запрос PPL.
Query Assist: вопрос словами и сгенерированный запрос PPL.

Шаг 6. Кнопка Explain Document

С этой функции удобнее всего начинать внедрение. В чате модель сама составляет запрос и иногда ошибается. Здесь она получает готовый документ в формате JSON и только объясняет его, поэтому результат стабильный.

Включить кнопку одной настройкой не получится. Плагин ассистента дает механизм для таких кнопок, но сами кнопки регистрируют плагины страниц. В OpenSearch это делает плагин Alerting, но плагин Wazuh на это не способен, так что добавим кнопку в код плагина Wazuh и пересоберем его. Правка общая для всех модулей: панель просмотра документа у Wazuh одна, и кнопка появится и в Threat Hunting, и в Vulnerability Detection, и во всех остальных разделах.

6.1. Агент объяснения

1. Зарегистрируйте агента:


      POST _plugins/_ml/agents/_register
{
  "name": "Wazuh explain document",
  "type": "flow",
  "description": "Explains a Wazuh document",
  "memory": { "type": "demo" },
  "tools": [
    {
      "type": "MLModelTool",
      "name": "SummaryTool",
      "parameters": {
        "model_id": "<MODEL_ID>",
        "prompt": """
Ты дежурный аналитик SOC. Ниже документ из Wazuh: алерт, запись об уязвимости или другое событие.
Объясни по-русски, по делу и по разделам: суть (что произошло, где, от кого), почему это важно и насколько серьезно, техника MITRE ATT&CK, если она есть в документе, и что проверить в первую очередь.
Опирайся только на поля документа, ничего не выдумывай.
 
${parameters.context}
 
Вопрос: ${parameters.question}
"""
      }
    }
  ]
}

Плагин ассистента передает агенту два параметра: ${parameters.context} — сам документ и ${parameters.question} — вопрос. В ответе придет agent_id — это <SUMMARY_AGENT_ID>.

2. Привяжите агента под ключом os_summary:


      docker exec <INDEXER_CONTAINER> curl -sk \
  --cert /usr/share/wazuh-indexer/config/certs/admin.pem \
  --key /usr/share/wazuh-indexer/config/certs/admin-key.pem \
  -X PUT "https://localhost:9200/.plugins-ml-config/_doc/os_summary?refresh=true" \
  -H "Content-Type: application/json" \
  -d '{"type": "os_chat_root_agent", "configuration": {"agent_id": "<SUMMARY_AGENT_ID>"}}'

6.2. Правка кода

Все действия этого и следующего раздела выполняются на машине сборки. Плагин Wazuh собирается только внутри исходников платформы, поэтому понадобятся два репозитория одной версии.

1. Скачайте исходники и положите плагины Wazuh внутрь платформы:


      mkdir -p ~/wazuh-build && cd ~/wazuh-build
git clone --depth 1 -b v4.14.7 https://github.com/wazuh/wazuh-dashboard.git
git clone --depth 1 -b v4.14.7 https://github.com/wazuh/wazuh-dashboard-plugins.git
cp -r wazuh-dashboard-plugins/plugins/main \
      wazuh-dashboard-plugins/plugins/wazuh-core \
      wazuh-dashboard-plugins/plugins/wazuh-check-updates \
      wazuh-dashboard/plugins/

2. Создайте файл кнопки incontext-insight.tsx. Полный текст — в приложении Г.


      wazuh-dashboard/plugins/main/public/components/common/wazuh-discover/components/incontext-insight.tsx

3. Подключите кнопку в двух компонентах просмотра документа. Они лежат в том же каталоге. В каждом файле нужно добавить импорт и обернуть возвращаемую разметку: вместо return ( написать const content = (, а в конце функции вернуть результат withIncontextInsight. Строки со знаком «+» добавляются, а со знаком «-» — удаляются.


       import { EuiCodeBlock, EuiFlexGroup, EuiTabbedContent } from '@elastic/eui';
+import { withIncontextInsight } from './incontext-insight';
 ...
+  const docId = item?._id ?? 'wazuh-alert';
 
-  return (
+  const content = (
     <EuiFlexGroup direction='column' style={{ width: '100%' }}>
 ...
     </EuiFlexGroup>
   );
+
+  return withIncontextInsight(item, docId, content);
 };

       import { useDocViewer } from '../../doc-viewer';
+import { withIncontextInsight } from './incontext-insight';
 ...
-  return (
+  const content = (
     <EuiFlexItem>
 ...
     </EuiFlexItem>
   );
+
+  return withIncontextInsight(
+    document,
+    document?._id ?? 'wazuh-alert',
+    content,
+  );
 };

4. Выровняйте версию библиотеки node-cron. В плагине main указана версия ^1.1.2, а в wazuh-core и wazuh-check-updates — ^3.0.2. 


      sed -i 's/"node-cron": "\^3.0.2"/"node-cron": "^1.1.2"/' \
  wazuh-dashboard/plugins/wazuh-core/package.json \
  wazuh-dashboard/plugins/wazuh-check-updates/package.json
grep -n node-cron wazuh-dashboard/plugins/*/package.json

6.3. Сборка

Собирать удобнее в контейнере с Node.js 18: не нужно ничего ставить на машину. Сборка идет от обычного пользователя node, потому что OpenSearch Dashboards отказывается работать от root. Первая сборка занимает 30–60 минут, почти все это время уходит на загрузку зависимостей.

1. Запустите контейнер сборки:


      sudo chown -R 1000:1000 wazuh-dashboard
docker run --rm -it -u node -v "$PWD/wazuh-dashboard":/work -w /work node:18 bash

2. Внутри контейнера загрузите зависимости и соберите плагин:


      yarn osd bootstrap --single-version=loose
cd plugins/main
export NODE_OPTIONS=--max-old-space-size=4096
export OPENSEARCH_DASHBOARDS_VERSION=2.19.5
yarn build --skip-archive
exit

Версию платформы передавайте только переменной OPENSEARCH_DASHBOARDS_VERSION. Скрипт сборки сам добавляет флаг --opensearch-dashboards-version, и если указать его еще раз вручную, сборка остановится с ошибкой expected a single --opensearch-dashboards-version flag.

Успешная сборка заканчивается строкой bundles compiled successfully. Готовые файлы лежат в каталоге plugins/main/build/opensearch-dashboards/wazuh/target/public.

6.4. Установка

Замените собранными файлами каталог target/public плагина Wazuh и перезапустите дашборд. Команды ниже написаны для случая, когда сборка шла на том же сервере. Если собирали на другой машине, скопируйте каталог public на сервер и укажите в команде путь к нему.


      docker cp ~/wazuh-build/wazuh-dashboard/plugins/main/build/opensearch-dashboards/wazuh/target/public/. \
  <DASHBOARD_CONTAINER>:/usr/share/wazuh-dashboard/plugins/wazuh/target/public/
docker exec -u root <DASHBOARD_CONTAINER> chown -R 1000:1000 /usr/share/wazuh-dashboard/plugins/wazuh/target/public
docker restart <DASHBOARD_CONTAINER>

      cp -r ~/wazuh-build/wazuh-dashboard/plugins/main/build/opensearch-dashboards/wazuh/target/public/. \
  /usr/share/wazuh-dashboard/plugins/wazuh/target/public/
chown -R wazuh-dashboard:wazuh-dashboard /usr/share/wazuh-dashboard/plugins/wazuh/target/public
systemctl restart wazuh-dashboard

Правка живет до обновления. В Docker скопированные файлы пропадут при пересоздании контейнера, а в пакетной установке — при обновлении Wazuh. После таких операций повторите установку или соберите свой образ дашборда.

Обновите страницу дашборда с очисткой кэша (Ctrl+Shift+R): имена файлов после сборки не меняются, и браузер может показать старую версию. Откройте любой документ, например алерт в Threat Hunting. Над вкладками Table и JSON появится кнопка Explain Document. Первый ответ приходит за 10–20 секунд.

Скриншот разбора алерта о подборе пароля по SSH.
Разбор алерта о подборе пароля по SSH.

Если что-то не работает

Здесь собраны ошибки, с которыми мы столкнулись при настройке. Большинство из них не объясняет причину, поэтому таблица начинается с текста ошибки.

Что видноПричинаЧто сделать
Создание коннектора: Connector URL is not matching the trusted connector endpoint regexАдрес модели не подходит под регулярное выражение из шага 2Проверьте хост в выражении. Точки в нем записываются как \\., нестандартный порт разрешается группой (:[0-9]+)? сразу после хоста
Регистрация модели: No eligible node found to execute this requestНе выключен параметр only_run_on_ml_node: ml-commons ищет отдельные ML-узлыПовторите запрос настроек из шага 2
Invalid payload в чатеВ коннекторе используется ${parameters.messages} из стандартного шаблонаПересоздайте коннектор с request_body из шага 3
Remote endpoint fails to inferenceУ коннектора нет post_process_function, и PPLTool не находит поле responseПересоздайте коннектор с функцией из шага 3
NullPointerException при вызове агентаУ коннектора или агента задан response_filterУберите response_filter: разбор ответа уже делает post_process_function
security_exception при записи в .plugins-ml-configЗапись в системный индекс по паролю запрещенаВыполните привязку командой с административным сертификатом
Ответ пустой, чаще на третьем вопросе диалогаПереполнен контекст моделиПроверьте message_history_limit: "3" и head: 5
В ответе нет блока с запросомУ агента нет prompt.format_instructionЗарегистрируйте агента заново с текстом приложения В
Запрос PPL падает на имени поля с точкойУ PPLTool указан model_type: "OPENAI"Зарегистрируйте агента заново с model_type: "FINETUNE"
Query Assist пишет <cppl> или падает на символе <Модель перепутала тег вокруг запросаФункция разбора из шага 3 исправляет тег. Проверьте, что коннектор создан именно с ней
Кнопка отвечает Invalid payloadДокумент передан модели чистым JSON. ml-commons не экранирует параметры, которые уже являются корректным JSON, и кавычки ломают запрос к моделиПередавайте документ строкой с текстовым префиксом, как в приложении Г
Кнопка не появилась после установкиБраузер показывает старые файлы из кэшаОбновите страницу через Ctrl+Shift+R
Сборка: should not be run as rootСборка запущена от rootЗапускайте контейнер с -u node
Изменили промпт, а агент отвечает по-старомуЗарегистрированного агента нельзя изменитьЗарегистрируйте нового агента и привяжите его под тем же ключом

Ограничения

  • Чат ошибается чаще, чем кнопка. Составляя запрос, модель может выбрать не то поле или добавить лишний фильтр.
  • Чат ошибается чаще, чем кнопка. Составляя запрос, модель может выбрать не то поле или добавить лишний фильтр. Поэтому в каждом ответе есть выполненный запрос. Для отчетов проверяйте цифры обычным поиском.
  • Модель получает данные. В промпт уходят вопрос, результат запроса или документ целиком: адреса, имена пользователей, команды. Если модель работает во внешнем сервисе, согласуйте это со службой безопасности.
  • Проверено под пользователем admin. Для других пользователей понадобятся права на ml-commons, отдельно мы их не настраивали.
  • Список групп нужно обновлять. Появились новые источники событий — соберите список заново, обновите приложение Б и перерегистрируйте агентов.

Заключение

Два часа настройки — и Wazuh начинает отвечать на вопросы обычными словами. Для этого хватает штатных плагинов OpenSearch, своей модели и трех агентов, а код приходится трогать только ради кнопки Explain Document.

Если внедрять по шагам, начните с кнопки: она объясняет готовый документ и почти не ошибается в фактах. Чат оставьте для быстрых вопросов к данным и не забывайте смотреть на запрос под ответом.