False positive под небом цвета телевизора, настроенного на мертвый канал

от автора

LLM-триаж SAST, который не стыдно пустить в pipeline

AppSec-инженер перед терминалами ночью

AppSec-инженер перед терминалами ночью

В «Ростелекоме», как в любом крупном корпоративном контуре, 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:

finding_id:  ******rule:        Hardcoded Credentials (secret-scan)cwe:         CWE-798severity:    Highstatus:      To Verifypath:        src/main/**/AppConfig.java : 42match:       private static final String TOKEN = "****************"trace:       source —> assignment —> (sink not in payload)env_label:   UNKNOWN

Ничего секретного тут нет, и в этом половина проблемы. Сканер увидел присваивание строки переменной с именем TOKEN и поднял тревогу. Реальный это ключ или плейсхолдер из шаблона — по одной строке не скажешь, а таких строк тысячи.

Акт I. Город ложных тревог

Город CWE-небоскрёбов под дождём из JSON

Город CWE-небоскрёбов под дождём из JSON

SAST похож на нервного сторожа в старом дата-центре. Он слышит каждый шорох, видит каждую тень и иногда принимает кофейный автомат за нарушителя периметра. Но это не делает его бесполезным. Статический анализ хорош именно тем, что работает системно: не устает, не забывает заглянуть в файл, не говорит «да ладно, потом проверю». Он вытаскивает подозрительные места из кода, конфигов, пайплайнов, инфраструктурных манифестов и зависимостей. Но у этой дотошности есть цена, и имя ей — false positive.

False positive ломает доверие. Если разработчик десять раз подряд открывает finding и видит там ерунду, на одиннадцатый он уже не читает и закрывает. Иногда он прав. Иногда нет. Так безопасность и превращается в переговоры с реальностью.

В большом корпоративном контуре поток срабатываний в крупных проектах достигает таких масштабов, при которых ручной триаж перестает быть инженерной работой. Даже если бóльшая часть — очевидный шум, каждое срабатывание формально требует решения: подтвердить, отклонить, отложить, уточнить, передать человеку. Ручная проверка не масштабируется: экспертов мало, контекст проекта надо держать в голове, правила анализатора меняются, кодовая база живет своей жизнью, срабатывания повторяются. Часть ошибок сидит даже не в коде, а в исходной разметке CWE, но вечер пятницы все равно победит любую красивую методологию.

Тут и появляется соблазн: пусть LLM делает первичный триаж. Наивный вариант звучит сладко:

Вот finding.
Это уязвимость или false positive?

И здесь из логов вылезает маленький злой демон. Дай модели открытый вопрос, и она превращается в гадалку с уверенным голосом консультанта. Она красиво объяснит то, чего нет. Увидит секрет в UUID. Закроет боевой ключ как тестовый. Вернет JSON без одной кавычки.

Главный урок всей этой истории: LLM нельзя использовать как магический классификатор. Ее нужно посадить в клетку из улик.

Почему LLM — не Пифия

Модель не знает ваш проект целиком. Она не видит весь dataflow, если вы ей его не дали. И она охотно заполняет пустоты правдоподобным текстом. Без evidence триаж превращается в гадание по логам, а гадание плохо проходит security review.

Поэтому вопрос должен звучать не «что ты думаешь?», а «вот улики, вот правила, вот допустимые вердикты, вот формат ответа, вот условия, при которых ты обязана отказаться от решения».

Акт II. Модель в клетке из улик

Инженер собирает evidence package

Инженер собирает evidence package

Подход в духе ZeroFalse начинается с простой мысли: finding нельзя отдавать модели сырым комком текста. Его нужно превратить в пакет доказательств. Не «посмотри на это», а так:

Вот CWE.Вот rule.Вот подозрительное значение.Вот source. Вот sink. Вот trace.Вот sanitizer — или его отсутствие.Вот контекст кода. Вот признаки достижимости.Вот шумные зоны: тесты, примеры, генережка.Вот как похожие случаи решались раньше.Теперь вынеси вердикт строго по схеме.

После такой нормализации модель уже не рассуждает о finding вообще: она проверяет ограниченный набор признаков и возвращает один из допустимых ответов. В общих чертах конвейер выглядит так:

Схема конвейера: ответ модели еще не вердикт — после генерации идут schema validation и проверка улик

Схема конвейера: ответ модели еще не вердикт — после генерации идут schema validation и проверка улик

