ГлавнаяБлогКак я поднял recall с 0.07 до 1.00: честный лог разработки ИИ-сканера
Алгоритмы

Как я поднял recall с 0.07 до 1.00: честный лог разработки ИИ-сканера

Разбор первой версии ИИ-сканера уязвимостей: precision 0.60, recall 0.07. Как я исправил это до recall 1.00 без потери точности. Читайте и улучшайте свои инструменты!

Al
Редакция Algolitalgolit.ru
12 мин чтения7 августа 2026 г.

Почему стоит прочитать эту статью?

Вы когда-нибудь запускали свой код и получали ужасные метрики? Я получил recall 0.07 — мой ИИ-сканер находил лишь 7% реальных уязвимостей. Вместо того чтобы спрятать результат, я опубликовал его. В этой статье — честный лог: как я поднял recall до 1.00, какие ошибки совершил и почему публикация плохих чисел — это стратегия доверия.

Контекст: что за сканер и что за бенчмарк

Я разрабатываю ИИ-сканер уязвимостей. Архитектура: статический анализ ищет потенциальные проблемы, а LLM оценивает каждое срабатывание — реальный это баг или ложная тревога. Тестовый набор — OWASP Benchmark: 2740 размеченных Java-кейсов, стандартный экзамен для Java-сканеров. В моих четырёх классах уязвимостей (SQL-инъекции, командные инъекции, path traversal, XSS) — 1478 кейсов: 777 реальных уязвимостей и 701 ловушка, созданная для проверки на ложные срабатывания. Все инструменты (Semgrep, CodeQL) проходят один и тот же экзамен.

Новичкам: три ключевых термина

Source — место, где недоверенные данные входят в программу (например, request.getParameter("id")). Sink — место, где данные становятся опасными (например, executeUpdate(sql)). Уязвимость — это поток данных от source к sink без очистки. Такой поток называется заражённым (tainted), а отслеживание — taint-анализом. Если вы это знаете — пропускайте.

Почему recall 0.07 — правильный первый результат

Я запускал спайк — минимальную версию: один паттерн source (getParameter), несколько sink-ов, полный конвейер: парсинг, граф кода, taint-запросы, скоринг. Задача спайка — не хорошо выступить, а ответить: работает ли механика вообще? И уродливое число ответило: precision 0.60 — когда сканер срабатывал, он чаще всего был прав. Значит, taint-движок корректно отслеживает потоки. Recall 0.07 — он слеп к 93% багов. Движок не сломан, у него маленький словарь. Я слушал у одной двери здания со множеством дверей. Это не сломанная идея, а диагностика: слишком узкий список источников. Если бы precision был 0.10 — была бы проблема с движком, пришлось бы переделывать. А проблема recall — это проблема списка. Списки исправимы.

Почему очевидные решения не работают

Очевидное решение №1: никому не говорить. Исправить тихо, опубликовать только финальный результат. Но в этой серии я — единственный судья. Читатель может доверять только моей репутации. Если я показываю только победы — доверия нет. Плохие числа тоже публикуются, и первыми. Позже я сравнюсь с Semgrep и CodeQL, и хочу, чтобы читатель думал: «Это человек, который опубликовал свой 0.07». Честность — не добродетель, а инфраструктура.

Очевидное решение №2: добавить побольше паттернов. Recall вырастет, но precision упадёт, и после двадцати изменений вы не поймёте, что сработало. Я сделал иначе: читал код бенчмарка, добавлял источники по частоте использования и пересчитывал метрики после каждого изменения. Одна переменная за раз.

Подъём: три фикса

Фикс 1: Изучаем словарь бенчмарка — recall 0.07 → 0.83

Я проанализировал, какие методы ввода реально использует код бенчмарка: getRequestURI в 724 файлах, getCookies в 664, getParameter в 538, getParameterValues в 510, getHeaders в 400. Мой спайк покрывал только один. Я сделал общее определение источников — регулярное выражение по полным именам методов, привязанное к типам, которые делают данные контролируемыми атакующим. Пример запроса (Joern, Scala DSL):

// Запросы на Joern — open-source движок анализа кода
".*(HttpServletRequest|ServletRequest)\.(getParameter|getParameterNames|getHeader|" +
"getHeaders|getCookies|getQueryString|getRequestURI|getInputStream|…)\\b.*" +
"|.*Cookie\\.(getValue|getName)\\b.*" +
"|.*SeparateClassRequest\\.(getTheParameter|getTheValue)\\b.*"
// обёртка запроса в бенчмарке

Важно: .*Cookie\.getValue.* матчит только геттер куки, иначе зацепит все getValue. Внутри этого шага нашлись две скрытые проблемы. Первая: 117 реальных SQL-инъекций идут через Spring JdbcTemplate, а не java.sql. Добавил один sink — recall по SQLi вырос с 0.57 до 0.86. Вторая: 6060 XSS-sink-вызовов были невидимы, потому что без библиотеки сервлетов движок не мог определить тип response.getWriter(). Решение: принимать sink, если текст приёмника (как написано в коде) совпадает с getWriter|getOutputStream. Это подняло XSS recall с 0.03 до 0.73. Итог: precision 0.53, recall 0.83.

