Skip to content

ТЗ — Антиспам (cms/antispam)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Фильтр спама для публичных форм и комментариев: эвристики + скоринг, карантин вместо немедленного удаления, обучаемый список признаков. Новая разработка поверх honeypot, уже заложенного в ядро.

  • Эвристики: стоп-слова, плотность ссылок, скорость заполнения формы (слишком быстро — бот);
  • скоринг записи (0–100), порог карантина и порог автоблокировки — настраиваемые;
  • карантин: подозрительная запись не публикуется и не удаляется, ждёт решения модератора;
  • обучаемый список: модератор помечает ложное срабатывание/пропуск, список стоп-слов растёт;
  • интеграция с honeypot-полем ядра (скрытое поле форм — заполнено ботом = мгновенный скоринг в максимум);
  • внешняя капча (провайдер captcha-provider ядра) как дополнительный сигнал при пограничном скоринге;
  • provides: spam-filter — используется cms/comments и формами ядра.

Зависимости и выключение

requires: ядро (формы, honeypot, captcha-provider) · suggests: cms/attack-monitor (сигнал спам-шторма), cms/consents (обезличивание карантина по DataRemovalRequested) · provides: spam-filter

Поведение при выключении: формы и cms/comments принимают все отправки без скоринга (остаётся только honeypot и rate-limit ядра — деградация до базового уровня защиты).

Стоимость внешнего API (капча): captcha-provider ядра — платный сервис с тарифным лимитом обращений в месяц/сутки у большинства провайдеров. Расход и остаток квоты видны в админке (виджет, см. «Компоненты»); при исчерпании квоты применяется тот же переключатель, что и при недоступности провайдера — antispam.fail_open_on_captcha_unavailable (деградация описана в «Крайние случаи») — 500-й ответ посетителю не считается допустимым исходом ни при сбое, ни при исчерпании лимита.

Модель данных

ТаблицаКлючевые поляПримечание
cms_antispam_rulesid, type, pattern, weight, is_activeстоп-слова/паттерны с весом
cms_antispam_quarantineid, subject_type, subject_id, score, reasons (json), statusкарантин, полиморфная привязка к форме/комментарию

Индекс на (subject_type, subject_id) в cms_antispam_quarantine под полиморфную выборку; reasons — JSONB → GIN-индекс, если фильтруется по содержимому; status — PHP Enum. Пользовательские regex в pattern — только через ReDoS-валидатор ядра.

ПДн-паспорт: cms_antispam_quarantine хранит слепок отправленных данных формы/комментария (в reasons/связанном снапшоте) — email, телефон, текст обращения отправителя, не только скор. Ретеншн — настройка antispam.quarantine_retention_days (см. «Настройки»): просроченные карантинные записи с вынесенным решением (одобрено/отклонено) анонимизируются/удаляются джобой cms:antispam:purge-quarantine (см. «Фоновая работа»); записи «на рассмотрении» ретеншн не трогает — модератор обязан сначала вынести решение. Хук ядра «выгрузить всё по субъекту» отдаёт карантинные записи с известным subject_id; хук «забыть по запросу» и события UserDeleted/DataRemovalRequested (cms/consents, ревизия ядра 14.07.2026) анонимизируют карантинную запись (стирают слепок данных, оставляя score/категории reasons для статистики точности), если отправитель был авторизован (subject_id заполнен); анонимные отправки (subject_id пуст) в каскад не попадают — сопоставлять не с чем.

Входные и выходные данные

Входы:

ИсточникДанные/поляЧем валидируется
Отправка публичной формы (событие ядра)все поля формы + honeypot-значение + fill_seconds (время заполнения)FormRequest ядра уже прошёл до модуля; антиспам скорит уже валидные по типу поля значения
CommentSubmitted (cms/comments)текст комментария, автор, IP, fill_secondsто же — событие приходит уже провалидированным cms/comments
Filament: правило стоп-слова/паттернаtype, pattern, weight, is_activePolicy antispam.manage; pattern — ReDoS-валидатор ядра (компилируемость + эвристика катастрофического бэктрекинга); weight — числовой диапазон
Filament: решение модератора по карантинуstatus (approve/reject), опционально «добавить в стоп-лист»Policy antispam.moderate, whitelist допустимых переходов статуса

