
LLM-триаж SAST, который не стыдно пустить в pipeline
В «Ростелекоме», как в любом крупном корпоративном контуре, SAST-поток исчисляется тысячами срабатываний. Ручной триаж перестает быть инженерной работой и становится конвейером. Привет Хабр, меня зовут Юрий Туманов, я AppSec-инженер в блоке ИБ «Ростелекома». Хочу рассказать о том, как я выполнял инференс на локальных моделях, в полной цифровой изоляции от внешнего мира.
Все числа получены на конкретном корпусе, модели и наборе промптов; внутренний контур, названия систем, пути и значения секретов обезличены. Это не бенчмарк «LLM против SAST», а отчет о том, где локальная модель реально помогает, а где честно отдает работу человеку.
Ночью SAST выглядит особенно убедительно. Терминал светит холодным фосфором, отчет шуршит тысячами findings, а где-то в CI/CD опять родился JSON — почти валидный, если прищуриться и не трогать пробелы.
Проблема старая: статический анализ выявляет подозрительные места быстрее человека, но вместе с полезными находками приносит мешки false positive. Разработчики устают. AppSec-инженер превращается в сортировщика тревог, а безопасность начинает все больше напоминать склад с неразобранными коробками.
Теперь — про наш эксперимент по LLM-триажу SAST-срабатываний на локальной модели, вдохновленный идеями ZeroFalse. Мы проверяли более узкую вещь: можно ли передать локальной модели часть SAST-срабатываний и принимать её ответ только там, где он опирается на проверяемые признаки: CWE, trace, source, sink, sanitizer, контекст кода, история решений — и строгий JSON на выходе.
Стенд был вполне земной: vLLM, Qwen2.5-Coder-14B в AWQ-квантизации, RTX 4080 с 16 ГБ VRAM и закрытый контур, ручная разметка и много неприятных мест, где модель уверенно хотела быть умнее процесса. Ее пришлось посадить в клетку. Клетка называется evidence package.
Вот как примерно выглядит то, с чем приходится работать. Обезличенная карточка одного finding:
Ничего секретного тут нет, и в этом половина проблемы. Сканер увидел присваивание строки переменной с именем TOKEN и поднял тревогу. Реальный это ключ или плейсхолдер из шаблона — по одной строке не скажешь, а таких строк тысячи.
Акт I. Город ложных тревог
SAST похож на нервного сторожа в старом дата-центре. Он слышит каждый шорох, видит каждую тень и иногда принимает кофейный автомат за нарушителя периметра. Но это не делает его бесполезным. Статический анализ хорош именно тем, что работает системно: не устает, не забывает заглянуть в файл, не говорит « да ладно, потом проверю ». Он вытаскивает подозрительные места из кода, конфигов, пайплайнов, инфраструктурных манифестов и зависимостей. Но у этой дотошности есть цена, и имя ей — false positive.
False positive ломает доверие. Если разработчик десять раз подряд открывает finding и видит там ерунду, на одиннадцатый он уже не читает и закрывает. Иногда он прав. Иногда нет. Так безопасность и превращается в переговоры с реальностью.
В большом корпоративном контуре поток срабатываний в крупных проектах достигает таких масштабов, при которых ручной триаж перестает быть инженерной работой. Даже если бóльшая часть — очевидный шум, каждое срабатывание формально требует решения: подтвердить, отклонить, отложить, уточнить, передать человеку. Ручная проверка не масштабируется: экспертов мало, контекст проекта надо держать в голове, правила анализатора меняются, кодовая база живет своей жизнью, срабатывания повторяются. Часть ошибок сидит даже не в коде, а в исходной разметке CWE, но вечер пятницы все равно победит любую красивую методологию.
Тут и появляется соблазн: пусть LLM делает первичный триаж. Наивный вариант звучит сладко:
Вот finding . Это уязвимость или false positive ?
И здесь из логов вылезает маленький злой демон. Дай модели открытый вопрос, и она превращается в гадалку с уверенным голосом консультанта. Она красиво объяснит то, чего нет. Увидит секрет в UUID. Закроет боевой ключ как тестовый. Вернет JSON без одной кавычки.
Главный урок всей этой истории: LLM нельзя использовать как магический классификатор. Ее нужно посадить в клетку из улик.
Почему LLM — не Пифия Модель не знает ваш проект целиком. Она не видит весь dataflow, если вы ей его не дали. И она охотно заполняет пустоты правдоподобным текстом. Без evidence триаж превращается в гадание по логам, а гадание плохо проходит security review. Поэтому вопрос должен звучать не «что ты думаешь?», а «вот улики, вот правила, вот допустимые вердикты, вот формат ответа, вот условия, при которых ты обязана отказаться от решения».
Почему LLM — не Пифия
Модель не знает ваш проект целиком. Она не видит весь dataflow, если вы ей его не дали. И она охотно заполняет пустоты правдоподобным текстом. Без evidence триаж превращается в гадание по логам, а гадание плохо проходит security review.
Поэтому вопрос должен звучать не «что ты думаешь?», а «вот улики, вот правила, вот допустимые вердикты, вот формат ответа, вот условия, при которых ты обязана отказаться от решения».
Акт II. Модель в клетке из улик
Подход в духе ZeroFalse начинается с простой мысли: finding нельзя отдавать модели сырым комком текста. Его нужно превратить в пакет доказательств. Не «посмотри на это», а так:
После такой нормализации модель уже не рассуждает о finding вообще: она проверяет ограниченный набор признаков и возвращает один из допустимых ответов. В общих чертах конвейер выглядит так:
В рабочем стенде это выглядело подчеркнуто прагматично: локальный inference через vLLM, Qwen2.5-Coder-14B в AWQ, одна RTX 4080 на 16 ГБ, строгий JSON на выходе.
Почему локально? Потому что триаж питается данными, которые нельзя отдавать во внешний API: фрагменты исходников, trace, пути файлов, имена переменных, конфигурация, служебные значения, иногда куски внутренней бизнес-логики. Внешняя модель может быть сильнее, но для закрытого контура она непригодна.
Зачем нужно
cwe
Класс риска и базовая логика проверки
rule_id
Связь с правилом анализатора
source
Откуда приходит потенциально опасное значение
sink
Куда это значение утекает
sanitizer
Есть ли очистка, кодирование, проверка формата
code_context
Что реально происходит вокруг строки
trace
Как значение идет по коду
exploitability_hints
Признаки достижимости и практической эксплуатации
noisy_zones
Тесты, документация, примеры, генерёжка, third-party
history
Как похожие случаи решались раньше
heuristic_signals
Что нашли регулярки и pre-check до LLM
Главное правило — пакет должен быть компактным. У локальной модели контекст не резиновый: нельзя свалить в промпт весь проект, весь trace, все логи, README, Dockerfile и надежду. Надежда в JSON-схему не сериализуется.
На входе это примерно такой обезличенный объект:
Перед обращением к LLM полезно поставить слой быстрых эвристик: шаблонные значения вида ${PASSWORD} , слова-маркеры placeholder / dummy / sample / example , документация и тестовые каталоги, короткие строки, узнаваемые форматы токенов, признаки реального секрета, наконец, контекст CI/CD или IaC, где даже тестовый секрет может оказаться боевым — все это регулярки и правила ловят дешевле и надежнее модели.
Эвристики здесь не временный костыль, а первый и самый дешевый слой фильтрации. Регулярки экономят токены на очевидном мусоре и не дают ей считать каждую длинную строку паролем от ядерного чемоданчика.
Ответ модели — не эссе, не размышление, не «на мой взгляд». Конвейеру нужен объект, который можно разобрать, проверить и положить в базу. В упрощенном виде контракт ответа выглядел так:
Здесь невалидный JSON — это эксплуатационный дефект. Автоматизация не должна угадывать, что модель имела в виду. Если обработчик начинает восстанавливать пропущенные поля и кавычки за моделью, строгого контракта уже нет: pipeline принимает не ответ, а собственную догадку о нем. Правило короткое: не прошла schema validation — вердикт не принимается.
Unknown нужен не для красоты. Это аварийный клапан. Для hardcoded credentials иногда хватает строки, имени переменной, пути файла и формы значения. Для XSS, SQL injection, XXE, path traversal или resource exhaustion одной строки мало — нужны source, sink, sanitizer, поведение фреймворка, путь данных, контекст вызова. Нет этих данных — правильный ответ модели unknown, и никакой другой.
Почему Unknown — это норма Честный unknown лучше уверенного ложного confirmed . И он же лучше опасного false_positive , который закрыл реальную дыру. Unknown уходит человеку, попадает в очередь ручного триажа и становится сырьем для новых тестовых кейсов. В городе фантазеров это редкий персонаж, который умеет сказать: «Я не знаю».
Почему Unknown — это норма
Честный unknown лучше уверенного ложного confirmed . И он же лучше опасного false_positive , который закрыл реальную дыру.
Unknown уходит человеку, попадает в очередь ручного триажа и становится сырьем для новых тестовых кейсов. В городе фантазеров это редкий персонаж, который умеет сказать: «Я не знаю».
После LLM нужен слой проверки, особенно если вердикт автоматически меняет статус finding в агрегаторе. Минимальная цепочка: валидация JSON-схемы, проверка, что вердикт входит в допустимый enum , что confidence в диапазоне, что в evidence лежат только цитаты из переданного контекста, что confirmed не выставлен без проверяемых улик, а false_positive — при явных признаках реального секрета. И что каждое решение пишется в лог вместе с версией модели, промптом и набором эвристик.
Особая осторожность с авто-confirmed и авто-false_positive. Первое допустимо только при сильных проверяемых признаках: literal-присваивание секрета, узнаваемый формат токена, контекст использования, путь CI/CD или IaC. Второе должно быть еще строже. Закрыть боевую проблему как ложную тревогу больнее, чем прогнать через человека лишний finding : лишняя ручная проверка — это раздражение, а невидимый false negative возвращается уже инцидентом.
Где ставить post-validation Schema validation — JSON соответствует контракту Evidence check — цитаты реально существуют во входе Sanity check по правилам — сильные признаки секрета не исчезают под словом test Порог уверенности — низкая confidence ведет в unknown или к человеку Очередь human review — спорное уходит человеку, а не в автозакрытие
Где ставить post-validation
Schema validation — JSON соответствует контракту
Schema validation — JSON соответствует контракту
Evidence check — цитаты реально существуют во входе
Evidence check — цитаты реально существуют во входе
Sanity check по правилам — сильные признаки секрета не исчезают под словом test
Sanity check по правилам — сильные признаки секрета не исчезают под словом test
Порог уверенности — низкая confidence ведет в unknown или к человеку
Порог уверенности — низкая confidence ведет в unknown или к человеку
Очередь human review — спорное уходит человеку, а не в автозакрытие
Очередь human review — спорное уходит человеку, а не в автозакрытие
Самым полезным в эксперименте оказалась эволюция промптов. Первая версия была сырой: « проверь finding и скажи, уязвимость это или нет ». Модель видела секреты почти везде. Длинная строка? Подозрительно. UUID? В тюрьму. Тестовая константа? В отчет.
Следующая итерация добавила известные форматы токенов — GitHub, AWS, JWT и прочих узнаваемых зверей. Стало лучше, но частные правила быстро затрещали. Реальный код всегда грязнее демо: staging, examples, legacy, генережка, CI/CD, переменные с дурацкими именами и комментарии, написанные живым человеком в 02:17.
Качество заметно изменилось после того, как промпт превратили из открытого вопроса в пошаговую процедуру. Сначала модель определяла класс evidence — SECRET_VALUE , IDENTIFIER_ONLY , ENDPOINT_ONLY , TEST_PLACEHOLDER или INSUFFICIENT_CONTEXT , — затем применяла правила для этого класса и только после этого выбирала вердикт. Появились явные правила: UUID, client_uuid, request_id, trace_id — это идентификаторы, а не секреты, даже если сканер обозвал их паролем. OAuth Client ID — публичный идентификатор; секрет — это Client Secret. Шаблон {service}_{suffix} (kafka_password, redis_user, postgres_secret) — конфигурационный плейсхолдер, а не учетка. И отдельный протокол на случай, когда код в payload вырезан: решать по описанию, пути и правилу, но честно помечать code_source : description_only .
После этого каждое изменение промпта стало возможно проверять на одной и той же размеченной выборке, а не оценивать по двум удачным ответам в консоли. Меняешь промпт, прогоняешь эталонный набор и видишь, стало лучше или просто красивее.
По одному удачному демо качество не судят. LLM умеет выглядеть убедительно даже когда ошибается. Нужен ground truth — вручную размеченная выборка, независимая от вердиктов модели, воспроизводимая, покрывающая разные CWE, хранящая спорные случаи и переезжающая в regression-тесты после каждой ошибки.
Смотреть стоит вот на что:
Зачем нужна
precision
Сколько confirmed от модели действительно confirmed
recall
Сколько реальных проблем модель не потеряла
false positive reduction
Насколько упал шум
unknown rate
Сколько ушло человеку
agreement with human triage
Совпадение с экспертной разметкой
invalid JSON rate
Пригодность модели для конвейера
latency / cost per finding
Во что обходится триаж по времени и ресурсам
Теперь цифры. Эксперимент шел на корпусе из 746 независимо размеченных срабатываний . Сразу остужу ожидания: точного совпадения трех классов (Confirmed / False Positive / Unknown) между моделью и экспертом лишь 354 из 746 (47,5%) . Это честная картина: как универсальный классификатор по всему потоку 14B-модель посредственна.
Матрица ошибок объясняет, куда все утекает (строки — эксперт, столбцы — Qwen):
Эксперт ↓ / Qwen →
190
143
103
131
Большая часть расхождений — не «модель сказала черное вместо белого», а «модель ушла в Unknown , где эксперт решился». Это видно и по покомпонентным метрикам (задача из трех классов, не бинарный детектор уязвимостей):
0,471
0,232
0,311
0,576
0,544
0,560
0,379
0,514
0,436
0,475
0,430
0,436
Если оставить только 276 случаев, где и эксперт, и модель выбрали один из двух содержательных вердиктов — Confirmed или False Positive , — совпадение составит 223 из 276, или 80,8%. Это условная метрика: она не учитывает случаи, в которых хотя бы одна сторона выбрала unknown , поэтому воспринимать ее как общую точность системы нельзя. И если сузиться до классов с детерминированными, проверяемыми признаками — хардкод секретов, ключей и паролей, слабая случайность — на этом срезе уже можно обсуждать ограниченный пилот с ручным контролем:
Что это
Решающих совпадений
Хранение пароля открытым текстом
100,0%
Захардкоженный криптоключ
100,0%
Недостаточно случайные значения
93,3%
Встроенный вредоносный код
100,0%
Захардкоженные учетные данные
100
91,0%
160
150
93,8%
На отобранных классах, при правильных уликах, строгом формате и калибровке локальная модель реально снимает с человека рутину. На общем потоке — нет. Разница между 47,5% по всему корпусу и 93,8% на пяти классах. Поэтому допускать модель имеет смысл не ко всему потоку, а к заранее выбранным CWE, для которых собраны достаточные признаки и отдельная калибровочная выборка.
Контрпример полезно держать рядом, чтобы не обмануться. На свежем стокейсовом срезе по CWE-328 (слабые хеши) согласие оказалось низким — около 15% по решающим парам. Раньше этот класс в демо выглядел триумфально. На честном корпусе он переехал в раздел «угрозы валидности». Так и должно быть.
Все эти числа относятся к конкретному корпусу, модели, промптам, эвристикам и условиям прогона. На другом анализаторе и другом наборе CWE все может пойти совсем по-другому. Перепродавать результат — последнее дело.
Акт III. Последний прогон перед рассветом
Финальный прогон начался тихо. SAST отдал findings . Context builder собрал evidence . Pre-check отсеял очевидный мусор. Модель получила компактный prompt. vLLM пошевелил памятью. Видеокарта нагрела комнату до температуры легкого DevOps-раскаяния.
Первый вердикт лег в лог:
Потом другой:
И третий — самый важный:
Третий ответ важнее первых двух: система не пытается восстановить отсутствующий dataflow догадкой, а явно перечисляет недостающие данные и передаёт finding человеку.
А в логе инференса все это выглядело буднично:
Часть false positive погасла. Часть настоящих проблем всплыла. Часть ушла в unknown . И это оказалось нормальной развилкой. Теперь человек просматривает не весь мешок, а то, что осталось после фильтра.
Кстати, про скорость. На узком историческом срезе (тот же набор по CWE-256, 15 кейсов) калибровка промпта уронила среднее время с 21,1 до 3,0 секунд на finding, p95 — с 67,7 до 5,9, а долю выдуманных цитат-улик — со 100% до 0. Это не общая гарантия по корпусу, а локальная победа над конкретной «болячкой», локальный результат на пятнадцати кейсах; его еще нужно воспроизвести на других CWE и на независимом корпусе.
Получилось собрать pipeline, где модель работает локально, без отправки кода наружу; получает не сырой finding, а структурированные улики; возвращает машинно-разбираемый JSON; различает confirmed , false_positive и unknown ; не выносит финального решения без post-validation и встроена в feedback loop, а не строит из себя директора по ИБ.
Универсального классификатора не получилось: качество по-прежнему зависит от полноты trace, выбранного CWE и того, попал ли нужный контекст во вход. Модель по-прежнему упирается в размер контекста, качество evidence, ошибки самого SAST и слабое знание проекта. Добавьте галлюцинации, чувствительность к формулировке промпта и железо, которое временами напоминает домашний обогреватель с CUDA. Особенно тяжело там, где нужна полная цепочка данных: SQL injection, XSS, XXE, path traversal. Если source не виден, sink обрезан, а sanitizer не попал в контекст, место модели в unknown . Никакой prompt не восстановит dataflow, которого нет во входе. В лучшем случае он красиво допишет недостающий кусок. А красиво выдуманный dataflow нужен security-процессу примерно, как открытый Redis в интернете
Канва внедрения та же, что на схеме выше: сканер → агрегатор → context builder → эвристический pre-check → локальный LLM-триаж → post-validation → предложение статуса → human review → хранилище метрик → feedback loop. А дальше несколько правил, основанных на реальном опыте:
Начинайте с узкого класса дефектов с понятными признаками: hardcoded secrets, credentials, слабая криптография.
Начинайте с узкого класса дефектов с понятными признаками: hardcoded secrets, credentials, слабая криптография.
Не подключайте сразу XSS и SQL injection без нормального dataflow.
Не подключайте сразу XSS и SQL injection без нормального dataflow.
Сначала считайте вердикт рекомендацией, а не автоматическим действием.
Сначала считайте вердикт рекомендацией, а не автоматическим действием.
Версионируйте все: модель, промпт, эвристики, схему.
Версионируйте все: модель, промпт, эвристики, схему.
Заведите эталонную выборку и гоняйте regression-тесты после каждой правки промпта.
Заведите эталонную выборку и гоняйте regression-тесты после каждой правки промпта.
Логируйте unknown , а не прячьте их.
Логируйте unknown , а не прячьте их.
Не закрывайте finding как false positive без проверяемых оснований.
Не закрывайте finding как false positive без проверяемых оснований.
Периодически пересматривайте ручную разметку, ведь эксперт тоже человек.
Периодически пересматривайте ручную разметку, ведь эксперт тоже человек.
Практические выводы
LLM-триаж SAST реалистичен, если относиться к нему как к инженерному процессу, а не как к красивому запросу к модели.
Сработало то, что превращало модель в узел системы :
локальный inference для закрытого контура,
локальный inference для закрытого контура,
evidence package вместо сырого finding,
evidence package вместо сырого finding,
pre-check до модели,
pre-check до модели,
отдельные промпты под CWE,
отдельные промпты под CWE,
строгая JSON-схема,
строгая JSON-схема,
post-validation,
post-validation,
честный статус unknown,
честный статус unknown,
независимый бенчмарк и регулярная калибровка.
независимый бенчмарк и регулярная калибровка.
Не сработало предсказуемое :
один универсальный промпт на все CWE,
один универсальный промпт на все CWE,
ставка на то, что модель сама поймет,
ставка на то, что модель сама поймет,
автозакрытие спорного,
автозакрытие спорного,
попытки чинить битый JSON как ни в чем не бывало,
попытки чинить битый JSON как ни в чем не бывало,
вера в метрики без проверки на свежем срезе.
вера в метрики без проверки на свежем срезе.
Человек остается незаменим там, где спорный unknown , неполный dataflow, новый CWE, расхождение модели с ground truth, смена policy, принятие риска и финальное решение по критичным находкам. А regression-тесты обязательны после любого изменения промпта, модели, эвристик, правил SAST, формата данных агрегатора или появления нового класса CWE.
Если совсем коротко : LLM triage = evidence + strict schema + validation + metrics + human review Без evidence LLM триаж — гадание. Без схемы — болтовня. Без валидации — риск. Без метрик — вера, а без человека — слишком смело для кибербеза.
Если совсем коротко :
LLM triage = evidence + strict schema + validation + metrics + human review
Без evidence LLM триаж — гадание. Без схемы — болтовня. Без валидации — риск. Без метрик — вера, а без человека — слишком смело для кибербеза.
Клиффхэнгер: models/big_small/
Под утро город стал серым. Неон выцвел. Видеокарта остыла. Последний лог лежал на экране, как протокол допроса:
Средняя модель сделала свою работу. Не идеально, но достаточно честно, чтобы стать частью процесса.
Смотрим на очередь экспериментов. Там ждут новые папки:
Большие модели обещали разум, но требовали железа, денег и отдельного договора с богами инфраструктуры. Малые бегали быстро, почти не грели комнату, но иногда не видели того, что лежало прямо под курсором. Где-то между ними шевелился RAG — темный коридор с мигающей вывеской, за которой пахло индексами, эмбеддингами и новыми ошибками.
Следующая глава будет не про то, может ли LLM помочь SAST. Она будет про то, кто выживет в настоящем pipeline: большая модель, средняя, маленькая или связка с RAG, которая знает проект лучше, чем усталый инженер в 03:40.
Экран мигнул.
cd models/big_small/
История не закончена.
Ссылки и материалы:
ZeroFalse: Improving Precision in Static Analysis with LLMs — arXiv:2510.02534.
ZeroFalse: Improving Precision in Static Analysis with LLMs — arXiv:2510.02534.
Qwen2.5-Coder Technical Report — arXiv:2409.12186.
Qwen2.5-Coder Technical Report — arXiv:2409.12186.
AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration — arXiv:2306.00978.
AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration — arXiv:2306.00978.
Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM) — SOSP 2023, arXiv:2309.06180.
Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM) — SOSP 2023, arXiv:2309.06180.
Habr Flavored Markdown — документация Хабра по разметке.
Habr Flavored Markdown — документация Хабра по разметке.