Как мы строили свою базу данных о киберугрозах для LLM-агентов и SOC

от автора

Введение

Сначала задача звучала просто: складывать PDF, CVE и статьи по кибербезопасности в одну базу, затем давать LLM-агенту подходящие фрагменты через HTTP API. На доске это помещалось в одну цепочку: загрузка, извлечение текста, разбиение на части, построение векторов, поиск.

Рабочий прототип появился быстро. Настоящая работа началась потом.

Первая полная загрузка показала, что данные об угрозах (Threat Intelligence (TI)) плохо укладываются в модель “обычный поиск по документам, только про безопасность”. Здесь мало найти похожий абзац. Нужно знать, откуда он взялся, когда был опубликован, насколько надежен источник, какие индикаторы компрометации (IOC) встретились в тексте и не устарели ли они. А еще система должна “прожевать” кривой RSS, большой PDF, падение процесса и повторный запуск без тысячи копий одной статьи.

Расскажу, как мы прошли путь от локального прототипа с поиском по документам до сервиса с комбинированным поиском, управляемыми источниками, STIX/TAXII и проверенным восстановлением из резервной копии. Пока ещё у сервиса нет веб-интерфейса, Celery и отдельной графовой БД.

Почему обычного векторного поиска для TI оказалось мало

Главная особенность TI-базы в том, что ценность документа зависит от времени и происхождения. Статья об атаке, опубликованная вчера, обычно полезнее пересказа двухлетней давности. Но справочное описание старой CVE не становится бесполезным только из-за возраста.

С источниками то же самое. Бюллетень производителя, запись из RSS-агрегатора и случайное сообщение в канале нельзя считать равноценными, даже если в них совпадают слова. Точное упоминание CVE-2026-1234 или домена часто важнее общей похожести текстов.

Представьте два абзаца. В первом много слов про уязвимости VPN, во втором всего одна строка с нужным номером CVE и адресом вредоносного сервера. Обычный смысловой поиск может предпочесть первый. Аналитику почти наверняка нужен второй.

Поэтому мы сразу зафиксировали четыре требования:

  1. Делить текст по границам разделов, а не механически через каждые N токенов.

  2. Извлекать CVE, IP, домены, URL, хэши, техники MITRE ATT&CK, семейства вредоносного ПО и инструменты.

  3. Учитывать свежесть по-разному для новостей, кампаний и справочных материалов.

  4. Возвращать подтверждающие материалы: текст, источник, URL, дату и понятное объяснение оценки.

Это решение определило почти всю архитектуру.

Первый набор технологий: Postgres, pgvector и MinIO

Для минимально жизнеспособной версии мы выбрали

PDF / RSS / CVE       |       vFastAPI -> задания в Postgres -> фоновый обработчик                                   |                                   v                     извлечение и очистка текста                                   |                                   v                   фрагменты + сущности + векторы                         |                 |                         v                 v                       MinIO       Postgres + pgvector                                           |                                           v                         поиск / контекст / поток IOC

FastAPI принимает загрузки и запросы. Отдельный фоновый обработчик забирает задания со статусом queued, то есть “ждет обработки”. Postgres хранит документы, фрагменты, сущности, связи, задания и числовые представления текста через pgvector. В MinIO лежат исходные файлы и извлеченный текст.

Небольшое пояснение про векторы. Модель BAAI/bge-m3 превращает каждый фрагмент в набор чисел, который примерно описывает его смысл. pgvector умеет сравнивать такие наборы и находить близкие по смыслу тексты, даже если в них использованы разные слова. Все вычисления идут локально. Для быстрых проверок есть упрощенный и полностью повторяемый способ построения тестовых векторов.

Мы сознательно не добавляли Redis, Celery, LangChain и отдельную графовую БД. Очередь из четырех состояний (queued, running, complete, failed) поместилась в Postgres. Связи между сущностями на текущем объеме тоже нормально живут в обычных таблицах.

Таблица хорошо показывает, где мы упростили систему:

Хотелось добавить сразу

Что сделали в первой версии

Почему

Celery и Redis

Очередь в Postgres

Один обработчик, умеренный поток задач

Отдельную графовую БД