В рабочем стенде это выглядело подчеркнуто прагматично: локальный inference через vLLM, Qwen2.5-Coder-14B в AWQ, одна RTX 4080 на 16 ГБ, строгий JSON на выходе.

Почему локально? Потому что триаж питается данными, которые нельзя отдавать во внешний API: фрагменты исходников, trace, пути файлов, имена переменных, конфигурация, служебные значения, иногда куски внутренней бизнес-логики. Внешняя модель может быть сильнее, но для закрытого контура она непригодна.

Что класть в evidence package

Поле

Зачем нужно

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-схему не сериализуется.

На входе это примерно такой обезличенный объект:

{  "finding_id": "******",  "cwe": "CWE-798",  "rule_id": "secret-scan/hardcoded-credentials",  "evidence_class_hint": "TEST_PLACEHOLDER?",  "source": "literal assignment",  "sink": null,  "sanitizer": null,  "code_context": "private static final String TOKEN = \"${SERVICE_TOKEN}\"",  "path": "src/main/**/AppConfig.java",  "noisy_zones": ["none detected"],  "heuristic_signals": ["template_expression: ${...}", "var_name: TOKEN"],  "history": ["similar ${...} placeholders -> false_positive (x12)"]}

Pre-check до модели

Перед обращением к LLM полезно поставить слой быстрых эвристик: шаблонные значения вида ${PASSWORD}, слова-маркеры placeholder/dummy/sample/example, документация и тестовые каталоги, короткие строки, узнаваемые форматы токенов, признаки реального секрета, наконец, контекст CI/CD или IaC, где даже тестовый секрет может оказаться боевым — все это регулярки и правила ловят дешевле и надежнее модели.

Эвристики здесь не временный костыль, а первый и самый дешевый слой фильтрации. Регулярки экономят токены на очевидном мусоре и не дают ей считать каждую длинную строку паролем от ядерного чемоданчика.

Строгий вердикт

Ответ модели — не эссе, не размышление, не «на мой взгляд». Конвейеру нужен объект, который можно разобрать, проверить и положить в базу. В упрощенном виде контракт ответа выглядел так:

{  "verdict": "confirmed | false_positive | unknown",  "evidence_class": "SECRET_VALUE | IDENTIFIER_ONLY | ENDPOINT_ONLY | TEST_PLACEHOLDER | INSUFFICIENT_CONTEXT",  "confidence": 0.0,  "cwe": "CWE-798",  "evidence": ["literal quote from trace or code context"],  "reason": "Short explanation based only on provided evidence",  "missing_information": ["source not visible", "sanitizer not provided"],  "requires_human_review": true}

Здесь невалидный JSON — это эксплуатационный дефект. Автоматизация не должна угадывать, что модель имела в виду. Если обработчик начинает восстанавливать пропущенные поля и кавычки за моделью, строгого контракта уже нет: pipeline принимает не ответ, а собственную догадку о нем. Правило короткое: не прошла schema validation — вердикт не принимается.

Unknown: штатный отказ от решения

Unknown нужен не для красоты. Это аварийный клапан. Для hardcoded credentials иногда хватает строки, имени переменной, пути файла и формы значения. Для XSS, SQL injection, XXE, path traversal или resource exhaustion одной строки мало — нужны source, sink, sanitizer, поведение фреймворка, путь данных, контекст вызова. Нет этих данных — правильный ответ модели unknown, и никакой другой.

Почему Unknown — это норма

Честный unknown лучше уверенного ложного confirmed. И он же лучше опасного false_positive, который закрыл реальную дыру.

Unknown уходит человеку, попадает в очередь ручного триажа и становится сырьем для новых тестовых кейсов. В городе фантазеров это редкий персонаж, который умеет сказать: «Я не знаю».

Post-validation: ответ модели еще не меняет статус finding

После LLM нужен слой проверки, особенно если вердикт автоматически меняет статус finding в агрегаторе. Минимальная цепочка: валидация JSON-схемы, проверка, что вердикт входит в допустимый enum, что confidence в диапазоне, что в evidence лежат только цитаты из переданного контекста, что confirmed не выставлен без проверяемых улик, а false_positive — при явных признаках реального секрета. И что каждое решение пишется в лог вместе с версией модели, промптом и набором эвристик.

Особая осторожность с авто-confirmed и авто-false_positive. Первое допустимо только при сильных проверяемых признаках: literal-присваивание секрета, узнаваемый формат токена, контекст использования, путь CI/CD или IaC. Второе должно быть еще строже. Закрыть боевую проблему как ложную тревогу больнее, чем прогнать через человека лишний finding: лишняя ручная проверка — это раздражение, а невидимый false negative возвращается уже инцидентом.

