{ "index": 168, "slug": "editorial-2023-05-practice-static-analysis", "title": "Шумное правило статического анализа: как принять решение по одному сигналу", "excerpt": "Статический анализ показывает совпадение, а не готовый вердикт. Разбираем один сигнал по rule, result, location и контексту, затем выбираем проверяемое действие без глобального отключения защиты.", "contentHtml": "
В pull request появляется предупреждение: правило увидело передачу значения в функцию, которая строит команду. Строка выглядит безопасно. Значение приходит из внутреннего объекта, ветка закрыта проверкой, а правило повторяется в десятках файлов. После нескольких таких комментариев команда просит выключить его целиком.
\nСимптом понятен: статический анализ тормозит review и смешивает полезные находки с шумом. Цена ошибки выше, чем время на один комментарий. Глобальное отключение убирает сигнал для следующего участка, который никто ещё не видел. Автоматическое объявление каждой строки уязвимостью создаёт другую проблему: инженеры перестают различать риск и форму совпадения.
\nРабочий тезис простой: результат анализатора — это начало проверки, а не её конец. Сначала нужно отделить правило, результат, позицию и контекст. Потом выбрать одно из трёх действий: оставить сигнал, уточнить правило или временно ограничить один результат. Если контекст не собран, сигнал остаётся видимым.
\nПравило описывает синтаксическую или семантическую гипотезу. Например, оно ищет передачу условно недоверенного значения в runShell. Результат сообщает, что гипотеза совпала в конкретном месте. Позиция даёт URI, строку и иногда отпечаток. Ни одно из этих полей не говорит само по себе, что ветка исполняется, значение действительно приходит извне или команда достигнет production.
SARIF 2.1.0 полезен как формат обмена этими фактами. В нём можно связать инструмент, версию правила, result, location и fingerprint. Формат не добавляет сведения, которых инструмент не собирал. Поэтому ruleId нельзя читать как готовый security verdict, а startLine — как доказательство достижимости.
| Слой | Симптом | Причина | Проверка | Действие |
|---|---|---|---|---|
| Правило | Одинаковый ruleId повторяется в разных модулях | Паттерн шире ожидаемого сценария | Прочитать intent, revision и diff правила | Оставить или уточнить pattern |
| Result | Есть сообщение и строка, но нет решения | Совпадение приняли за вывод о коде | Сверить fingerprint и версию инструмента | Добавить контекст, не ставить verdict |
| Location | Указан URI, но файл уже изменился | Результат относится к другой ревизии | Проверить commit, строку и entry point | Повторить анализ на актуальной ревизии |
| Контекст | Непонятно, откуда пришло значение | Trust boundary не записана | Назначить владельца и назвать источник | Оставить сигнал видимым |
| Решение | Предлагают выключить правило глобально | Точечный результат смешали с политикой | Проверить scope, срок и rollback | Выбрать keep, tune или scoped suppress |
Ниже приведён синтетический фрагмент. Он нужен, чтобы показать границу между совпадением и выводом. Имена файла, строки и правило вымышлены. Пример не читает репозиторий, не запускает анализатор и не доказывает наличие уязвимости.
\nfunction exportReport(request) {\n const command = request.options.command;\n\n if (!ALLOWED_COMMANDS.has(command)) {\n throw new Error('unsupported command');\n }\n\n return runShell(command);\n}\nПравило demo.untrusted-command-construction может отметить вызов runShell(command). Синтаксически это разумный сигнал: функция получает значение, которое прошло через объект запроса. Но по одному совпадению нельзя установить, что request контролирует внешний пользователь, что проверка ALLOWED_COMMANDS корректна или что функция вызывается в интересующем артефакте.
Проверка должна идти по цепочке данных. Нужно найти источник request, определить границу доверия, проверить содержимое allowlist и проследить вызов до entry point. Если любое звено неизвестно, запись должна сказать «контекст неполный». Это точнее, чем «ложное срабатывание»: отсутствие данных не доказывает безопасность.
В учебной модели результат можно представить так: ruleId связывает совпадение с правилом, revision фиксирует его версию, uri и startLine указывают место, а fingerprint помогает сопоставить тот же результат после повторного запуска. Fingerprint не является оценкой риска. Он не заменяет чтение актуального исходника.
Для одного сигнала достаточно короткой context record. Поле asset называет компонент или артефакт. entryPoint показывает, откуда начинается путь. trustBoundary объясняет, почему значение считают недоверенным. owner называет человека или роль, которая может подтвердить устройство компонента. releaseScope связывает решение с ревизией или изменением, а не со всем продуктом.
Эти поля не обязаны быть заполнены сразу. Но неизвестное нужно записать как неизвестное. Если не найден entry point, нельзя утверждать, что код недостижим. Если неясен источник данных, нельзя утверждать, что значение безопасно. Если результат относится к generated code, сначала нужно выяснить, какой исходный файл владеет поведением. Контекст не превращает сигнал в уязвимость, но делает следующий вопрос проверяемым.
\nKeep. Правило и результат остаются видимыми. Это правильный исход, когда риск не исключён или данных ещё не хватает. В комментарии достаточно указать, какое поле контекста отсутствует и кто его проверит.
\nTune. Правило меняют, когда сама гипотеза слишком широка. Например, pattern можно ограничить известным небезопасным sink или потребовать явного признака внешнего источника. Изменение должно получить новую revision и описание того, какие будущие совпадения оно перестанет показывать. «Стало меньше шума» не объясняет trade-off.
\nScoped suppress. Один результат временно исключают, когда правило нужно сохранить, а конкретный участок уже проверен. Исключение должно ссылаться на точный fingerprint или другую устойчивую идентификацию, иметь scope, владельца, причину и дату пересмотра. Срок не должен превращать временное решение в бессрочное разрешение.
\nruleId, revision правила, fingerprint, URI, строку и ревизию исходника. Не добавляйте в запись вывод о безопасности.context-incomplete.disable-globally должно быть отклонено политикой, а неполный контекст не должен превращаться в «безопасно».Suppression решает вопрос об одном уже идентифицированном результате. Tune решает вопрос о гипотезе, которую правило применяет к будущим участкам. Если команда раздаёт исключения там, где pattern неправильно понимает boundary, она сохраняет старую ошибку и постепенно теряет карту покрытия. Если команда переписывает правило ради одного проверенного участка, она может скрыть реальные сигналы в других модулях.
\nНе стоит путать и другой отрицательный путь. Если анализатор показал результат на synthetic fixture, это доказывает только то, что учебный объект соответствует заданной форме. PASS у такого fixture не означает, что scanner читал файл, запускал ветку или получил finding в реальном проекте. Код примера ограничен учебной задачей и не является production-рецептом.
\nСтатический анализ не видит автоматически весь runtime-контекст. Feature flag может скрыть путь. Generated code может отличаться от исходного шаблона. Динамический импорт, конфигурация окружения и права доступа могут изменить достижимость. SARIF сохраняет результат инструмента, но не подтверждает корректность правила, полноту проекта и отсутствие других путей к sink.
\nМетод также не даёт production-метрику. Он не сообщает precision, recall, coverage, число предотвращённых инцидентов или время до исправления. Для таких утверждений нужны отдельные данные: запуски на определённых ревизиях, правила подсчёта и независимая проверка. В этой статье таких измерений нет.
\nРазбор одного сигнала готов, если другой инженер может повторить решение без устного контекста. В записи есть ruleId и revision, точный result, проверенная ревизия исходника, путь от источника до sink, владелец, scope и выбранное действие. Для tune виден diff правила. Для suppress видны идентификатор результата, причина и срок пересмотра. Для keep ясно, какая проверка ещё не выполнена.
\nОтдельно проверьте, что повторный запуск не создаёт новый необъяснимый сигнал, что соседние результаты не исчезли из-за широкого исключения и что rollback можно выполнить отдельным diff. Если одно из этих условий не выполнено, решение ещё не закрыто. Сигнал лучше оставить видимым, чем скрыть неизвестное за удобной зелёной проверкой.
\n