Таблицы сущностей и связей

Графовые запросы еще не стали узким местом

Универсальный конвейер загрузки

Три явных пути для PDF, RSS и CVE

Проще проверять поведение каждого источника

Облачный расчет векторов

Локальная модель

TI-тексты и клиентские данные не уходят наружу

Панель администратора

API и утилита командной строки

Для закрытого пилота интерфейс не был критичен

Минимум компонентов помог, когда начались проблемы. А они начались.

Как устроена загрузка PDF, RSS и CVE

Каждый тип источника проходит свой входной этап, но после очистки путь у всех общий.

PDF сначала сохраняется в MinIO. Текст извлекается через PyMuPDF. Если страница почти пустая, включается оптическое распознавание через Tesseract. RSS обрабатывается в два шага: сначала заголовок, дата и ссылка, затем полный текст статьи. CVE можно передать готовыми записями JSON или адресом источника.

Дальше система:

  1. Нормализует Unicode и пробельные символы.

  2. Сохраняет извлеченный текст как отдельный объект в MinIO.

  3. Делит документ на фрагменты по границам разделов.

  4. Извлекает значимые для ИБ сущности с помощью регулярных выражений и словарей.

  5. Считает векторы небольшими группами.

  6. Создает связи между фрагментами и сущностями.

  7. Записывает результат документа одной транзакцией.

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

Для повторных загрузок есть несколько признаков совпадения: external_id, canonical_url и content_sha256. Проверка RSS-дубля выполняется до скачивания полной статьи. На повторном запуске это экономит и сеть, и время процессора.

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

Первый большой сбор RSS убил API, и это было полезно

Изначально API создавал задание и сам продолжал обрабатывать его в фоне. На нескольких документах схема выглядела рабочей. Затем мы запустили сбор из 48 RSS/Atom-лент с лимитом до 100 записей на источник.

На отметке 374 RSS-документа контейнер API завершился с кодом 137. Docker подтвердил OOMKilled=true: процесс принудительно остановили из-за нехватки памяти. В Postgres задание осталось в состоянии running, то есть “выполняется”, хотя выполнять его было уже некому. Фоновый обработчик не мог забрать чужое якобы активное задание.

Не сработало.

Причина состояла не в одном большом объекте. API одновременно держал сетевую загрузку, разбирал статью, создавал фрагменты и считал векторы. Один ответ FeedBurner успел прокачать около 1,3 ГБ сетевого трафика. Ход выполнения обновлялся редко, поэтому снаружи процесс еще и выглядел зависшим.

После этого мы сделали несколько изменений:

  • API теперь только создает задание в очереди;

  • всю тяжелую работу выполняет отдельный обработчик;

  • векторы считаются группами по восемь фрагментов;

  • HTTP-ответ RSS или статьи ограничен 2 МБ;

  • текст одной статьи ограничен 60 тысячами символов;

  • задание записывает текущую ленту, номер записи, счетчики новых и пропущенных документов;

  • обработчик помечает давно не обновлявшиеся задания как завершившиеся с ошибкой;

  • повторный запуск сначала проверяет дубликат и только потом скачивает статью.

Тот же сбор после исправлений завершился: 1319 RSS-документов и три PDF дали 4726 фрагментов. Четыре проблемные ленты были пропущены с понятными причинами: две вернули HTTP 403, еще две превысили установленный лимит ответа.

Почему нормализация текста потребовала отдельной переиндексации

После успешного сбора поиск работал, но в части RSS-текстов появились строки вроде – вместо тире и  вместо неразрывного пробела. Такие кракозябры называют mojibake: байты записали в одной кодировке, а прочитали в другой. На качество поиска это влияло слабо, зато подтверждающий текст для агента выглядел неряшливо.

Мы добавили осторожный очиститель текста. Он пытается восстановить только фрагменты, похожие на текст в UTF-8, который ошибочно прочитали как latin-1 или cp1252. Нормальный русский и английский текст он не трогает. Новые документы стали чистыми, старые 4726 фрагментов остались прежними.

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

Полный проход оказался слишком тяжелым. Мы остановили его и добавили режим --only-bad, который выбирал только документы с известными признаками битой кодировки. В финальном точечном проходе были исправлены четыре документа и 59 фрагментов, затем еще несколько редких комбинаций cp1252 и управляющих символов.