Выходы:

ПотребительДанныеФормат
Форма/cms/comments (вызывающий код)булев вердикт + скор + сработавшие причины + машиночитаемый код spam_rejectedвозврат сервиса SpamFilter::score() через provides-контракт spam-filter
Filament (очередь карантина)subject_type, subject_id, score, reasons[]список с фильтром по типу/периоду
cms:antispam:rescan --jsonпересчитанные скорыJSON
события AntispamQuarantined/AntispamRuleLearnedсм. «События и обмен»payload события

Модуль не публикует собственный API, но его вердикт может стать причиной отказа в API вызывающего модуля (например форма ядра отвечает 422 на отклонённую отправку). Вердикт несёт машиночитаемый код spam_rejected по аналогии с обязательным полем code в конверте ошибок API (ревизия ядра 14.07.2026, п.1) — вызывающий код обязан прокинуть этот код в errors/code своего ответа, если публикует его через API, а не изобретать собственный текст ошибки.

Настройки (группа antispam)

КлючТипДефолтaffectsPageCacheОписание
antispam.quarantine_thresholdint50нетПорог скоринга для карантина
antispam.block_thresholdint85нетПорог автоблокировки без модерации
antispam.min_fill_secondsint3нетМинимальное время заполнения формы (быстрее — подозрительно)
antispam.captcha_on_borderlinebooltrueнетТребовать капчу при скоре в пограничной зоне (между обычным проходом и quarantine_threshold)
antispam.fail_open_on_captcha_unavailablebooltrueнетПри недоступности внешнего провайдера капчи — пропускать (true) или блокировать (false) отправку
antispam.storm_rate_limit_per_minuteint30нетПорог отправок формы в минуту с одной подсети — превышение считается спам-штормом
antispam.quarantine_retention_daysint180нетСрок хранения решённых карантинных записей (ПДн-паспорт, см. «Модель данных») до анонимизации/удаления
antispam.scoring_enabledbooltrueнетKill-switch: аварийно отключает скоринг целиком (остаются только honeypot и rate-limit ядра) без выключения модуля — на случай, когда правила дают массовые ложные блокировки на проде

antispam.quarantine_threshold, antispam.block_threshold, antispam.storm_rate_limit_per_minute — явные лимиты/квоты стандарта (§6, матрица v2.2) с дефолтами: превышение не роняет отправку 500-й, а переводит запись в карантин/блокировку с нейтральным для посетителя ответом (см. «UX-требования»).

API

Отдельного API нет — админ-CRUD через Filament; машиночитаемый код вердикта spam_rejected — см. «Входные и выходные данные».

Компоненты

Filament: очередь карантина (одобрить/отклонить/добавить в стоп-лист), редактор правил стоп-слов с весами, дашборд точности (FP/FN, см. ниже), виджет расхода квоты капчи. Команды: cms:antispam:rescan --json (пересчёт скоринга по обновлённым правилам), cms:antispam:purge-quarantine --json (ретеншн карантина, см. «Модель данных»/«Фоновая работа»).

Дашборд точности (FP/FN): Filament-виджет считает по AntispamRuleLearned false positive (запись одобрена из карантина — фильтр сработал ложно) и false negative (запись размечена модератором постфактум как пропущенный спам), с разбивкой по периодам (день/неделя/месяц), и показывает долю верных решений фильтра — модератор видит, не слишком ли агрессивны/мягки текущие quarantine_threshold/block_threshold и веса правил, не разбирая очередь целиком вручную.