Инженер в красном свете тревожной панели

Инженер в красном свете тревожной панели

Где ставить post-validation

1.         Schema validation — JSON соответствует контракту

2.         Evidence check — цитаты реально существуют во входе

3.         Sanity check по правилам — сильные признаки секрета не исчезают под словом test

4.         Порог уверенности — низкая confidence ведет в unknown или к человеку

5.         Очередь 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 →

Confirmed

False Positive

Unknown

Confirmed

33

37

72

False Positive

16

190

143

Unknown

21

103

131

Большая часть расхождений — не «модель сказала черное вместо белого», а «модель ушла в Unknown, где эксперт решился». Это видно и по покомпонентным метрикам (задача из трех классов, не бинарный детектор уязвимостей):

Класс

Precision

Recall

F1

Confirmed

0,471

0,232

0,311

False Positive

0,576

0,544

0,560

Unknown

0,379

0,514

0,436

Макро-среднее

0,475

0,430

0,436

Если оставить только 276 случаев, где и эксперт, и модель выбрали один из двух содержательных вердиктов — Confirmed или False Positive, — совпадение составит 223 из 276, или 80,8%. Это условная метрика: она не учитывает случаи, в которых хотя бы одна сторона выбрала unknown, поэтому воспринимать ее как общую точность системы нельзя. И если сузиться до классов с детерминированными, проверяемыми признаками — хардкод секретов, ключей и паролей, слабая случайность — на этом срезе уже можно обсуждать ограниченный пилот с ручным контролем:

CWE

Что это

N

Решающих совпадений

Согласие

CWE-256

Хранение пароля открытым текстом

15

15

100,0%

CWE-321

Захардкоженный криптоключ

15

15

100,0%

CWE-330

Недостаточно случайные значения

15

14

93,3%

CWE-506

Встроенный вредоносный код

15

15

100,0%

CWE-798

Захардкоженные учетные данные

100

91

91,0%

Итого

 

160

150

93,8%

На отобранных классах, при правильных уликах, строгом формате и калибровке локальная модель реально снимает с человека рутину. На общем потоке — нет. Разница между 47,5% по всему корпусу и 93,8% на пяти классах. Поэтому допускать модель имеет смысл не ко всему потоку, а к заранее выбранным CWE, для которых собраны достаточные признаки и отдельная калибровочная выборка.

Контрпример полезно держать рядом, чтобы не обмануться. На свежем стокейсовом срезе по CWE-328 (слабые хеши) согласие оказалось низким — около 15% по решающим парам. Раньше этот класс в демо выглядел триумфально. На честном корпусе он переехал в раздел «угрозы валидности». Так и должно быть.

Все эти числа относятся к конкретному корпусу, модели, промптам, эвристикам и условиям прогона. На другом анализаторе и другом наборе CWE все может пойти совсем по-другому. Перепродавать результат — последнее дело.

Акт III. Последний прогон перед рассветом

Рассвет над серверной: unknown остался гореть. И это хорошо

Рассвет над серверной: unknown остался гореть. И это хорошо

Финальный прогон начался тихо. SAST отдал findings. Context builder собрал evidence. Pre-check отсеял очевидный мусор. Модель получила компактный prompt. vLLM пошевелил памятью. Видеокарта нагрела комнату до температуры легкого DevOps-раскаяния.

Первый вердикт лег в лог:

{  "verdict": "false_positive",  "evidence_class": "TEST_PLACEHOLDER",  "confidence": 0.91,  "cwe": "CWE-798",  "evidence": ["password = \"${DB_PASSWORD}\""],  "reason": "The value is a template placeholder, not a literal credential.",  "requires_human_review": false}

Потом другой:

{  "verdict": "confirmed",  "evidence_class": "SECRET_VALUE",  "confidence": 0.88,  "cwe": "CWE-321",  "evidence": ["private static final String API_KEY = \"****\""],  "reason": "A literal key-like value is assigned in application code; no placeholder or test marker visible.",  "requires_human_review": true}

И третий — самый важный:

{  "verdict": "unknown",  "evidence_class": "INSUFFICIENT_CONTEXT",  "confidence": 0.42,  "cwe": "CWE-89",  "evidence": ["query variable is passed to execution function"],  "reason": "Sink is visible, but source and sanitization path are absent from the provided trace.",  "missing_information": ["source of input", "sanitizer chain", "full dataflow"],  "requires_human_review": true}

