Спустя более полугода разработки коррелятора, появилась идея отправить в свободное плавание данный проект в связи с большими временными затратами. В данной статье постараюсь заинтересовать читателя ознакомиться с данным продуктом и, возможно, дать ему право на жизнь в корпоративной среде. Для удобства (так как с фантазией все плохо) я назвал данное приложение "Logreact".
Ссылка на репозиторий

Что такое коррелятор и с чем его едят?
Для того чтобы понять принцип работы Logreact необходимо понимать, как устроены SIEM-системы, какие функции выполняет и какие требования к ней предъявляются.
SIEM-системы — это программные решения осуществляющие сбор, обработку, анализ, и хранение событий безопасности. Задача SIEM-систем в реальном времени анализировать события, формируемые приложениями, устройствами, отображать подозрительные активности и помогать специалистам информационной безопасности своевременно реагировать на угрозы.
Корреляция событий — одна из задач SIEM-систем, позволяющая определять значимые инциденты, такие как атаки, сбои в работе приложений, оборудования и т.д.
В различных источниках выделяют разные этапы обработки событий, кратко пройдемся по основным этапам обработки событий в SIEM-системах:
Сбор данных — как правило осуществляется с помощью агентов, установленных на АРМ.
Нормализация — процесс приведения данных, полученных из разных источников, к единому формату, понятному системе. Для работы Logreact данные необходимо преобразовать к формату JSON.
Агрегация событий — процесс, заключающийся в отборе однотипных событий и формирования единого события для снижения нагрузки на коррелятор.
Обогащение — процесс в ходе которого к событию добавляется дополнительная информация.
Корреляция — процесс, в ходе которого ищутся закономерности среди поступающего потока событий на основе заранее составленных правил корреляции.

Кому подойдет Logreact?
Не будем тянуть кота за одно место. Данное решение подойдет организациям малого и среднего бизнеса с ограниченным бюджетом для внедрения решений информационной безопасности.
Logreact имеет как свои преимущества, так и недостатки.
Преимущества:
Открытый исходный код позволяет дорабатывать продукт локально
Обработка более 20 тыс. событий в секунду (в ходе тестов)
Возможность создавать правила корреляции различной сложности
Интеграция с Apache Kafka, позволяющая упростить интеграцию новых источников событий в систему
Возможность настройки и работы с приложением с помощью JSON REST API
Ролевая модель доступа, позволяющая разграничить права между различными пользователями
Простой Web-интерфейс
Недостатки:
Необходимость нормализации событий перед отправкой в коррелятор
Составление правил значительно сложнее, чем в коммерческих SIEM-системах
Потенциальная необходимость доработки приложения для удовлетворения всех потребностей безопасности в организации
Отсутствие бессигнатурных методов анализа событий
Также стоит отметить, что Logreact хранит только данные о срабатывании правил, сами события приложение не сохраняет.
Выполняемые операции
В связи с простотой реализации и удобства работы с бинарными операциями разработанное приложение не умеет работать с унарными операциями, именно поэтому накладываются ограничения на операцию отрицания, которую можно выполнить только инвертировав возвращаемое значение операций с приоритетом равным 2.
Вид операции |
Условное обозначение |
Приоритет |
Назначение |
Группировка (скобки) |
() |
++ |
Изменение стандартных приоритетов |
Логическое сложение |
AND / and |
1 |
Возвращает истину, если оба входных значений истинно |
Логическое или |
OR / or |
0 |
Возвращает истину, если хотя бы одно из входных значений истинно |
Вхождение в поле |
: |
2 |
Возвращает истину если поле содержит данную строку или число |
Точное значение |
== / = |
2 |
Возвращает истину если значение поля точно совпадает с заданным значением |
Поиск вхождения в список |
-> / contains / CONTAINS |
2 |
Сравнивает значение поля с каждым элементом в списке и возвращает истину, если нашлось хотя бы одно совпадение |
Операции отрицания | |||
Отрицание вхождения в поле |
!: |
2 |
Возвращает ложь если поле содержит данную строку или число |
Отрицание точного значения |
!= |
2 |
Возвращает ложь если значение поля точно совпадает с заданным значением |
Отрицание вхождения в список |
!-> |
2 |
Сравнивает значение поля с каждым элементом в списке и возвращает ложь, если нашлось хотя бы одно совпадение |
Что под "капотом"?
Стек технологий
Backend (Golang, chi router, JSON REST API)
БД (PostgreSQL)
Брокер сообщений (Kafka)
Frontend (Next.js и TypeScript)
Почему именно Golang?
Удобный механизм синхронизации горутин и их легковесность позволяют распараллелить операции, избегая гонок и блокировок.
Большое количество обрабатываемых данных предоставляет требования к скорости очистки неиспользуемых участков памяти. Тут на помощь приходит "Garbage Collector", автоматически выполняющий очистку "мусорной" памяти.
Огромное количество возможностей при работе с JSON-форматом, начиная от преобразования в "мапу" и заканчивая преобразованием структуры в JSON.
Кроссплатформенность и быстрая скорость работы приложений.
Структура события и правила
Для дальнейшего понимания принципа работы обработчиков нужно погрузиться в структуру поступающих событий. Т.к. коррелятор не содержит нормализатора, необходимо с помощью стороннего решения преобразовывать события в формат JSON, с этим отлично справляются как некоторые сборщики, так и решения по типу Logstash.
Далее для оптимизации работы рекомендуется обогатить событие и добавить к нему поле ukey (содержащее источник события), зачем это надо пока не скажу, нужно же сохранять интригу.
В ходе данных манипуляций получаем событие в формате JSON, содержащее источник из которого оно было получено.
Далее перейдем к структуре правила. Смотря на нее понимаем, что обработчик каждого конкретного правила получает только события, которые ему необходимы (ведь нет смысла искать возможный дамп lsass в auth.log).

