Telegram-бот на Go для отслеживания новых уязвимостей по ключевым словам (nginx, iptables, nft, ...) в CVE (NVD), БДУ ФСТЭК, каталоге активно эксплуатируемых уязвимостей CISA KEV и GitHub Security Advisories. Работает независимо в каждом чате, а в чатах с включёнными темами (топиками) — независимо в каждой теме: у каждого чата/темы свой набор ключевых слов и свой набор включённых источников, настраиваемый через /settings (кнопки).
- Периодический опрос источников уязвимостей и рассылка совпадений по ключевым словам в нужный чат (и тему, если применимо).
- Полностью раздельная конфигурация по
(chat_id, topic_id): ключевые слова и источники в одном чате/теме никак не влияют на другие. - Источники данных:
- NVD CVE API 2.0 (
nvd) — инкрементальный опрос поlastModStartDate/lastModEndDate. - БДУ ФСТЭК (
fstec, bdu.fstec.ru) — официального инкрементального API нет, поэтому используется полный XML-экспорт (vulxml.zip), обновляемый примерно раз в сутки; парсится потоково, без загрузки полного XML (~600 МБ) в память. - CISA KEV (
kev) — каталог уязвимостей, подтверждённо эксплуатируемых «в дикой природе»; тоже без инкрементального API, но сам каталог небольшой (~1.5 МБ), поэтому полная перезагрузка не проблема. - GitHub Security Advisories (
ghsa) — уязвимости пакетных экосистем (npm, PyPI, Go, crates.io, Maven, ...); инкрементальный опрос поupdated.
- NVD CVE API 2.0 (
- Дедупликация и группировка между источниками: если одну и ту же уязвимость (по общему CVE ID) сообщают несколько включённых источников, уведомление отправляется один раз со списком всех источников, где она упоминается («Также упоминается в: ...»), а не по одному сообщению на источник.
- Включение источника не приводит к «затоплению» историческими записями: учитываются только уязвимости, изменённые после момента включения источника для этого чата/темы (актуально прежде всего для БДУ ФСТЭК и CISA KEV, которые каждый раз отдают данные целиком). Более старые совпадения по ключевым словам просто помечаются как уже обработанные, без уведомления.
- Порог серьёзности (CVSS/severity) — чтобы не уведомлять о малозначительных уязвимостях, и блоки — независимые наборы ключевых слов/фраз каждый со своим порогом (см. ниже).
- Два уровня порога — мгновенный и дайджест: то, что не проходит мгновенный порог, но проходит порог дайджеста, не пропадает, а копится и уходит одним батчем на периодической рассылке, вместо того чтобы либо шуметь, либо теряться (см. ниже).
- Ключевые слова могут быть многословными фразами (в кавычках), а не только отдельными словами.
- Чёрный список слов/фраз — если они встречаются в уязвимости, уведомление о ней не отправляется вообще, независимо от того, по какому ключевому слову или блоку она бы совпала (см. ниже).
- Экспорт/импорт настроек — вся конфигурация чата/темы выгружается в JSON-файл и загружается обратно, в тот же чат, другой чат или вовсе другой инстанс бота (см. ниже).
- Ограничение мутирующих действий администраторами чата (настраивается, в личных сообщениях не действует); корректно работает и для анонимных администраторов, пишущих от имени группы.
- Все настройки (ключевые слова, источники, порог серьёзности, блоки, чёрный список) меняются только кнопками через
/settings— никаких текстовых команд с аргументами запоминать не нужно. Текстовые команды остались только для чтения текущей конфигурации и для/export//import(см. ниже, почему это исключение).
| Команда | Описание |
|---|---|
/start, /help |
Список команд |
/keywords |
Ключевые слова текущего чата/темы |
/sources |
Доступные и включённые источники |
/severity |
Текущий минимальный порог серьёзности для уведомлений |
/blocks |
Показать блоки — независимые наборы ключевых слов со своим порогом |
/blacklist |
Показать чёрный список слов/фраз |
/status |
Текущая конфигурация чата/темы |
/settings |
Настройки кнопками (inline-клавиатура) — основной способ что-либо изменить |
/lookback |
Найти все уязвимости, когда-либо совпавшие с текущими ключевыми словами (не только новые), с постраничной навигацией |
/threatmodel |
Сгенерировать модель нарушителя, модель угроз, ключевые слова и (по кнопке в меню) цепочки атак среди найденных уязвимостей по описанию инфраструктуры (требует настройки LLM, см. ниже) |
/export |
Выгрузить настройки этого чата/темы в JSON-файл |
/import |
Загрузить настройки из файла, полученного через /export — заменяет текущие |
Источники в /sources показаны человекочитаемым названием (NVD, БДУ ФСТЭК, CISA KEV, GHSA) с внутренним именем в скобках (nvd, fstec, kev, ghsa) — то же внутреннее имя используется в конфиге (sources.<имя>...) и переменных окружения.
При добавлении первого ключевого слова в чат/тему, если источники ещё не настраивались явно, включаются все зарегистрированные источники — это можно потом сузить через /settings → 🗂 Источники.
Ключевое слово может быть многословной фразой — при добавлении через /settings → 🔑 Ключевые слова → ➕ Добавить возьмите её в кавычки:
"remote code execution" nginx iptables
Отдельные слова после фразы разбираются как раньше. Поддерживаются прямые кавычки ("..."), «ёлочки» («...») и «умные» кавычки ("..."/„...") — мобильные клавиатуры (особенно iOS) автоматически заменяют введённые прямые кавычки на них, так что распознаются оба варианта. Незакрытая кавычка не «съедает» остаток сообщения — такое слово просто разбирается как обычное. Та же поддержка фраз и кавычек действует и при добавлении блоков, и при добавлении в чёрный список.
По умолчанию бот уведомляет о любом совпадении по ключевому слову независимо от серьёзности уязвимости. Если это слишком «шумно» — задайте минимальный порог через /settings → /severity).
Уязвимость сверяется с порогом по числовому баллу CVSS, если он известен (границы диапазонов — стандартные для CVSS v3.1: Low 0.1–3.9, Medium 4.0–6.9, High 7.0–8.9, Critical 9.0–10.0); если балла нет — по текстовому severity источника (NVD/GHSA отдают его на английском, БДУ ФСТЭК — по-русски, оба распознаются). Если нет ни балла, ни узнаваемого severity (так всегда у CISA KEV — у него в принципе нет этих полей, хотя туда попадают только уязвимости, уже подтверждённо эксплуатируемые «в дикой природе»), уязвимость проходит порог, а не отбрасывается — на отсутствии данных лучше показать лишнее, чем молча спрятать то, что не удалось оценить. Уязвимости ниже порога не теряются — они всё равно попадают в базу и доступны через /lookback, просто не шлют уведомление.
Порог, заданный через /settings → 🎯 Блоки → ➕ Новый блок, затем настройте его ключевые слова и порог там же. Например, nginx/iptables в блоке с порогом critical, а postgresql — в основном наборе без порога вовсе. Уведомление показывает, через какой блок оно сработало («блок: critical-only»), если сработал не основной набор.
Мгновенные уведомления можно и вовсе выключить — отдельной кнопкой 🚫 Выключены рядом с пресетами порога, симметрично и для основного набора, и для каждого блока по отдельности. Это не то же самое, что порог «Любой»: «Любой» — это самый низкий, но всё же активный порог (мгновенно уведомляет вообще обо всём), а «Выключены» полностью отключает мгновенные уведомления для этого набора — дайджест (если включён) при этом продолжает работать как обычно, просто без мгновенной части. Выбор любого пресета серьёзности автоматически снова включает мгновенные уведомления — отдельного шага «сначала включить, потом выбрать порог» не требуется.
У каждого набора ключевых слов (основного и каждого блока) на самом деле два порога, не один: мгновенный (описан выше) и дайджест. По умолчанию порог дайджеста выключен, и всё ведёт себя как раньше — ничего не проходит мгновенный порог, просто отбрасывается (хотя и остаётся доступным через /lookback). Если порог дайджеста включить и задать ему уровень ниже мгновенного, то уязвимость, которая не дотягивает до мгновенного порога, но дотягивает до порога дайджеста, не отбрасывается — она копится и уходит одним сообщением на следующей периодической рассылке, вместо мгновенного уведомления. Уязвимость, которая не проходит даже порог дайджеста, по-прежнему просто отбрасывается (доступна через /lookback, как и раньше).
Настраивается там же, где мгновенный порог: /settings → /severity, /blocks, /status.
Дайджест — общий для всего бота по расписанию: интервал рассылки задаётся один раз в конфиге (digest.interval, по умолчанию раз в сутки), а не отдельно для каждого чата/темы — то же соотношение сложности и гибкости, что уже принято для scanner.poll_interval и storage.housekeeping_interval. Каждый чат/тема с включённым порогом дайджеста получает сообщение с суммарным списком того, что накопилось для него за интервал (по несколько сообщений, если список большой), с указанием блока и ключевого слова для каждой записи. Если дайджест не удалось отправить (например, Telegram временно недоступен), накопленное не теряется — очередь остаётся нетронутой до следующего интервала.
Если конкретное слово/фраза не должны приводить к уведомлению независимо от того, по какому ключевому слову или блоку уязвимость иначе совпала бы — добавьте их в чёрный список через /settings → 🚫 Чёрный список → ➕ Добавить.
Проверяется той же регистронезависимой подстрокой по названию/описанию/списку продуктов, что и обычные ключевые слова, и той же поддержкой кавычек для многословных фраз. Это общий, безусловный чёрный список чата/темы: если совпадение есть, уведомление не отправляется вовсе — ни через основной набор, ни через какой-либо блок, никакой порог его не переопределит. Уязвимость при этом никуда не пропадает — она всё так же попадает в базу и видна через /lookback (отмечена значком 🚫), просто не шлёт уведомление.
Чёрный список блока — то же самое, но у́же: у каждого блока есть собственный чёрный список (кнопка 🚫 Чёрный список блока (N) внутри блока, /settings → 🎯 Блоки → выбрать блок), независимый от общего и от чёрных списков других блоков. Термин, добавленный в чёрный список блока nginx-only, подавляет совпадение только через этот блок — та же уязвимость всё равно уведомит через основной набор или другой блок, если их ключевые слова тоже совпали. Это осознанно у́же общего чёрного списка: он для «этот блок никогда не должен упоминать X», а не «весь чат никогда не должен видеть X» — для последнего используйте общий чёрный список выше.
Вся конфигурация чата/темы (ключевые слова, источники, оба порога серьёзности вместе с их вкл/выкл, блоки со своими ключевыми словами, порогами и собственным чёрным списком, общий чёрный список) выгружается одним файлом и загружается обратно — в тот же чат/тему для восстановления после ошибки, в другой чат/тему для копирования настройки, или даже в другой инстанс бота.
/export присылает JSON-файл с настройками (человекочитаемый: уровни серьёзности — словами, не числами). /import — единственная мутирующая команда, оставшаяся текстовой, а не кнопкой (см. «Возможности» выше): бот сначала явно предупреждает, что текущие настройки будут заменены, затем просит прислать файл одним сообщением. Файл проверяется (валидный JSON, распознанная версия формата) и только после этого применяется — если что-то не так с файлом, ничего не меняется. Источники, которых нет в текущей конфигурации бота (например, файл получен от другого инстанса с другим набором источников), при импорте пропускаются, а не сохраняются как есть; остальное применяется как обычно.
/export доступен всем (как /status, конфигурация — не секрет); /import, как и любое другое мутирующее действие, — только администраторам чата.
/settings открывает меню на inline-клавиатуре — единственный способ изменить конфигурацию чата/темы:
- 🔑 Ключевые слова — список текущих слов, у каждого кнопка ❌ для удаления; кнопка ➕ Добавить просит одним сообщением прислать новые слова через пробел (фразу — в кавычках) и добавляет их.
- 🗂 Источники — переключатели ✅/⬜ по каждому источнику, нажатие сразу включает/выключает его.
⚠️ Порог серьёзности — отдельная кнопка 🚫 Выключены (выключает мгновенные уведомления целиком) и пять кнопок-пресетов (Любой/Низкий/Средний/Высокий/Критический), каждая в своём ряду, для мгновенного порога основного набора ключевых слов, а следом — такая же кнопка 🚫 Выключен и такой же набор из пяти пресетов для порога дайджеста (см. «Дайджест» выше). Текущие значения обоих отмечены ✅.- 🎯 Блоки — список блоков с числом ключевых слов и порогом; кнопка ➕ Новый блок создаёт блок по присланному имени. Внутри блока: список слов с ❌ для удаления, ➕ Добавить слово/фразу, кнопка 🚫 Выключены и ряд из пяти кнопок мгновенного порога, кнопка 🚫 Выключен и ряд из пяти кнопок порога дайджеста, кнопка 🚫 Чёрный список блока (N) — ведёт в собственный чёрный список этого блока (см. «Чёрный список» ниже), и 🗑 Удалить блок.
- 🚫 Чёрный список — список слов/фраз с ❌ для удаления у каждой; кнопка ➕ Добавить просит одним сообщением прислать новые слова/фразы через пробел (фразу — в кавычках). У каждого блока есть точно такой же собственный чёрный список, отдельный от этого — см. ниже.
- Всё меню работает per-топик так же, как остальные команды: если сообщение с
/settingsотправлено внутри темы форума, все действия применяются к этой теме, а не ко всему чату. - Все мутирующие действия (добавление/удаление слова, переключение источника и т.д.) проверяют права администратора (
admin.require_group_admin).
Обычные уведомления — это только то, что произошло после подписки на источник (см. «Как группируются уязвимости из разных источников» выше — исторический бэклог намеренно не шлётся). /lookback — способ явно заглянуть в эту историю: ищет среди всех уязвимостей, когда-либо совпавших с чьими-либо ключевыми словами (таблица vulnerability_sources), независимо от того, отправлялось ли по ним уведомление, по объединению ключевых слов основного набора чата/темы и всех его блоков — уязвимость, которая матчится только через ключевое слово внутри блока, тоже находится.
Результат — компактный список (ID уязвимости ссылкой + дата + короткое название + список источников человекочитаемыми названиями, по одной строке на запись) с постраничной навигацией на кнопках ◀ Назад / Вперёд ▶ (по 10 записей на страницу), а не одно длинное сообщение или десятки отдельных. Не требует прав администратора (только чтение) и не делает запросов к внешним источникам — ищет по уже накопленным в базе данным.
Каждая строка помечена значком: 🔔 — по этой уязвимости уведомление в этот чат/тему уже отправлялось, 🚫 — она сейчас в чёрном списке (уведомление по ней не придёт, даже если ещё не отправлялось). Если верно и то, и другое, показывается 🚫 — это более полезная информация о том, почему уведомления больше не будет.
Список отсортирован по дате самой уязвимости (её собственная дата изменения/публикации), а не по тому, когда бот последний раз записал строку в базу. Это различие принципиально: источник, перепрашивая уже известную уязвимость (в пределе — первая полная закачка базы ФСТЭК, которая одним проходом трогает всю свою историю), обновляет служебную метку записи «сейчас», но это не делает саму уязвимость свежей — старый CVE не должен из-за этого перепрыгивать в начало списка перед реально недавним.
И /sources, и пустой результат /lookback показывают для каждого включённого источника, когда он последний раз завершил цикл опроса — «N мин./ч./дн. назад» или «ещё ни разу не опрошен». Это позволяет отличить три разных ситуации, которые иначе выглядят одинаково («ничего не нашлось»):
- источник ещё не успел опроситься (только что включён, или интервал опроса большой — например ФСТЭК по умолчанию раз в 24 часа);
- источник опрашивается, но по текущим ключевым словам пока правда ничего нет;
- источник тихо и стабильно падает на каждой попытке (типичный случай — ФСТЭК без настроенного корневого сертификата, см. «БДУ ФСТЭК и TLS» ниже) — тогда время последнего опроса никогда не обновляется, и это будет видно как «ещё ни разу не опрошен» сколько угодно долго после включения.
Статус времени опроса не зависит от того, находил ли источник совпадения — только от того, завершился ли сам цикл опроса (успешно или с ошибкой fetch, но без падения).
По свободному текстовому описанию инфраструктуры/продукта и зоны ответственности LLM генерирует три артефакта:
- Модель нарушителя — на русском, по категориям из методики ФСТЭК России (внешний/внутренний нарушитель, потенциал: базовый/базовый повышенный/высокий), применительно к описанной инфраструктуре.
- Модель угроз — конкретные сценарии угроз, привязанные к упомянутым технологиям, а не общие фразы.
- Ключевые слова — короткий список названий технологий/продуктов на английском, пригодный для точного поиска по базам уязвимостей — это и есть связка между анализом и остальным ботом: после генерации бот предлагает добавить их той же кнопкой, которой добавляются обычные ключевые слова, ничего не добавляется без подтверждения.
Диалог: /threatmodel без сохранённого профиля просит одним сообщением описать инфраструктуру и зону ответственности; описание сохраняется как профиль чата/темы (как ключевые слова и источники), так что при следующем вызове не нужно вводить его заново — открывается меню с кнопками «🔁 Перегенерировать», «✏️ Изменить описание», «🗑 Удалить профиль». Требует прав администратора (как и остальные настраивающие действия).
Если LLM не настроен (или конфигурация была убрана после того, как профиль уже сохранён): создание нового профиля недоступно — /threatmodel без сохранённого профиля отвечает пояснением, что нужно настроить (см. ниже). Но если профиль уже был сохранён в прошлом запуске, он никуда не девается: /threatmodel по-прежнему показывает его описание, а кнопки «🗑 Удалить профиль» и «❌ Закрыть» остаются рабочими — недоступны только «🔁 Перегенерировать», «✏️ Изменить описание» и «🔗 Цепочки атак» (все три требуют вызова LLM). Меню в этом случае явно поясняет, что генерация не настроена. Это касается именно профиля — свободного текстового описания; сама сгенерированная модель нарушителя/угроз нигде не хранится между запусками (см. «Известные ограничения» ниже), она всегда генерируется заново по сохранённому описанию.
Цепочки атак («🔗 Цепочки атак» в меню сохранённого профиля): отдельный LLM-вызов, который берёт (а) сохранённый профиль инфраструктуры и (б) уязвимости, уже найденные для этого чата/темы по его текущим ключевым словам/блокам — тот же набор, что показывает /lookback, с одним отличием: анализ цепочек ограничен 40 самыми свежими совпадениями (/lookback, наоборот, показывает вообще все совпадения постранично), — и ищет среди них правдоподобные цепочки эксплуатации: комбинации из двух и более уязвимостей, которые вместе дают больше, чем любая по отдельности (например, две по отдельности не критичные уязвимости вместе ведут к RCE). Смысл в том, что уязвимость ниже порога мгновенного уведомления не обязательно неважна сама по себе — она может быть звеном цепочки, о которой иначе никто не узнает.
Для каждой найденной цепочки показывается название, описание сценария, итоговый эффект и — обязательно — список составляющих её уязвимостей со ссылками на источник и теми же значками 🚫/🔔, что и в /lookback: цепочка без прослеживаемости до конкретных CVE была бы бесполезна для проверки. Модель прямо проинструктирована не изобретать уязвимости за пределами предоставленного списка и укладываться в 5 цепочек по 12 уязвимостей максимум, но ответ LLM — это внешний, не гарантированный контракт, поэтому GenerateChains/validateChains (internal/threatmodel/threatmodel.go) дополнительно сами отбрасывают любой group_key, которого не было в списке кандидатов, обрезают число цепочек/уязвимостей в цепочке до тех же лимитов на случай, если модель их не соблюла, а если после отбрасывания невалидных ключей в цепочке осталось меньше двух подтверждённых уязвимостей — отбрасывают всю цепочку целиком, а не показывают её как есть с недостающими звеньями. Если подходящих уязвимостей для анализа нет вовсе (/lookback пуст) — LLM не вызывается и показывается соответствующее пояснение, отдельно от случая «анализ прошёл, правдоподобных цепочек не нашлось».
Настройка (опционально, выключено по умолчанию):
threatmodel:
llm:
base_url: "https://api.openai.com/v1" # или любой OpenAI-совместимый эндпоинт
api_key: "sk-..."
model: "gpt-4o-mini"Подходит любой сервер, реализующий /chat/completions в формате OpenAI (сам OpenAI, vLLM, Ollama, LiteLLM-прокси и т.п.). Пока base_url/api_key/model не заданы, команда отвечает пояснением, что нужно настроить, а не притворяется, что её нет.
Трейсинг через Langfuse (опционально):
langfuse:
host: "https://cloud.langfuse.com"
public_key: "pk-lf-..."
secret_key: "sk-lf-..."Каждый вызов LLM (промпт, ответ, латентность, использование токенов) отправляется в Langfuse как OTLP-трейс — у Langfuse нет официального Go SDK, официально документированный путь для Go как раз «направить обычный OTLP/HTTP-экспортёр на наш эндпоинт с GenAI-атрибутами», что здесь и реализовано напрямую (internal/llm/langfuse.go), без затягивания полного SDK go.opentelemetry.io/otel ради одного типа спана. Трейсинг никогда не влияет на результат /threatmodel — недоступность Langfuse только тихо логируется.
Соответствующие переменные окружения: BOT_LLM_BASE_URL, BOT_LLM_API_KEY, BOT_LLM_MODEL, BOT_LLM_HTTP_TIMEOUT, BOT_LANGFUSE_HOST, BOT_LANGFUSE_PUBLIC_KEY, BOT_LANGFUSE_SECRET_KEY.
cmd/bot — точка входа, сборка зависимостей, graceful shutdown,
периодическая очистка БД (housekeeping)
internal/config — конфигурация (koanf): YAML-файл + переменные окружения
internal/models — доменные типы: Vulnerability (+ GroupKey для группировки
по CVE ID между источниками), Scope (chat+topic)
internal/storage — SQLite (modernc.org/sqlite, без CGO): ключевые слова,
источники, история уведомлений, курсоры провайдеров,
кросс-ссылки между источниками, Prune/Vacuum
internal/sources — интерфейс Provider + реестр
/nvd — адаптер NVD CVE API 2.0
/fstec — адаптер полного XML-экспорта БДУ ФСТЭК
/kev — адаптер каталога CISA KEV
/ghsa — адаптер GitHub Security Advisories
internal/scanner — фоновый опрос источников, сопоставление по ключевым
словам (мгновенно/дайджест/ничего), дедупликация/
группировка по CVE ID, периодическая рассылка
накопленных дайджестов (RunDigest)
internal/llm — минимальный OpenAI-совместимый chat completions клиент
+ трейсинг вызовов в Langfuse (OTLP/HTTP)
internal/threatmodel — промпт и разбор ответа LLM в модель нарушителя/угроз/
ключевые слова для /threatmodel, плюс синтез и
валидация цепочек атак (GenerateChains)
internal/bot — Telegram-бот на Telego: команды, меню, форматирование, admin-guard
Каждый провайдер источника имеет собственный интервал опроса (scanner.poll_interval для NVD, sources.<source>.refresh_interval для остальных), независимо от общего тика сканера.
Каждая запись Vulnerability несёт CVEID — CVE-идентификатор, если он есть (для NVD и CISA KEV это её собственный ID; для БДУ ФСТЭК и GHSA — перекрёстная ссылка на CVE, если источник её указывает). GroupKey() возвращает этот CVE ID, а если его нет — пару источник:ID, так что записи без общего CVE никогда случайно не схлопнутся в одну.
Сканер помечает уязвимость как «увиденную» (seen_vulnerabilities) именно по GroupKey(), а не по ID конкретного источника — поэтому если, скажем, NVD и БДУ ФСТЭК сообщают об одном и том же CVE, уведомление уходит только один раз (при первом обнаружении), а не дважды. Отдельно, таблица vulnerability_sources хранит для каждого GroupKey() все источники, которые о нём когда-либо сообщали — это позволяет включить в единственное отправляемое уведомление ссылки на все источники, известные на момент отправки, а не только на тот, что вызвал уведомление.
git clone <repo> && cd cvegochibot
cp config/config.example.yaml config/config.yaml
# впишите токен от @BotFather в config/config.yaml, либо экспортируйте BOT_TOKEN
go build -o bin/cvegochibot ./cmd/bot
./bin/cvegochibot -config config/config.yamlЧерез make:
make build
make run # соберёт и запустит с config/config.yamlНастройки читаются из YAML-файла (путь через флаг -config, по умолчанию config/config.yaml) и переопределяются переменными окружения с префиксом BOT_. Токен лучше не хранить в файле — задавайте через BOT_TOKEN:
export BOT_TOKEN="123456:AA...your-token"
./bin/cvegochibot -config config/config.yamlПолный список переменных окружения и значений по умолчанию — в config/config.example.yaml и internal/config/config.go. Из специфичного для дайджеста: digest.interval / BOT_DIGEST_INTERVAL (по умолчанию 24h) — общий для всего бота интервал рассылки, см. «Дайджест» выше.
Сертификат bdu.fstec.ru подписан российским национальным корневым УЦ («Russian Trusted Root CA» / Минцифры России), который отсутствует в большинстве стандартных хранилищ доверия ОС. Если запросы к ФСТЭК падают с ошибкой проверки сертификата (tls: failed to verify certificate: x509: certificate signed by unknown authority), есть три варианта — от простого к ручному:
sources.fstec.fetch_trusted_ca: true(илиBOT_FSTEC_FETCH_TRUSTED_CA=true) — рекомендуемый способ. При старте бот скачивает официальный корневой сертификат Минцифры сgu-st.ruи сверяет его SHA-256-отпечаток с зашитым в код значением (см.internal/sources/fstec/trustedca.go) — если отпечаток не совпал, сертификату не доверяют и подключение к ФСТЭК просто продолжит падать с той же ошибкой, а не тихо примет что попало. Промежуточный сертификат, которым реально подписанbdu.fstec.ru, при этом не зашивается статично: у Минцифры было как минимум два параллельно действующих экземпляра «Russian Trusted Sub CA» с одинаковым именем, но разными ключами, так что фиксированный файл рано или поздно перестал бы совпадать. Вместо этого бот один раз при старте подключается кbdu.fstec.ru, читает из сертификата сервера расширение Authority Information Access («CA Issuers» — это открытые метаданные, не решение о доверии), скачивает указанный там сертификат и криптографически проверяет его подпись относительно уже запиненного корня (cert.CheckSignatureFrom(root)). Доверенным считается только то, что реально подписано приватным ключом корневого УЦ — какой конкретно это файл и откуда он был скачан, значения не имеет. Требует исходящего HTTPS кgu-st.ruи к тому хосту, что укажет ФСТЭК в CA Issuers (сейчас этоnuc-cdp.voskhod.ru/nuc-cdp.digital.gov.ru).sources.fstec.extra_ca_cert_path(илиBOT_FSTEC_EXTRA_CA_CERT_PATH) — путь к PEM-файлу с CA-сертификатом(ами), которые оператор получил и проверил самостоятельно (например для деплоя без исходящего доступа к российской инфраструктуре). Можно сочетать с вариантом 1.- Добавить корневой сертификат в доверенное хранилище ОС/контейнера на уровне системы.
Раньше в этом разделе объяснялось, почему сертификат нельзя зашивать в код бота напрямую — заявлять байты криптографического корня доверия без возможности сверить их с официальным источником небезопасно. Вариант 1 не нарушает этот принцип: зашит не сам сертификат, а лишь его отпечаток (проверяемый против официального источника при каждом запуске), а всё остальное доверие выводится криптографически, а не декларируется.
Без токена GHSA опрашивается с лимитом 60 запросов/час — этого достаточно только при большом refresh_interval. Заведите personal access token без каких-либо scope'ов (нужен только для более высокого лимита на публичные данные, 5000/час) на https://github.com/settings/tokens и укажите его в sources.ghsa.token (или BOT_GHSA_TOKEN, предпочтительно — не хранить токен в конфиг-файле).
Без ограничений таблицы seen_vulnerabilities (какие уязвимости уже разосланы, по каждому чату/теме) и vulnerability_sources (кросс-ссылки между источниками, общие для всех чатов) росли бы бесконечно. Вместо этого фоновая задача (runHousekeeping в cmd/bot/main.go) периодически:
- Удаляет из обеих таблиц строки старше
storage.retention(по умолчанию 180 дней) —storage.Store.Prune. - Освобождает место на диске, занятое удалёнными строками —
VACUUM(storage.Store.Vacuum).
Обе операции настраиваются в storage.retention / storage.housekeeping_interval (по умолчанию раз в сутки; 0 отключает очистку полностью). 180 дней — с большим запасом: максимальное окно инкрементального опроса NVD — 120 дней, БДУ ФСТЭК и CISA KEV в любом случае каждый раз отдают текущее состояние целиком (не полагаются на историю), а не на устаревшую метку в БД.
Ориентировочный размер. Каждая строка seen_vulnerabilities/vulnerability_sources — порядка 100–200 байт с учётом индексов. Итоговый размер ≈ (число тем чата × среднее число совпадений по ключевым словам в день × дни хранения) × ~150 байт. Например, 100 тем с широкими ключевыми словами (~20 совпадений/день) и retention 180 дней — это примерно 100 × 20 × 180 × 150 байт ≈ 50 МБ. Чтобы держать базу заведомо меньше гигабайта при большем масштабе (сотни/тысячи чатов), уменьшите storage.retention, например до 720h (30 дней) — этого достаточно за глазом на пределы NVD/GHSA сверху.
Почему SQLite, а не что-то ещё. Для этой нагрузки (один writer, простые ключ-значение/реляционные запросы, ни одного одновременного внешнего клиента) SQLite — практически идеальный выбор: не нужен отдельный процесс СУБД (меньше поверхность атаки и эксплуатационных забот), файл базы — это и есть бэкап (cp bot.db bot.db.bak), встроенная поддержка ACID и индексов бесплатно. Полноценный сервер вроде PostgreSQL добавил бы операционную сложность (отдельный процесс, аутентификация, сетевой доступ, обновления), совершенно непропорциональную задаче telegram-бота такого размера — оправдан только если вы уже эксплуатируете Postgres для других целей и хотите консолидировать инфраструктуру. Embedded key-value хранилища (BoltDB и подобные) заставили бы вручную реализовывать то, что здесь бесплатно даёт SQL (выборки по chat_id+topic_id, DELETE ... WHERE seen_at < ? и т.д.). С учётом Prune/VACUUM выше SQLite комфортно держит эту базу далеко в пределах гигабайта.
make build-static # статический бинарь для Linux в bin/cvegochibot
# (кросс-компилируется с любой ОС; для другой платформы:
# make build-static GOOS=linux GOARCH=arm64)Зависимостей у проекта нет ни одной с CGO (modernc.org/sqlite — чистый Go), поэтому CGO_ENABLED=0 уже сам по себе даёт полностью статический бинарь без внешних библиотек — make build-static просто делает это явным и добавляет -trimpath -ldflags="-s -w" (без путей сборочной машины, без отладочных символов).
Готовые unit-файлы — в deploy/systemd/: cvegochibot.service (с полным списком шагов установки в комментарии наверху файла) и cvegochibot.slice, ограничивающий cgroup сервиса по памяти/CPU/числу задач. Сервис запускается от временного непривилегированного пользователя (DynamicUser=yes, без ручного useradd) с ужесточением (ProtectSystem=strict, запрет новых привилегий, ограничение syscall'ов и т.д.). Секрет (BOT_TOKEN, опционально BOT_GHSA_TOKEN) передаётся через отдельный EnvironmentFile (cvegochibot.env.example), а не в самом unit-файле, чтобы не светиться в systemctl cat.
Перед использованием в проде стоит проверить юниты в реальной systemd-среде (systemd-analyze verify cvegochibot.service) — эта репозиторная разработка велась на macOS, где systemd-analyze недоступен для локальной проверки.
Путь к базе данных. storage.path по умолчанию относительный (./data/bot.db), а unit-файл задаёт его абсолютным (BOT_STORAGE_PATH=/var/lib/cvegochibot/bot.db) — так и должно быть: относительный путь резолвится от текущей рабочей директории процесса, а она у systemd-сервиса и у того же бинарника, запущенного вручную из другого каталога, разная. Если это разойдётся (например, при ручном запуске для отладки без явного -config/BOT_STORAGE_PATH), бинарник тихо откроет/создаст другой файл базы — со стороны это выглядит как «настройки почему-то сбросились», хотя на самом деле ничего не терялось. Бот резолвит путь в абсолютный при старте и логирует итоговый путь (storage в стартовом лог-сообщении) — сверяйте его в логах, если конфигурация выглядит неожиданно пустой после перезапуска.
Проект настроен так, чтобы небезопасный или неисправный код физически не мог попасть в коммит.
make hooks # эквивалент: ./scripts/install-hooks.shЭто направляет git на версионируемую директорию githooks/ (core.hooksPath), а не копирует файлы в .git/hooks/, — хуки одинаковы у всех, кто выполнил make hooks.
pre-commit(только если в коммите есть.go-файлы):gofmt,go vet,golangci-lint,gosec,govulncheck,go test. Любая ошибка блокирует коммит.pre-push:go build,go test -race— финальная проверка гонок перед выгрузкой кода.
Аварийный обход (не рекомендуется): SKIP_HOOKS=1 git commit ....
| Инструмент | Роль | Запуск вручную |
|---|---|---|
golangci-lint |
Линтер общего назначения + встроенный gosec (конфиг .golangci.yml) |
make lint |
gosec |
Выделенный SAST для Go | make sast |
govulncheck |
SCA: известные уязвимости в стандартной библиотеке и зависимостях, с учётом реальной достижимости кода | make sca |
Все три плюс тесты — одной командой:
make checkНа момент сдачи make check проходит с нулевым числом замечаний (golangci-lint: 0 issues, gosec: 0 issues, govulncheck: 0 достижимых уязвимостей). Зависимости — чистый Go без CGO (modernc.org/sqlite), что также упрощает SCA и кросс-компиляцию.
Toolchain Go зафиксирован в go.mod директивой toolchain, актуальной на патч-версию с исправлениями известных уязвимостей стандартной библиотеки; govulncheck перепроверяет это при каждом запуске.
.github/workflows/ci.yml дублирует те же проверки (build/vet/test, golangci-lint, gosec, govulncheck) в GitHub Actions на каждый push/PR — хуки защищают локальную разработку, CI защищает от --no-verify и внешних PR.
make test # unit-тесты
make test-race # то же самое с -raceМодульными тестами покрыты: сопоставление ключевых слов, источников и группировка по GroupKey, разбор и сравнение порога серьёзности (CVSS-баллы и текстовые severity на английском и русском, поведение при отсутствии данных), безусловное подавление общим чёрным списком (в том числе поверх блоков и совместно с обычным несовпадением) и отдельно — подавление собственным чёрным списком одного блока, не задевающее ни основной набор, ни другие блоки, классификация мгновенно/дайджест/ничего (Vulnerability.Tier) — включая регрессионные тесты на то, что и выключенный по умолчанию дайджест, и включённый по умолчанию мгновенный порог не меняют поведение уже существовавшей конфигурации, — и JSON-сериализация ScopeExport (уровни серьёзности читаемыми словами, а не числами, отказ на нераспознанном значении) (internal/models), загрузка и валидация конфигурации, включая реальный config.example.yaml (internal/config), CRUD (включая блоки и их каскадное удаление, чёрный список, оба порога серьёзности блока/основного набора, очередь дайджеста, ClearScope), изоляция по чату/теме, пометка Notified/Blacklisted в результатах /lookback (в том числе по-отдельности для каждого чата/темы), реальная миграция колонок дайджеста на БД, созданной до этой функции (проверяет именно то, что имеет значение: дайджест остаётся выключенным для уже существующих записей, а не включается со значением «любой» по недосмотру), Prune/Vacuum в хранилище (internal/storage), разбор ответов NVD/GHSA (включая постраничную загрузку по Link-заголовку) и XML-экспорта ФСТЭК/JSON-каталога KEV на фикстурах через httptest (internal/sources/...), логика планировщика — сопоставление (включая пороги блоков и тир дайджеста), дедупликация и группировка между источниками, интервалы по провайдерам, постановка/рассылка/повторная попытка очереди дайджеста с изоляцией между чатами (сбой отправки одному не блокирует другой) (internal/scanner), безопасное экранирование HTML, разбор ключевых фраз в кавычках (прямых/«ёлочек»/«умных»), различение адресата команды по @username в группах с несколькими ботами, человекочитаемые названия источников, вся настройка ключевых слов/источников/блоков/обоих порогов/чёрного списка через inline-меню, меню /threatmodel с сохранённым профилем в обоих состояниях LLM-конфигурации (настроен/не настроен), сбор кандидатов для анализа цепочек атак из /lookback-набора (включая ограничение по количеству) и рендер результата со ссылками на исходные уязвимости, HTML-экранированием и обрезкой длинного ответа под лимит сообщения Telegram, поиск /lookback по объединению основного набора и блоков (включая случай, когда ключевые слова есть только в блоке), и построение/применение ScopeExport — включая замену, а не слияние, старой конфигурации новой и пропуск источников, не зарегистрированных в текущем инстансе (internal/bot).
Доверие к «Russian Trusted Root CA» ФСТЭК (sources.fstec.fetch_trusted_ca) проверено не только на фикстурах, но и полным end-to-end запуском против реального bdu.fstec.ru во время разработки (см. историю изменений) — после первичного подхода, который оказался нерабочим (см. ниже), итоговая реализация подтверждённо скачала и разобрала реальный экспорт (91740 записей).
internal/llm и internal/threatmodel (клиент OpenAI-совместимого API, отправка трейсов в Langfuse, разбор JSON-ответа LLM, синтез цепочек атак) покрыты тестами на httptest-фикстурах: корректный/некорректный ответ, отсутствие обязательных полей, таймауты, аутентификация, форма OTLP-запроса к Langfuse, и — для GenerateChains отдельно — отбрасывание group_key, не входивших в список кандидатов (защита от того, что LLM «придумает» уязвимость), отбрасывание цепочки целиком, если после этого в ней осталось меньше двух подтверждённых уязвимостей, отбрасывание цепочек без названия/описания, дедупликация повторяющихся ключей внутри одной цепочки, отсутствие вызова LLM вовсе при пустом списке кандидатов, и ограничение числа кандидатов, отправляемых в промпт. Реального вызова к настоящему LLM-провайдеру или Langfuse в этом окружении не делалось — нет API-ключей; корректность именно формата запроса/ответа (а не факта работы конкретного провайдера) проверена фикстурами, воспроизводящими задокументированный контракт обеих API.
Отдельно проверено сценарием-регрессией (TestScannerNotifiesNewEntryOnSubsequentFullRedownload), что для источников без инкрементального API (ФСТЭК, KEV) реально новая запись, появившаяся в очередной полной перезагрузке архива/каталога, доходит до подписчика, а уже известные записи не рассылаются повторно — именно это должно работать надёжно, раз оба источника отдают данные целиком при каждом опросе.
Ограничение проверки: в этом окружении нет реального токена Telegram-бота, поэтому полноценная end-to-end проверка (реальная отправка команд боту, доставка уведомлений в реальный чат/тему) не выполнялась. Что удалось проверить с реальным Bot API: с синтаксически корректным, но недействительным токеном бот при старте делает настоящий вызов getMe к api.telegram.org и корректно завершается с понятной ошибкой на невалидном токене (используется для быстрой проверки токена при старте — см. internal/bot/bot.go), а также вызов getUpdates для long polling — оба подтверждают, что запросы к реальному API формируются правильно (сервер отвечает 401 Unauthorized, а не ошибкой формата запроса). Весь код, работающий поверх Bot API, дополнительно покрыт unit-тестами на уровне используемых им данных и логики. Перед продакшен-использованием стоит один раз вручную прогнать основные команды с реальным ботом.
- БДУ ФСТЭК и CISA KEV не дают инкрементального доступа — каждый цикл опроса скачивает и парсит текущий полный набор данных (~30 МБ сжато у ФСТЭК, ~1.5 МБ у KEV), поэтому
refresh_intervalу обоих намеренно грубый (по умолчанию раз в сутки и раз в 6 часов соответственно). - Сопоставление по ключевым словам — регистронезависимый поиск подстроки по названию, описанию и списку затронутых продуктов; это не полноценное сопоставление по CPE.
- NVD без
api_keyограничен 5 запросами/30с, GHSA без токена — 60 запросами/час; при большом количестве чатов/тем это не блокирует опрос (он общий, не per-chat), но стоит завести ключ/токен для продакшена, особенно для GHSA с короткимrefresh_interval. - Проверка прав администратора использует
getChatMember, которая, по документации Telegram, «гарантированно работает для других пользователей только если сам бот — администратор чата». Если бот добавлен в группу без прав администратора, аadmin.require_group_adminвключён (по умолчанию так), проверка прав может быть ненадёжной. Добавляйте бота администратором группы, если используете ограничение по умолчанию. - При сбое доставки уведомления бот делает несколько повторных попыток с задержкой (до 3 попыток), но не бесконечно: если все попытки исчерпаны, для источников со строго продвигающимся окном опроса (NVD, GHSA) это конкретное уведомление может быть больше не предложено повторно (в логах об этом будет явная запись уровня ERROR). Источники с полной перезагрузкой данных при каждом опросе (БДУ ФСТЭК, CISA KEV) естественным образом переотправляют такие уведомления на следующем цикле.
- Группировка по CVE ID (см. «Как группируются уязвимости из разных источников» выше) завязана на текущий
GroupKey()записи. Если запись изначально появляется без известного CVE (например, у БДУ ФСТЭК не всегда указана перекрёстная ссылка) и лишь позже обретает CVE-привязку, это технически новыйGroupKey()— теоретически возможен повторный алерт по факту той же уязвимости. Это узкий и редкий случай, отдельно не обрабатывается. /threatmodelполагается на то, что ответит LLM — бот не проверяет фактическую точность сгенерированной модели нарушителя/угроз (это неверифицируемо в общем случае) и просит подтверждения только для списка ключевых слов перед добавлением, не для остального текста. Каждый вызов (генерация и перегенерация) — реальный платный запрос к настроенному провайдеру; лимитов на частоту вызовов на стороне бота нет, кроме административного ограничения на саму команду. Защита от prompt injection — многослойная, но ни один из слоёв не строгий: (1) описание инфраструктуры в промпте оборачивается тегами<описание_инфраструктуры>...</описание_инфраструктуры>(wrapUntrustedData, техника иногда называемая «spotlighting»), и системный промпт явно указывает считать всё внутри этих тегов данными, а не инструкциями, даже если текст похож на системное сообщение или просьбу раскрыть промпт; (2) итоговые артефакты (модель нарушителя/угроз) в любом случае непроверенная проза — единственное, что реально что-то решает без участия человека, это список предлагаемых ключевых слов, и он применяется только по явному нажатию кнопки подтверждения. Учитывайте это, если в чат может писать кто угодно (команда и так ограничена администраторами по умолчанию) — оборачивание в теги существенно повышает устойчивость модели к попыткам подмены инструкций, но ничего в поведении внешнего LLM не гарантировано на 100%.- «🔗 Цепочки атак» проверяется строже, чем остальной вывод
/threatmodel, но не полностью: гарантированно реальны толькоgroup_keyсоставляющих цепочку уязвимостей (см.GenerateChains/validateChains— любой несуществующий ключ отбрасывается, как и вся цепочка, если валидных ключей осталось меньше двух) — а вот название, описание сценария и итоговый эффект («Итог») остаются непроверенной прозой модели, ровно как и весь текст обычного/threatmodel, включая то, почему модель считает пару уязвимостей цепочкой. Кроме описания инфраструктуры, в промпт также попадают названия и описания уязвимостей из внешних источников (NVD/GHSA/БДУ ФСТЭК/CISA KEV) — теоретически более широкая поверхность для prompt injection, чем у обычного/threatmodel, поэтому здесь та же техника с тегами<описание_инфраструктуры>/<уязвимости>применена и к описанию, и ко всему списку кандидатов целиком, а не только к отдельным полям. Кандидаты внутри тега<уязвимости>передаются модели построчно в виде JSON-объектов (buildChainUserMessage), а не текстом со своим разделителем — это отдельно закрывает подмену через спецсимволы видаgroup_key=...; title=...внутри вредоносного описания уязвимости (сериализация JSON экранирует кавычки/переносы строк/спецсимволы, так что такой фрагмент не может быть принят моделью за отдельную, «настоящую» строку кандидата). Числового порога серьёзности (CVSS/severity) у кандидатов нет вообще: этот показатель нигде не сохраняется дольше одного цикла опроса (см.vulnerability_sourcesв «Архитектура» выше), так что оценка «что с чем цепляется» и итоговый эффект — это интерпретация модели по названию/описанию, а не по формальным метрикам. Каждый вызов — тоже отдельный платный запрос к LLM, без дополнительного троттлинга сверх обычного административного ограничения команды. - Между профилем
/threatmodelи сгенерированной моделью — асимметрия хранения: сохраняется (в таблицеthreat_profiles) только исходное текстовое описание инфраструктуры, а не сама сгенерированная модель нарушителя/угроз/ключевых слов — тот текст показывается один раз сразу после генерации/перегенерации и не сохраняется отдельно; при обычном открытии/threatmodelдля уже существующего профиля повторно показывается только сохранённое описание (без нового вызова LLM). Именно сохранённое описание и остаётся доступным «из прошлых запусков», если LLM сейчас не настроен (просмотр и удаление профиля работают, регенерация — нет, поскольку ей нужен LLM). - Порог серьёзности (
/severity, блоки) сверяется по CVSS-баллу или, если его нет, по текстовому severity источника; у CISA KEV нет ни того, ни другого, поэтому его записи всегда проходят любой порог (см. «Порог серьёзности и блоки» выше — это осознанное решение в пользу отсутствия ложноотрицательных срабатываний, а не недосмотр). Названия блоков уникальны в пределах чата/темы без учёта регистра (Criticalиcritical— один и тот же блок). - Дайджест шлётся по единому для всего бота расписанию (
digest.interval), не по индивидуальному для каждого чата/темы — как и уscanner.poll_interval/storage.housekeeping_interval, это сознательный выбор в пользу простоты над гибкостью cron-подобного планировщика на чат. Уязвимость, попавшая в очередь дайджеста, сразу помечается как «уже уведомлена» (видна как 🔔 в/lookback) — даже до того, как дайджест физически отправлен; это упрощение, а не баг: она гарантированно уйдёт на ближайшей рассылке (очередь переживает падение и рестарт бота), а при сбое отправки очередь остаётся нетронутой и просто повторяется на следующем интервале. /importзаменяет всю конфигурацию чата/темы целиком, а не сливает с текущей — так и задумано (обычный смысл «восстановить из бэкапа»), но значит, что случайный импорт не того файла требует либо повторного/exportс диска до этого, либо ручного восстановления настроек. Резервную копию перед экспериментальным импортом стоит держать под рукой.- Анонимные администраторы группы (посты от имени чата) все делят один и тот же служебный
From-ID (Telegram подставляет фиксированногоGroupAnonymousBot), а ожидание следующего сообщения (например, после «➕ Добавить» в/settings) ключуется по паре чат/тема + этот ID. Если два разных анонимных администратора одновременно начнут такой ввод в одном чате/теме, их ожидания перезапишут друг друга — узкий, редко встречающийся край случая, отдельно не обрабатывается (обычные, не анонимные администраторы этой проблеме не подвержены — у них свой настоящийFrom.ID). - Дайджест, пороги серьёзности и связанная логика покрыты тестами на уровне каждого модуля отдельно (
internal/storage,internal/scanner,internal/bot— везде с реальными/поддельными зависимостями по месту) и вручную через/settingsв Telegram, но нет отдельного сквозного теста, поднимающего настоящую SQLite-версиюstorage.Storeвместе соScannerиBotв одном процессе. Осознанный компромисс: такой тест дал бы дополнительную уверенность именно в стыках между слоями, но их поведение по отдельности уже покрыто, а настройка полноценного сквозного сценария (реальная БД + переопределяемое время + поддельный Telegram API) заметно увеличила бы сложность тестов ради проверки, которая для этого проекта не выглядит соразмерно ценной.