Третий ответ важнее первых двух: система не пытается восстановить отсутствующий dataflow догадкой, а явно перечисляет недостающие данные и передаёт finding человеку.

А в логе инференса все это выглядело буднично:

run_id:        2026-05-night-buildmodel:         qwen2.5-coder-14b-awq   (vLLM, ctx 8192, temp 0.0)batch:         100 findingsverdicts:      confirmed=7  false_positive=44  unknown=49invalid_json:  0mean_latency:  3.0 s / findingstatus:        completed

Часть 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 в интернете

Как встроить это в CI/CD

Канва внедрения та же, что на схеме выше: сканер → агрегатор → context builder → эвристический pre-check → локальный LLM-триаж → post-validation → предложение статуса → human review → хранилище метрик → feedback loop. А дальше несколько правил, основанных на реальном опыте:

1.         Начинайте с узкого класса дефектов с понятными признаками: hardcoded secrets, credentials, слабая криптография.

2.         Не подключайте сразу XSS и SQL injection без нормального dataflow.

3.         Сначала считайте вердикт рекомендацией, а не автоматическим действием.

4.         Версионируйте все: модель, промпт, эвристики, схему.

5.         Заведите эталонную выборку и гоняйте regression-тесты после каждой правки промпта.

6.         Логируйте unknown, а не прячьте их.

7.         Не закрывайте finding как false positive без проверяемых оснований.

8.         Периодически пересматривайте ручную разметку, ведь эксперт тоже человек.

Практические выводы

LLM-триаж SAST реалистичен, если относиться к нему как к инженерному процессу, а не как к красивому запросу к модели.

Сработало то, что превращало модель в узел системы:

  • локальный inference для закрытого контура,

  • evidence package вместо сырого finding,

  • pre-check до модели,

  • отдельные промпты под CWE,

  • строгая JSON-схема,

  • post-validation,

  • честный статус unknown,

  • независимый бенчмарк и регулярная калибровка.

Не сработало предсказуемое:

  • один универсальный промпт на все CWE,

  • ставка на то, что модель сама поймет,

  • автозакрытие спорного,

  • попытки чинить битый JSON как ни в чем не бывало,

  • вера в метрики без проверки на свежем срезе.

Человек остается незаменим там, где спорный unknown, неполный dataflow, новый CWE, расхождение модели с ground truth, смена policy, принятие риска и финальное решение по критичным находкам. А regression-тесты обязательны после любого изменения промпта, модели, эвристик, правил SAST, формата данных агрегатора или появления нового класса CWE.

Если совсем коротко:

LLM triage = evidence + strict schema + validation + metrics + human review

Без evidence LLM триаж — гадание. Без схемы — болтовня. Без валидации — риск. Без метрик — вера, а без человека — слишком смело для кибербеза.

Клиффхэнгер: models/big_small/

Инженер на пороге дата-центра: впереди тёмный светящийся коридор RAG

Инженер на пороге дата-центра: впереди тёмный светящийся коридор RAG

Под утро город стал серым. Неон выцвел. Видеокарта остыла. Последний лог лежал на экране, как протокол допроса:

run_id: 2026-05-night-buildmodel: qwen2.5-coder-14b-awqstatus: completedunknown: not_zerofalse_positive_reduced: yessecurity_paranoia: still_required

Средняя модель сделала свою работу. Не идеально, но достаточно честно, чтобы стать частью процесса.

Смотрим на очередь экспериментов. Там ждут новые папки:

models/
  medium/
  big_small/
  rag/
  evals/

Большие модели обещали разум, но требовали железа, денег и отдельного договора с богами инфраструктуры. Малые бегали быстро, почти не грели комнату, но иногда не видели того, что лежало прямо под курсором. Где-то между ними шевелился RAG — темный коридор с мигающей вывеской, за которой пахло индексами, эмбеддингами и новыми ошибками.

Следующая глава будет не про то, может ли LLM помочь SAST. Она будет про то, кто выживет в настоящем pipeline: большая модель, средняя, маленькая или связка с RAG, которая знает проект лучше, чем усталый инженер в 03:40.

Экран мигнул.

cd models/big_small/

История не закончена.


Ссылки и материалы:

  • ZeroFalse: Improving Precision in Static Analysis with LLMs — arXiv:2510.02534.

  • Qwen2.5-Coder Technical Report — arXiv:2409.12186.

  • 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.

  • Habr Flavored Markdown — документация Хабра по разметке.

ссылка на оригинал статьи https://habr.com/ru/articles/1059670/