Виджет квоты капчи: расход и остаток тарифного лимита captcha-provider за текущий период на дашборде карантина (см. «Зависимости и выключение», «Крайние случаи»); приближение к лимиту — визуальное предупреждение, не только запись в логе.

События и обмен

СобытиеКогдаPayload
AntispamQuarantinedзапись ушла в карантинsubject_type, subject_id, score
AntispamRuleLearnedмодератор пометил ложное срабатывание/пропускrule_id (nullable), feedback_type, suggested_pattern (nullable)
AntispamQuarantineAnonymizedкарантинная запись анонимизирована по ПДн-хукуsubject_type, subject_id

Обучение на разметке админа: AntispamRuleLearned не запускает автообучение без контроля — фиксирует только факт разметки. Если модератор несколько раз подряд помечает один и тот же пропущенный паттерн (одинаковая фраза/шаблон) как false negative, модуль предлагает новое правило: создаёт черновую строку в cms_antispam_rules с is_active=false и дефолтным весом, уведомляет модератора — активация черновика (is_active=true) требует явного действия с правом antispam.manage (см. «Безопасность»), автоматически правило не включается. Веса уже существующих активных правил разметкой напрямую не меняются — вес трогает только явное редактирование правила тем же правом.

Слушает: события отправки форм ядра, CommentSubmitted (cms/comments), а также UserDeleted (ядро) и DataRemovalRequested (cms/consents, suggests, ревизия ядра 14.07.2026) для анонимизации карантина (см. «Модель данных»). Provides-контракт spam-filter — потребляется cms/comments и формами ядра через DI; при нескольких провайдерах spam-filter — выбор в настройках, при нуле — деградация.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро — формы (движок полей)событие отправки формыinмодуль слушает факт отправки, скорит поля до публикации записи/лида
cms/commentsсобытие CommentSubmittedinтот же скоринг для комментариев, полиморфная запись в cms_antispam_quarantine
cms/comments + ядро — формыprovides-контракт spam-filteroutвызывающий код получает вердикт синхронно через DI, не знает о реализации
Ядро — captcha-providerprovides-контракт (потребление)inпри пограничном скоре запрашивает капчу-челлендж как дополнительный сигнал
cms/attack-monitor (suggests)сигнал/агрегат (не жёсткая зависимость)outвсплеск карантина с одной подсети/за окно — сырьё для детектора спам-шторма, если attack-monitor включён
Ядро — UserDeletedсобытиеinанонимизация карантинных записей автора, если известен subject_id
cms/consents (suggests)событие DataRemovalRequestedinанонимизация карантинных записей по запросу субъекта на удаление (152-ФЗ)

Фоновая работа

Именованная очередь antispam: cms:antispam:rescan — пересчёт скоринга по обновлённым правилам; cms:antispam:purge-quarantine — ретеншн карантина по antispam.quarantine_retention_days (анонимизирует/удаляет только записи с вынесенным решением старше срока, см. «Модель данных»), запускается по расписанию через ScheduleRegistrar ядра. Обе джобы идемпотентны — повторный прогон не дублирует эффект и не трогает уже обработанные записи повторно.

Ранбук (типовые инциденты):

СимптомЧто проверитьКоманда
Капча-провайдер лёг (ошибки/таймауты на всех запросах капчи)health-статус captcha-provider в cms/health, активен ли режим деградацииcms:antispam:doctor --json; при необходимости временно переключить antispam.fail_open_on_captcha_unavailabletrue ослабляет (пропуск без капчи), false усиливает (блокировка формы) в зависимости от текущего риска
Волна ложных блокировок (честные формы массово в карантине)дашборд точности (FP за период, см. «Компоненты»), не понижен ли quarantine_threshold/веса правил после недавней правкипересмотр quarantine_threshold/весов правил в редакторе; при критичности — временный antispam.scoring_enabled=false (kill-switch, см. «Настройки») и массовое одобрение очереди
Спам-шторм (резкий вал отправок с одной подсети/ботнета)текущее значение antispam.storm_rate_limit_per_minute, включён ли cms/attack-monitor для корреляции по подсетямcms:antispam:doctor --json; точечно снизить storm_rate_limit_per_minute на время атаки, сверка с cms/attack-monitor, если включён
Квота капчи на исходе/исчерпанавиджет расхода капчи в админке (см. «Компоненты»), тарифный лимит у провайдерапродлить/поднять лимит у провайдера; до восстановления поведение определяет fail_open_on_captcha_unavailable (см. «Крайние случаи»)

