{ "index": 166, "slug": "editorial-2023-05-field-static-analysis", "title": "Шумное правило статического анализа: как принять обратимое решение", "excerpt": "Один результат статического анализа не объясняет, нужно ли менять правило или ограничить только этот сигнал. Разбираем контекст, scope, срок, rollback и проверяемый критерий готовности.", "contentHtml": "
В CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.
\nОбратная ошибка стоит не меньше. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения, а затем перестают доверять анализатору. Результат анализатора — это повод проверить контекст, а не готовый вердикт. В этой статье keep, tune и scoped suppress — названия проектной policy, а не универсальные поля SARIF и не обещание, что любой анализатор понимает эти действия.
SARIF (Static Analysis Results Interchange Format) описывает обмен результатами статического анализа. В записи можно найти инструмент, правило, результат, сообщение, расположение в артефакте и уровень сигнала. Эти поля отвечают на вопросы «какая гипотеза сработала» и «где её обнаружили». Они сами по себе не доказывают, что ветка исполняется, значение пришло от внешнего пользователя или опасный вызов достижим в выпущенном артефакте.
\nУ результата есть ещё одна важная граница: fingerprint нужен системе управления результатами для сопоставления логически одинаковых сигналов между запусками. Спецификация допускает, что fingerprint добавит именно result management system после загрузки отчёта; прямой производитель SARIF обычно не должен выдумывать его без устойчивого алгоритма. Поэтому строка, которую команда вручную назвала fingerprint, не становится стабильным идентификатором только из-за имени.
Возьмём узкую гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую может искать правило. Это не результат реального сканирования и не доказательство уязвимости.
\nfunction runReport(request) {\n const reportName = request.query.name;\n return runShell(\"report --name \" + reportName);\n}\n\n// Отдельно проверяем источник, экранирование,\n// достижимость ветки и фактический sink.\nДля этого совпадения нужны четыре независимых ответа. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, точная формулировка звучит так: «результат требует проверки, контекст неполный».
Начинайте с наблюдаемого результата, а не с предполагаемого исправления. Сохраните ruleId, revision правила, URI, строку, уровень, сообщение и идентификатор сопоставления, если его выдала система управления результатами. Затем пройдите значение от входа до операции, на которую указывает правило. На каждом переходе фиксируйте не впечатление, а проверяемый факт: какой тип данных получен, какая функция вызвана и в какой сборочный путь она попадает.
Граница доверия находится там, где данные переходят из внешнего или неуправляемого источника в код, принимающий решение. Для HTTP-запроса такой границей может быть контроллер; для очереди — consumer; для файла конфигурации — загрузчик и права на файл. Валидация меняет риск, но её наличие нужно подтвердить кодом и тестом. Название функции вроде sanitize не является доказательством корректного экранирования.
| Что видно | Чего не хватает | Проверка | Решение policy |
|---|---|---|---|
| Один результат выглядит безопасным | Источник и trust boundary | Проследить значение до sink и проверить достижимость | keep до завершения triage |
| Сигнал появляется на безопасном API | Правило различает формы слишком грубо | Сравнить intent правила с двумя минимальными примерами | tune с новой revision и diff |
| Один результат мешает выпуску | Точный scope, владелец и срок | Сверить fingerprint, owner, reviewBy и expiresOn | Только scoped suppress |
| Предлагают выключить правило целиком | Оценка будущей потери сигнала | Рассмотреть изменение категории как отдельную policy | Отдельное решение с rollback |
| После изменения непонятен возврат | Повторный запуск на том же commit | Сравнить конфигурацию и новый отчёт | Вернуть узкое исключение или revision |
Если подозрение относится к гипотезе правила, сначала подготовьте два маленьких примера: один должен соответствовать намерению правила, второй — быть безопасной формой, которую оно не должно захватывать. Запускайте одну и ту же версию CLI с одной и той же конфигурацией. Команда ниже показывает общий путь для локального Semgrep-скана; имя конфигурации и каталог замените своими. Она сохраняет SARIF-файл, но не отвечает за достижимость кода или эксплуатацию сигнала.
\nsemgrep --version\nsemgrep scan \\\n --config rules/command-input.yml \\\n --sarif \\\n --output /tmp/command-input.sarif \\\n src/\nПосле запуска сравните не только число строк. Проверьте ruleId, revision, URI и содержимое results. Если CLI обновился, формат дополнительных полей и поддерживаемые опции нужно сверить с документацией именно этой версии. Отдельный diff правила должен объяснять, какой класс безопасных совпадений исключается и какие опасные формы остаются в области проверки.
keep оставляет результат видимым. Выбирайте его, когда контекст ещё не собран или проверка не завершена. Это не признание уязвимости и не отказ от исправления: команда сохраняет наблюдаемость до следующего шага.
tune меняет гипотезу правила. Такое действие оправданно, если правило захватывает форму, которая не соответствует его назначению. Укажите новую revision, покажите минимальный diff и проверьте положительный и отрицательный пример. Не называйте tune снижением false-positive rate без измерения на заранее выбранном наборе кода.
scoped suppress ограничивает один идентифицируемый результат. В нашей policy у него должны быть точный fingerprint, узкий scope, владелец, причина, дата следующей проверки и дата окончания. Suppress не делает код безопасным: он меняет видимость конкретного сигнала. Формат исключения и его область зависят от инструмента, поэтому эти поля нельзя механически перенести в конфигурацию другого анализатора.
Глобальное disable не заменяет ни одно из трёх действий. Оно меняет поведение правила для текущих и будущих результатов. Если проекту действительно нужна такая смена, назовите категорию, оцените потерю сигнала, назначьте владельца, зафиксируйте срок и отдельно опишите способ возврата. Комментарий к одной строке не может быть policy для всей категории.
Evidence — это короткая запись, по которой другой инженер может повторить решение. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание diff. Для scoped suppress добавьте точный fingerprint, scope и expiresOn. Дата следующей проверки не должна быть позже срока окончания исключения.
Причина «шум» ничего не объясняет. Причина должна связывать решение с фактом: «вызов получает массив аргументов после нормализации; правило ожидает конкатенацию строки; оба минимальных примера проверены». В настоящем проекте добавьте ссылку на задачу или commit, но не добавляйте токены, пользовательские данные и полный чувствительный фрагмент кода.
\nДо применения policy проверьте, что неполная запись не проходит. Нужна не проверка непустых строк, а минимальный контракт: разрешены только три действия; suppress требует точного scope и двух корректных дат; tune требует новой revision. Фрагмент ниже запускается в Node.js и работает только с объектом в памяти. Он не читает репозиторий, не запускает сканер и не применяет конфигурацию.
\nnode --input-type=module <<'NODE'\nconst decision = {\n action: 'scoped-suppress',\n owner: 'security-review',\n reason: 'Проверены источник, граница доверия и sink',\n fingerprint: 'result-command-001',\n scope: 'exact-result',\n reviewBy: '2023-05-20',\n expiresOn: '2023-05-27',\n ruleRevision: 'command-input/v3'\n};\n\nconst allowedActions = new Set(['keep', 'tune', 'scoped-suppress']);\nconst isDate = (value) => {\n if (!/^\\\\d{4}-\\\\d{2}-\\\\d{2}$/.test(value)) return false;\n return new Date(value + 'T00:00:00Z').toISOString().startsWith(value);\n};\n\nconst valid =\n allowedActions.has(decision.action) &&\n decision.owner && decision.reason &&\n (decision.action !== 'tune' || decision.ruleRevision !== 'command-input/v2') &&\n (decision.action !== 'scoped-suppress' ||\n (decision.fingerprint && decision.scope === 'exact-result' &&\n isDate(decision.reviewBy) && isDate(decision.expiresOn) &&\n decision.reviewBy <= decision.expiresOn));\n\nconsole.log(valid ? 'plan-valid' : 'plan-invalid');\nNODE\nПоложительный путь печатает plan-valid. Для отрицательной проверки удалите fingerprint, поставьте 2023-02-31, замените scope на общий или используйте действие disable-globally: во всех случаях должен получиться plan-invalid. Сравнение дат строкой безопасно только после строгой проверки формата и календаря, как в примере. Без этого значение вроде 31 февраля может пройти поверхностную проверку.
Rollback — не удаление строки из конфигурации. Для suppress удалите именно это исключение, повторите анализ на том же commit и проверьте, что ожидаемый результат снова виден. Для tune верните прежнюю revision, повторите минимальную пару и сравните отчёты. Если повторный анализ невозможен, зафиксируйте причину и риск, а не называйте возврат завершённым.
\nУ rollback есть ограничение: он возвращает видимость правила или убирает узкое исключение. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным запуском и сверкой baseline.
\nСтатический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF фиксирует структуру обмена, но не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.
\nКоманда Semgrep в примере — ориентир для CLI, а не зафиксированный контракт всех будущих версий. Закрепите версию в CI, сохраните вывод semgrep --version и проверьте опции в документации перед миграцией. Не переносите названия keep, tune и scoped-suppress в инструмент без адаптера и теста его реального формата.
Не заявляйте покрытие, снижение ложных срабатываний или отсутствие пропущенных опасных случаев без измерения на конкретном наборе кода. Если неизвестны источник, граница доверия, владелец или артефакт, оставьте результат видимым. Это честнее, чем скрыть неопределённость глобальным выключателем.
\nРешение готово, когда другой инженер может ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали действие, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune существует новая revision и проверенный diff. Для suppress совпадают fingerprint и scope, даты корректны, а expiry не прошёл. Для rollback есть повторный анализ или явно записана причина, почему он невозможен.
\n