Тут обнаружилась забавная ловушка. Условие “в базе нет символа â” неверно: он встречается в нормальных именах и португальских словах. Проверять нужно известные битые последовательности, а не отдельную букву.

Финальная проверка дала 4730 векторов на 4730 фрагментов, ноль известных последовательностей с битой кодировкой и ноль неразрывных пробелов.

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

Комбинированный поиск возвращает не ответ, а проверяемый контекст

Поиск учитывает сразу несколько признаков:

итоговая оценка =    близость по смыслу  + совпадение слов  + доверие к источнику  + актуальность по времени  + совпадение CVE, доменов и других сущностей

У каждого признака свой вес. В ответе API части оценки возвращаются отдельно, поэтому пользователь или агент видит, почему фрагмент оказался наверху. Это не математическое доказательство истинности. Скорее прозрачная ведомость с баллами вместо одной неизвестной цифры.

Для новостей и описаний активных атак свежесть весит больше. Для CVE и справочных документов возраст штрафуется мягче. Совпадение точной сущности, например CVE или домена, поднимает фрагмент даже при неидеальной близости по смыслу.

Метод /v1/agent/context принимает вопрос и возвращает набор подтверждений: текст фрагмента, заголовок документа, URL, дату публикации, найденные сущности и оценки. Языковая модель не должна воспринимать содержимое как команду. Это внешние данные, которым нельзя слепо доверять.

Способ генерации ответа можно менять. Источники и подтверждения остаются.

Как база документов превратилась в сервис IOC для SOC

Следующий этап начался с простого вопроса: как подключить первый центр мониторинга безопасности (SOC) или систему управления событиями безопасности (SIEM)? Одного смыслового поиска мало. Потребителю нужен стабильный поток данных, фильтры, получение только новых записей и понятная схема доступа.

Мы добавили версионированный /v1 API и выгрузку IOC в JSON, CSV, STIX 2.1 и упрощенный TAXII только для чтения. STIX задает общий формат описания угроз, а TAXII определяет, как этими описаниями обмениваться по сети. В выдаче у индикатора есть тип, нормализованное значение, уровень доверия, критичность, вердикт, маркировка распространения TLP, время первого и последнего наблюдения, число источников и ссылки на подтверждения.

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

Для пилота сделали API-ключи. В базе хранится только хэш SHA-256, то есть необратимый цифровой отпечаток ключа. У каждого ключа есть дневной лимит и учет запросов. Общий корпус доступен всем организациям, а частные PDF, источники, задания и решения аналитика разделены.

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

RSS, PDF и отчеты хорошо подходят для поиска по документам. Быстро обновляемый список IP или URL устроен иначе. У него важны точные значения, время первого и последнего наблюдения, срок жизни, вердикт и происхождение каждой записи.

К системе подключились DShield, Feodo Tracker, CISA KEV, обогащение через Shodan, ThreatFox, URLhaus и собственный сенсор ThreatRadar. Для каждого источника правила разные:

  • IP из DShield получает вердикт unknown, то есть “неизвестно”, потому что наблюдение сканирования еще не доказывает вредоносность;

  • управляющие серверы Feodo и IOC из ThreatFox получают вердикт malicious, то есть “вредоносный”;

  • у URLhaus точный URL считается вредоносным, а связанный домен или IP только подозрительным, ведь легитимный сайт мог быть взломан;

  • Shodan добавляет сведения об уже известном IP, но не повышает уровень доверия и не меняет вердикт;

  • из собственного сенсора импортируются IP, время, счетчики и типы активности.

Следующее архитектурное изменение уже видно: путь аналитических документов и путь точных наблюдений нужно разделить окончательно. Документ отвечает на вопрос “что об этом известно”, наблюдение отвечает “кто, когда и где видел этот IOC”.

Почему это существенно? Десять сайтов могут перепечатать одну новость и создать иллюзию десяти независимых подтверждений. Для документа это десять страниц. Для TI это одно наблюдение и девять пересказов.

Что понадобилось рабочему серверу кроме самого поиска

Закрытый пилот работает на одном виртуальном сервере под Ubuntu. Caddy принимает защищенные TLS-соединения, наружу открыты только HTTP/HTTPS, а Postgres и MinIO остаются во внутренней сети Docker. Рабочая конфигурация отказывается запускаться со стандартными учетными данными, коротким административным токеном или отключенной проверкой API-ключей.

Фоновый обработчик регулярно маякует “я жив”. API и обработка документов пишут структурированные журналы в JSON. Утилита администратора проверяет доступность сервисов, активные ключи, ошибки источников, зависшие задания, пробную выгрузку IOC и наличие резервной копии.

Резервная копия содержит выгрузку SQL, архив MinIO и описание с контрольными суммами. Общий архив шифруется AES-256-CBC с PBKDF2. Сразу после создания система пробует расшифровать его и сравнивает контрольную сумму с исходником. Отдельно хранится SHA-256 уже зашифрованного файла.

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

Поэтому мы проверяли не наличие архива, а полное восстановление. Зашифрованную копию скачали на компьютер оператора, развернули в отдельных томах Docker и подключили к временному API. Срез на 2 июля содержал 1745 документов, 134041 фрагмент, 98523 сущности, 474 источника и 1823 объекта MinIO. Проверка доступности, описание API, выгрузка IOC, поиск и выдача контекста сработали.

Архив без такой проверки остался бы гипотезой.

Какие решения себя оправдали, а какие еще предстоит проверить

Лучше всего сработали простые границы ответственности. API принимает и отдает данные. Фоновый обработчик выполняет тяжелую работу. MinIO хранит файлы. Postgres остается главным хранилищем для описаний, очереди и векторов.

Еще один удачный выбор: хранить подтверждения и части оценки с самого начала. Добавить красивую генерацию ответа поверх этого легко. Восстанавливать происхождение ответа задним числом гораздо больнее.

Но текущая версия не закончена:

  • очередь в Postgres подходит одному обработчику, но не бесконечному наращиванию числа серверов;

  • TAXII реализован только для чтения и одной коллекции;

  • OCR зависит от доступности Tesseract;

  • правила качества пока в основном глобальные;

  • нет веб-интерфейса, автоматического выставления счетов, высокой доступности и автоматического переключения при сбое;

  • структурированные наблюдения, срок действия, отзыв и история происхождения источников требуют отдельной модели.

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

Большая база выглядит эффектно. Полезная база сокращает время решения.

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

Протестировать решение можно запросив бесплатный api ключ у нас на сайте.

Если вы строили похожий конвейер, интересно сравнить опыт: где у вас проходит граница между документом, наблюдением и готовым IOC?

Частые вопросы

Почему вы не взяли готовую TIP-платформу?

TIP (Threat Intelligence Platform) представляет собой готовую платформу для сбора и управления данными об угрозах. Нам же был нужен компактный серверный сервис для LLM-агентов и закрытого пилота с SOC. Собственная реализация позволила проверить модель данных и поиск без внедрения большой платформы.

Зачем одновременно pgvector и полнотекстовый поиск?

Векторный поиск хорошо находит близкие по смыслу тексты. Но точные CVE, домены и названия инструментов часто лучше находятся по словам и выделенным сущностям. Совместная оценка надежнее любого одного способа.

Почему исходные файлы хранятся отдельно от Postgres?

Так базу проще мигрировать и индексировать повторно. Postgres хранит структуру и связи, MinIO хранит исходный материал и извлеченный текст. Переиндексация не требует снова скачивать документ.

Нужна ли TI-проекту отдельная графовая БД?

Не обязательно на старте. Пока связи помещаются в обычные таблицы и графовые запросы не стали узким местом, отдельная БД добавит больше забот по эксплуатации, чем пользы.

Можно ли сразу собирать Telegram, теневые форумы и “приватные 0-day”?

Технически часть источников подключить можно, но правовые, лицензионные и операционные риски там выше. Мы сначала построили учет происхождения, контроль качества и безопасную доставку. Доступ к закрытым теневым площадкам разумнее покупать у специализированного поставщика.

ссылка на оригинал статьи https://habr.com/ru/articles/1056206/