8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 168,
|
||
"slug": "editorial-2023-05-practice-static-analysis",
|
||
"title": "Шумное правило статического анализа: как разобрать сигнал и выбрать действие",
|
||
"excerpt": "Статический анализ сообщает о совпадении, а не выносит готовый вердикт. Разбираем SARIF-результат, восстанавливаем путь данных и выбираем keep, tune или точечное suppress без глобального отключения правила.",
|
||
"contentHtml": "<p>В pull request появляется предупреждение об ошибке: правило увидело значение рядом с функцией, которая запускает команду. Строка выглядит безопасной: значение приходит из внутреннего объекта, выше стоит проверка, а такое сообщение повторяется в десятках файлов. Команда предлагает выключить правило целиком, чтобы review снова стал быстрым.</p>\n<p>Это плохой выбор по двум причинам. Статический анализ мог заметить настоящий путь к опасному sink, а мог увидеть только форму кода, не зная источника данных. Глобальное отключение смешивает эти случаи и убирает следующий сигнал вместе с текущим. Полезная единица работы — не «правило шумное» и не «строка уязвима», а один результат с проверяемым контекстом.</p>\n<p>Ниже — схема разбора для JavaScript и других языков. Она отвечает на один вопрос: что нужно проверить, прежде чем оставить результат, уточнить правило или ограниченно подавить совпадение. Названия анализатора и внутренней системы в примерах условны; поля SARIF соответствуют формату версии 2.1.0.</p>\n<h2>Сначала отделите совпадение от вывода</h2>\n<p>Правило задаёт гипотезу: например, «значение из потенциально недоверенного источника попало в функцию, которая может выполнять команду». Результат сообщает, что инструмент нашёл подходящую форму в конкретной ревизии. Это полезное наблюдение, но оно не доказывает, что внешний пользователь управляет значением, ветка достижима или команда дойдёт до production.</p>\n<p>SARIF стандартизирует обмен результатами анализа. В объекте результата могут быть <code>ruleId</code>, сообщение, <code>locations</code> с артефактом и регионом, а также <code>partialFingerprints</code> для корреляции между запусками. Формат описывает данные, которые собрал инструмент или система результатов; он не исправляет неточность правила и не добавляет отсутствующий runtime-контекст.</p>\n<div class='table-scroll'><table><caption>Четыре слоя проверки одного результата</caption><thead><tr><th scope='col'>Слой</th><th scope='col'>Что ищем</th><th scope='col'>Как проверить</th><th scope='col'>Ошибка в решении</th></tr></thead><tbody><tr><td>Rule</td><td>Идентификатор, версия и условие совпадения</td><td>Открыть описание и diff правила; понять язык, sink и source</td><td>Считать <code>ruleId</code> оценкой риска</td></tr><tr><td>Result</td><td>Сообщение, уровень и связь с запуском</td><td>Сверить инструмент, ревизию и повторяемость результата</td><td>Назвать результат уязвимостью без проверки кода</td></tr><tr><td>Location</td><td>URI, строка, столбец или логическое имя</td><td>Открыть ту же ревизию исходника и проверить соседние строки</td><td>Довериться старой строке после изменения файла</td></tr><tr><td>Context</td><td>Источник, граница доверия, entry point и владелец</td><td>Проследить данные до sink и записать неизвестные звенья</td><td>Объявить «false positive», когда данных не хватает</td></tr></tbody></table></div>\n<figure><img src='/assets/editorial/2023/static-analysis-2023-triage-funnel.svg' alt='Воронка разбора сигнала статического анализа: rule и SARIF result проверяются по форме, location и контексту, после чего выбираются keep, tune или ограниченное suppress' loading='lazy' /><figcaption>Порядок triage: сначала идентификация результата и его места, затем путь данных, владелец и точный scope действия. Глобальное отключение находится за пределами обычного разбора одного результата.</figcaption></figure>\n<h2>Минимальный SARIF, который можно прочитать</h2>\n<p>Начните с артефакта, а не со скриншота комментария в review. Для проверки структуры достаточно сохранить ответ анализатора в файл <code>result.sarif</code>. Следующий фрагмент специально мал: в нём есть инструмент, правило, результат, место и частичный отпечаток. Значения <code>demo.untrusted-command/v1</code> и <code>src/export.js</code> придуманы для примера и не являются выводом конкретного scanner.</p>\n<pre><code>{\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}</code></pre>\n<p>Здесь <code>level</code> — уровень, который выбрал инструмент, а не универсальная шкала ущерба. <code>startLine</code> помогает открыть место, но не подтверждает достижимость. Частичный отпечаток помогает системе сопоставлять результаты; он не доказывает, что два похожих сообщения относятся к одной причине. Официальная спецификация SARIF отдельно предупреждает, что абсолютный номер строки плохо подходит для устойчивого fingerprint.</p>\n<h2>Воспроизводимая проверка без доверия к интерфейсу</h2>\n<p>После сохранения результата выполните команду в каталоге проекта. Она проверит JSON и напечатает для каждого результата правило, URI и строку:</p>\n<pre><code>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</code></pre>\n<p>Ожидаемый вывод для примера — <code>demo.untrusted-command src/export.js 8</code>. Команда проверяет только читаемость и наличие нескольких полей. Она не запускает анализатор, не открывает исходник и не устанавливает безопасность. Для настоящего результата дополнительно зафиксируйте commit, имя инструмента, версию правил и команду запуска. Иначе при повторе можно незаметно сравнить разные ревизии.</p>\n<h2>Восстановите путь данных до sink</h2>\n<p>В отмеченной строке найдите не только аргумент функции, но и его происхождение. Запишите пять точек: source, преобразования, проверку, sink и entry point. Источник может быть HTTP-параметром, сообщением очереди, конфигурацией или внутренней таблицей. «Внутренний объект» — не доказательство доверия: его поля могли быть заполнены раньше из внешнего ввода.</p>\n<pre><code>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}</code></pre>\n<p>Правило вправе отметить последний вызов. Но для решения нужно проверить, кто создаёт <code>request</code>, что именно входит в <code>ALLOWED_COMMANDS</code>, можно ли изменить объект после проверки и вызывается ли функция из внешнего entry point. Если хотя бы одно звено неизвестно, статус должен быть «контекст не собран», а не «безопасно».</p>\n<p>Если проверка подтверждает риск, исправляйте границу данных, а не комментарий анализатору. Для фиксированного набора операций безопаснее сопоставить внешний ключ с заранее заданными исполняемым файлом и аргументами, чем собирать shell-строку:</p>\n<pre><code>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 });</code></pre>\n<p>Это только иллюстрация для Node.js: абсолютный путь, фиксированные аргументы и <code>shell: false</code> не заменяют проверку прав, окружения, таймаута и обработки ошибок. Документация Node.js предупреждает не передавать непроверенный ввод при включённом shell. На другой платформе и для другого API нужны собственные ограничения.</p>\n<h2>Выберите одно из трёх действий</h2>\n<p><strong>Keep</strong> оставляет результат видимым. Это нормальный исход, когда путь данных опасен или контекст ещё не собран. В записи укажите конкретную недостающую проверку и владельца следующего шага.</p>\n<p><strong>Tune</strong> меняет гипотезу правила. Например, правило можно сузить до подтверждённого sink или потребовать явный признак внешнего источника. У нового варианта должна быть версия, описание изменения и список совпадений, которые теперь перестанут появляться. Снижение количества сообщений само по себе не доказывает улучшение.</p>\n<p><strong>Scoped suppress</strong> ограничивает уже разобранный результат. Укажите устойчивый идентификатор, файл или иной точный scope, причину, владельца и дату пересмотра. Исключение должно быть обратимым отдельным diff. В разных системах действие называется по-разному: например, Semgrep различает ignored и fixed и предлагает указывать причины вроде false positive, acceptable risk или no time to fix. Их нельзя переносить в другую систему без проверки её политики.</p>\n<h2>Порядок разбора в pull request</h2>\n<ol><li>Скачайте или сохраните SARIF для конкретного commit. Зафиксируйте инструмент, версию правила, <code>ruleId</code>, сообщение, URI, регион и fingerprint.</li><li>Откройте описание правила и его revision. Сформулируйте одним предложением, какое условие породило результат.</li><li>Проверьте location в той же ревизии. Если файл изменился, повторите анализ; не исправляйте старый номер строки вручную.</li><li>Проследите source → преобразования → проверка → sink → entry point. Для каждой точки запишите факт или неизвестность.</li><li>Назначьте владельца проверки и scope изменения. Generated-файл связывайте с исходным шаблоном, а не объявляйте автоматически безопасным.</li><li>Выберите keep, tune или scoped suppress. Для suppress запишите причину и review-by; для tune сохраните diff и ожидаемую потерю покрытия.</li><li>Повторите анализ на актуальном commit и проверьте два отрицательных сценария: соседний опасный путь всё ещё виден, а глобальная команда <code>disable-globally</code> не прошла вместо точечного решения.</li></ol>\n<h2>Почему глобальное отключение скрывает проблему</h2>\n<p>Глобальное отключение отвечает на вопрос «нужно ли показывать будущие совпадения этого правила?» и потому имеет масштаб всего проекта или pipeline. Разбор одного результата отвечает на другой вопрос: «что произошло в конкретном месте и что с ним делать?» Эти решения нельзя подменять друг другом.</p>\n<p>Если pattern широк, tune исправляет гипотезу для будущих запусков. Если конкретный участок проверен и правило всё ещё нужно в остальных местах, scoped suppress ограничивает исключение. Если данных не хватает, keep сохраняет сигнал и делает пробел видимым. Раздавать suppress там, где на самом деле неверна модель source/sink, значит терять карту покрытия. Переписывать правило ради одного проверенного участка — значит рисковать соседними результатами.</p>\n<h2>Ограничения применимости</h2>\n<p>Эта схема подходит для triage результатов статического анализа в review и CI, когда доступны исходник, ревизия, описание правила и владелец кода. Она не заменяет динамический тест, threat modeling, ручной security review или проверку разрешений в рабочем окружении.</p>\n<p>Достижимость может зависеть от feature flag, конфигурации, динамического импорта, сгенерированного кода и прав пользователя. Taint-анализ может потерять связь при неизвестном преобразовании. Линтер может проверять только синтаксическую форму. SARIF может не содержать физической строки или может ссылаться на артефакт, которого нет в текущем checkout. В каждом случае утверждение нужно ограничивать тем, что действительно проверено.</p>\n<p>Статья не сообщает precision, recall, coverage, число предотвращённых инцидентов или экономию времени: для таких чисел нужны определение выборки, версии запусков и отдельный измерительный отчёт. Учебные JSON и JavaScript выше показывают форму проверки, но не являются результатом сканирования реального проекта.</p>\n<h2>Критерий готового решения</h2>\n<p>Разбор закончен, когда другой инженер может повторить его без устного объяснения. Есть исходная ревизия, идентификатор правила, точный результат и location; путь данных описан до entry point и sink; неизвестные отмечены; владелец и scope назначены. Для tune виден diff правила и ожидаемая потеря совпадений. Для suppress видны причина, точный идентификатор и дата пересмотра.</p>\n<p>Последний контроль — повторный запуск. Сигнал должен либо исчезнуть из-за исправления, либо остаться с объяснимой причиной. Соседние результаты не должны пропасть из-за широкого исключения, а rollback должен быть отдельным понятным изменением. Пока эти условия не выполнены, видимый сигнал полезнее зелёной проверки без доказательств.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html' target='_blank' rel='noopener noreferrer'>OASIS: SARIF Version 2.1.0 Plus Errata 01</a> — спецификация объектов result, ruleId, locations и partialFingerprints; текущая статья использует формат как контракт данных, а не как оценку риска.</li><li><a href='https://semgrep.dev/docs/for-developers/resolve-findings-through-app' target='_blank' rel='noopener noreferrer'>Semgrep: Resolve findings through Semgrep AppSec Platform</a> — официальное описание состояний finding, причин игнорирования и поведения результатов между ветками; названия действий зависят от конкретной системы.</li><li><a href='https://nodejs.org/api/child_process.html' target='_blank' rel='noopener noreferrer'>Node.js: Child process</a> — официальная документация о <code>spawn</code>, параметре <code>shell</code> и запрете передавать непроверенный ввод shell-интерпретатору.</li><li><a href='https://csrc.nist.gov/projects/ssdf' target='_blank' rel='noopener noreferrer'>NIST: Secure Software Development Framework</a> — официальная риск-ориентированная рамка безопасной разработки; она не подтверждает качество конкретного правила и не заменяет проверку результата.</li></ul>"
|
||
}
|