Небольшой дисклеймер: я технарь, а не писатель — поэтому в оформлении статьи мне помогал AI. Все конфиги, скрипт и правила писал сам и проверял на реальном железе. AI просто помог не превратить это в простыню из bullet points 😅


Привет, друзья! С вами pensecfort. Сегодня мы закрываем одну из самых частых болей в любой инфраструктуре — управление доступом к инструментам мониторинга.

Эта статья — текстовое дополнение к одноимённому видео на YouTube. Если вы предпочитаете смотреть, вот ссылочка на видео https://youtu.be/YpBtcoefblY или вот альтернатива https://rutube.ru/video/a48737880959683a83e97cf9e8fab4c0/. Если предпочитаете читать — вы попали по адресу. Здесь всё то же самое: реальные конфиги, мои скрипты, и ни капли маркетингового булшита.


🤔 Зачем вообще нужен Authentik?

Представьте типичную ситуацию. У вас есть Wazuh, Grafana, Portainer, Proxmox, несколько сетевых девайсов, и у каждого сервиса — своя база пользователей. Пользователь заходит в Wazuh с одним паролем, в Grafana — с другим, в Portainer — с третьим. Когда сотрудник уходит из команды, вам нужно вспомнить, где вообще у него есть доступ, и руками удалить его из каждого сервиса. Это хаос. Это дыры в безопасности. И это реальная головная боль для любого, кто обслуживает инфраструктуру.

Authentik — self-hosted Identity Provider, то есть ваш собственный центр управления удостоверениями. Думайте о нём как о личном Google-аккаунте, но для вашей инфраструктуры. Вы сами контролируете данные, вы сами управляете доступом, и ничего не уходит в облако третьих сторон.

Какие конкретные проблемы он закрывает:

Единый вход (SSO). Один аккаунт — доступ ко всем сервисам. Пользователь логинится один раз в Authentik и автоматически заходит в Wazuh, Grafana, и всё остальное, что вы подключили. Никаких отдельных паролей.

Централизованное управление пользователями. Уволился сотрудник — заблокировали один аккаунт в Authentik. Всё. Доступ ко всем сервисам сразу отрезан. Не надо бегать по системам.

2FA из коробки. Включили TOTP один раз — и он работает для всех подключённых сервисов. Не нужно настраивать двухфакторку отдельно в каждом приложении.

RBAC — разграничение доступа по ролям. Разработчик видит только логи своего проекта. Аналитик SOC видит алерты, но не может менять конфиги. Администратор видит всё. Это настраивается через группы и роли в Authentik и пробрасывается в Wazuh через SAML.

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

Короче говоря, Authentik превращает разрозненную инфраструктуру в единую управляемую экосистему.

Сегодня сделаем три вещи:

  1. Настроим SSO — вход в Wazuh через Authentik по протоколу SAML.

  2. Включим обязательный 2FA через TOTP.

  3. Настроим сбор логов Authentik в Wazuh с готовыми правилами.


⚙️ Часть 1. Интеграция SSO: Authentik → Wazuh через SAML

Развёртывание самого Authentik здесь не разбираем — исходим из того, что он у вас уже запущен. Официальная документация по интеграции живёт по адресу: https://integrations.goauthentik.io/monitoring/wazuh/ — там есть точные технические детали, но статья написана сухо и без контекста. Я покажу своими словами и со своими конфигами.

На стороне Authentik

Шаг 1 — Создаём группу и добавляем пользователя

Идём в Directory → Groups, создаём группу wazuh-administrators (wazuh-alalytics или иную другую, которую вы захотите). Добавляем в неё нужных пользователей через вкладку Users → Add existing user. Именно по группам потом будет работать RBAC — это фундамент всей схемы.

Шаг 2 — Property Mappings: передаём роли в SAML-ответ

Идём в Customization → Property Mappings → Create. Выбираем тип SAML Provider Property Mapping. Это ключевой момент — именно здесь мы говорим Authentik, что нужно передавать в SAML-ответ атрибут Roles, который Wazuh потом читает и назначает внутренние роли.

Параметры маппинга:

Поле

Значение

Name

Wazuh Roles Mapping

SAML Attribute Name

Roles

Friendly Name

(оставляем пустым)

Expression

(см. ниже)

if ak_is_group_member(request.user, name="wazuh-administrators"):
    yield "wazuh-admin"
# Если необходимо ещё какие то группы добавить
elif ak_is_group_member(request.user, name="wazuh-analytics"):
    yield "wazuh-analytics"

Выражение простое: если пользователь состоит в группе wazuh-administrators — добавляем ему роль wazuh-admin в атрибут Roles. Если понадобятся дополнительные роли (например, wazuh-readonly) — добавляем аналогичные if-блоки, либо elif. Жмём Finish.

Шаг 3 — Создаём SAML Provider

Идём в Applications → Providers → Create → SAML Provider. Заполняем поля:

  • Name: Wazuh

  • Authorization Flow: выбираем дефолтный или свой кастомный flow аутентификации

  • ACS URL: здесь важный момент, который я хочу разобрать отдельно

Есть два варианта:

Я использую второй вариант — мне нравится работать через единый портал Authentik. Все сервисы в одном месте, пользователи видят только то, к чему у них есть доступ. Это удобнее и чище с точки зрения UX. Поэтому в моём конфиге idp_initiated: true — об этом чуть ниже.

Остальные важные поля:

  • Issuer: wazuh-saml

  • Service Provider Binding: Post

  • Property Mappings: добавляем созданный нами маппинг ролей

  • NameID Property Mapping: выбираем, что будет использоваться как имя пользователя в Wazuh (например, authentik default SAML Mapping: Name или Email)

Шаг 4 — Создаём Application и скачиваем metadata

Идём в Applications → Applications → Create. Привязываем созданный Provider к приложению. После создания возвращаемся к провайдеру — там будет раздел Related objects → Metadata → Download. Скачиваем XML-файл с метаданными. Он нам понадобится на стороне Wazuh.


На стороне Wazuh Indexer

⚠️ Важно для кластера. Если у вас несколько нод wazuh-indexer — все операции с файлами и securityadmin.sh нужно выполнить на каждой ноде.

Копируем скачанный idp-metadata.xml на сервер:

cp idp-metadata.xml /etc/wazuh-indexer/opensearch-security/
chown wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/opensearch-security/idp-metadata.xml
chmod 640 /etc/wazuh-indexer/opensearch-security/idp-metadata.xml

Теперь открываем главный конфиг аутентификации. Сначала сделайте бэкап:

cp /etc/wazuh-indexer/opensearch-security/config.yml \
   /etc/wazuh-indexer/opensearch-security/config.yml.bak
vim /etc/wazuh-indexer/opensearch-security/config.yml

Этот файл управляет тем, как пользователи могут заходить в систему. Мы настраиваем два метода одновременно — Basic Auth как запасной вариант и SAML как основной.

---
authc:
  basic_internal_auth_domain:
    description: "HTTP basic authentication"
    http_enabled: true
    transport_enabled: true
    order: 0
    http_authenticator:
      type: basic
      challenge: false
    authentication_backend:
      type: intern

  saml_auth_domain:
    http_enabled: true
    transport_enabled: false
    order: 1
    http_authenticator:
      type: saml
      challenge: true
      config:
        idp_initiated: true
        idp:
          metadata_file: "/etc/wazuh-indexer/opensearch-security/idp-metadata.xml"
          entity_id: "wazuh-saml"
        sp:
          entity_id: "wazuh-saml"
        kibana_url: "https://wazuh.yourdomain.com/"
        roles_key: Roles
        exchange_key: "сюда_вставляете_ключ_из_openssl_rand_-hex_32"
    authentication_backend:
      type: noop

Разберём ключевые параметры:

idp_initiated: true — включает flow, при котором пользователь начинает из Authentik, а не из дашборда Wazuh. Именно поэтому я и выбрал IdP-initiated flow: один портал для всего, и этот параметр его включает на стороне Wazuh.

metadata_file — путь к XML с метаданными Authentik. Там все сертификаты и эндпоинты, которые Wazuh использует для проверки SAML-ответов.

entity_id — уникальный идентификатор. Должен совпадать с Issuer, указанным в Authentik при создании провайдера.

kibana_url — базовый URL вашего дашборда. Обязательно с / в конце! Authentik использует его для формирования ссылок возврата после аутентификации.

roles_key: Roles — атрибут из SAML-ответа, в котором Authentik передаёт группы пользователя. Именно тот, что мы настраивали в Property Mappings.

exchange_key — секретный ключ для подписи внутренних JWT-токенов после SAML-аутентификации. Генерируем:

openssl rand -hex 32

⚠️ Ключ должен быть одинаковым на всех нодах кластера. Если он разный — пользователь будет залогинен на одной ноде и выбит на другой.

challenge: false в basic-блоке — важно, чтобы браузер не показывал стандартное окно Basic Auth и не конфликтовал с SAML.

authentication_backend: noop в SAML-блоке — после успешного SAML Wazuh сразу принимает пользователя, не идёт проверять его в internal_users.yml.

Как это работает в итоге:

  1. Пользователь открывает дашборд (или заходит через портал Authentik).

  2. Basic Auth не проходит (нет заголовка) — переходим к SAML (order: 1).

  3. Wazuh редиректит на Authentik (или при IdP-initiated Authentik сам инициирует flow).

  4. Пользователь логинится + проходит 2FA.

  5. Authentik присылает SAML Response с атрибутом Roles.

  6. Wazuh создаёт внутренний токен через exchange_key и пускает пользователя.

Применяем конфиг:

export JAVA_HOME=/usr/share/wazuh-indexer/jdk/ && bash \
/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh \
-f /etc/wazuh-indexer/opensearch-security/config.yml \
-icl -key /etc/wazuh-indexer/certs/admin-key.pem \
-cert /etc/wazuh-indexer/certs/admin.pem \
-cacert /etc/wazuh-indexer/certs/root-ca.pem \
-h indexer.yourdomain.com -p 443 -nhnv 

⚠️ Необходимо явно указать порт, а ту по умолчанию подставляется 9200 по крайней мене у меня так происходило. Если у вас кластер достаточно применить конфиг на одной из нод. НО файлы на диске ДОЛЖНЫ быть на каждой ноде.

Замените indexer.yourdomain.com на FQDN вашей ноды.


Маппинг ролей (roles_mapping.yml)

Теперь говорим Wazuh, что роль wazuh-admin из SAML-атрибута соответствует внутренней роли all_access. Открываем файл (не забудьте бэкап):

cp /etc/wazuh-indexer/opensearch-security/roles_mapping.yml \
   /etc/wazuh-indexer/opensearch-security/roles_mapping.yml.bak
vim /etc/wazuh-indexer/opensearch-security/roles_mapping.yml

Добавляем или дополняем секцию all_access:

all_access:
  reserved: true
  hidden: false
  backend_roles:
    - "wazuh-admin"
    - "admin"
  hosts: []
  users: []
  and_backend_roles: []
  description: "Maps admin to all_access"

Wazuh очень гибкий в плане RBAC: можно ограничить доступ до конкретного агента, группы агентов или типа логов. Применяем маппинг:

export JAVA_HOME=/usr/share/wazuh-indexer/jdk/ && bash \
/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh \
-f /etc/wazuh-indexer/opensearch-security/roles_mapping.yml \
-icl -key /etc/wazuh-indexer/certs/admin-key.pem \
-cert /etc/wazuh-indexer/certs/admin.pem \
-cacert /etc/wazuh-indexer/certs/root-ca.pem \
-h indexer.yourdomain.com -p 443 -nhnv

Настройка Wazuh Dashboard

Если в wazuh.yml у вас run_as: true — нужен дополнительный маппинг на уровне дашборда. Идём в веб-интерфейс: Server Management → Security → Roles mapping → Create Role mapping и настраиваем:

  • Role Name: authentik_admins

  • Roles: administrator

  • Custom rules: User field = backend_roles, Operation = FIND, Value = wazuh-admin

Затем добавляем параметры в конфиг Wazuh Dashboard:

vim /etc/wazuh-dashboard/opensearch_dashboards.yml
opensearch_security.auth.type: "saml"
server.xsrf.allowlist:
  - "/_opendistro/_security/saml/acs"
  - "/_opendistro/_security/saml/logout"
  - "/_opendistro/_security/saml/acs/idpinitiated"