Производительность и кеш

  • Ожидаемый объём: сотни–тысячи отправок форм/комментариев в сутки на активный сайт; карантин обычно 1–5% от потока (иначе пороги настроены неверно — повод для алерта админу);
  • горячий путь — скоринг при отправке формы: бюджет ≤3 запроса (чтение активных правил из кеша группы, вставка карантина при срабатывании, без дополнительных SELECT на каждое правило — правила прогоняются в памяти после одной загрузки);
  • индекс (subject_type, subject_id) критичен для перехода из карантина в одобрение/отклонение и для полиморфной привязки; при большом объёме карантина — дополнительный индекс на (status, created_at) под очередь модерации (keyset);
  • кешируется: активные правила стоп-слов/паттернов (тег antispam:rules, малый объём — держится в кеше группы целиком, не постранично);
  • инвалидация — событием изменения правил (afterSave() в Filament-ресурсе); скоринг конкретной записи не кешируется — считается на лету, стоимость вычисления линейна от числа активных правил (сотни правил — не миллисекунды);
  • дашборд точности (FP/FN) и виджет квоты капчи — admin-only агрегатные запросы, не горячий путь отправки формы; при большом объёме карантина агрегаты кешируются отдельным тегом antispam:stats на несколько минут, не пересчитываются на каждый заход в админку;
  • cms:antispam:purge-quarantine использует тот же индекс (status, created_at), что и очередь модерации — батчами, без полного скана таблицы карантина.

Безопасность

Вход — через FormRequest-whitelist ядра на полях формы; honeypot-поле проверяется до скоринга. Пользовательские regex в правилах — только через ReDoS-валидатор ядра. Карантин не удаляет данные — запись восстанавливаема при одобрении модератором. Векторы атак, специфичные для модуля:

  • обход эвристик текстовым обфусцированием (замена букв, невидимые символы) — правила стоп-слов нормализуют текст перед сопоставлением (транслит/юникод-нормализация — уточняется в реализации, фиксируется в docs/module.md);
  • спам-шторм (резкий вал отправок с одной подсети) — storm_rate_limit_per_minute плюс сигнал в cms/attack-monitor, если включён;
  • отравление обучаемого списка — разметку FP/FN может ставить antispam.moderate (в рамках обычной модерации карантина), но предложенное на её основе правило активирует только antispam.manage — добавление в стоп-лист не автоматическое по единственной жалобе;
  • ReDoS через правило-паттерн — валидатор ядра на сохранении, исполнение с лимитом backtracking (правило студии, не самодельная проверка).

Матрица ролей:

РольПросмотр карантина/дашборда точностиОдобрить/отклонить карантинПравка правил стоп-слов и весовПороги, kill-switch, настройки капчи
Редактор (antispam.view + antispam.moderate)
Менеджер/админ (antispam.manage)
Studio

Разделение намеренное: разбор очереди карантина — рутинная модерация (доступна редактору), а правка весов правил, порогов и kill-switch способна замаскировать спам под легитимный трафик или, наоборот, массово заблокировать честных посетителей — только доверенные роли (antispam.manage). Активация правила, предложенного механизмом обучения (см. «События и обмен»), требует того же antispam.manage.

Права: antispam.view, antispam.moderate, antispam.manage.

UX-требования