Пример абстрактного правила
{
"rule": "SSH Brute Force",
"ukey": "auth.log",
"params": {
"ttl": "1h",
"sev_level": 3,
"desc": "Правило для обнаружения SSH Brute Force",
"no_alert": false
},
"condition": {
"logic": "event.program == 'sshd' AND event.message: 'Failed password' AND source.ip = $STICKY$",
"freq": "100/min"
},
"alert": {
"fields": "source.ip, event.program",
"addfields": {
"message": "Попытка брутфорса SSH от %source.ip%",
"from": "logreact",
"key": "ssh_bruteforce"
}
}
}
Схема работы
Для понимания принципа работы приведу упрощенную схему работы приложения, исходя из которой должно быть (надеюсь) понятно взаимодействие различных обработчиков. Вся магия происходит на этапе попадания события в глобальный обработчик, о чем и пойдет речь дальше...

Глобальный обработчик
Принцип работы глобального обработчика хорошо передает именно эта картинка

Сам глобальный обработчик не производит корреляцию событий, он лишь запускает нужные горутины в работу и распределяет события между ними. Основную работу выполняют обработчики отдельных правил, которые, исходя из картинки выше, будем называть Worker'ы.
Структура Глобального обработчика
type GlobalHandler struct {
IsReady bool
FramePointer map[string]*Frame
RuleChan chan *Rule // Channel for calls, for example creating new rule or stopping one
LogChan chan []byte // Channel for Reader
}
Так как обработчик постоянно должен проверять два канала и не засыпать (помним что при попытке чтения пустого канала горутина засыпает) появляется необходимость использования конструкции for-select
for {
select {
case <-ctx.Done():
logger.InfoLogger.Println("Stop signal recieved, stopping handler")
return
case rule, ok := <-g.RuleChan: // because rule channel is rarely used it has the biggest priority
if !ok {
logger.InfoLogger.Println("Meassage channel closed, stopping handler.")
return
}
g.manageRule(ctx, rule)
case msg, ok := <-g.LogChan: // Checking if new log
if !ok {
logger.InfoLogger.Println("Log channel closed, stopping handler.")
return
}
g.parseLog(msg)
default:
time.Sleep(1 * time.Nanosecond)
}
}
Глобальный обработчик перед запуском Worker'a создает так называемые Frame'ы, содержащие список всех Worker'ов с одним и тем же значением ukey и каналом, по которому и будут передаваться события. Сам глобальный обработчик сначала смотрит на поле ukey, ищет указатель на нужный Frame и передает ему необходимое событие, которое уже Frame рассылает всем Worker'ам.
Структура Frame
type Frame struct {
WorkersPointer map[string]*Worker // Pool of workers
Ukeys string // unique keys, if slice is given separator is ,
LogChan chan map[string]interface{} // Channel for logs
RuleChan chan *Rule // Channel for rules, if no Rule with such Rule_Name then creating new one, else deleting rule from map
}
Принцип работы Worker'ов
Есть причина, почему принцип работы "работяг" вынесен в отдельный заголовок. На данном этапе происходит самая настоящая цыганская магия.
Т.к. одно правило может отслеживать сразу несколько различных источников (иногда есть необходимость генерации алерта для каждого отдельного источника) появляется необходимость сохранять состояние для каждого отдельного источника отдельно. На данном этапе появляются сразу 2 новых для нас сущности: Container и Step
Container — хранит в себе временные метки срабатывания правила (для подсчета частоты или количества срабатываний), время жизни, паузу после срабатывания (для избегания генерации множества одинаковых алертов в течении секунды) и указатель на Step
Структура Container
// struct that contains how much container will live
type Container struct {
Ttl time.Duration
Timeout time.Duration // How long new alert won't be created
TimeoutTrigger time.Time // time since last alert
Trigger time.Time // When container created
Selectors map[string]string // STICKY fields and their values
CurrentStep *Step // contains list of steps (each step contains pointer on next and orherwise step)
TriggerTimes []time.Time // Timestamps of alarms
DifferentValues map[string][]string // fields and values that already were in logs
Alert *Alert
}
Step — данную структуру вы уже могли видеть в блоке, где объяснялась структура правила (оттуда видно, что данная структура хранит указатель на следующий Step, в случае если следующего Step'a нет, указатель равен nil). Она нужна для того, чтобы выполнять только те операции, что объявлены на текущем шаге. После выполнения всех Step'ов правило генерирует алерт и отправляет его "Writer'у".
Структура Step
// Struct that defines steps in rules, there is minimum one step in each rule
type Step struct {
Logic []string // Reverse polish notation of logic
Times int // defines amount of times for freq
Per time.Duration // defines time period for freq
Count int // Count for amount of alarms
NextStep *Step // Pointer to a next step of the rule
}
При инициализации Worker смотрит, нужно ли создание множества контейнеров для работы с данным правилом, если нет, то создается только один контейнер в котором и происходит дальнейшая обработка событий. Если создание множества контейнеров необходимо, то при попадании события с новым источником под него заводится контейнер с заданным временем жизни (ttl), по истечению данного времени контейнер удаляется.
Выводы
Надеюсь что тонна текста была понятна и полезна рядовому читателя, я не стал углубляться в чистый кодинг и попытался объяснить суть работы приложения максимально доступным языком. На данный проект было убито много сил и времени, было бы приятно если он оставил след в инфобезном сообществе, лег в основу какого-либо другого проекта или впоследствии был доработан до полноценной SIEM-системы.
Если кого-то заинтересует данная тема, то в дальнейшем возможен выход ряда статей о принципе работы других обработчиков Logreact, его развертывании, или о том как же все-таки пользоваться этим многострадальным Frontend'ом.