{ "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

Сначала отделите совпадение от вывода

\n

Правило задаёт гипотезу: например, «значение из потенциально недоверенного источника попало в функцию, которая может выполнять команду». Результат сообщает, что инструмент нашёл подходящую форму в конкретной ревизии. Это полезное наблюдение, но оно не доказывает, что внешний пользователь управляет значением, ветка достижима или команда дойдёт до production.

\n

SARIF стандартизирует обмен результатами анализа. В объекте результата могут быть ruleId, сообщение, locations с артефактом и регионом, а также partialFingerprints для корреляции между запусками. Формат описывает данные, которые собрал инструмент или система результатов; он не исправляет неточность правила и не добавляет отсутствующий runtime-контекст.

\n
Четыре слоя проверки одного результата
СлойЧто ищемКак проверитьОшибка в решении
RuleИдентификатор, версия и условие совпаденияОткрыть описание и diff правила; понять язык, sink и sourceСчитать ruleId оценкой риска
ResultСообщение, уровень и связь с запускомСверить инструмент, ревизию и повторяемость результатаНазвать результат уязвимостью без проверки кода
LocationURI, строка, столбец или логическое имяОткрыть ту же ревизию исходника и проверить соседние строкиДовериться старой строке после изменения файла
ContextИсточник, граница доверия, entry point и владелецПроследить данные до sink и записать неизвестные звеньяОбъявить «false positive», когда данных не хватает
\n
Воронка разбора сигнала статического анализа: rule и SARIF result проверяются по форме, location и контексту, после чего выбираются keep, tune или ограниченное suppress
Порядок triage: сначала идентификация результата и его места, затем путь данных, владелец и точный scope действия. Глобальное отключение находится за пределами обычного разбора одного результата.
\n

Минимальный SARIF, который можно прочитать

\n

Начните с артефакта, а не со скриншота комментария в review. Для проверки структуры достаточно сохранить ответ анализатора в файл result.sarif. Следующий фрагмент специально мал: в нём есть инструмент, правило, результат, место и частичный отпечаток. Значения demo.untrusted-command/v1 и src/export.js придуманы для примера и не являются выводом конкретного scanner.

\n
{\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.

\n

Воспроизводимая проверка без доверия к интерфейсу

\n

После сохранения результата выполните команду в каталоге проекта. Она проверит JSON и напечатает для каждого результата правило, URI и строку:

\n
node -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, имя инструмента, версию правил и команду запуска. Иначе при повторе можно незаметно сравнить разные ревизии.

\n

Восстановите путь данных до sink

\n

В отмеченной строке найдите не только аргумент функции, но и его происхождение. Запишите пять точек: source, преобразования, проверку, sink и entry point. Источник может быть HTTP-параметром, сообщением очереди, конфигурацией или внутренней таблицей. «Внутренний объект» — не доказательство доверия: его поля могли быть заполнены раньше из внешнего ввода.

\n
function 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. Если хотя бы одно звено неизвестно, статус должен быть «контекст не собран», а не «безопасно».

\n

Если проверка подтверждает риск, исправляйте границу данных, а не комментарий анализатору. Для фиксированного набора операций безопаснее сопоставить внешний ключ с заранее заданными исполняемым файлом и аргументами, чем собирать shell-строку:

\n
const 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 нужны собственные ограничения.

\n

Выберите одно из трёх действий

\n

Keep оставляет результат видимым. Это нормальный исход, когда путь данных опасен или контекст ещё не собран. В записи укажите конкретную недостающую проверку и владельца следующего шага.

\n

Tune меняет гипотезу правила. Например, правило можно сузить до подтверждённого sink или потребовать явный признак внешнего источника. У нового варианта должна быть версия, описание изменения и список совпадений, которые теперь перестанут появляться. Снижение количества сообщений само по себе не доказывает улучшение.

\n

Scoped suppress ограничивает уже разобранный результат. Укажите устойчивый идентификатор, файл или иной точный scope, причину, владельца и дату пересмотра. Исключение должно быть обратимым отдельным diff. В разных системах действие называется по-разному: например, Semgrep различает ignored и fixed и предлагает указывать причины вроде false positive, acceptable risk или no time to fix. Их нельзя переносить в другую систему без проверки её политики.

\n

Порядок разбора в pull request

\n
  1. Скачайте или сохраните SARIF для конкретного commit. Зафиксируйте инструмент, версию правила, ruleId, сообщение, URI, регион и fingerprint.
  2. Откройте описание правила и его revision. Сформулируйте одним предложением, какое условие породило результат.
  3. Проверьте location в той же ревизии. Если файл изменился, повторите анализ; не исправляйте старый номер строки вручную.
  4. Проследите source → преобразования → проверка → sink → entry point. Для каждой точки запишите факт или неизвестность.
  5. Назначьте владельца проверки и scope изменения. Generated-файл связывайте с исходным шаблоном, а не объявляйте автоматически безопасным.
  6. Выберите keep, tune или scoped suppress. Для suppress запишите причину и review-by; для tune сохраните diff и ожидаемую потерю покрытия.
  7. Повторите анализ на актуальном commit и проверьте два отрицательных сценария: соседний опасный путь всё ещё виден, а глобальная команда disable-globally не прошла вместо точечного решения.
\n

Почему глобальное отключение скрывает проблему

\n

Глобальное отключение отвечает на вопрос «нужно ли показывать будущие совпадения этого правила?» и потому имеет масштаб всего проекта или pipeline. Разбор одного результата отвечает на другой вопрос: «что произошло в конкретном месте и что с ним делать?» Эти решения нельзя подменять друг другом.

\n

Если pattern широк, tune исправляет гипотезу для будущих запусков. Если конкретный участок проверен и правило всё ещё нужно в остальных местах, scoped suppress ограничивает исключение. Если данных не хватает, keep сохраняет сигнал и делает пробел видимым. Раздавать suppress там, где на самом деле неверна модель source/sink, значит терять карту покрытия. Переписывать правило ради одного проверенного участка — значит рисковать соседними результатами.

\n

Ограничения применимости

\n

Эта схема подходит для triage результатов статического анализа в review и CI, когда доступны исходник, ревизия, описание правила и владелец кода. Она не заменяет динамический тест, threat modeling, ручной security review или проверку разрешений в рабочем окружении.

\n

Достижимость может зависеть от feature flag, конфигурации, динамического импорта, сгенерированного кода и прав пользователя. Taint-анализ может потерять связь при неизвестном преобразовании. Линтер может проверять только синтаксическую форму. SARIF может не содержать физической строки или может ссылаться на артефакт, которого нет в текущем checkout. В каждом случае утверждение нужно ограничивать тем, что действительно проверено.

\n

Статья не сообщает precision, recall, coverage, число предотвращённых инцидентов или экономию времени: для таких чисел нужны определение выборки, версии запусков и отдельный измерительный отчёт. Учебные JSON и JavaScript выше показывают форму проверки, но не являются результатом сканирования реального проекта.

\n

Критерий готового решения

\n

Разбор закончен, когда другой инженер может повторить его без устного объяснения. Есть исходная ревизия, идентификатор правила, точный результат и location; путь данных описан до entry point и sink; неизвестные отмечены; владелец и scope назначены. Для tune виден diff правила и ожидаемая потеря совпадений. Для suppress видны причина, точный идентификатор и дата пересмотра.

\n

Последний контроль — повторный запуск. Сигнал должен либо исчезнуть из-за исправления, либо остаться с объяснимой причиной. Соседние результаты не должны пропасть из-за широкого исключения, а rollback должен быть отдельным понятным изменением. Пока эти условия не выполнены, видимый сигнал полезнее зелёной проверки без доказательств.

\n

Проверяемые источники

\n" }