Админ:

  • пустая очередь карантина — подсказка «спама не обнаружено» вместо пустой таблицы;
  • массовые действия в очереди карантина: «одобрить выбранные», «отклонить и добавить причины в стоп-лист»;
  • ошибка «капча-провайдер недоступен» на человеческом языке с указанием текущего режима деградации (fail_open_on_captcha_unavailable), не стек-трейс HTTP-клиента;
  • подтверждение необратимого действия — «отклонить и удалить» (в отличие от «отклонить в карантине», которое хранит запись) требует явного подтверждения;
  • редактор правил показывает, сколько раз правило сработало за период — помогает понять, не слишком ли агрессивен вес;
  • дашборд точности показывает FP/FN не только числом, но и трендом по периоду — падение точности видно без ручного пересчёта карантина;
  • приближение к лимиту квоты капчи — предупреждение в интерфейсе (не только запись в логе), заранее, до фактического исчерпания.

Посетитель:

  • отправка формы, ушедшая в карантин, не должна выглядеть как ошибка — посетитель видит обычное «спасибо, заявка принята» (иначе спамер понимает, что его вычислили, и меняет тактику); служебная информация о карантине — только в админке;
  • при captcha_on_borderline=true капча запрашивается заранее понятно (не как внезапный повторный шаг после отправки), не теряя уже введённые данные формы.

Крайние случаи и типовые баги

  • честный посетитель попал в карантин (ложноположительное) → посетитель не видит признаков блокировки (нейтральное «заявка принята»), обращение по email/телефону из контактов сайта — единственный способ узнать о задержке; модератор одобряет из карантина, запись публикуется без потери данных;
  • деградация при недоступной внешней капчеfail_open_on_captcha_unavailable: true пропускает отправку без капчи (риск больше спама), false блокирует форму целиком (риск потери легитимных заявок) — обе ветки покрыты тестом, дефолт документирован как компромисс, не «правильный» ответ;
  • спам-шторм (сотни отправок за минуты с одной подсети/ботнета) → storm_rate_limit_per_minute режет поток до скоринга каждой записи (дешёвый барьер раньше дорогого); при включённом cms/attack-monitor — дополнительный сигнал на эскалацию;
  • honeypot и автозаполнение браузера → честные браузерные автозаполнители иногда трогают скрытые поля; min_fill_seconds и honeypot комбинируются, а не решают в одиночку — единичное срабатывание honeypot без остальных сигналов не должно сразу давать block_threshold (вес настраивается, не хардкод);
  • двойной сабмит формы (гонка) → повторная отправка с идентичными данными в короткий промежуток — дедупликация на уровне формы/лида ядра, антиспам скорит каждую попытку независимо (не полагается на дедуп чужого домена);
  • параллельный пересчёт cms:antispam:rescan и изменение правил → джоба идемпотентна: пересчёт использует снимок правил на момент старта, конкурентное изменение правила отражается только в следующем прогоне, не рвёт текущий;
  • выключение модуля посреди карантина → уже накопленный карантин не публикуется автоматически и не удаляется — остаётся видимым в БД, но без активного скоринга новых записей; включение обратно не теряет историю;
  • отсутствие cms/attack-monitor → сигнал спам-шторма просто не имеет слушателя, локальный storm_rate_limit_per_minute продолжает работать самостоятельно;
  • противоречивые пороги (quarantine_threshold >= block_threshold) → ⚠️ Противоречие: текущее ТЗ не описывает валидацию соотношения порогов. Разрешение: Filament-форма настроек обязана проверять block_threshold > quarantine_threshold при сохранении (доменное правило, не полагаться на администратора);
  • огромный список стоп-слов (тысячи правил) → правила читаются одним запросом и прогоняются в памяти; при деградации производительности — вынести в отдельный скомпилированный индекс (документируется как известное ограничение, не блокер MVP);
  • отсутствие измерения locale/site → правила стоп-слов языко-независимы по умолчанию (паттерны, не словарь конкретного языка); мультиязычный список — отдельные правила с locale-меткой как nullable-измерение, не отдельная ветка кода модуля;
  • суррогатная накрутка одобрений (модератор массово одобряет без проверки, чтобы разгрузить очередь) → одобрение из карантина фиксируется с moderator_id в аудите; массовое действие «одобрить все» требует отдельного явного подтверждения (не совпадает с обычным одиночным одобрением по клику), чтобы не стать случайным обходом фильтра под давлением большой очереди;
  • исчерпание платной квоты капчи (не путать с недоступностью провайдера) → та же настройка fail_open_on_captcha_unavailable определяет ветку деградации, но причина другая — тариф, не сбой; виджет расхода (см. «Компоненты») обязан показать приближение к лимиту заранее, чтобы админ продлил тариф до фактического исчерпания, а не постфактум по логам;
  • запрос на удаление ПДн при нерешённой карантинной записи → анонимизация по DataRemovalRequested/UserDeleted стирает слепок данных, но не сам факт наличия записи в очереди — модератор видит анонимизированную запись («данные удалены по запросу субъекта») и может только отклонить её; одобрение (публикация) уже невозможно — данных для публикации не осталось;
  • ретеншн карантина при незакрытом FP/FN-обученииantispam.quarantine_retention_days не должен утилизировать запись раньше, чем модератор вынес решение (иначе теряется база для дашборда точности) — cms:antispam:purge-quarantine трогает только записи со статусом «решено» (approve/reject), не «на рассмотрении».

