{ "index": 168, "slug": "editorial-2023-05-practice-static-analysis", "title": "Шумное правило статического анализа: как разобрать сигнал и выбрать действие", "excerpt": "Статический анализ сообщает о совпадении, а не выносит готовый вердикт. Разбираем SARIF-результат, восстанавливаем путь данных и выбираем keep, tune или точечное suppress без глобального отключения правила.", "contentHtml": "
В pull request появляется предупреждение об ошибке: правило увидело значение рядом с функцией, которая запускает команду. Строка выглядит безопасной: значение приходит из внутреннего объекта, выше стоит проверка, а такое сообщение повторяется в десятках файлов. Команда предлагает выключить правило целиком, чтобы review снова стал быстрым.
\nЭто плохой выбор по двум причинам. Статический анализ мог заметить настоящий путь к опасному sink, а мог увидеть только форму кода, не зная источника данных. Глобальное отключение смешивает эти случаи и убирает следующий сигнал вместе с текущим. Полезная единица работы — не «правило шумное» и не «строка уязвима», а один результат с проверяемым контекстом.
\nНиже — схема разбора для JavaScript и других языков. Она отвечает на один вопрос: что нужно проверить, прежде чем оставить результат, уточнить правило или ограниченно подавить совпадение. Названия анализатора и внутренней системы в примерах условны; поля SARIF соответствуют формату версии 2.1.0.
\nПравило задаёт гипотезу: например, «значение из потенциально недоверенного источника попало в функцию, которая может выполнять команду». Результат сообщает, что инструмент нашёл подходящую форму в конкретной ревизии. Это полезное наблюдение, но оно не доказывает, что внешний пользователь управляет значением, ветка достижима или команда дойдёт до production.
\nSARIF стандартизирует обмен результатами анализа. В объекте результата могут быть ruleId, сообщение, locations с артефактом и регионом, а также partialFingerprints для корреляции между запусками. Формат описывает данные, которые собрал инструмент или система результатов; он не исправляет неточность правила и не добавляет отсутствующий runtime-контекст.
| Слой | Что ищем | Как проверить | Ошибка в решении |
|---|---|---|---|
| Rule | Идентификатор, версия и условие совпадения | Открыть описание и diff правила; понять язык, sink и source | Считать ruleId оценкой риска |
| Result | Сообщение, уровень и связь с запуском | Сверить инструмент, ревизию и повторяемость результата | Назвать результат уязвимостью без проверки кода |
| Location | URI, строка, столбец или логическое имя | Открыть ту же ревизию исходника и проверить соседние строки | Довериться старой строке после изменения файла |
| Context | Источник, граница доверия, entry point и владелец | Проследить данные до sink и записать неизвестные звенья | Объявить «false positive», когда данных не хватает |
Начните с артефакта, а не со скриншота комментария в review. Для проверки структуры достаточно сохранить ответ анализатора в файл result.sarif. Следующий фрагмент специально мал: в нём есть инструмент, правило, результат, место и частичный отпечаток. Значения demo.untrusted-command/v1 и src/export.js придуманы для примера и не являются выводом конкретного scanner.
{\n "version": "2.1.0",\n "runs": [{\n "tool": {\n "driver": {\n "name": "demo-scanner",\n "rules": [{ "id": "demo.untrusted-command" }]\n }\n },\n "results": [{\n "ruleId": "demo.untrusted-command",\n "level": "warning",\n "message": { "text": "value reaches a command sink" },\n "locations": [{\n "physicalLocation": {\n "artifactLocation": { "uri": "src/export.js" },\n "region": { "startLine": 8 }\n }\n }],\n "partialFingerprints": {\n "demo.untrusted-command/v1": "example-fingerprint"\n }\n }]\n }]\n}\nЗдесь level — уровень, который выбрал инструмент, а не универсальная шкала ущерба. startLine помогает открыть место, но не подтверждает достижимость. Частичный отпечаток помогает системе сопоставлять результаты; он не доказывает, что два похожих сообщения относятся к одной причине. Официальная спецификация SARIF отдельно предупреждает, что абсолютный номер строки плохо подходит для устойчивого fingerprint.
После сохранения результата выполните команду в каталоге проекта. Она проверит JSON и напечатает для каждого результата правило, URI и строку:
\nnode -e "const fs=require('node:fs'); const x=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const rs=(x.runs||[]).flatMap(r=>r.results||[]); for (const r of rs) { const p=r.locations?.[0]?.physicalLocation; console.log([r.ruleId || '<no-rule>', p?.artifactLocation?.uri || '<no-uri>', p?.region?.startLine || '?'].join('\t')); }" result.sarif\nОжидаемый вывод для примера — demo.untrusted-command src/export.js 8. Команда проверяет только читаемость и наличие нескольких полей. Она не запускает анализатор, не открывает исходник и не устанавливает безопасность. Для настоящего результата дополнительно зафиксируйте commit, имя инструмента, версию правил и команду запуска. Иначе при повторе можно незаметно сравнить разные ревизии.
В отмеченной строке найдите не только аргумент функции, но и его происхождение. Запишите пять точек: source, преобразования, проверку, sink и entry point. Источник может быть HTTP-параметром, сообщением очереди, конфигурацией или внутренней таблицей. «Внутренний объект» — не доказательство доверия: его поля могли быть заполнены раньше из внешнего ввода.
\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Правило вправе отметить последний вызов. Но для решения нужно проверить, кто создаёт request, что именно входит в ALLOWED_COMMANDS, можно ли изменить объект после проверки и вызывается ли функция из внешнего entry point. Если хотя бы одно звено неизвестно, статус должен быть «контекст не собран», а не «безопасно».
Если проверка подтверждает риск, исправляйте границу данных, а не комментарий анализатору. Для фиксированного набора операций безопаснее сопоставить внешний ключ с заранее заданными исполняемым файлом и аргументами, чем собирать shell-строку:
\nconst commands = new Map([\n ['weekly', { file: '/usr/bin/report-export', args: ['--weekly'] }],\n]);\n\nconst selected = commands.get(request.options.command);\nif (!selected) throw new Error('unsupported command');\nreturn spawn(selected.file, selected.args, { shell: false });\nЭто только иллюстрация для Node.js: абсолютный путь, фиксированные аргументы и shell: false не заменяют проверку прав, окружения, таймаута и обработки ошибок. Документация Node.js предупреждает не передавать непроверенный ввод при включённом shell. На другой платформе и для другого API нужны собственные ограничения.
Keep оставляет результат видимым. Это нормальный исход, когда путь данных опасен или контекст ещё не собран. В записи укажите конкретную недостающую проверку и владельца следующего шага.
\nTune меняет гипотезу правила. Например, правило можно сузить до подтверждённого sink или потребовать явный признак внешнего источника. У нового варианта должна быть версия, описание изменения и список совпадений, которые теперь перестанут появляться. Снижение количества сообщений само по себе не доказывает улучшение.
\nScoped suppress ограничивает уже разобранный результат. Укажите устойчивый идентификатор, файл или иной точный scope, причину, владельца и дату пересмотра. Исключение должно быть обратимым отдельным diff. В разных системах действие называется по-разному: например, Semgrep различает ignored и fixed и предлагает указывать причины вроде false positive, acceptable risk или no time to fix. Их нельзя переносить в другую систему без проверки её политики.
\nruleId, сообщение, URI, регион и fingerprint.disable-globally не прошла вместо точечного решения.Глобальное отключение отвечает на вопрос «нужно ли показывать будущие совпадения этого правила?» и потому имеет масштаб всего проекта или pipeline. Разбор одного результата отвечает на другой вопрос: «что произошло в конкретном месте и что с ним делать?» Эти решения нельзя подменять друг другом.
\nЕсли pattern широк, tune исправляет гипотезу для будущих запусков. Если конкретный участок проверен и правило всё ещё нужно в остальных местах, scoped suppress ограничивает исключение. Если данных не хватает, keep сохраняет сигнал и делает пробел видимым. Раздавать suppress там, где на самом деле неверна модель source/sink, значит терять карту покрытия. Переписывать правило ради одного проверенного участка — значит рисковать соседними результатами.
\nЭта схема подходит для triage результатов статического анализа в review и CI, когда доступны исходник, ревизия, описание правила и владелец кода. Она не заменяет динамический тест, threat modeling, ручной security review или проверку разрешений в рабочем окружении.
\nДостижимость может зависеть от feature flag, конфигурации, динамического импорта, сгенерированного кода и прав пользователя. Taint-анализ может потерять связь при неизвестном преобразовании. Линтер может проверять только синтаксическую форму. SARIF может не содержать физической строки или может ссылаться на артефакт, которого нет в текущем checkout. В каждом случае утверждение нужно ограничивать тем, что действительно проверено.
\nСтатья не сообщает precision, recall, coverage, число предотвращённых инцидентов или экономию времени: для таких чисел нужны определение выборки, версии запусков и отдельный измерительный отчёт. Учебные JSON и JavaScript выше показывают форму проверки, но не являются результатом сканирования реального проекта.
\nРазбор закончен, когда другой инженер может повторить его без устного объяснения. Есть исходная ревизия, идентификатор правила, точный результат и location; путь данных описан до entry point и sink; неизвестные отмечены; владелец и scope назначены. Для tune виден diff правила и ожидаемая потеря совпадений. Для suppress видны причина, точный идентификатор и дата пересмотра.
\nПоследний контроль — повторный запуск. Сигнал должен либо исчезнуть из-за исправления, либо остаться с объяснимой причиной. Соседние результаты не должны пропасть из-за широкого исключения, а rollback должен быть отдельным понятным изменением. Пока эти условия не выполнены, видимый сигнал полезнее зелёной проверки без доказательств.
\nspawn, параметре shell и запрете передавать непроверенный ввод shell-интерпретатору.