Фикс 2: 97 из 130 пропусков — один метод — recall 0.83 → 0.95

Осталось 130 пропущенных багов. Я сравнил промахи с кодом бенчмарка: 97 из них (три четверти) используют getParameterNames(). Почему его пропускают? getParameter("id") возвращает значение — очевидно опасно. А getParameterNames() возвращает имена параметров — кажется, что это структура, а не данные. Но клиент выбирает имена: ?=x — и имя так же контролируется атакующим. Одно имя в регулярном выражении восстановило 93 уязвимости: recall 0.83 → 0.95. Остальные 4 были заблокированы другой проблемой (вернутся в фиксе 3). Пугает то, что пропущенный source не вызывает ошибок — он молчаливо теряет баги. Без размеченного бенчмарка я бы не узнал.

Фикс 3: Taint-мост — recall 0.95 → 1.00

Осталось 37 пропусков — все содержали .split(...). Я измерил цепочку заражения на одном падающем случае: переменная достижима из source, вызов split достижим, а вот доступ к индексу результата (param.split(" ")[0]) — нет. Taint проходил через split и умирал на операции индексации. Движок имеет стандартное правило для этого оператора, но переопределение не помогло — проблема в применении правила. Я построил мост с условием: доступ к индексу над split-подобным вызовом становится дополнительным source только если индексируемый массив сам достижим из реального source. Индексация незаражённого массива остаётся незаражённой. Это разница между точечным фиксом и массовым перезаражением. Все 37 пропусков восстановлены ценой трёх новых ложных срабатываний и 84 секунд на скан. Recall и precision выросли одновременно.

Итоговые числа

Вся лестница изменений, по одному замеру на шаг:

  • Спайк (только getParameter): precision 0.60, recall 0.07, F1 0.13
  • + расширенные sources, sink по тексту: precision 0.53, recall 0.83, F1 0.65
  • + getParameterNames: precision 0.55, recall 0.95, F1 0.70
  • + taint-мост для индексов: precision 0.56, recall 1.00, F1 0.72

Ноль пропущенных уязвимостей. Все четыре класса: SQL-инъекции 272/272, командные инъекции 126/126, path traversal 133/133, XSS 246/246. Такой же recall, как у CodeQL на тех же 1478 кейсах, с таблицей из семи строк правил и без шага сборки.

Теперь неудобная часть. Precision 0.56 означает 614 ложных срабатываний, и мой сканер попадается в 88% ловушек бенчмарка — хуже, чем CodeQL. Идеальный recall со слабой дискриминацией — это почти инструмент, который на всё отвечает «может быть». Я лучше напишу это сам, чем читатель напишет за меня.

Три возражения, на которые я отвечаю заранее

«Вы подгоняли под тест, читая код бенчмарка»

Частично верно. Бенчмарк публичный, и все инструменты настраиваются на нём. Но мои фиксы — стандартная поверхность ввода HttpServletRequest (getHeader, getCookies, getRequestURI), а не хитрости под бенчмарк. Единственный особый случай — обёртка бенчмарка, я её раскрыл в регулярном выражении. И ловушки против подгонки: я попадаю в 88% ложных срабатываний — если бы я подгонял, это число было бы первым, что я исправил. Что честно утверждает recall 1.00: при данном словаре движок не пропускает ничего, что говорит на этом языке. Цикл построения словаря (изучай код, добавляй sources по частоте, пересчитывай) переносится на реальные кодовые базы. Обобщение на реальные репозитории не измерено; когда измерю — опубликую.

«Почему не использовать CodeQL?»

У CodeQL такой же recall и лучше precision. Но мой слой обнаружения — таблица из семи строк без шага сборки, а CodeQL требует годы смоделированных библиотек и вашу сборку. Вторая причина: precision — это излишек намеренно. Слой обнаружения переотчитывает, чтобы слой судьи (LLM) имел что удалять. В контролируемых тестах лучшая модель убрала половину ложных срабатываний (52% в последнем прогоне) ценой 2% потери recall. Подробности в следующей статье.

«Вы публикуете только успешные метрики?»

Нет. Эта статья — первая, и в ней recall 0.07. Я обещаю публиковать и плохие результаты. Доверие строится на честности, а не на идеальных графиках.

Практический вывод: что делать прямо сейчас

Возьмите свой проект и запустите бенчмарк. Если метрики плохие — не паникуйте и не прячьте их. Определите, precision это или recall. Если recall низкий — расширяйте список источников, измеряя после каждого изменения. Если precision низкий — добавьте слой верификации (LLM или ручные правила). И главное: публикуйте свои числа, даже плохие. Это единственный способ получить обратную связь и доверие сообщества. Начните с малого: выберите один класс уязвимостей, настройте сканер и поделитесь результатом в комментариях.

#ИИ-сканер#OWASP Benchmark#taint-анализ#recall#precision
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →