Три дня назад вышел обзор PHP Form Spam Checker 2.1.2 — конвейера из двадцати проверок для форм. Версия 2.2.0 закрывает слабое место, которое я тогда отметил как ограничение: заблокированная заявка исчезала навсегда. Теперь при блокировке реестр отклонённых заявок сохраняет запрос целиком — до отправки ответа клиенту, — и его можно разобрать командой list, посмотреть маскированно через show и при необходимости отправить повторно через replay. Ниже — что именно изменилось, что получилось при моих прогонах на PHP 8.5.0 и одна неприятность в самой поставке релиза: у v2.2.0 нет приложенных файлов, в отличие от предыдущей версии.

php form spam checker 2 2 0 cover

TL;DR: что в 2.2.0 и как проверить

  1. Что нового. RejectedStore — реестр отклонённых заявок: сохраняет метод, URI, поля POST, клиента, вердикт с баллами, метаданные загрузок и заголовки, нужные для повтора.
  2. Инструмент. bin/rejected-forms с командами list, show, replay, ignore, purge; коды выхода 0/1/2/3 описаны в шапке файла.
  3. Как включено. Блок rejected в конфигурации; enabled по умолчанию true, capture_suspicious — false.
  4. Безопасность. Реестр намеренно хранит персональные данные: права 0700/0600, .htaccess с запретом, вне корня сайта, срок хранения по умолчанию 30 дней.
  5. Что проверить в первую очередь. Что установка взята из тега: у релиза v2.2.0 нет приложенных архивов и SHA256SUMS.txt.
php bin/rejected-forms --help          # команды и коды выхода
php bin/rejected-forms list --path=/путь/к/rejected --site=site.ru
php bin/rejected-forms show <id>       # маскировано, --reveal открывает значения
php bin/rejected-forms replay <id>     # сухой прогон, --confirm отправляет

Что нового в 2.2.0

Что добавлено Где Зачем
Класс RejectedStore src/Integration/RejectedStore.php, 1 281 строка сохраняет отклонённую заявку одной записью JSON
Пересборка запроса ReplayRequest src/Integration/ReplayRequest.php, 443 строки собирает повторную отправку из сохранённых данных
CLI bin/rejected-forms bin/ разбор реестра без SQL и без админки
Блок rejected в конфигурации Config::defaults() девять ключей: путь, срок, лимиты, флаги
Захват в FormGuard src/Integration/FormGuard.php сохраняет заявку до ответа о блокировке
flowchart TB
  GUARD["drop-in guard или WAF"] -->|"вердикт спам или блок"| CAPTURE["RejectedStore::capture"]
  GUARD -->|"подозрительно, если включён capture_suspicious"| CAPTURE
  CAPTURE --> REC["запись JSON: запрос, поля, файлы, вердикт, клиент, заголовки"]
  REC --> STORE["дерево rejected/<сайт>/, права 0700/0600, .htaccess deny"]
  STORE --> CLI["bin/rejected-forms: list, show, replay, ignore, purge"]
  CLI --> SEND["повторная отправка: --confirm, тот же хост"]

Новая возможность включается сама: rejected.enabled по умолчанию true. Это редкий случай, когда новая функция при обновлении начинает работать без правки конфигурации — и об этом надо помнить, потому что реестр пишет файлы на диск.

Ключевые изменения

Реестр пишет заявку до ответа клиенту

Порядок важен: запись создаётся перед отправкой блокирующего ответа. Смысл в том, что спор с владельцем формы или с отправителем идёт по факту, а не по памяти: в записи лежат метод и URI, схема и хост, ключ формы и сайт, вердикт со всеми результатами проверок, клиентские IP и User-Agent, поля POST без обрезки значений, метаданные загрузок (имя, размер, MIME, SHA-256) и заголовки, нужные для повторной отправки.

Записываются три отметки времени, и это сделано намеренно: ts — ISO-8601 со смещением, ts_utc — тот же момент в UTC с суффиксом Z, ts_epoch — для сортировки. В заметках к релизу прямо сказано, почему так: смешивать локальное время с меткой Z нельзя, иначе хронология отказов перестаёт читаться.

CLI вместо чтения файлов руками

Команда Что делает Код возврата
list таблица: сайт, форма, балл, статус, наличие почты и телефона, первая причина 0
show <id> одна запись, значения скрыты до --reveal 0, 1 если записи нет
replay <id> собирает запрос и не отправляет без --confirm 0, 1 отказ
ignore <id> --reason= помечает запись как осознанно не отправляемую 0
purge --older-than=DAYS удаляет старое, сначала сухой прогон 0, 1, 3

