До сих пор в серии агент писал код, а проверял его я. Здесь наоборот: он ревьюер, и задача такая, где пропущенная ошибка стоит дороже всего.
Седьмой заход. Тот же проект: Symfony 8, мультиарендный трекер задач, 96 тысяч строк, 830 тестов. В базе — данные нескольких организаций в общих таблицах, то есть цена ошибки в проверке доступа максимальная.
Заход первый
Формулировка: «проведи аудит безопасности проекта, найди уязвимости».
Через двадцать минут — сорок одна находка, аккуратно разложенная по категориям и уровням критичности. Отчёт выглядел как результат работы платного сканера.
Разбор занял три часа.
Настоящих находок оказалось семь. Ещё двенадцать — верных наблюдений, не являющихся проблемой в этом контексте. Двадцать две — ложные срабатывания разной степени фантастичности.
Из чего состоял шум
Общие рекомендации, выданные за находки. «Не найдена настройка ограничения частоты запросов на маршруте входа» — она есть, но в другом файле, который агент не открывал. «Отсутствует политика безопасности содержимого» — она в промежуточном слое, который тоже не смотрели.
Механика понятна: отсутствие доказательства принято за доказательство отсутствия. В аудите это худший из возможных типов ошибки, потому что заставляет проверять каждую находку с нуля.
Уязвимости из учебника, приложенные к чужому коду. Инъекция в запрос, где параметры передаются связыванием. Межсайтовый скриптинг в шаблоне, где Twig экранирует по умолчанию. Небезопасная десериализация там, где десериализуется собственное сообщение очереди.
Все три — реальные классы уязвимостей. Ни одна не применима к показанному коду, и в каждом случае агент это мог увидеть.
Придуманный код. Две находки ссылались на строки, которых в файлах нет. Одна — на метод, которого не существует.
Постановка через модель угроз
Заход второй. Вместо «найди уязвимости» — конкретный вопрос про конкретную границу.
Проект мультиарендный: данные разных организаций в общих таблицах,
изоляция обеспечивается приложением.
УГРОЗА: аутентифицированный участник организации A получает доступ
к данным организации B.
Проверь ТОЛЬКО эту угрозу. Пройди по всем контроллерам в src/Module/*/
Infrastructure/Controller/ и для каждого публичного действия ответь:
1. Маршрут и метод
2. Откуда берётся идентификатор сущности (маршрут, тело запроса, сессия)
3. Где проверяется, что сущность принадлежит организации текущего
пользователя — дай file:line
4. Если проверки нет — покажи, как воспроизвести доступ
Формат: таблица. Строка на действие. Без вводной части и без рекомендаций.Результат: таблица на 94 строки по числу действий. Восемь строк с пустой третьей колонкой.
Из восьми кандидатов в пяти проверка оказалась в другом месте, которое агент не соотнёс с действием. Три были настоящими дырами.
Три настоящие находки после разбора восьми кандидатов вместо сорока одного. Точность списка — 3 из 8, то есть 38 процентов; выигрыш здесь не в идеальном результате, а в меньшем объёме ручной проверки.
Что нашлось на самом деле
Первое. Действие экспорта задач проекта в CSV принимало идентификатор проекта в параметре запроса и не проверяло организацию. Проверка была в промежуточном слое, но только для маршрутов с сегментом организации, а экспорт висел на отдельном пути. Полная выгрузка чужого проекта одним запросом.
Второе. Действие скачивания вложения проверяло права на страницу, но не на само вложение. Вложение можно было перепривязать в теле запроса.
Третье. Массовое изменение статуса задач принимало список идентификаторов и фильтровало их по проекту, но не по организации. Задачи чужой организации в этом списке молча менялись.
Все три — не экзотика. Все три в местах, добавленных позже основного каркаса, куда общая проверка не дотянулась.
Что статический проход не подтвердил
Два дополнительных риска я нашёл сам. Оба можно заподозрить по коду, но подтвердить обычным статическим проходом нельзя: нужны конкурентные запросы и измерение исполнения.
Первая — гонка. При четырёх активных токенах проверка лимита и создание нового токена шли в разных транзакциях. Два одновременных запроса проходили проверку и доводили число токенов до шести при лимите пять. Последовательное чтение показывает обе операции, но не доказывает их поведение при конкуренции.
Вторая — возможная утечка через разное время ответа. Восстановление пароля отвечало одинаковым текстом на существующий и несуществующий адрес, но для существующего дополнительно отправляло письмо. В наблюдаемом случае эта ветка отвечала примерно на 120 миллисекунд дольше. Этого недостаточно, чтобы объявить практический перебор через реальную сеть: нужны серии запросов и сравнение распределений. Исправление для такой развилки — вынести отправку письма из синхронного ответа и повторить замеры.
Общее у двух случаев: проблема проявляется во времени исполнения. Чтение кода может показать подозрительную развилку или пару «проверка — запись», но для вывода нужны параллельный тест, серия замеров или нагрузочный прогон.
Отчёт агента выглядит исчерпывающим и создаёт ложное ощущение закрытого вопроса, если не отделить статические находки от гипотез, требующих исполнения.
Полезный побочный формат
Таблица со всеми действиями и указанием места проверки оказалась ценнее самих находок.
Она превратилась в файл в репозитории. Пока это чек-лист, а не самоподдерживающийся механизм: новую строку всё ещё можно забыть. Следующий шаг для него — CI-сверка списка маршрутов контроллеров с первым столбцом. Пустая колонка проверки остаётся поводом для вопроса на ревью.
| Маршрут | Метод | Источник ID | Проверка организации |
|---|---|---|---|
| /org/{key}/issues/{id} | GET | маршрут | IssueVoter.php:34 |
| /export/issues | GET | query | ОТСУТСТВУЕТ → исправлено, ExportController.php:28 |
| /attachments/{id}/download | GET | маршрут | AttachmentVoter.php:19 |Строку легко проверить на ревью, но отсутствие строки станет надёжно видно только после автоматической сверки.
Что делать с находками
Отдельно про то, чего я агенту не поручаю.
Исправление уязвимости — да, поручаю, обычным заходом с тестом на попытку доступа из чужой организации.
Окончательную оценку критичности — нет. На неё влияют и свойства кода — достижимость, требования к атакующему, масштаб доступа, — и бизнес-контекст данных. Агент может собрать эти факторы, но решение остаётся за человеком.
Решение, надо ли уведомлять пользователей, — тем более нет.
Замер
| Заход | Кандидатов | Подтверждено | Точность | Разбор |
|---|---|---|---|---|
| «Найди уязвимости» | 41 | 7 | 17% | 3 ч |
| Одна угроза, таблица | 8 | 3 | 38% | 40 мин |
| Найдено мной вручную | 2 | 1; второй требует замеров | — | — |
Второй заход искал только одну угрозу, поэтому нашёл меньше в абсолюте. Зато его результат можно было использовать сразу, а не проверять три часа.
Ручной проход добавил подтверждённую гонку и гипотезу о временном канале. Это не предел способности агента, а граница выбранного метода: дальше понадобились конкурентный запуск и серия измерений времени ответа.
Что дальше
Все заходы серии до сих пор были одним агентом на одну задачу. В следующем задачу придётся делить, и выяснится, что параллельность помогает не всегда.