opensearch_security.session.keepalive: false

Разберём что здесь происходит:

auth.type: "saml" — главный переключатель. Без него дашборд не знает про SAML и продолжает требовать логин/пароль.

Если нужно оставить оба метода одновременно (например, пока тестируете и хотите сохранить Basic Auth как запасной вариант):

opensearch_security.auth.type: ["basicauth", "saml"]
opensearch_security.auth.multiple_auth_enabled: true

Я у себя оставил Basic Auth и SAML. Если что-то сломается на уровне Authentik, можно зайти напрямую на wazuh-indexer/wazuh-dashboard и временно включить basic обратно.

xsrf.allowlist — белый список эндпоинтов, исключённых из XSRF-защиты. Без этого SAML просто не сработает: Authentik успешно проверит пользователя и отправит подтверждение обратно, а Wazuh ответит «нет секретного заголовка» и заблокирует вход. Эта строка это предотвращает. Обратите внимание — в списке три эндпоинта, включая /idpinitiated, который нужен именно для нашего IdP-initiated flow.

session.keepalive: false — когда keepalive включён, дашборд пытается продлевать сессию в фоне, и при SAML это часто приводит к конфликтам. Ставим false и отдаём управление сессией Authentik.

Перезапускаем дашборд:

sudo systemctl restart wazuh-dashboard

Всё, вход теперь через Authentik. В моём случае пользователи хранятся локально в Authentik, но можно подключить AD, FreeIPA или любой другой LDAP-провайдер — Authentik это поддерживает из коробки.


🔐 Часть 2. Включаем обязательный 2FA через TOTP

Теперь сделаем вход безопаснее — добавим обязательный второй фактор. Это можно сделать двумя способами: пользователь настраивает его сам, или вы делаете его принудительным для всех.

В статье я укажу принудительный вариант (если хотите посмотреть как включается у пользователя, посмотрите в видео) — это правильная практика для production-среды.

В Authentik идём в Flows → Flows. Находим дефолтный flow аутентификации (обычно называется default-authentication-flow). Заходим в него и смотрим на список Stage Bindings.

Добавляем Stage:

  1. Нажимаем Edit Stage в default-authentication-mfa-validation

  2. В настройках Not configured action указываем Force the user to configure an authenticator — это ключевой параметр

  3. В Device classes оставляем TOTP или добавляем всё что хотите (WebAuthn, Static tokens)

  4. Устанавливаем порядок (order) после stage проверки пароля, но до финального Stage

После этого каждый вход в Authentik — а значит и в Wazuh, и во все остальные подключённые сервисы — будет требовать код из Google Authenticator или Microsoft Authenticator, ну или что вы указали в Device classes.

Пользователь при первом входе увидит QR-код, отсканирует его приложением-аутентификатором и привяжет свой телефон. Повторно настраивать это нигде больше не нужно — 2FA работает на уровне Authentik для всего.

💡 Лайфхак: если пользователь потерял телефон или сбросил приложение — зайдите в Directory → Users → выберите пользователя → вкладка MFA Authenticators и удалите привязанный TOTP. При следующем входе он пройдёт заново через QR-код.


📋 Часть 3. Сбор логов Authentik в Wazuh — самое вкусное

Теперь самое интересное — заставим Wazuh следить за самим Authentik. Каждый вход, каждый отказ в доступе, каждое изменение пользователя — всё это попадёт в дашборд как алерт. Ваша система безопасности будет следить в том числе за собой.

Шаг 1 — Создаём API-токен в Authentik

Идём в Admin → Directory → Tokens and App Passwords → Create.

Параметр

Значение

Identifier

audit-api-token

User

akadmin (или отдельный сервис-пользователь)

Intent

API Token

Expiring

Выключено

⚠️ Токен можно скопировать сразу в Tokens and App Passwords в Таблице увидите Action. Там 3 действия: edit, permissions, СOPY.

Я рекомендую создать для этого отдельного сервис-пользователя с минимальными правами, а не использовать akadmin. Так безопаснее — скомпрометированный токен сервис-аккаунта не даст полного доступа к системе.

Шаг 2 — Настраиваем retention событий

Admin → System → Settings → Event retention. По умолчанию 365 дней. Если скрипт регулярно забирает события — можно уменьшить до 30-90 дней, чтобы не раздувать базу Authentik. Всё равно основное хранение теперь в Wazuh.

Шаг 3 — Скрипт сбора логов

Все события Authentik логируются автоматически — пользовательские действия, системные события, ошибки конфигурации. Пароли и credentials из событий вырезаются Authentik автоматически, так что в логах они не появятся.

API endpoint для получения событий:

GET https://your-authentik-domain/api/v3/events/events/

Я написал скрипт, который забирает события через API, дедуплицирует их между запусками через state-файл и отправляет в Wazuh. Логи сразу пишутся в JSON-формате — отдельный декодер не нужен, Wazuh читает JSON нативно.

