- BrainTools - https://www.braintools.ru -
В «Ростелекоме», как в любом крупном корпоративном контуре, 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-798
severity: High
status: To Verify
path: src/main/**/AppConfig.java : 42
match: private static final String TOKEN = "****************"
trace: source —> assignment —> (sink not in payload)
env_label: UNKNOWN
Ничего секретного тут нет, и в этом половина проблемы. Сканер увидел присваивание строки переменной с именем TOKEN и поднял тревогу. Реальный это ключ или плейсхолдер из шаблона — по одной строке не скажешь, а таких строк тысячи.
SAST похож на нервного сторожа в старом дата-центре. Он слышит каждый шорох, видит каждую тень и иногда принимает кофейный автомат за нарушителя периметра. Но это не делает его бесполезным. Статический анализ хорош именно тем, что работает системно: не устает, не забывает [1] заглянуть в файл, не говорит «да ладно, потом проверю». Он вытаскивает подозрительные места из кода, конфигов, пайплайнов, инфраструктурных манифестов и зависимостей. Но у этой дотошности есть цена, и имя ей — false positive.
False positive ломает доверие. Если разработчик десять раз подряд открывает finding и видит там ерунду, на одиннадцатый он уже не читает и закрывает. Иногда он прав. Иногда нет. Так безопасность и превращается в переговоры с реальностью.
В большом корпоративном контуре поток срабатываний в крупных проектах достигает таких масштабов, при которых ручной триаж перестает быть инженерной работой. Даже если бóльшая часть — очевидный шум, каждое срабатывание формально требует решения: подтвердить, отклонить, отложить, уточнить, передать человеку. Ручная проверка не масштабируется: экспертов мало, контекст проекта надо держать в голове, правила анализатора меняются, кодовая база живет своей жизнью, срабатывания повторяются. Часть ошибок сидит даже не в коде, а в исходной разметке CWE, но вечер пятницы все равно победит любую красивую методологию.
Тут и появляется соблазн: пусть LLM делает первичный триаж. Наивный вариант звучит сладко:
Вот finding.
Это уязвимость или false positive?
И здесь из логов вылезает маленький злой демон. Дай модели открытый вопрос, и она превращается в гадалку с уверенным голосом консультанта. Она красиво объяснит то, чего нет. Увидит секрет в UUID. Закроет боевой ключ как тестовый. Вернет JSON без одной кавычки.
Главный урок всей этой истории: LLM нельзя использовать как магический классификатор. Ее нужно посадить в клетку из улик.
Почему LLM — не Пифия
Модель не знает ваш проект целиком. Она не видит весь dataflow, если вы ей его не дали. И она охотно заполняет пустоты правдоподобным текстом. Без evidence триаж превращается в гадание по логам, а гадание плохо проходит security review.
Поэтому вопрос должен звучать не «что ты думаешь?», а «вот улики, вот правила, вот допустимые вердикты, вот формат ответа, вот условия, при которых ты обязана отказаться от решения».
Подход в духе ZeroFalse начинается с простой мысли: finding нельзя отдавать модели сырым комком текста. Его нужно превратить в пакет доказательств. Не «посмотри на это», а так:
Вот CWE.
Вот rule.
Вот подозрительное значение.
Вот source. Вот sink. Вот trace.
Вот sanitizer — или его отсутствие.
Вот контекст кода. Вот признаки достижимости.
Вот шумные зоны: тесты, примеры, генережка.
Вот как похожие случаи решались раньше.
Теперь вынеси вердикт строго по схеме.
После такой нормализации модель уже не рассуждает о finding вообще: она проверяет ограниченный набор признаков и возвращает один из допустимых ответов. В общих чертах конвейер выглядит так:
В рабочем стенде это выглядело подчеркнуто прагматично: локальный inference через vLLM, Qwen2.5-Coder-14B в AWQ, одна RTX 4080 на 16 ГБ, строгий JSON на выходе.
Почему локально? Потому что триаж питается данными, которые нельзя отдавать во внешний API: фрагменты исходников, trace, пути файлов, имена переменных, конфигурация, служебные значения, иногда куски внутренней бизнес-логики. Внешняя модель может быть сильнее, но для закрытого контура она непригодна.
|
Поле |
Зачем нужно |
|
cwe |
Класс риска и базовая логика [2] проверки |
|
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)"]
}
Перед обращением к 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 нужен не для красоты. Это аварийный клапан. Для hardcoded credentials иногда хватает строки, имени переменной, пути файла и формы значения. Для XSS, SQL injection, XXE, path traversal или resource exhaustion одной строки мало — нужны source, sink, sanitizer, поведение [3] фреймворка, путь данных, контекст вызова. Нет этих данных — правильный ответ модели 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
1. Schema validation — JSON соответствует контракту
2. Evidence check — цитаты реально существуют во входе
3. Sanity check по правилам — сильные признаки секрета не исчезают под словом
test4. Порог уверенности — низкая
confidenceведет вunknownили к человеку5. Очередь human review — спорное уходит человеку, а не в автозакрытие
Самым полезным в эксперименте оказалась эволюция [4] промптов. Первая версия была сырой: «проверь 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-тесты после каждой ошибки [5].
Смотреть стоит вот на что:
|
Метрика |
Зачем нужна |
|
precision |
Сколько |
|
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, поэтому воспринимать ее как общую точность системы нельзя. И если сузиться до классов с детерминированными, проверяемыми признаками — хардкод секретов, ключей и паролей, слабая случайность [6] — на этом срезе уже можно обсуждать ограниченный пилот с ручным контролем:
|
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 все может пойти совсем по-другому. Перепродавать результат — последнее дело.
Финальный прогон начался тихо. SAST отдал findings. Context builder собрал evidence. Pre-check отсеял очевидный мусор. Модель получила компактный prompt. vLLM пошевелил памятью [7]. Видеокарта нагрела комнату до температуры легкого 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-build
model: qwen2.5-coder-14b-awq (vLLM, ctx 8192, temp 0.0)
batch: 100 findings
verdicts: confirmed=7 false_positive=44 unknown=49
invalid_json: 0
mean_latency: 3.0 s / finding
status: 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 в интернете
Канва внедрения та же, что на схеме выше: сканер → агрегатор → context builder → эвристический pre-check → локальный LLM-триаж → post-validation → предложение статуса → human review → хранилище метрик → feedback loop. А дальше несколько правил, основанных на реальном опыте [8]:
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 триаж — гадание. Без схемы — болтовня. Без валидации — риск. Без метрик — вера, а без человека — слишком смело для кибербеза.
Под утро город стал серым. Неон выцвел. Видеокарта остыла. Последний лог лежал на экране, как протокол допроса:
run_id: 2026-05-night-build
model: qwen2.5-coder-14b-awq
status: completed
unknown: not_zero
false_positive_reduced: yes
security_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 — документация Хабра по разметке.
Автор: psycho_drake
Источник [9]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/33136
URLs in this post:
[1] забывает: http://www.braintools.ru/article/333
[2] логика: http://www.braintools.ru/article/7640
[3] поведение: http://www.braintools.ru/article/9372
[4] эволюция: http://www.braintools.ru/article/7702
[5] ошибки: http://www.braintools.ru/article/4192
[6] случайность: http://www.braintools.ru/article/6560
[7] памятью: http://www.braintools.ru/article/4140
[8] опыте: http://www.braintools.ru/article/6952
[9] Источник: https://habr.com/ru/companies/rostelecom/articles/1059670/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1059670
Нажмите здесь для печати.