Тема
ТЗ — Антиспам (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_rules | id, type, pattern, weight, is_active | стоп-слова/паттерны с весом |
cms_antispam_quarantine | id, 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_active | Policy 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_threshold | int | 50 | нет | Порог скоринга для карантина |
antispam.block_threshold | int | 85 | нет | Порог автоблокировки без модерации |
antispam.min_fill_seconds | int | 3 | нет | Минимальное время заполнения формы (быстрее — подозрительно) |
antispam.captcha_on_borderline | bool | true | нет | Требовать капчу при скоре в пограничной зоне (между обычным проходом и quarantine_threshold) |
antispam.fail_open_on_captcha_unavailable | bool | true | нет | При недоступности внешнего провайдера капчи — пропускать (true) или блокировать (false) отправку |
antispam.storm_rate_limit_per_minute | int | 30 | нет | Порог отправок формы в минуту с одной подсети — превышение считается спам-штормом |
antispam.quarantine_retention_days | int | 180 | нет | Срок хранения решённых карантинных записей (ПДн-паспорт, см. «Модель данных») до анонимизации/удаления |
antispam.scoring_enabled | bool | true | нет | 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 | событие CommentSubmitted | in | тот же скоринг для комментариев, полиморфная запись в cms_antispam_quarantine |
cms/comments + ядро — формы | provides-контракт spam-filter | out | вызывающий код получает вердикт синхронно через DI, не знает о реализации |
Ядро — captcha-provider | provides-контракт (потребление) | in | при пограничном скоре запрашивает капчу-челлендж как дополнительный сигнал |
cms/attack-monitor (suggests) | сигнал/агрегат (не жёсткая зависимость) | out | всплеск карантина с одной подсети/за окно — сырьё для детектора спам-шторма, если attack-monitor включён |
Ядро — UserDeleted | событие | in | анонимизация карантинных записей автора, если известен subject_id |
cms/consents (suggests) | событие DataRemovalRequested | in | анонимизация карантинных записей по запросу субъекта на удаление (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_unavailable — true ослабляет (пропуск без капчи), 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запрещены.