← Назад к новостям

Как мы строили свою базу данных о киберугрозах для 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 и адресом вредоносного сервера. Обычный смысловой поиск может предпочесть первый. Аналитику почти наверняка нужен второй. Поэтому мы сразу зафиксировали четыре требования: Делить текст по границам разделов, а не механически через каждые N токенов. Извлекать CVE, IP, домены, URL, хэши, техники MITRE ATT&CK, семейства вредоносного ПО и инструменты. Учитывать свежесть по-разному для новостей, кампаний и справочных материалов. Возвращать подтверждающие материалы: текст, источник, 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 или адресом источника. Дальше система: Нормализует Unicode и пробельные символы. Сохраняет извлеченный текст как отдельный объект в MinIO. Делит документ на фрагменты по границам разделов. Извлекает значимые для ИБ сущности с помощью регулярных выражений и словарей. Считает векторы небольшими группами. Создает связи между фрагментами и сущностями. Записывает результат документа одной транзакцией. Транзакция здесь работает как запечатанный пакет: либо в базе появляется весь обработанный документ, либо не появляется ничего. Полусобранного документа с текстом, но без векторов, остаться не должно. Для повторных загрузок есть несколько признаков совпадения: 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”? Технически часть источников подключить можно, но правовые, лицензионные и операционные риски там выше. Мы сначала построили учет происхождения, контроль качества и безопасную доставку. Доступ к закрытым теневым площадкам разумнее покупать у специализированного поставщика.
📊 Источник: Habr | Оригинал