Персональные данные: осознанное исключение

В прошлой версии журнал вердиктов был псевдонимизирован (log.pii по умолчанию false). Реестр устроен наоборот: он намеренно хранит персональные данные, иначе повтор невозможен. Защита — не шифрование, а файловая система и срок: каталоги 0700, файлы 0600, .htaccess с Require all denied (проверил содержимое — 122 байта, оба варианта: для mod_authz_core и для старых Apache), дерево вне корня сайта, retention_days по умолчанию 30.

Заголовки Cookie, Authorization, Proxy-Authorization и X-Api-Key отбрасываются, если не включён rejected.store_headers_secrets, и никогда не воспроизводятся при повторе.

Как устроен реестр

Дерево простое: rejected/<сайт>/<id>.json. Путь по умолчанию выводится из хранилища — dirname(dirname(storage.path))/rejected/<site>, то есть рядом с var/log, но отдельным верхним каталогом, а не внутри счётчиков сайта.

Ключ rejected По умолчанию Что ограничивает
enabled true включает захват целиком
store_payload true сохранять ли поля POST
path пусто явный путь; пусто — выводится из хранилища
retention_days 30 срок жизни записи
max_records_per_site 2 000 предел числа записей на сайт
max_bytes_per_record 262 144 предел размера записи, при обрезке ставится отметка truncated
store_files false сохранять ли содержимое загрузок
capture_suspicious false писать ли «подозрительные» вердикты, которые никого не блокировали
store_headers_secrets false сохранять ли Cookie и Authorization для разбора

Отказоустойчивость заявлена как «fail open»: ошибка реестра не должна ни блокировать, ни разблокировать форму. Это я проверил отдельно, и проверка ниже.

Замеры и цифры

Снято 07.11.2026 на PHP 8.5.0 CLI. Архив версии получен из тега (codeload), песочница — отдельный каталог со своим конфигом и приманкой keep-me.txt, чтобы видеть, что реестр не трогает посторонние файлы.

Что измерено Значение Как измерено
Приложенных файлов у релиза v2.2.0 0 gh release download v2.2.0 → no assets to download; gh api .../releases/tags/v2.2.0 → assets: 0
Приложенных файлов у v2.1.2 2 тот же запрос: assets: 2 (архив и SHA256SUMS.txt)
Архив из тега 449 870 байт, SHA-256 b304ffad…a03e2 codeload.github.com/.../tar.gz/refs/tags/v2.2.0
Вердикт тестовой заявки spam, балл 100 SpamChecker::check() — заполнено скрытое поле website
Запись в реестре 1 файл 20261107T030341…-c18c3ebf.json, 1 220 байт RejectedStore::capture()
Каталог реестра rejected/sbx/, рядом .htaccess 122 байта обход дерева после захвата
list одна строка: score 100, status new, MAIL yes, PHONE no, причина honeypot: honeypot field filled: website bin/rejected-forms list --path=… --site=sbx
show без --reveal поля скрыты с длиной: name = [hidden 11 chars], ua=[hidden 10 chars] bin/rejected-forms show <id>
replay без --confirm Dry run: nothing was sent, тело 175 байт, 5 заголовков bin/rejected-forms replay <id>
replay на чужой хост отказ, код 1, Refusing to replay to a different host replay <id> --url=http://evil.example/contact
ignore --reason= Ignored …, код 0 bin/rejected-forms ignore <id> --reason=…
purge --older-than=0 без --confirm сухой прогон: Would delete 1 record(s), код 0 purge --older-than=0
purge --confirm Deleted 1 record(s), код 0, файлов осталось 0 purge --older-than=0 --confirm
Fail open: путь реестра занят файлом в лог ушло [rejected] cannot create registry directory, запись null, исключения нет подмена rejected.path на существующий файл
Приманка keep-me.txt цела на всех шагах проверка после каждого прогона
Старый CLI коды те же: 0 clean, 1 suspicious, 2 spam, 3 error; 12 проверок в прогоне bin/spam-check --help и один вызов
Цифры проекта по README 96 файлов и 13 011 строк в src/, 20 проверок, 9 правил WAF, 626 тестов и 2 435 проверок таблица «В цифрах» README.ru на 06.11.2026
PHP Form Spam Checker 2.2.0: реестр, CLI и результаты прогонов на PHP 8.5

Чего замеры не показывают. Я не проверял работу под Apache и nginx «в бою»: .htaccess создан, но его действие на реальном веб-сервере не воспроизводилось. Не проверял и обрезку записи по max_bytes_per_record с реальной большой загрузкой — только наличие самого ограничителя в коде. Повторную отправку по-настоящему (--confirm) не выполнял: цель в песочнице недостижима, поэтому проверен только сухой прогон и отказ по чужому хосту.

Установка и обновление

flowchart LR
  A["релиз v2.2.0"] --> B{"есть приложенные файлы?"}
  B -- нет --> C["архив из тега: codeload tar.gz"]
  B -- да --> D["tar.gz плюс SHA256SUMS.txt"]
  C --> E["распаковать, автозагрузка без composer"]
  D --> E
  E --> F["настроить блок rejected"]
  F --> G["прогнать спам-заявку и list"]
# вариант 1: архив из тега — единственный, который сейчас работает
curl -L -o pfs-2.2.0.tar.gz \
  https://codeload.github.com/paulmann/PHP-Form-Spam-Checker/tar.gz/refs/tags/v2.2.0
tar -xzf pfs-2.2.0.tar.gz -C /opt
Способ Состояние в 2.2.0
архив, приложенный к релизу отсутствует: assets: 0, gh release download отвечает no assets to download
сверка SHA256SUMS.txt невозможна: файла в релизе нет
архив из тега через codeload работает, но контрольную сумму считать не с чем — надо сверять с коммитом тега
composer require по-прежнему не работает: пакета на Packagist нет (проверено в разборе 2.1.2)

Обновление с 2.1.2 — замена дерева файлов. Автозагрузка autoload.php работает и без vendor/, поэтому копирование каталога достаточно. Единственное, что нужно сделать руками: посмотреть, куда будет писать реестр, и при необходимости задать rejected.path явно.

'rejected' => [
    'enabled' => true,
    'path' => '/var/lib/spam-checker/rejected', // вне корня сайта
    'retention_days' => 30,
    'capture_suspicious' => false,
],

Проверка после обновления

flowchart TD
  V1["bin/rejected-forms --help, коды 0/1/2/3"] --> V2["заявка-спам через guard"]
  V2 --> V3["list: строка с причиной"]
  V3 --> V4["show: данные закрыты, --reveal открывает"]
  V4 --> V5["replay без --confirm: ничего не уходит"]
  V5 --> V6["purge --older-than: сначала сухой прогон"]

Пять шагов, каждый даёт проверяемый результат:

Шаг Ожидание
php bin/rejected-forms --help список команд, коды 0/1/2/3, код возврата 0
отправить заявку с заполненным скрытым полем в rejected/<сайт>/ появляется один файл JSON
list --path=… --site=… ровно одна строка, STATUS new, причина вида honeypot: …
show <id> и show <id> --reveal без флага значения закрыты, с флагом — открыты
replay <id> Dry run: nothing was sent, в сеть ничего не уходит

Отдельно проверьте отказоустойчивость: подставьте в rejected.path путь, занятый файлом. В журнал должна уйти строка [rejected] cannot create registry directory, а форма — продолжить работу. Если форма падает — реестр настроен неверно или версия не та.

Совместимость

Что Состояние
PHP 8.5 и новее; матрица CI — 8.5.12 и 8.5
Зависимости Composer при работе нет, только ext-json и ext-mbstring
Старые CLI spam-check и waf-check на месте, коды выхода прежние
Конфигурация 2.1.2 читается без правок; блок rejected появляется из умолчаний
Включение по умолчанию rejected.enabled = true — реестр начинает писать сразу после обновления
Тесты 2.1.2 существующие фикстуры enforce/WAF теперь отключают реестр, чтобы набор не писал файлы

Последняя строка — не мелочь для тех, кто держит свои тесты поверх библиотеки: если в фикстурах не выключить rejected, прогон начнёт оставлять записи в каталоге реестра.

Подводные камни

  • У релиза нет приложенных файлов. Проверено дважды: gh release download v2.2.0 отвечает no assets to download, API показывает assets: 0 при assets: 2 у v2.1.2. Скрипт обновления, который качает архив из релиза, на этой версии сломается.
  • Реестр включён по умолчанию. Обновление без правки конфигурации означает, что отклонённые заявки начинают сохраняться сразу, вместе с персональными данными. Место на диске надо предусмотреть заранее.
  • E-mail/phone в show противоречит сам себе. На одной и той же записи: без --reveal печатается email present, no phone, с --reveal — no email, no phone. Воспроизвёл дважды на двух разных записях. Причина видна в коде CLI: строка берётся из поля personal_data маскированного представления, а в режиме --reveal печатается сырая запись, где этого поля нет. Признак наличия почты в отчёте поэтому ненадёжен — смотрите сами поля.
  • replay по умолчанию ничего не отправляет. Это правильно, но легко принять сухой прогон за выполненный повтор: в выводе нет слова «ошибка», есть Dry run: nothing was sent. Отправка — только --confirm.
  • Повтор на другой хост отклоняется с кодом 1. Для тестовых и staging-копий нужен --allow-host-change, иначе команда просто откажет.
  • purge без --confirm удаляет ноль записей. Сухой прогон с --older-than=0 показывает «Would delete 1 record(s)» и код 0 — не путайте с удалением.
  • Запись может быть обрезана. При превышении max_bytes_per_record (262 144 байта) запись сохраняется усечённой с отметкой truncated=true, а загрузка без сохранённых байтов при повторе будет отклонена, а не отправлена частично.
  • capture_suspicious по умолчанию выключен. «Подозрительные» вердикты, которые никого не блокировали, в реестр не попадают; для настройки порогов их иногда хотят видеть — тогда флаг надо включить осознанно.

Чек-лист внедрения

  • [ ] Взять архив из тега: приложенных файлов у релиза нет.
  • [ ] Задать rejected.path вне корня сайта и проверить, что каталог создаётся с правами 0700.
  • [ ] Убедиться, что .htaccess в каталоге реестра есть и содержит запрет.
  • [ ] Выставить retention_days и max_records_per_site под свой трафик.
  • [ ] Решить судьбу capture_suspicious и store_headers_secrets (по умолчанию оба false).
  • [ ] Прогнать тестовую спам-заявку и увидеть строку в list.
  • [ ] Проверить маскирование: show без --reveal не должен показывать значения.
  • [ ] Проверить fail open: путь занят файлом — форма работает, в логе одна строка ошибки.
  • [ ] Проверить, что обновление старых фикстур отключает rejected.

FAQ

Зачем хранить отклонённые заявки, если они спам?

Затем, что «спам» — это вердикт фильтра, а не факт. В реестре видно, что именно пришло и какие проверки сработали: это единственный способ разобрать ложное срабатывание и не гадать, потеряли вы клиента или бота.

Это не опасное хранение персональных данных?

Опасно ровно настолько, насколько опасно любое хранение заявок с формами. Здесь всё сделано явно: отдельное дерево вне корня сайта, права 0700/0600, .htaccess с запретом, срок хранения 30 дней по умолчанию и выключенное сохранение Cookie и Authorization. Хотите меньше риска — отключите store_payload или уменьшите retention_days.

Почему архив релиза не скачивается?

Потому что у v2.2.0 нет приложенных файлов: assets: 0 против двух у v2.1.2. Берите архив из тега через codeload — он собирается GitHub автоматически из того же коммита.

Можно ли отправить заявку повторно автоматически?

Можно, но только осознанно: replay <id> --confirm. По умолчанию команда работает как сухой прогон, а на другой хост не пойдёт без --allow-host-change. Загрузки воспроизводятся только из сохранённых байтов, иначе запись отклоняется, а не отправляется неполной.

Что будет, если каталог реестра недоступен?

Захват вернёт null и напишет в журнал [rejected] cannot create registry directory; форма продолжит работу. Это проверено подменой пути на существующий файл: ни исключения, ни блокировки.

Совместим ли новый реестр с настройками 2.1.2?

Да, конфигурация читается как есть, новые ключи берутся из умолчаний. Но именно поэтому реестр включится сам — проверьте путь и срок хранения до обновления, а не после.

Итог

2.2.0 добавляет то, чего не хватало конвейеру: возможность посмотреть на отклонённую заявку и решить, была ли блокировка правильной. Реализовано аккуратно — запись до ответа клиенту, отдельное дерево с правами и запретом через .htaccess, маскирование по умолчанию, отказ отправлять на чужой хост, сухой прогон у replay и purge, отказоустойчивость с записью в лог вместо исключения. Два дефекта, которые я нашёл и подтвердил: у релиза нет приложенных файлов (обновляться придётся из тега), и строка E-mail/phone в show --reveal противоречит маскированному выводу.

Цитата: Фильтр без реестра отказов нельзя улучшать — можно только надеяться, что он не съел живого клиента.

Связанные материалы

LLM Summary

  • Тема: PHP Form Spam Checker 2.2.0 — реестр отклонённых (неотправленных) заявок для форм на PHP 8.5+.
  • Что нового: классы RejectedStore (1 281 строка) и ReplayRequest (443 строки), CLI bin/rejected-forms с командами list, show, replay, ignore, purge, блок конфигурации rejected из девяти ключей.
  • Умолчания: enabled=true, store_payload=true, retention_days=30, max_records_per_site=2000, max_bytes_per_record=262144, store_files=false, capture_suspicious=false, store_headers_secrets=false.
  • Замеры на PHP 8.5.0 (07.11.2026): заявка с заполненной приманкой — spam/100; запись 1 220 байт; list показал score 100, status new, причину honeypot: honeypot field filled: website; replay без --confirm — Dry run: nothing was sent, тело 175 байт; replay на чужой хост — отказ с кодом 1; purge сначала сухой прогон, с --confirm удалил 1 запись; fail open подтверждён: путь занят файлом → в лог [rejected] cannot create registry directory, запись null, исключения нет.
  • Дефект поставки: у релиза v2.2.0 нет приложенных файлов (assets: 0 против assets: 2 у v2.1.2), gh release download отвечает no assets to download; архив берётся из тега через codeload (449 870 байт, SHA-256 b304ffad…a03e2).
  • Второй дефект: строка E-mail/phone в show противоречит себе — без --reveal «email present», с --reveal «no email»; причина в том, что поле personal_data есть только в маскированном представлении.
  • Не проверялось: действие .htaccess на живом Apache/nginx, обрезка по max_bytes_per_record с реальной загрузкой, фактическая повторная отправка --confirm.
  • Ссылки: https://github.com/paulmann/PHP-Form-Spam-Checker/releases/tag/v2.2.0, https://sysadministrator.ru/php-form-spam-checker/

Промт для агента (установка и настройка)

Готовый промт для AI-агента: скопируйте блок целиком. Шаги опираются на AGENTS.md репозитория (контракт для агентов: коды выхода, запреты, ловушки) и дополнены проверками реестра из этого разбора.

Задача: обновить PHP Form Spam Checker до 2.2.0 и включить реестр отклонённых заявок так, чтобы его можно было разобрать и не потерять персональные данные.

Материалы:
- репозиторий: https://github.com/paulmann/PHP-Form-Spam-Checker
- релиз: https://github.com/paulmann/PHP-Form-Spam-Checker/releases/tag/v2.2.0
- контракт для агентов: https://github.com/paulmann/PHP-Form-Spam-Checker/blob/main/AGENTS.md

Получение кода (внимание: у релиза v2.2.0 НЕТ приложенных файлов, assets: 0):
curl -L -o pfs-2.2.0.tar.gz https://codeload.github.com/paulmann/PHP-Form-Spam-Checker/tar.gz/refs/tags/v2.2.0
tar -xzf pfs-2.2.0.tar.gz -C /opt
php -v                                  # 8.5 или новее
php bin/rejected-forms --help           # код 0, коды выхода 0/1/2/3

Настройка реестра (по умолчанию enabled=true, поэтому путь задаётся до первого боевого запроса):
'rejected' => ['enabled' => true, 'path' => '/var/lib/spam-checker/rejected', 'retention_days' => 30, 'capture_suspicious' => false, 'store_headers_secrets' => false]

Порядок:
1. Скопировать дерево, проверить, что autoload.php грузится без vendor/.
2. Задать rejected.path вне корня сайта; убедиться, что каталог создаётся с правами 0700, а внутри появляется .htaccess с запретом.
3. Отправить тестовую заявку с заполненным скрытым полем и увидеть одну запись в rejected/<site>/.
4. bin/rejected-forms list --path=... --site=... — одна строка, статус new, причина honeypot.
5. bin/rejected-forms show <id> — значения скрыты; --reveal — открыты.
6. bin/rejected-forms replay <id> — сухой прогон, в сеть ничего не уходит; на другой хост без --allow-host-change команда откажет с кодом 1.
7. Проверить fail open: подставить в rejected.path путь, занятый файлом; в логе [rejected] cannot create registry directory, форма работает.

Запрещено:
- включать capture_suspicious и store_headers_secrets «на всякий случай»;
- хранить реестр внутри корня сайта или в каталоге, доступном из веба;
- отправлять replay с --confirm на прод без согласования и без --allow-host-change для тестового хоста;
- считать сухой прогон purge удалением: без --confirm он ничего не удаляет;
- доверять строке E-mail/phone в show --reveal: на 2.2.0 она противоречит маскированному выводу, смотреть сами поля.

Что считать успехом: одна запись на отклонённую заявку, запись создаётся до ответа клиенту, list и show дают разбор, replay по умолчанию не отправляет, purge без --confirm только показывает.