Практический опыт Юрия Туманова / Эльария / Psycho Drake
Статический анализ кода давно умеет находить подозрительные места быстрее человека. Но в реальном AppSec-процессе после этого начинается самая неприятная часть: нужно понять, что из найденного действительно похоже на уязвимость, а что является очередным false positive. Если SAST каждую неделю приносит огромный поток срабатываний, ручной triage быстро превращается не в инженерную работу, а в бесконечное просеивание шума.
В докладе речь шла именно об этой боли: по опыту команд, которые работают с SAST, 95-98% срабатываний часто оказываются false positive. При масштабе порядка миллиона SAST-срабатываний в неделю это уже не косметическая проблема, а отдельная производственная линия. Люди открывают finding, смотрят на trace, смотрят на rule, проверяют код и слишком часто приходят к одному и тому же выводу: анализатор опять соврал.
Идея эксперимента была простой: можно ли поставить между SAST-агрегатором и человеком локальную LLM, которая будет выполнять первичный triage, возвращать машинно обрабатываемый verdict и снижать объём ручной рутины? Не заменить эксперта полностью, а сделать так, чтобы эксперт меньше тратил время на очевидный шум и чаще разбирал действительно спорные случаи.
Подход получился ZeroFalse-inspired: автор отталкивался от идеи LLM-триажа SAST-срабатываний, но переносил её в более жёсткие практические условия. Без отправки внутреннего кода наружу, без большой облачной модели, на локальной видеокарте и с небольшим контекстом.
Почему локально
Главная причина локального inference банальна и важна: внутренний код, trace'ы и данные из SAST-агрегатора нельзя отправлять за корпоративный периметр. Даже если внешняя модель хороша, в таком процессе она часто непригодна организационно. Triage питается именно теми данными, которые обычно нельзя отдавать во внешний API: фрагментами кода, путями вызовов, названиями переменных, строками, конфигурацией, иногда кусками внутренней логики.
Рабочий стенд докладчика был построен на RTX 4080 с 16 GB VRAM. Изначально видеокарта покупалась не под AppSec-автоматизацию, но в итоге стала локальной лабораторией для LLM-триажа. На ней удалось поднять inference через vLLM и использовать Qwen2.5-Coder-14B-AWQ. По времени одно решение triage занимало примерно 3-6 секунд.
Это не промышленный кластер и не бесконечный облачный бюджет. Но именно в этом ценность опыта: он показывает, что полезный triage можно начать собирать на относительно ограниченном железе, если не пытаться сразу решать всё одной большой просьбой к модели.
Ограничения железа сразу влияют на архитектуру. Максимальный контекст, который удалось стабильно использовать на RTX 4080, составлял 8192 токена. Для обычного LLM-чата это может звучать терпимо, но для SAST это мало: полный trace, кусок кода, описание rule, surrounding context и служебные данные быстро съедают окно. Поэтому в pipeline приходится заранее решать, что действительно нужно модели, а что только мешает.
Pipeline: vLLM, Qwen, JSON и обвязка
Текущий pipeline устроен прагматично.
Сначала SAST-агрегатор отдаёт finding: rule, trace, raw data и сопутствующие поля. До LLM эти данные проходят pre-check: набор heuristics/регулярок, которые отсекают очевидное, достают важные маркеры и помогают сформировать компактный input для модели.
После этого LLM выполняет triage. В рабочей конфигурации это vLLM + Qwen2.5-Coder-14B-AWQ на RTX 4080. Модель получает подготовленный контекст: данные по rule, релевантные фрагменты trace, результат heuristic pre-check и инструкции по конкретному классу дефекта.
На выходе требуется не красивое рассуждение, а строгий JSON по JSON schema. Это один из центральных практических выводов доклада. Модель может убедительно объяснять решение, но если результат нельзя автоматически разобрать, он плохо встраивается в pipeline. Нужен verdict, reason, possibly status, confidence или другие поля, которые система дальше по конвейеру может прочитать и отправить обратно в агрегатор.
invalid JSON в такой схеме становится не мелкой неприятностью, а эксплуатационным дефектом. Если модель дала полезный текст, но сломала формат, автоматический triage не состоялся. Поэтому качество работы с JSON schema нужно проверять отдельно от качества рассуждения. Модель должна быть не только «умной», но и дисциплинированной.
Упрощённо процесс выглядит так:
SAST finding
-> aggregator data
-> heuristics / регулярки / pre-check
-> compact LLM input
-> vLLM + Qwen2.5-Coder-14B-AWQ
-> JSON schema verdict
-> aggregator / повторный triage / статистика
Важная деталь: это stateless inference. Каждый запрос в vLLM является новым запросом. У модели нет волшебной памяти, куда она сама складывает предыдущие findings. Если нужна история, статистика, повторные прогоны и сравнение вердиктов, это должна делать внешняя обвязка.
Зачем нужны эвристики до LLM
Наивный вариант звучит заманчиво: взять весь finding, засунуть его в prompt и попросить модель решить, real это vulnerability или false positive. В реальности такой подход быстро упирается в контекст, шум и нестабильность.
Эвристики до LLM нужны не для того, чтобы заменить модель, а чтобы подготовить для неё нормальную задачу. Регулярки и pre-check могут:
- отбросить очевидный мусор;
- вытащить из raw data важные признаки;
- подсказать модели, какие поля имеют значение;
- сократить prompt;
- отделить технический шум от смысл с точки зрения безопасностиа;
- повысить продуктивность на классах дефектов, где признаки хорошо формализуются.
В докладе звучала оценка, что в некоторых классах дефектов такой слой повышает продуктивность примерно на 30%. Особенно естественно это выглядит для hardcoded secrets и credentials: до LLM можно регулярками найти подозрительные токены, проверить форму строки, отбросить условные тестовые заглушки или маркеры, которые явно не являются секретом, и уже после этого дать модели более чистый контекст.
Пример из практики: если модель видит raw data без подготовки, она может начать считать hardcoded credentials вообще всё подряд: большие числа, константы, технические идентификаторы. Для человека очевидно, что не каждая строковая константа является секретом. Для небольшой локальной модели это нужно явно оформить: какие признаки важны, какие нет, как трактовать значение, что делать с тестовыми placeholder'ами.
Эвристики помогают не задавать модели открытый вопрос «что ты об этом думаешь?». Вместо этого ей дают рамку: вот rule, вот признаки, вот результат pre-check, вот политика принятия решения. Так LLM становится не оракулом, а частью инженерного pipeline.
Эволюция prompt: v0, v1, v2, v3
Отдельная часть опыта связана с prompt engineering. Здесь главный вывод неприятный, но честный: универсального prompt'а для всех моделей и всех CWE не получается. Даже coder-модели отличаются друг от друга. При смене модели prompt'ы, скорее всего, придётся менять.
У докладчика была эмпирическая эволюция prompt'ов.
prompt v0 можно описать как ранний сырой подход: дать модели данные finding и попросить определить, есть ли проблема. На hardcoded credentials такой вариант быстро показал слабое место: модель начинала расширять понятие «секрет» слишком широко и видела хардкод там, где были просто константы.
prompt v1 стал более структурным: больше внимания к rule, к признакам, к ожидаемому формату ответа. На этом этапе особенно важным становится JSON schema. Модель должна возвращать не свободный текст, а строго заданный объект, который можно обработать автоматически.
prompt v2 добавил более явные эвристические подсказки и policy-контекст. Для разных классов дефектов нужны разные критерии: hardcoded credentials, weak hash, XSS, SQL injection и XXE требуют разных входных данных и разных правил сомнения. Чем меньше модель, тем полезнее не заставлять её выводить все критерии из воздуха.
prompt v3 можно понимать как более осторожную эксплуатационную версию: с явным местом для unknown, с отказом от агрессивного закрытия спорных findings как false positive и с учётом регулярной калибровки. Если данных недостаточно, лучше признать неопределённость или downgrade'ить приоритет, чем уверенно закрыть реальную проблему.
Эта эволюция была не академическим упражнением, а реакцией на реальные ошибки. В марте обнаружился неприятный случай: секрет получил статус false positive, хотя таковым не был. Причина была в политике prompt'а: если срабатывание не похоже на hardcoded secret, его можно было закрыть как false positive. После этого подход изменился. В сомнительных случаях безопаснее снижать критичность или приоритет, чем автоматически объявлять finding ложным.
Unknown лучше ложной уверенности
Для LLM-триажа важно проектировать не только happy path, где модель уверенно говорит confirmed или false_positive, но и нормальный статус неопределённости. unknown нужен не как слабость, а как предохранитель.
SAST finding часто неполон. Для XSS, SQL injection и XXE модели может понадобиться много raw data: путь данных, контекст вызовов, sanitization, sink, source, framework behavior. Если trace обрезан или нужный кусок кода не попал в контекст, маленькая модель может начать гадать. Хороший pipeline должен не поощрять гадание, а позволять сказать: данных недостаточно.
Это особенно важно при ограничении в 8192 токена. Нельзя просто положить в prompt весь проект и ожидать, что модель разберётся. Приходится выбирать фрагменты, а значит, иногда не хватит решающего контекста. В таких случаях честный unknown лучше красивого, но неверного verdict.
Weekly calibration и ground truth
LLM-триаж нельзя один раз настроить и забыть. В докладе это звучало как отдельная рутина: weekly calibration. Каждую неделю нужно смотреть расхождения, пересматривать prompt'ы, уточнять эвристики и возвращаться к эталонам.
Для этого нужен ground truth: вручную размеченный набор findings, которому команда доверяет. В описанном опыте было 245 вручную размеченных эталонов. Это не огромный датасет, но уже достаточно, чтобы не спорить с моделью на уровне ощущений. Можно прогонять одни и те же случаи повторно, смотреть, где модель совпадает с человеческим triage, где расходится, а где человек сам ошибся.
Здесь важно не романтизировать метрики. В исходных материалах уверенно зафиксировано сравнение с ground truth и agreement на small independent benchmark. Конкретные recall/precision в доступном транскрипте не восстановлены надёжно, поэтому их лучше не превращать в публикационный факт без дополнительной сверки. Для внутренней эксплуатации, конечно, такие метрики полезны: recall показывает, сколько реальных проблем не потеряли, precision — сколько подтверждённых моделью findings действительно подтверждаются человеком. Но в этой статье фиксируем только то, что уверенно следует из источников.
Small independent benchmark включал 88 решений: половина false positive, половина confirmed. На нём смотрели agreement модели с человеческой разметкой. И здесь проявилась важная особенность: часть расхождений была не просто ошибками модели. Иногда модель оказывалась права, а ручная разметка требовала пересмотра. Это нормальный эффект для triage-систем: ground truth тоже создают люди, а значит, его нужно поддерживать, чистить и обсуждать.
D-23 rule: осторожное правило эксплуатации
В материалах к докладу отдельно упоминается D-23 rule, но в расшифровке это место восстановлено неуверенно. Поэтому корректнее говорить о нём осторожно: как о внутреннем эксплуатационном правиле, которое помогает не принимать опасные решения на основании недостаточного контекста.
Практический смысл такого правила в LLM-триаже понятен: модель не должна автоматически закрывать finding как false positive, если не выполнены условия доверия к решению. Для спорных случаев лучше оставить unknown, отправить на ручной triage, downgrade'ить приоритет или дождаться дополнительного контекста. Важна не сама магическая формула D-23, а дисциплина: у команды должны быть правила, когда LLM-вердикту можно доверять, а когда он является только подсказкой.
Без такой дисциплины LLM легко превращается в ещё один источник уверенного шума. С ней модель становится фильтром, который работает в границах процесса.
CWE-328: weak hash и роль policy
Weak hash — хороший пример того, почему triage не сводится к вопросу «нашла модель уязвимость или нет». Для CWE-328 weak hash важно учитывать внутреннюю policy.
Например, использование MD5 или SHA-1 может быть критичным, если речь идёт о криптографической защите. Но похожее срабатывание может оказаться менее опасным, если hash используется для совместимости, checksum, не-security идентификатора или внешнего протокола, который нельзя быстро поменять. Решение зависит не только от кода, но и от политики организации.
Значит, prompt должен знать не абстрактную «мировую истину», а рабочую политику команды. Если политика меняется, prompt'ы тоже нужно менять. Иначе модель может продолжать правильно выполнять старую инструкцию, но выдавать уже неправильный для организации verdict.
Именно поэтому weekly calibration важна не меньше, чем выбор модели. Модель не живёт отдельно от процесса. Она отражает текущие правила triage, а правила со временем меняются.
CWE-798: hardcoded credentials и сила pre-check
Hardcoded credentials, связанные с CWE-798, были одним из первых классов, на котором проявились ограничения сырого prompt'а. Для человека разница между реальным секретом, тестовым значением и технической константой часто видна по контексту. Модель без подсказок может расширить понятие credentials слишком широко.
Здесь хорошо работают heuristics/регулярки до LLM. Они могут найти подозрительные паттерны, выделить имя переменной, значение, surrounding code, проверить похожесть на placeholder и передать модели уже не весь шум, а структурированные признаки.
Но даже для CWE-798 важно избегать агрессивного auto-close. Ошибка с секретом, ошибочно помеченным как false positive, показывает главный риск: false negative в security-процессе хуже лишней ручной проверки. Поэтому для hardcoded credentials полезна многоступенчатая логика:
- очевидный мусор можно отсеивать pre-check'ом;
- сильные признаки можно отправлять в confirmed;
- слабые или неполные признаки лучше downgrade'ить или оставлять unknown;
- автоматическое false positive должно требовать достаточных оснований.
XSS, SQL injection и XXE: когда контекст дороже prompt'а
Для XSS, SQL injection и XXE проблема другая. Там часто недостаточно увидеть одну строку. Нужно понимать поток данных: откуда пришёл input, где он прошёл обработку, чем был очищен, куда попал, какой framework участвует, что реально является sink.
Маленькая локальная модель может рассуждать по этим признакам, но ей нужно дать правильный контекст. Если в prompt попал только кусок trace без source или sink, модель либо начнёт сомневаться, либо выдаст слишком уверенный ответ на неполных данных. Поэтому для таких классов особенно важны:
- компактное извлечение релевантного кода;
- эвристики по source/sink/sanitizer;
- явное разрешение на
unknown; - отдельные prompt'ы под класс дефекта;
- регулярная проверка на размеченном эталоне.
Это не тот случай, где одна регулярка решает всё. Но регулярки и pre-check помогают подготовить вопрос так, чтобы LLM отвечала на security-задачу, а не на случайную смесь логов, кода и служебных полей.
Маленький benchmark полезнее большой веры
Один из самых практичных выводов доклада: не надо верить модели на слово. Даже если она звучит уверенно, красиво объясняет и почти всегда возвращает JSON, качество нужно мерить на эталоне.
В описанном процессе использовались 245 вручную размеченных эталонов и отдельный small independent benchmark на 88 решений, сбалансированный между false positive и confirmed. Этого достаточно, чтобы начать видеть тенденции: какие CWE модель понимает лучше, где ломается JSON, где prompt слишком агрессивен, где эвристика помогает, а где мешает.
Такой benchmark не обязан быть большим, чтобы быть полезным. Он должен быть понятным, воспроизводимым и регулярно прогоняемым. Его задача — не доказать, что LLM «решила SAST», а показать, улучшается ли triage после изменения prompt'а, policy или pre-check.
Отдельно стоит смотреть не только на итоговый verdict, но и на тип ошибки:
- модель закрыла реальную проблему как false positive;
- модель подтвердила очевидный false positive;
- модель ушла в
unknown, хотя данных было достаточно; - модель дала хороший reasoning, но invalid JSON;
- модель следовала старой policy;
- человек в ground truth мог ошибиться.
Последний пункт особенно важен. LLM-триаж иногда обнаруживает не только ошибки модели, но и шероховатости человеческой разметки.
Практические выводы
Первый вывод: локальный LLM-триаж SAST-срабатываний реалистичен, но только как инженерная система, а не как один prompt. Нужны vLLM, модель, JSON schema, эвристики, хранение статистики, повторные прогоны и процесс калибровки.
Второй вывод: маленькой модели нужно помогать. Qwen2.5-Coder-14B-AWQ на RTX 4080 с 16 GB VRAM может быть полезной, но контекст ограничен, а prompt'ы чувствительны к деталям. Чем меньше модель, тем важнее подготовка input и класс-специфичные инструкции.
Третий вывод: heuristics/регулярки до LLM не являются «старым скучным способом», который LLM должна заменить. Наоборот, они делают LLM полезнее: уменьшают шум, экономят контекст и дают модели признаки, по которым можно принять более стабильное решение.
Четвёртый вывод: JSON schema — не формальность. Если результат triage должен возвращаться в агрегатор, invalid JSON ломает автоматизацию. Формат ответа нужно тестировать так же внимательно, как качество вердикта с точки зрения security.
Пятый вывод: prompt v0/v1/v2/v3 — это не лестница к финальной идеальной версии, а нормальная эволюция процесса. Prompt'ы меняются вместе с policy, набором CWE, ошибками, benchmark'ом и пониманием того, где модель опасно уверена.
Шестой вывод: weekly calibration обязательна. Размеченный ground truth на 245 эталонов и small independent benchmark на 88 решений дают основу для разговоров о качестве. Без них обсуждение быстро скатывается в «мне кажется, стало лучше».
Седьмой вывод: лучше честный unknown или downgrade, чем неправильное закрытие реальной находки как false positive. В security-процессе стоимость такой ошибки слишком высока.
Что бы я забрал в свой AppSec-процесс
Если переносить этот опыт в команду, начинать стоит не с покупки большого железа и не с выбора «самой умной» модели. Начинать стоит с карты процесса:
- какие SAST rules дают больше всего шума;
- какие CWE можно безопасно триажить первыми;
- какие поля из агрегатора реально нужны для решения;
- где можно написать pre-check;
- какой JSON schema нужен системе дальше по конвейеру;
- кто размечает ground truth;
- как часто команда делает calibration;
- какие решения LLM имеет право принимать автоматически.
Хороший первый кандидат — узкий класс с понятными признаками, например hardcoded credentials. Для него можно быстро увидеть пользу регулярных pre-check'ов, JSON verdict и ручной валидации. Более сложные классы вроде XSS, SQL injection и XXE стоит добавлять осторожнее: там выше зависимость от trace и контекста кода.
И главное: LLM-триаж не должен маскировать неопределённость. Его задача — уменьшить мешок false positive, но не ценой невидимых false negative. Поэтому ZeroFalse-inspired подход в локальной реализации — это не «модель сама всё закрывает», а дисциплинированная система, где модель помогает человеку принимать решения быстрее и стабильнее.