Донорский код

Донор: — (новая разработка).

Легаси-импорт (§16 стандарта): не применимо. У модуля нет донора с боевыми данными — эвристики, правила стоп-слов и карантин проектируются с нуля под новую схему, старых таблиц антиспама на донорских сайтах для маппинга нет, командный cms:antispam:import-legacy не нужен. Если в парке найдётся похожий legacy-антиспам с полезным накопленным списком стоп-слов, перенос — точечный ручной ввод через существующий Filament-редактор правил, а не полноценный импортёр (не оправдан для разового переноса нескольких сотен строк).

Тесты и приёмка

  • [ ] Контрактный тест: заполненное honeypot-поле даёт максимальный скор и карантин без публикации;
  • [ ] Запись с превышением block_threshold не появляется публично даже временно;
  • [ ] Карантин не удаляет данные — запись восстанавливаема при одобрении модератором;
  • [ ] Обучаемый список обновляется через Filament без деплоя (правила в БД, не в коде);
  • [ ] Деградация при выключении не ломает отправку форм — просто без скоринга;
  • [ ] Нет ложной блокировки при отсутствии min_fill_seconds (например, автозаполнение браузером) — порог настраиваемый;
  • [ ] Посетитель не видит признаков карантина в ответе формы (нейтральное сообщение);
  • [ ] Деградация капчи ведёт себя по настройке fail_open_on_captcha_unavailable (обе ветки протестированы);
  • [ ] storm_rate_limit_per_minute режет поток при спам-шторме до дорогого скоринга;
  • [ ] Валидация Filament отклоняет block_threshold <= quarantine_threshold;
  • [ ] Дашборд точности корректно считает FP/FN по разметке AntispamRuleLearned (одобрение из карантина = FP, постфактум-разметка пропуска = FN);
  • [ ] antispam.scoring_enabled=false отключает скоринг, оставляя honeypot и rate-limit ядра рабочими;
  • [ ] Вердикт содержит код spam_rejected; вызывающий модуль пробрасывает его в errors/code своего API-ответа при публикации через API;
  • [ ] Виджет квоты капчи виден в админке; исчерпание квоты не даёт 500 — только деградацию по fail_open_on_captcha_unavailable;
  • [ ] cms:antispam:purge-quarantine анонимизирует/удаляет только решённые записи старше quarantine_retention_days, не трогает записи «на рассмотрении»;
  • [ ] Хуки «выгрузить всё по субъекту»/«забыть по запросу» и события UserDeleted/DataRemovalRequested корректно анонимизируют карантинные записи с известным subject_id;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только antispam_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии