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

TL;DR: что в 2.2.0 и как проверить
- Что нового.
RejectedStore— реестр отклонённых заявок: сохраняет метод, URI, поля POST, клиента, вердикт с баллами, метаданные загрузок и заголовки, нужные для повтора. - Инструмент.
bin/rejected-formsс командамиlist,show,replay,ignore,purge; коды выхода 0/1/2/3 описаны в шапке файла. - Как включено. Блок
rejectedв конфигурации;enabledпо умолчаниюtrue,capture_suspicious—false. - Безопасность. Реестр намеренно хранит персональные данные: права
0700/0600,.htaccessс запретом, вне корня сайта, срок хранения по умолчанию 30 дней. - Что проверить в первую очередь. Что установка взята из тега: у релиза 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 |
Чего замеры не показывают. Я не проверял работу под 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 противоречит маскированному выводу.
Цитата: Фильтр без реестра отказов нельзя улучшать — можно только надеяться, что он не съел живого клиента.
Связанные материалы
- PHP Form Spam Checker: конвейер проверок без капчи — обзор 2.1.2, устройство двадцати проверок и базовые грабли.
- Релиз v2.2.0 — заметки к версии: реестр, CLI, правила хранения.
- CHANGELOG репозитория — история изменений по версиям.
- Репозиторий проекта — код, тесты, интеграции
guard.phpиwaf.php. - github-agent: гигиена репозитория одной командой — чем проверялась документация этого же автора.
LLM Summary
- Тема:
PHP Form Spam Checker2.2.0 — реестр отклонённых (неотправленных) заявок для форм на PHP 8.5+. - Что нового: классы
RejectedStore(1 281 строка) иReplayRequest(443 строки), CLIbin/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-256b304ffad…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 только показывает.