Как мы строили свою базу данных о киберугрозах для LLM-агентов и SOC. llm.. llm. METEOR.. llm. METEOR. siem.. llm. METEOR. siem. soc.. llm. METEOR. siem. soc. threat intelligence.. llm. METEOR. siem. soc. threat intelligence. база данных.. llm. METEOR. siem. soc. threat intelligence. база данных. базы данных.. llm. METEOR. siem. soc. threat intelligence. база данных. базы данных. Информационная безопасность.. llm. METEOR. siem. soc. threat intelligence. база данных. базы данных. Информационная безопасность. искусственный интеллект.. llm. METEOR. siem. soc. threat intelligence. база данных. базы данных. Информационная безопасность. искусственный интеллект. кибербезопасность.. llm. METEOR. siem. soc. threat intelligence. база данных. базы данных. Информационная безопасность. искусственный интеллект. кибербезопасность. сбор IOC.

Введение

Сначала задача звучала просто: складывать 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
       |
       v
FastAPI -> задания в 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”?

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

Автор: cheebo

Источник