Скрипт полностью:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
╔════════════════════════════════════════════════════════════════════════════╗
║                  Authentik → Wazuh Events Integration                      ║
║                                                                            ║
║  Developed by pensecfort                                                   ║
║  Author: pensecfort                                                        ║
║  Purpose: Reliable forwarding of Authentik audit events to Wazuh SIEM      ║
║                                                                            ║
║  © pensecfort • 2026                                                       ║
╚════════════════════════════════════════════════════════════════════════════╝
"""

import json
import socket
import os
import sys
import datetime
import time
import requests
import urllib3
from base64 import b64encode
from datetime import timezone

# ---------------------------------------------------------------------------
# AUTHENTIK CONFIGURATION
# ---------------------------------------------------------------------------
AUTHENTIK_URL        = "https://authentik.example.com"   # ← замените на ваш URL
AUTHENTIK_TOKEN      = "your-authentik-api-token"         # ← токен из шага 1
AUTHENTIK_PAGE_SIZE  = 1000           # max events per API page
AUTHENTIK_VERIFY_SSL = True
AUTHENTIK_LOOKBACK_MINUTES = 60       # используется только при первом запуске (нет state-файла)

# ---------------------------------------------------------------------------
# WAZUH CONFIGURATION
# ---------------------------------------------------------------------------
WAZUH_URL          = "wazuh.example.com"                  # ← FQDN вашего Wazuh
WAZUH_USER         = "integration"                        # ← пользователь с правом /events
WAZUH_PASSWORD     = "your-wazuh-password"                # ← пароль
WAZUH_API_ENDPOINT = f"https://{WAZUH_URL}/events"
WAZUH_VERIFY_SSL   = True
WAZUH_BATCH_SIZE   = 100              # число событий на один POST-запрос

# ---------------------------------------------------------------------------
# OUTPUT TOGGLES — включайте нужное
# ---------------------------------------------------------------------------
WRITE_EVENTS_TO_FILE = True           # писать события в JSON-файл (для Filebeat/логротации)
SEND_TO_WAZUH_API    = True           # отправлять события через REST API Wazuh
WRITE_ERROR_LOG      = True           # писать ошибки скрипта в отдельный лог

# ---------------------------------------------------------------------------
# FILE PATHS — укажите реальные пути на вашем сервере
# ---------------------------------------------------------------------------
EVENTS_FILE_PATH = "/var/log/authentik/events.json"       # ← путь к файлу событий
ERROR_LOG_PATH   = "/var/log/authentik/errors.log"        # ← путь к логу ошибок
STATE_FILE_PATH  = "/var/lib/authentik/state.json"        # ← путь к state-файлу

# Отключаем InsecureRequestWarning, если SSL-верификация выключена для одного из сервисов
if not AUTHENTIK_VERIFY_SSL or not WAZUH_VERIFY_SSL:
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ---------------------------------------------------------------------------
# SYSLOG-FORMAT ERROR LOGGER
# ---------------------------------------------------------------------------
_HOSTNAME  = socket.gethostname()
_PID       = os.getpid()
_COMPONENT = "authentik_wazuh"

CRITICAL = "CRITICAL"
ERROR    = "ERROR"
WARNING  = "WARNING"
INFO     = "INFO"
DEBUG    = "DEBUG"


def log_error(level: str, message: str, **extra):
    """
    Пишет строку ошибки в stderr и опционально в ERROR_LOG_PATH.
    """
    timestamp = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    fields    = " | ".join(f"{k}={v}" for k, v in extra.items()) if extra else ""
    line      = f"{timestamp} {_HOSTNAME} {_COMPONENT}[{_PID}]: {level.upper()} {message}"
    if fields:
        line += f" | {fields}"

    print(line, file=sys.stderr)

    if WRITE_ERROR_LOG:
        try:
            os.makedirs(os.path.dirname(ERROR_LOG_PATH), exist_ok=True)
            with open(ERROR_LOG_PATH, "a") as f:
                f.write(line + "\n")
        except Exception as e:
            print(f"{timestamp} {_HOSTNAME} {_COMPONENT}[{_PID}]: ERROR Failed to write error log | error={e}",
                  file=sys.stderr)


# ---------------------------------------------------------------------------
# STATE — сохраняем последнее обработанное событие между запусками
# ---------------------------------------------------------------------------
def load_last_state() -> dict:
    """
    Загружает последнее сохранённое состояние (timestamp и UUID события).
    Возвращает словарь: {'timestamp': str, 'last_event_pk': str | None}
    """
    if os.path.exists(STATE_FILE_PATH):
        try:
            with open(STATE_FILE_PATH, 'r') as f:
                state = json.load(f)
                if state.get('last_created_timestamp'):
                    return {
                        'timestamp': state['last_created_timestamp'],
                        'last_event_pk': state.get('last_event_pk')
                    }
        except Exception as e:
            log_error(WARNING, "Could not read state file, using default lookback",
                      path=STATE_FILE_PATH, error=e)

    default_ts = (datetime.datetime.now(timezone.utc)
                  - datetime.timedelta(minutes=AUTHENTIK_LOOKBACK_MINUTES)).isoformat().replace('+00:00', 'Z')
    log_error(INFO, "No valid state found, falling back to lookback window",
              lookback_minutes=AUTHENTIK_LOOKBACK_MINUTES, since=default_ts)
    return {'timestamp': default_ts, 'last_event_pk': None}


def save_last_state(timestamp: str, event_pk: str):
    """Сохраняет timestamp и UUID последнего обработанного события."""
    try:
        os.makedirs(os.path.dirname(STATE_FILE_PATH), exist_ok=True)
        with open(STATE_FILE_PATH, 'w') as f:
            state = {'last_created_timestamp': timestamp, 'last_event_pk': event_pk}
            json.dump(state, f)
    except Exception as e:
        log_error(ERROR, "Could not save state file", path=STATE_FILE_PATH, error=e)


# ---------------------------------------------------------------------------
# AUTHENTIK — получаем аудит-события с пагинацией
# ---------------------------------------------------------------------------
def fetch_authentik_logs(since_timestamp: str) -> list[dict]:
    """
    Получает все события новее since_timestamp.
    Обрабатывает пагинацию и возвращает события от старых к новым.
    """
    headers = {
        "Authorization": f"Bearer {AUTHENTIK_TOKEN}",
        "Accept": "application/json"
    }

    url = f"{AUTHENTIK_URL}/api/v3/events/events/"
    params = {
        "ordering":     "-created",
        "page_size":    AUTHENTIK_PAGE_SIZE,
        "created__gte": since_timestamp,
    }

    all_logs = []

    while url:
        try:
            response = requests.get(
                url,
                headers=headers,
                params=params,
                verify=AUTHENTIK_VERIFY_SSL,
                timeout=15
            )

            if response.status_code == 404:
                break

            if response.status_code != 200:
                log_error(ERROR, "Authentik API returned unexpected status",
                          status_code=response.status_code, body=response.text[:300])
                break

            data    = response.json()
            results = data.get('results', [])

            if not results:
                break

            all_logs.extend(results)

            next_value = data.get('next')
            if isinstance(next_value, str):
                url = next_value
                params = None
            else:
                url = None

        except Exception as e:
            log_error(ERROR, "Request to Authentik API failed", error=e)
            break

    # Разворачиваем, чтобы обрабатывать от старых к новым
    return all_logs[::-1]


# ---------------------------------------------------------------------------
# LOCAL FILE — пишем события как JSON Lines
# ---------------------------------------------------------------------------
def write_logs_to_file(logs: list[dict]):
    """
    Дописывает события в EVENTS_FILE_PATH в формате newline-delimited JSON.
    Пропускается если WRITE_EVENTS_TO_FILE = False.
    """
    if not logs or not WRITE_EVENTS_TO_FILE:
        return

    try:
        os.makedirs(os.path.dirname(EVENTS_FILE_PATH), exist_ok=True)
        with open(EVENTS_FILE_PATH, "a") as f:
            for event in logs:
                f.write(json.dumps(event, ensure_ascii=False) + "\n")
    except Exception as e:
        log_error(ERROR, "Could not write events to file",
                  path=EVENTS_FILE_PATH, error=e)


# ---------------------------------------------------------------------------
# WAZUH — аутентификация и отправка событий
# ---------------------------------------------------------------------------
def wazuh_authenticate() -> dict | None:
    url_login = f"https://{WAZUH_URL}/security/user/authenticate"
    headers   = {
        "Authorization": f'Basic {b64encode(f"{WAZUH_USER}:{WAZUH_PASSWORD}".encode()).decode()}',
        "Content-Type":  "application/json"
    }
    try:
        response = requests.get(
            url_login,
            headers=headers,
            verify=WAZUH_VERIFY_SSL,
            timeout=10
        )
        if response.status_code == 200:
            return {
                "Authorization": f"Bearer {response.json()['data']['token']}",
                "Content-Type":  "application/json"
            }
        log_error(ERROR, "Wazuh authentication failed",
                  status_code=response.status_code, body=response.text[:300])
    except Exception as e:
        log_error(ERROR, "Could not connect to Wazuh", url=url_login, error=e)
    return None


def send_events_to_wazuh(logs: list[dict]):
    """
    Отправляет события в Wazuh батчами по WAZUH_BATCH_SIZE штук.
    """
    if not logs:
        return

    auth_headers = wazuh_authenticate()
    if not auth_headers:
        log_error(CRITICAL, "Aborting Wazuh send — authentication failed")
        return

    total = len(logs)
    for i in range(0, total, WAZUH_BATCH_SIZE):
        batch     = logs[i:i + WAZUH_BATCH_SIZE]
        batch_num = i // WAZUH_BATCH_SIZE + 1

        payload = {"events": [json.dumps(event, ensure_ascii=False) for event in batch]}

        try:
            response = requests.post(
                WAZUH_API_ENDPOINT,
                headers=auth_headers,
                json=payload,
                verify=WAZUH_VERIFY_SSL,
                timeout=15
            )
            if response.status_code == 200:
                print(f"[OK] Batch {batch_num}: {len(batch)} events sent to Wazuh.")
            else:
                log_error(ERROR, "Wazuh rejected batch",
                          batch=batch_num, status_code=response.status_code,
                          body=response.text[:300])
        except Exception as e:
            log_error(ERROR, "Network error while sending batch to Wazuh",
                      batch=batch_num, error=e)

        time.sleep(1)  # не перегружаем API Wazuh


# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    started = datetime.datetime.now(timezone.utc)
    print(f"--- Run started: {started.strftime('%Y-%m-%dT%H:%M:%SZ')} ---")

    last_state = load_last_state()
    last_timestamp = last_state['timestamp']
    last_event_pk = last_state['last_event_pk']

    print(f"[INFO] Fetching events since: {last_timestamp} (last PK: {last_event_pk or 'None'})")

    fetched_logs = fetch_authentik_logs(last_timestamp)

    if not fetched_logs:
        print("[INFO] No new events found.")
    else:
        # Фильтруем события, которые уже были обработаны в прошлом запуске
        new_logs = []
        if last_event_pk:
            last_pks_indices = [i for i, event in enumerate(fetched_logs) if event.get('pk') == last_event_pk]
            if last_pks_indices:
                start_index = last_pks_indices[-1] + 1
                new_logs = fetched_logs[start_index:]
            else:
                new_logs = fetched_logs
        else:
            new_logs = fetched_logs

        if not new_logs:
            print("[INFO] No new events found after filtering.")
        else:
            print(f"[INFO] Events fetched: {len(fetched_logs)}, new events to process: {len(new_logs)}")

            # Тегируем каждое событие источником интеграции
            for event in new_logs:
                event['integration'] = 'authentik'

            write_logs_to_file(new_logs)

            if SEND_TO_WAZUH_API:
                send_events_to_wazuh(new_logs)

        # Сохраняем state по самому последнему из полученных событий
        newest_event = fetched_logs[-1]
        save_last_state(newest_event['created'], newest_event['pk'])
        print(f"[INFO] State updated. Last timestamp: {newest_event['created']}, PK: {newest_event['pk']}")

    elapsed = (datetime.datetime.now(timezone.utc) - started).total_seconds()
    print(f"--- Run finished in {elapsed:.2f}s ---")

Что нужно поправить перед запуском

В блоке конфигурации в начале файла:

Параметр

Что вставить

AUTHENTIK_URL

URL вашего Authentik, например https://authentik.company.com

AUTHENTIK_TOKEN

Токен, созданный в шаге 1

WAZUH_URL

FQDN вашего Wazuh API

WAZUH_USER / WAZUH_PASSWORD

Пользователь с доступом к /events API
⚠️ рекоммендую создать отдельного пользователя ТОЛЬКО с правами на отправку событий.

EVENTS_FILE_PATH

Путь,wazuh-agent будет читать события, если не хотите собирать через API, или для дублирования логов.

ERROR_LOG_PATH

Куда писать ошибки скрипта

STATE_FILE_PATH

Куда сохранять состояние между запусками

Запускаем скрипт по cron или systemd timer — раз в минуту или реже, в зависимости от интенсивности событий:

# Пример crontab:
* * * * * /usr/bin/python3 /opt/scripts/authentik_audit_ver2.py >> /var/log/authentik/cron.log 2>&1

Шаг 4 — Правила Wazuh для событий Authentik

Это, пожалуй, самая трудоёмкая часть — написать правила, которые правильно детектируют каждый тип события. Я уже сделал это за вас. Правила покрывают 41 сценарий, разбиты на три секции и маппятся на MITRE ATT&CK, PCI DSS, GDPR, HIPAA и NIST.

Кладём файл в директорию правил (либо добавляем через GUI: Server management->Rules->Add new rules file):

cp authentik_custom.xml /var/ossec/etc/rules/
chown root:wazuh /var/ossec/etc/rules/authentik_custom.xml
chmod 640 /var/ossec/etc/rules/authentik_custom.xml
systemctl restart wazuh-manager

За основу правил взят action из документации: https://docs.goauthentik.io/sys-mgmt/events/event-actions/
Полный файл правил:

<!--
  =============================================================================
  Wazuh Detection Rules for Authentik Identity Provider
  =============================================================================
  Author  : pensecfort
  Channel : pensecfort
  Rule IDs: 110000 - 110040
  Version : 1.3
  =============================================================================
  ID Map (sequential, no gaps):

    110000-110024  Section 1 — Basic authentication events
    110025-110036  Section 2 — Model: user accounts / apps / groups / flows / providers / outposts
    110037-110040  Section 3 — Frequency-based and correlation rules
-->
<group name="authentik,">
  <!-- ======================================================================= -->
  <!-- SECTION 1: BASIC AUTHENTICATION EVENTS (110000-110024)                  -->
  <!-- ======================================================================= -->
  <rule id="110000" level="3">
    <field name="integration">authentik</field>
    <action>login</action>
    <description>Authentik: Successful user login</description>
    <mitre>
      <id>T1078</id>
    </mitre>
    <group>authentication_success,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.b,nist_800_53_AU.14.1,tsc_CC6.1,</group>
  </rule>
  <rule id="110001" level="5">
    <field name="integration">authentik</field>
    <action>login_failed</action>
    <description>Authentik: Failed login attempt</description>
    <mitre>
      <id>T1078</id>
      <id>T1110</id>
    </mitre>
    <group>authentication_failures,pci_dss_10.2.4,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.b,hipaa_164.312.d,nist_800_53_AU.14.1,nist_800_53_AC.7,tsc_CC6.1,tsc_CC7.2,</group>
  </rule>
  <rule id="110002" level="3">
    <field name="integration">authentik</field>
    <action>logout</action>
    <description>Authentik: User logged out of the system</description>
    <mitre>
      <id>T1078</id>
    </mitre>
    <group>pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.b,nist_800_53_AU.14.1,tsc_CC6.1,</group>
  </rule>
  <rule id="110003" level="5">
    <field name="integration">authentik</field>
    <action>user_write</action>
    <description>Authentik: User data modified in flow</description>
    <mitre>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_8.1.2,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.b,hipaa_164.312.a,nist_800_53_AC.2,nist_800_53_AU.2,tsc_CC6.3,</group>
  </rule>
  <rule id="110004" level="10">
    <field name="integration">authentik</field>
    <action>suspicious_request</action>
    <description>Authentik: Suspicious request (e.g. revoked token)</description>
    <mitre>
      <id>T1550</id>
      <id>T1078</id>
    </mitre>
    <group>pci_dss_10.6.1,pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.1.ii,hipaa_164.312.b,nist_800_53_SI.4,nist_800_53_AU.6,tsc_CC7.2,tsc_CC7.3,</group>
  </rule>
  <rule id="110005" level="5">
    <field name="integration">authentik</field>
    <action>password_set</action>
    <description>Authentik: User set a new password</description>
    <mitre>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_8.3.6,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.d,hipaa_164.312.b,nist_800_53_IA.5,nist_800_53_AU.2,tsc_CC6.1,</group>
  </rule>
  <rule id="110006" level="8">
    <field name="integration">authentik</field>
    <action>secret_view</action>
    <description>Authentik: Viewing of token or certificate</description>
    <mitre>
      <id>T1552</id>
    </mitre>
    <group>pci_dss_3.5.1,pci_dss_10.2.2,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.3,nist_800_53_AU.2,tsc_CC6.1,tsc_CC6.3,</group>
  </rule>
  <rule id="110007" level="3">
    <field name="integration">authentik</field>
    <action>secret_rotate</action>
    <description>Authentik: Automatic token rotation</description>
    <mitre>
      <id>T1552</id>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_8.6.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_IA.5,nist_800_53_AU.2,tsc_CC6.1,</group>
  </rule>
  <rule id="110008" level="5">
    <field name="integration">authentik</field>
    <action>invitation_used</action>
    <description>Authentik: Invitation link used</description>
    <mitre>
      <id>T1078</id>
    </mitre>
    <group>pci_dss_8.2.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.2,nist_800_53_AU.2,tsc_CC6.1,</group>
  </rule>
  <rule id="110009" level="5">
    <field name="integration">authentik</field>
    <action>authorize_application</action>
    <description>Authentik: User authorized access to the application</description>
    <mitre>
      <id>T1078</id>
      <id>T1550</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.3,nist_800_53_AU.2,tsc_CC6.3,tsc_CC6.8,</group>
  </rule>
  <rule id="110010" level="6">
    <field name="integration">authentik</field>
    <action>source_linked</action>
    <description>Authentik: User linked an external identity source</description>
    <mitre>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_8.2.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.2,nist_800_53_IA.8,tsc_CC6.3,</group>
  </rule>
  <rule id="110011" level="10">
    <field name="integration">authentik</field>
    <action>impersonation_started</action>
    <description>Authentik: Administrator started user impersonation</description>
    <mitre>
      <id>T1078</id>
      <id>T1134</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.2.2,gdpr_IV_32.2,hipaa_164.308.a.1.ii,hipaa_164.312.b,nist_800_53_AC.3,nist_800_53_AU.2,tsc_CC6.3,tsc_CC7.2,</group>
  </rule>
  <rule id="110012" level="6">
    <field name="integration">authentik</field>
    <action>impersonation_ended</action>
    <description>Authentik: Administrator ended user impersonation</description>
    <mitre>
      <id>T1078</id>
      <id>T1134</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.308.a.1.ii,hipaa_164.312.b,nist_800_53_AC.3,nist_800_53_AU.2,tsc_CC6.3,</group>
  </rule>
  <rule id="110013" level="3">
    <field name="integration">authentik</field>
    <action>policy_execution</action>
    <description>Authentik: Policy execution (requires Execution logging enabled on policy)</description>
    <group>pci_dss_10.2.5,hipaa_164.312.b,nist_800_53_AU.2,tsc_CC7.2,</group>
  </rule>
  <rule id="110014" level="9">
    <field name="integration">authentik</field>
    <action>policy_exception</action>
    <description>Authentik: Exception during policy execution</description>
    <group>pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_SI.2,nist_800_53_AU.6,tsc_CC7.3,</group>
  </rule>
  <rule id="110015" level="7">
    <field name="integration">authentik</field>
    <action>property_mapping_exception</action>
    <description>Authentik: Exception during property mapping execution</description>
    <group>pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_SI.2,tsc_CC7.3,</group>
  </rule>
  <rule id="110016" level="9">
    <field name="integration">authentik</field>
    <action>system_task_exception</action>
    <description>Authentik: Exception in system task</description>
    <group>pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_SI.2,nist_800_53_IR.6,tsc_CC7.3,</group>
  </rule>
  <rule id="110017" level="9">
    <field name="integration">authentik</field>
    <action>system_exception</action>
    <description>Authentik: General system exception</description>
    <group>pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_SI.2,nist_800_53_IR.6,tsc_CC7.3,</group>
  </rule>
  <rule id="110018" level="9">
    <field name="integration">authentik</field>
    <action>configuration_error</action>
    <description>Authentik: Configuration error (e.g. during application authorization)</description>
    <mitre>
      <id>T1562</id>
    </mitre>
    <group>pci_dss_6.4.1,pci_dss_2.2.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_CM.6,nist_800_53_SI.2,tsc_CC7.3,</group>
  </rule>
  <!-- Generic audit rules — refined in Section 2 below -->
  <rule id="110019" level="8">
    <field name="integration">authentik</field>
    <action>model_created</action>
    <description>Authentik: Object creation in the system (audit)</description>
    <mitre>
      <id>T1098</id>
      <id>T1136</id>
    </mitre>
    <group>pci_dss_10.2.5,pci_dss_7.2.1,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.2,nist_800_53_AU.2,tsc_CC6.3,tsc_CC8.1,</group>
  </rule>
  <rule id="110020" level="8">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <description>Authentik: Object modification in the system (audit)</description>
    <mitre>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_10.2.5,pci_dss_10.6.1,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.2,nist_800_53_AU.2,tsc_CC6.3,tsc_CC8.1,</group>
  </rule>
  <rule id="110021" level="8">
    <field name="integration">authentik</field>
    <action>model_deleted</action>
    <description>Authentik: Object deletion from the system (audit)</description>
    <mitre>
      <id>T1531</id>
      <id>T1070</id>
    </mitre>
    <group>pci_dss_10.2.5,pci_dss_10.6.1,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.2,nist_800_53_AU.2,tsc_CC6.3,tsc_CC8.1,</group>
  </rule>
  <rule id="110022" level="3">
    <field name="integration">authentik</field>
    <action>email_sent</action>
    <description>Authentik: System email sent</description>
    <group>pci_dss_10.2.5,hipaa_164.312.b,nist_800_53_AU.2,tsc_CC6.1,</group>
  </rule>
  <rule id="110023" level="3">
    <field name="integration">authentik</field>
    <action>update_available</action>
    <description>Authentik: System update available</description>
    <group>nist_800_53_SI.2,tsc_CC7.1,</group>
  </rule>
  <rule id="110024" level="8">
    <field name="integration">authentik</field>
    <action>export_ready</action>
    <description>Authentik: Data export generated and ready</description>
    <mitre>
      <id>T1048</id>
      <id>T1567</id>
    </mitre>
    <group>pci_dss_10.2.5,pci_dss_3.5.1,gdpr_IV_32.2,hipaa_164.312.a,hipaa_164.312.b,nist_800_53_AC.3,nist_800_53_AU.2,tsc_CC6.3,</group>
  </rule>
  <!-- ======================================================================= -->
  <!-- SECTION 2: SPECIFIC MODEL EVENTS (110025-110036)                        -->
  <!-- Refine generic rules 110019-110021 for specific object types.           -->
  <!-- Both generic and specific rules fire for the same event (expected).     -->
  <!-- ======================================================================= -->
  <!-- User accounts (110025-110027) -->
  <rule id="110025" level="9">
    <field name="integration">authentik</field>
    <action>model_created</action>
    <field name="context.model.model_name">user</field>
    <description>Authentik: New user account created</description>
    <mitre>
      <id>T1136</id>
    </mitre>
    <group>pci_dss_8.2.1,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <rule id="110026" level="9">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name">user</field>
    <description>Authentik: User account modified (possible privilege change)</description>
    <mitre>
      <id>T1098</id>
      <id>T1078.003</id>
    </mitre>
    <group>pci_dss_7.2.1,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <rule id="110027" level="9">
    <field name="integration">authentik</field>
    <action>model_deleted</action>
    <field name="context.model.model_name">user</field>
    <description>Authentik: User account deleted</description>
    <mitre>
      <id>T1531</id>
    </mitre>
    <group>pci_dss_8.1.4,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <!-- OAuth/OIDC applications (110028-110029) -->
  <rule id="110028" level="9">
    <field name="integration">authentik</field>
    <action>model_created</action>
    <field name="context.model.model_name">application</field>
    <description>Authentik: New OAuth/OIDC application created</description>
    <mitre>
      <id>T1136</id>
      <id>T1550</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.2.5,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <rule id="110029" level="10">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name">application</field>
    <description>Authentik: OAuth application configuration modified (verify redirect URIs)</description>
    <mitre>
      <id>T1550</id>
      <id>T1190</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.6.1,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.3,tsc_CC6.3,</group>
  </rule>
  <!-- Groups (110030) -->
  <rule id="110030" level="9">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name">group</field>
    <description>Authentik: Group membership changed (possible privilege escalation)</description>
    <mitre>
      <id>T1098</id>
      <id>T1078.003</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_8.1.2,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <!-- Property mappings — token claims injection risk (110031) -->
  <rule id="110031" level="10">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name">propertymapping</field>
    <description>Authentik: Property mapping modified (possible malicious claims injection)</description>
    <mitre>
      <id>T1098</id>
      <id>T1556</id>
    </mitre>
    <group>pci_dss_7.2.1,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.2,tsc_CC6.3,</group>
  </rule>
  <!-- Outpost lifecycle (110032-110033) -->
  <rule id="110032" level="8">
    <field name="integration">authentik</field>
    <action>model_created</action>
    <field name="context.model.model_name">outpost</field>
    <description>Authentik: New outpost created/registered</description>
    <mitre>
      <id>T1136</id>
    </mitre>
    <group>pci_dss_10.2.5,nist_800_53_CM.2,tsc_CC8.1,</group>
  </rule>
  <rule id="110033" level="11">
    <field name="integration">authentik</field>
    <action>model_deleted</action>
    <field name="context.model.model_name">outpost</field>
    <description>Authentik: Outpost deleted - authentication proxy may be unavailable</description>
    <mitre>
      <id>T1562</id>
      <id>T1531</id>
    </mitre>
    <group>pci_dss_10.6.1,nist_800_53_SI.4,tsc_CC7.2,</group>
  </rule>
  <!-- Authentication sources — LDAP, SAML, OAuth, etc. (110034) -->
  <rule id="110034" level="10">
    <field name="integration">authentik</field>
    <action>model_deleted</action>
    <field name="context.model.model_name" type="pcre2">(ldap|saml|oauth2|plex|discord)</field>
    <description>Authentik: Authentication source deleted</description>
    <mitre>
      <id>T1562</id>
      <id>T1531</id>
    </mitre>
    <group>pci_dss_10.6.1,gdpr_IV_32.2,nist_800_53_CM.6,tsc_CC7.3,</group>
  </rule>
  <!-- Authentication/enrollment flows (110035) -->
  <rule id="110035" level="11">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name">flow</field>
    <description>Authentik: Authentication/enrollment flow modified</description>
    <mitre>
      <id>T1556</id>
      <id>T1562</id>
    </mitre>
    <group>pci_dss_6.4.1,gdpr_IV_32.2,hipaa_164.308.a.5,nist_800_53_CM.6,tsc_CC7.3,</group>
  </rule>
  <!-- Authentication providers — LDAP, SAML, OAuth2, OIDC (110036) -->
  <rule id="110036" level="9">
    <field name="integration">authentik</field>
    <action>model_updated</action>
    <field name="context.model.model_name" type="pcre2">(ldap|saml|oauth2|openidconnect)</field>
    <description>Authentik: Critical authentication provider configuration modified</description>
    <mitre>
      <id>T1562</id>
      <id>T1098</id>
    </mitre>
    <group>pci_dss_7.2.1,pci_dss_10.6.1,gdpr_IV_32.2,nist_800_53_CM.6,tsc_CC7.3,</group>
  </rule>
  <!-- ======================================================================= -->
  <!-- SECTION 3: FREQUENCY-BASED AND CORRELATION RULES (110037-110040)        -->
  <!-- ======================================================================= -->
  <!-- 110037: Brute force — 5 failed logins from same IP within 60s -->
  <rule id="110037" level="10" frequency="5" timeframe="60">
    <if_matched_sid>110001</if_matched_sid>
    <same_field>client_ip</same_field>
    <description>Authentik: Brute force detected (5 failed logins from same IP)</description>
    <mitre>
      <id>T1110.001</id>
    </mitre>
    <group>authentication_failures,pci_dss_10.2.4,nist_800_53_AC.7,tsc_CC7.2,</group>
  </rule>
  <!-- 110038: Password spraying — multiple users targeted from same IP -->
  <rule id="110038" level="12" frequency="5" timeframe="120">
    <if_matched_sid>110001</if_matched_sid>
    <same_field>client_ip</same_field>
    <different_field>user.username</different_field>
    <description>Authentik: Password spraying detected (multiple users, same IP)</description>
    <mitre>
      <id>T1110.003</id>
    </mitre>
    <group>authentication_failures,pci_dss_10.2.4,nist_800_53_AC.7,tsc_CC7.2,</group>
  </rule>
  <!-- 110039: Token harvesting — 3+ secret views by same user within 60s -->
  <rule id="110039" level="12" frequency="3" timeframe="60">
    <if_matched_sid>110006</if_matched_sid>
    <same_field>user.username</same_field>
    <description>Authentik: Possible token harvesting (multiple secret views by same user)</description>
    <mitre>
      <id>T1552</id>
    </mitre>
    <group>pci_dss_3.5.1,gdpr_IV_32.2,hipaa_164.312.a,nist_800_53_AC.3,tsc_CC6.1,</group>
  </rule>
  <!-- 110040: OAuth phishing — user authorizes 5+ different apps within 60s -->
  <rule id="110040" level="10" frequency="5" timeframe="60">
    <if_matched_sid>110009</if_matched_sid>
    <same_field>user.username</same_field>
    <different_field>app</different_field>
    <description>Authentik: User authorized multiple different apps rapidly (possible OAuth phishing)</description>
    <mitre>
      <id>T1550.001</id>
      <id>T1566</id>
    </mitre>
    <group>pci_dss_7.2.1,nist_800_53_AC.3,tsc_CC6.8,</group>
  </rule>
</group>

Всё аккуратно маппируется на MITRE ATT&CK, PCI DSS, GDPR и NIST — так что compliance-отчёты тоже будут красивыми.


🏁 Итог — что мы сделали

Давайте подведём итог.

Мы взяли Wazuh — мощный инструмент мониторинга безопасности — и интегрировали его в единую систему управления доступом на базе Authentik.

Теперь в вашей инфраструктуре:

Единый вход. Один аккаунт в Authentik даёт доступ в Wazuh и все остальные подключённые сервисы. Никаких отдельных паролей, никакого хаоса с учётками.

Обязательный 2FA. Каждый вход в дашборд требует второй фактор. Угнанный пароль сам по себе больше ничего не даёт.

Централизованный контроль доступа. Новый сотрудник — один аккаунт в Authentik. Уволился — заблокировали одну учётку и он потерял доступ ко всему сразу.

RBAC по проектам. Разработчики видят только свои логи, аналитики — только алерты, администраторы — всё. Wazuh очень гибок в этом плане, и если статья наберёт 100 лайков — сделаю отдельный подробный разбор: как настраивать доступ вплоть до конкретного агента.

Мониторинг самой системы доступа. Логи Authentik теперь в Wazuh. Вы видите каждый вход, каждую попытку и каждое изменение в правах. Ваша система безопасности следит в том числе за собой.

До следующего материала! 🚀