Введение
Сначала задача звучала просто: складывать 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 | 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 или адресом источника.
Дальше система:
-
Нормализует 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”?
Технически часть источников подключить можно, но правовые, лицензионные и операционные риски там выше. Мы сначала построили учет происхождения, контроль качества и безопасную доставку. Доступ к закрытым теневым площадкам разумнее покупать у специализированного поставщика.
ссылка на оригинал статьи https://habr.com/ru/articles/1056206/