diff --git a/editorial/agent-rewrites/167.json b/editorial/agent-rewrites/167.json index 3775977..07ed509 100644 --- a/editorial/agent-rewrites/167.json +++ b/editorial/agent-rewrites/167.json @@ -1,7 +1,7 @@ { "index": 167, "slug": "editorial-2023-05-mechanism-static-analysis", - "title": "SARIF без контекста: как читать результат статического анализа", - "excerpt": "SARIF переносит результат проверки, но не принимает решение за команду. Разбираем границы между правилом, совпадением, строкой в коде и контекстом, который нужен для действия.", - "contentHtml": "
В pull request появляется результат статического анализа. В нём есть ruleId, сообщение, URI файла и номер строки. Один инженер предлагает заблокировать слияние. Другой называет результат ложным срабатыванием и хочет отключить правило. Оба решения преждевременны: файл описывает наблюдение инструмента, но не объясняет, что происходит в приложении.
Цена ошибки зависит от выбранного обхода. Глобальное отключение убирает сигнал и для следующих участков кода. Безусловное блокирование превращает каждое совпадение формы в аварию. Команда тратит время на споры, а важный результат может затеряться среди шумных комментариев. Нужна простая граница: формат хранит данные, правило формулирует гипотезу, результат указывает на совпадение, а решение требует контекста проекта.
\nSARIF 2.1.0 — формат обмена результатами статического анализа. В нём можно передать версию формата, инструмент, набор правил, результат и позицию в артефакте. Это общий контейнер для CI, анализатора и просмотрщика. Он не знает, является ли участок достижимым в нужном релизе, кто владеет компонентом и разрешено ли исключение в конкретной команде.
\nПравило задаёт проверяемую гипотезу. Например: «значение из условно недоверенного источника передали в построение команды». Совпадение с шаблоном показывает только то, что форма кода похожа на гипотезу. Оно не доказывает источник значения, исполнение ветки или наличие уязвимости.
\nРезультат связывает гипотезу с наблюдением. ruleId показывает, какое правило сработало. message объясняет, что заметил инструмент. fingerprint помогает сопоставить результат между запусками. location указывает на файл и строку. Эти поля нужны для навигации и повторной проверки. Они не заменяют проверку исходника и границ данных.
| Слой | Пример данных | Что это означает | Чего не доказывает |
|---|---|---|---|
| Формат | version: 2.1.0 | Как читать log | Качество проверки |
| Правило | id, revision, level | Какая гипотеза задана | Риск именно в этом месте |
| Результат | ruleId, message, fingerprint | Какое совпадение найдено | Достижимость и влияние |
| Позиция | URI и номер строки | Где искать наблюдение | Что код исполняется |
| Контекст | asset, boundary, owner, scope | В каких условиях принимать решение | Полное покрытие сценариев |
Чтобы выбрать действие, добавьте к результату пять полей. asset называет компонент или поток данных. entryPoint показывает предполагаемую точку входа. trustBoundary фиксирует, почему значение считают недоверенным. owner указывает роль или человека, который может подтвердить устройство компонента. releaseScope связывает проверку с изменением, веткой или релизом.
Поле может быть неизвестно. Тогда запишите это прямо. Если не найден entry point, статус должен быть «контекст неполный», а не «безопасно». Если неизвестна граница доверия, нельзя объявлять значение проверенным. Такая запись сохраняет отрицательный путь: отсутствие доказательств не превращается ни в finding, ни в false positive.
\nНиже приведён искусственный объект в памяти. Он не читает файл, не запускает Semgrep, не вызывает shell и не описывает настоящий finding. Значения src/demo-command.js, строки и fingerprint нужны только для показа связей между полями.
const result = {\n ruleId: 'demo.untrusted-command-construction',\n message: { text: 'Проверить передачу значения в команду' },\n partialFingerprints: {\n primaryLocationLineHash: 'demo-fingerprint-001'\n },\n locations: [{\n physicalLocation: {\n artifactLocation: { uri: 'src/demo-command.js' },\n region: { startLine: 14 }\n }\n }]\n};\n\nconst context = {\n asset: 'demo-export-job',\n entryPoint: 'demo-http-handler',\n trustBoundary: 'demo-request-parameter',\n owner: 'demo-security-owner',\n releaseScope: 'demo-change-2023-05'\n};\nОбъект результата отвечает на вопрос «что и где совпало». Контекст отвечает на вопрос «какие условия нужно проверить перед действием». В примере нет исходного файла и нет доказательства, что строка исполняется. Поэтому допустимый вывод ограничен: нужно открыть соответствующую ревизию кода, проверить поток значения и подтвердить владельца.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один ruleId повторяется в разных файлах | Синтаксическая форма шире проектного контекста | Сверить intent и revision правила, затем проверить источники значений | Оставить сигнал или уточнить правило с новой revision |
| В сообщении есть строка, но нет решения | Location приняли за доказательство исполнения | Проверить актуальную ревизию, entry point и достижимость ветки | Записать контекст; не повышать result до вердикта |
| Команда хочет убрать правило целиком | Шум одного результата смешали с политикой для всех файлов | Сравнить scope исключения с областью будущих результатов | Выбрать точечное исключение или изменить pattern |
| Результат исчез после обновления | Нет fingerprint и версии правила в записи review | Сопоставить base commit, tool version и revision | Повторить проверку и сохранить исходный result |
| Никто не подтверждает безопасность | У контекста нет owner или trust boundary | Назначить владельца и явно отметить неизвестные поля | Оставить result видимым до получения evidence |
ruleId, revision анализатора, fingerprint, URI, строку и commit. Не добавляйте вывод о риске, которого нет в данных.Строка в SARIF может устареть между анализом и review. Файл мог измениться, ветка могла не попасть в релиз, а код мог быть недостижимым при нужной конфигурации. Поэтому location — это адрес для проверки, а не доказательство runtime-пути.
\nSeverity тоже не является итоговой оценкой. Уровень правила задаёт ожидаемую реакцию инструмента. Он не учитывает бизнес-ценность asset, права вызывающего кода, компенсирующие проверки и область релиза. Переносить его напрямую в слово «критично» нельзя.
\nFingerprint полезен для повторного review, но это не score риска. Он помогает увидеть, что один результат сохранился, переместился или исчез. Причину изменения нужно искать в diff, версии правила и коде, а не в самом fingerprint.
\nРазделение слоёв не даёт гарантии, что анализатор найдёт все ошибки. SARIF может быть неполным или заполненным по-разному разными producer. Static analysis может не знать о динамической загрузке, feature flag, сгенерированном коде и runtime-конфигурации. Контекстная запись не заменяет тест, ручной data-flow review, проверку доступа или воспроизводимый запуск инструмента.
\nНе каждое правило стоит расширять. Более широкий pattern может поднять шум и увеличить стоимость review. Не каждое исключение стоит запрещать. Узкое, временное исключение с понятным объектом иногда лучше, чем изменение общего правила ради одного безопасного участка. Важны область действия, владелец, причина и дата повторной проверки.
\nПример в этой статье синтетический. Он проверяет только смысл полей и порядок рассуждения. Он не сообщает число срабатываний, coverage, false-positive rate, production effect или факт запуска в каком-либо репозитории.
\nРезультат можно передавать в review, когда выполнены четыре условия: правило и его revision известны; location проверена на актуальном commit; asset, trust boundary и owner записаны либо явно отмечены как неизвестные; выбранное действие имеет scope и способ отмены. Для tune должна существовать новая revision и описание изменённой гипотезы. Для scoped suppress нужны точный объект результата, причина и дата пересмотра.
\nЕсли хотя бы одно условие не выполнено, готовый статус — «контекст не собран». Это проверяемый результат: указан недостающий факт, назначен владелец и определён следующий шаг. Такой статус сохраняет сигнал и не обещает того, чего не подтверждают данные.
\nВ pull request статический анализатор показывает несколько ошибок. Один разработчик предлагает сразу заблокировать слияние, другой — отключить правило целиком. Оба решения могут быть ошибочными. Отчёт сообщает, где и по какому правилу найдено совпадение, но не доказывает, что ветка исполняется, вход пришёл из недоверенной зоны или дефект попадёт в релиз.
\nЦена неверного действия практическая. Глобальное отключение убирает сигнал и для будущего кода, а безусловная блокировка превращает шум в очередь ручных исключений. Рабочая модель разделяет четыре слоя: правило формулирует гипотезу, анализатор фиксирует наблюдение, код и конфигурация дают контекст, а команда принимает решение с понятным scope. Ниже этот порядок можно повторить на обычном JavaScript-проекте.
\nСтатический анализ проверяет программу по исходному коду, его синтаксическому дереву, типам, зависимостям или правилам потока данных, не исполняя весь сценарий приложения как единое целое. Линтер обычно проверяет стиль и локальные конструкции. Проверка типов ищет несогласованные операции с типами. Security-анализатор может искать опасные источники, преобразования и точки использования данных.
\nРазные инструменты отвечают на разные вопросы. Линтер может уверенно сказать, что переменная объявлена, но не используется. Он не может по одной такой записи определить, доступен ли сервис в production. Проверка типов может найти несовместимость интерфейсов, но не подтверждает, что сервер вернул ожидаемое поле. Анализатор потока данных может показать возможный путь к опасному вызову, но должен учитывать конфигурацию, права и достижимость.
\n| Слой | Пример | Что подтверждает | Чего не подтверждает |
|---|---|---|---|
| Правило | no-unused-vars или security pattern | Какую гипотезу проверяет инструмент | Что гипотеза истинна во всех путях выполнения |
| Наблюдение | Файл, строка, сообщение, уровень | Где инструмент увидел совпадение | Что код достигнут в нужном окружении |
| Контекст | Commit, entry point, конфигурация, граница доверия | На какой ревизии и в каких условиях читать сигнал | Полноту runtime-карты без дополнительных проверок |
| Решение | Исправить, уточнить правило, оставить исключение | Какой ограниченный шаг принят и кто за него отвечает | Гарантию отсутствия других дефектов |
Эта граница защищает от подмены понятий. Статический анализ — ранний и дешёвый слой обратной связи, а не замена тестам, ручному review, проверке разрешений и наблюдению за работающей системой.
\nНа вход инструмент получает выбранную ревизию, набор файлов, конфигурацию и правила. Сначала он разбирает файлы или строит представление программы. Затем применяет правила и создаёт результаты. Результат обычно содержит идентификатор правила, сообщение, уровень и координату в файле. Формат отчёта может быть человекочитаемым или машинным.
\nИзменение любого входа меняет смысл результата. Тот же файл на другом commit может иметь иную строку. Новый parser может иначе понять синтаксис. Конфигурация может исключить generated-файлы или, наоборот, включить тестовые фикстуры. Поэтому в CI рядом с отчётом фиксируют commit, версию инструмента и конфигурацию. Без этих трёх значений повторная проверка превращается в догадку.
\nВозьмём ESLint как понятный пример локального статического анализа. Команда выполняется в проекте, где ESLint уже указан в package.json и зависимости установлены по lock-файлу. Это ограничение принципиально: запуск произвольной версии через сетевой загрузчик не даёт той же воспроизводимости.
git rev-parse HEAD\nnode --version\nnpm ci\nmkdir -p reports\n\nset +e\nnpx eslint . --format json --output-file reports/eslint.json\nstatus=$?\nset -e\n\ntest -s reports/eslint.json\nprintf 'eslint_exit=%s' $status\njq '[.[].errorCount, .[].warningCount] | add' reports/eslint.json\nЗдесь код выхода и JSON-отчёт имеют разные роли. Ненулевой status показывает, что политика ESLint нашла ошибки или предупреждения. Файл reports/eslint.json сохраняет детали для разбора. Последняя команда суммирует счётчики; если в проекте нет jq, можно прочитать JSON тем же Node.js. Команда не должна считать нулевой результат доказательством корректности бизнес-сценария.
Для честного сравнения запусков сохраняйте не только число ошибок, но и commit, версию Node.js, версию ESLint, конфигурацию и список исключений. Иначе уменьшение количества результатов может означать не исправление, а изменение области проверки.
\nSARIF 2.1.0 — стандартный формат обмена результатами статического анализа. Он описывает log, запускающий инструмент, правила, результаты и locations. Благодаря этому один producer может передать отчёт в другой просмотрщик или платформу. Но SARIF не запускает программу и не добавляет в результат сведения о бизнес-риске, владельце компонента или реальной достижимости.
\n{\n 'version': '2.1.0',\n 'runs': [{\n 'tool': { 'driver': { 'name': 'demo-linter', 'rules': [{ 'id': 'demo.no-command' }] } },\n 'results': [{\n 'ruleId': 'demo.no-command',\n 'level': 'warning',\n 'message': { 'text': 'Проверить передачу значения в командный вызов' },\n 'locations': [{\n 'physicalLocation': {\n 'artifactLocation': { 'uri': 'src/export.js' },\n 'region': { 'startLine': 14 }\n }\n }]\n }]\n }]\n}\nФрагмент показывает структуру JavaScript-объекта для чтения, а не готовый файл, который следует отправить без проверки схемы: настоящий SARIF использует JSON с двойными кавычками. Поле ruleId связывает результат с правилом, level задаёт уровень сообщения, а location помогает открыть место в конкретной ревизии. Отсутствие в примере commit и контекста — напоминание о том, что эти сведения нельзя восстановить из одной строки.
При загрузке SARIF в платформу нужно отдельно проверить адреса файлов. Например, GitHub сопоставляет относительный URI с файлом репозитория и учитывает partialFingerprints для повторных результатов. Несколько наборов результатов для одного commit требуют уникальной категории. Это правила конкретной платформы, а не универсальное свойство самого анализа.
| Симптом | Вероятная причина | Проверка | Ограниченное действие |
|---|---|---|---|
| Результат указывает на строку, которой уже нет | Отчёт относится к другому commit | Сверить commit отчёта и текущую ревизию, затем повторить запуск | Не переносить finding на новую строку вручную |
| Одинаковое правило срабатывает в тестах и production-коде | Scope правила шире предполагаемой границы | Сравнить назначение файлов и путь от входа до вызова | Уточнить pattern или разделить конфигурацию, не выключать правило глобально |
| После обновления инструмента результатов стало меньше | Изменились parser, правила или область файлов | Сравнить версии, конфигурацию и diff отчётов | Назвать причину изменения до принятия нового baseline |
| Найден возможный опасный вызов | Анализатор не знает проверку или права | Проследить источник, преобразования, allowlist, entry point и права | Исправить код либо оставить сигнал до подтверждения контекста |
| Команда просит добавить исключение | Конкретный участок признан допустимым или правило шумит | Определить точный объект, владельца, причину и срок пересмотра | Сделать узкое исключение с rollback, а не менять весь проект |
Термин «ложное срабатывание» тоже требует доказательства. Им можно назвать результат после проверки, которая показала, что условие правила не соответствует реальному риску в этом конкретном участке. Пока источник данных или путь выполнения неизвестны, точнее использовать статус «контекст не собран».
\nСтатический анализ не видит автоматически весь runtime. Динамические импорты, feature flags, generated-код, переменные окружения, ответы внешних сервисов и права пользователя могут изменить путь выполнения. Если инструмент не строит нужную модель данных, его результат может быть слишком узким или слишком широким.
\nЛинтер не заменяет тесты. Типы не заменяют проверку протокола на реальном ответе. Security-правило не является доказательством exploitability и не измеряет риск для бизнеса. Даже корректный SARIF-файл доказывает только соответствие формату и наличие записанных результатов.
\nПримеры с ESLint, jq и SARIF требуют установленного Node.js, lock-файла и настройки проекта. Имена src/export.js, demo.no-command и сообщение в JSON учебные. Команды не запускаются в статье и не дают результатов конкретного репозитория. Для другой CI-платформы изменятся путь к отчёту, способ загрузки и требования к permissions; модель фиксации commit, конфигурации и scope остаётся полезной, но её нужно сверить с документацией платформы.
Результат можно передать в review, когда другой инженер может повторить проверку на той же ревизии и ответить на четыре вопроса: какое правило сработало, где именно, в каком контексте и почему выбрано это действие. Для исправления должны быть видны тест или повторный запуск. Для изменения правила — новая версия гипотезы и её scope. Для исключения — точный объект, причина, владелец, срок пересмотра и способ отката.
\nЕсли хотя бы одно звено неизвестно, итогом становится не «безопасно» и не «сломано», а конкретный незакрытый вопрос: какой файл, вход, настройка или владелец ещё не проверены. Такая модель сохраняет полезные сигналы, уменьшает случайные блокировки и не обещает статическому инструменту того, чего он не измерял.
\nВ 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В 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-интерпретатору.Сканер сообщает об уязвимой версии пакета. Пакет не указан в package.json, поэтому команда решает, что он не используется. Через несколько часов обновление ломает сборку: изменилось транзитивное дерево, peer-зависимость перестала разрешаться, а новый пакет требует другой Node.js. Цена ошибки — остановленный деплой, срочный откат и неясный ответ на вопрос, какой артефакт уже попал в окружение.
Обратная ошибка тоже дорогая. Команда удаляет пакет из манифеста, получает зелёный install и закрывает предупреждение. Но уязвимый модуль остаётся в lockfile или в другом production-артефакте. Исправление должно отвечать на два разных вопроса: входит ли компонент в поставляемый артефакт и что изменится после его обновления.
\nБезопасное обновление — это не замена одной строки в манифесте. Это проверяемая цепочка: идентифицированный компонент, зафиксированное дерево, воспроизводимая установка, проверка приложения в целевом runtime и готовый путь возврата.
\nУязвимость обычно приходит через несколько уровней. Приложение зависит от http-client. Он зависит от parser. Advisory указывает на старую версию parser. Вызов метода может находиться далеко от корневого кода, но пакет всё равно входит в установленное дерево. Обратное также верно: запись в lockfile ещё не доказывает, что компонент вошёл в собранный образ или реально загружен процессом.
Сначала отделите четыре объекта. Манифест описывает намерение проекта. Lockfile фиксирует разрешённое дерево и версии записей. SBOM описывает состав конкретного артефакта, если его построили из этого артефакта. Runtime-наблюдение показывает, что произошло при запуске. Эти источники отвечают на разные вопросы и не заменяют друг друга.
\nНачните с baseline. Запишите commit, package manager, версию Node.js, команду установки и digest исходного артефакта. Затем назовите candidate: пакет, исходную версию, новую версию и advisory. Для транзитивной зависимости добавьте прямую цепочку родителей. Без baseline нельзя понять, удалил ли update уязвимый узел или только переставил его в другое место.
\n| Проверка | Что она доказывает | Что сохранить |
|---|---|---|
| Dependency tree | Какие записи разрешил установщик и кто приводит к уязвимому пакету | Diff lockfile и команду получения дерева |
| Clean install | Что зафиксированный проект устанавливается в чистой среде | Версию инструмента, exit code и лог |
| Application tests | Что выбранные контракты приложения не изменились | Набор тестов и окружение запуска |
| Runtime smoke | Что сервис стартует и проходит важный сценарий | Запрос, ответ, лог и trace или метрику |
| Artifact scan | Что проверенный образ или архив не содержит запрещённую версию | Digest артефакта и результат сканирования |
| Rollback | Что команда может вернуть baseline без новой догадки | Старый digest, lockfile и условие остановки |
Следующий фрагмент — учебный. Он не читает настоящий lockfile и не устанавливает пакеты. Он показывает, почему проверка должна искать не только прямые зависимости. В реальном проекте результат нужно получить командой package manager и сопоставить с образом, который будет выпущен.
\nconst tree = {\n name: 'checkout-service',\n version: '1.4.0',\n dependencies: {\n 'http-client': {\n version: '4.2.0',\n dependencies: {\n parser: { version: '1.0.0' }\n }\n }\n }\n};\n\nfunction findPackage(node, name, path = [node.name]) {\n if (node.name === name) return { version: node.version, path };\n\n for (const [childName, child] of Object.entries(node.dependencies ?? {})) {\n const found = findPackage(child, name, [...path, childName]);\n if (found) return found;\n }\n\n return null;\n}\n\nconsole.log(findPackage(tree, 'parser'));\n// { version: '1.0.0', path: [\n// 'checkout-service', 'http-client', 'parser'\n// ] }\nРезультат примера означает только одно: узел найден в заданной структуре. Он не доказывает наличие CVE, достижимость опасного кода, состав production-образа или безопасность версии 1.0.1. Чтобы сделать вывод о конкретной системе, добавьте источник advisory, реальный lockfile и digest артефакта.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Пакет не виден в package.json | Он транзитивный | Построить дерево от production root | Обновить родителя или добавить безопасное разрешение с проверкой результата |
| Lockfile обновился, но advisory остался | Resolver выбрал другую ветку или копию пакета | Найти все записи имени и версии | Разобрать каждого родителя и пересчитать artifact contents |
npm ci зелёный, сервис не стартует | Не совпал runtime, peer range, native addon или module format | Запустить smoke в том же образе и с тем же Node.js | Остановить выпуск, сузить update или подготовить совместимый runtime |
| Сканер образа не находит старый пакет | Проверен не тот digest | Сопоставить digest скана и release candidate | Пересканировать именно публикуемый артефакт |
| Откат возвращает версию, но данные не читаются | Update изменил формат или внешний протокол | Проверить обратную совместимость миграции | Разделить изменение формата и замену зависимости |
Поле engines выражает заявленный диапазон. Оно не запускает приложение, не проверяет native addon и не показывает, какая ветка разрешилась в конкретной платформе. При мягкой настройке package manager несовпадение может остаться предупреждением. Поэтому engine check полезен как ранний фильтр, но не как итог.
Lockfile даёт воспроизводимую точку для установки. Он не является снимком уже работающего процесса. Сборка может исключить пакет, добавить его в другой слой, заменить optional dependency или использовать иной lockfile. Состав проверяйте на выходном артефакте, а поведение — в целевом runtime.
\nDependency scanner не знает автоматически, может ли атакующий достичь уязвимого вызова. Runtime smoke не доказывает отсутствие всех опасных путей. SBOM не доказывает, что его создали из того же digest, который публикуют. Результаты нужно связывать с конкретным commit и артефактом.
\nЕсли компонент найден, но его путь не достигается в вашем сценарии, это не разрешение игнорировать advisory. Зафиксируйте границу утверждения: «путь не достигнут в проверенном сценарии». Затем проверьте другие entry point, worker, CLI, background job и режимы сборки. Если среда не позволяет выполнить нужный сценарий, статус должен остаться неизвестным. Не заменяйте его словом «безопасно».
\nЕсли clean install не проходит, не чините результат добавлением случайного флага. Сначала сравните manifest и lockfile, peer range и runtime. Если smoke не проходит после установки, возврат к baseline предпочтительнее выпуска с неясной совместимостью. Если digest скана не совпадает с digest релиза, остановите публикацию.
\nОбновление готово к выпуску, когда команда может предъявить один набор связанных доказательств: advisory и scope, baseline и candidate, полный diff дерева, успешную чистую установку, тесты затронутого контракта, smoke в целевом образе, результат сканирования того же digest и проверяемый rollback. Каждый результат имеет команду, окружение и exit status или наблюдаемый ответ.
\nЛюбой пропущенный элемент помечается явно: not run, not applicable с причиной или unknown. Слово PASS допустимо только для реально выполненной проверки. Если доказательства относятся к другому commit, образу или runtime, обновление не готово.
Сканер сообщает об уязвимой версии пакета, но в package.json этого имени нет. Команда удаляет случайную строку, получает зелёный install и закрывает задачу. Позже выясняется, что пакет пришёл транзитивно, остался в другом production-артефакте или уже был упакован в образ. Обратный сценарий тоже опасен: обновление без проверки меняет peer-зависимость, требует другой Node.js и останавливает деплой.
Проверяемый вывод должен быть уже: какой пакет найден, кто его привёл, в каком артефакте он оказался, достигается ли уязвимая ветка в названном сценарии и можно ли вернуться к исходному digest. Предупреждение scanner — вход в расследование, а не доказательство ни безопасности, ни эксплуатации.
\nЗависимость может отсутствовать в корневом манифесте и всё равно входить в установленное дерево. Приложение зависит от http-client, тот — от parser, а advisory указывает на старую версию parser. Нужно проверить не только имя и версию, но и путь от production root до компонента.
Есть и обратная граница. Запись в lockfile описывает результат разрешения, но не доказывает, что пакет физически попал в конкретный образ. Сборка может исключить dev-зависимость, заменить optional-ветку, собрать другой workspace или использовать другой lockfile. Поэтому после дерева проверяют именно выходной артефакт и его digest.
\nРазделите объекты до первого изменения. Манифест описывает намерение проекта и диапазоны прямых зависимостей. Lockfile фиксирует разрешённое дерево. SBOM (Software Bill of Materials) перечисляет компоненты и отношения поставки для названного артефакта. Runtime evidence показывает наблюдение конкретного процесса и сценария. Каждый слой отвечает на свой вопрос и не заменяет соседний.
\nСначала зафиксируйте baseline: commit, package manager, Node.js, настройки .npmrc, команду установки и digest текущего артефакта. Затем назовите candidate: пакет, исходную и целевую версии, advisory и предполагаемый путь обновления. Без baseline нельзя отличить удаление уязвимого узла от его перемещения в другую ветку.
| Слой | Вопрос | Что сохранить |
|---|---|---|
package.json | Какие direct dependencies и диапазоны объявлены? | Commit и diff манифеста |
package-lock.json | Какое дерево разрешено зафиксированным проектом? | Полный diff, integrity и настройки resolver |
node_modules / image | Что физически установлено в проверенном артефакте? | Имя образа, digest и состав слоя |
| SBOM | Какие компоненты заявлены для named artifact? | Формат, источник генерации и связь с digest |
| Runtime | Что произошло в названном entry point? | Команду, вход, ответ, лог и отрицательный путь |
Ниже команды для проекта на npm. Подставьте в PACKAGE имя из advisory и выполняйте их в том workspace, который собирается в production. Если pipeline использует --legacy-peer-deps, workspaces или иной .npmrc, повторите те же настройки: разрешение зависит не только от текста манифеста.
PACKAGE=parser\n\nnode --version\nnpm --version\nnpm ci\nnpm ls \"$PACKAGE\" --all --omit=dev --json > /tmp/dependency-tree.json\nnpm explain \"$PACKAGE\"\nnpm audit --omit=dev --json > /tmp/npm-audit.json\nnpm ci — контрольная точка. По официальной документации ему нужен существующий lockfile; при расхождении с package.json команда завершается ошибкой, удаляет имеющийся node_modules и не переписывает манифест или lockfile. Если установка упала, это отдельный результат: сначала разберите peer-диапазоны, версию npm и флаги, с которыми lockfile был создан.
Не заменяйте npm ci на npm install ради зелёного exit code: обычная установка может пересчитать lockfile. Флаг --omit=dev убирает dev-зависимости с диска, но записи о них остаются разрешёнными в lockfile. Если build использует dev-инструменты, описывайте build-артефакт отдельной проверкой.
npm ls --all --omit=dev --json показывает логическое дерево установленных пакетов и может отметить missing, invalid или extraneous узлы. npm explain даёт обратный путь — почему пакет присутствует. Нулевой вывод не закрывает advisory, пока не проверены правильные workspace, lockfile, режим установки и artifact.
Одна строка parser@1.0.0 отвечает только на вопрос о версии. Один пакет может присутствовать несколько раз из-за несовместимых диапазонов. Логическое дерево npm также не равно физическому расположению на диске: deduplication и peer-зависимости меняют картину. Для решения нужны все пути от корня.
function findPaths(node, wanted, path = []) {\n const name = node.name || '<root>';\n const version = node.version || 'unknown';\n const current = path.concat(name + '@' + version);\n const matches = node.name === wanted\n ? [{ version: version, path: current }]\n : [];\n\n for (const entry of Object.entries(node.dependencies || {})) {\n const childName = entry[0];\n const child = entry[1];\n matches.push(...findPaths(\n Object.assign({ name: childName }, child),\n wanted,\n current,\n ));\n }\n\n return matches;\n}\n\nconst tree = JSON.parse(require('fs').readFileSync(0, 'utf8'));\nconst packageName = process.argv[2] || 'parser';\nconsole.log(JSON.stringify(findPaths(tree, packageName), null, 2));\nСохраните фрагмент как find-paths.js и выполните node find-paths.js parser < /tmp/dependency-tree.json. Учебный вывод может показать две цепочки — через http-client@4 и через markdown-tool@2. Это доказывает две записи в переданном JSON, но не доказывает, что обе попали в image и что опасная ветка вызывается. Учитывайте это ограничение при формулировке результата.
SBOM полезен только вместе с идентичностью сборки: commit, digest, временем и понятным источником генерации. Файл с названием sbom.json без связи с release candidate может относиться к соседнему образу. Сверяйте компонент, версию, источник и relationship; расхождение с lockfile — сигнал расследования, а не повод выбрать более удобный результат.
Сканируйте immutable digest именно публикуемого образа или архива. Локальный node_modules, staging-образ и предыдущий digest не являются заменой. Если сборка создаёт несколько image layers, выясните, где лежит компонент и не остаётся ли старая копия в другом слое.
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
Пакета нет в package.json | Он транзитивный или пришёл из другого workspace | npm ls --all и npm explain в production root | Назвать родителя и проверить каждый путь |
| Lockfile изменился, advisory остался | Осталась другая копия или ветка | Сравнить все записи и версии в дереве и SBOM | Обновить каждого владельца либо обосновать исключение |
npm ci зелёный, сервис не стартует | Не совпали Node.js, peer range, ABI или module format | Smoke в том же image и runtime | Остановить выпуск и сузить candidate |
| Сканер не видит старую версию | Проверен другой digest или слой | Сопоставить digest отчёта и release | Пересканировать публикуемый artifact |
| Rollback вернул версию, но данные не читаются | Изменился формат или внешний протокол | Проверить обратную совместимость миграции | Разделить security update и изменение данных |
Наличие пакета и достижимость уязвимого кода — разные утверждения. Назовите entry point, импорт или вызов, входные данные и режим исполнения. Для сервера это HTTP-обработчик, для CLI — команда, для worker — сообщение из очереди. Формулировка «ветка не достигнута в проверенном сценарии» честнее, чем «уязвимости нет».
\nПроверьте optional-зависимости на целевой платформе, peer-зависимости, bundled packages, worker, cron и отдельный CLI. Сборщик может исключить статический импорт, а динамический require сохранить путь. Строковый поиск находит имя, но не учитывает условие, экспорт, bundler и конфигурацию.
Минимальное evidence — связка dependency path, содержимого production-артефакта и названного сценария с ожидаемым результатом. Добавьте отрицательный путь: некорректный вход должен быть отклонён ожидаемым способом, а недоступная optional-часть не должна молча создавать видимость исправности.
\nСформируйте небольшой candidate. Обновляйте прямого родителя, если он выпускает совместимую версию, или добавляйте явное разрешение с объяснением владельца риска. Не меняйте одновременно Node.js, package manager и несколько крупных библиотек: rollback перестанет показывать причину отказа.
\nПрочитайте весь diff lockfile: resolved URL, integrity, peer и optional-поля, количество копий и новые install scripts. Поле engines выражает заявленный диапазон совместимости. Без engine-strict npm может оставить предупреждение и продолжить, а строгая проверка всё равно не запускает приложение и не проверяет native addon.
Повторите clean install с параметрами pipeline. Затем запустите startup, импорт компонента, валидный и невалидный вход, сборку, сетевой вызов и затронутый CLI. У каждого теста должны быть команда, окружение и exit code. Фраза «всё прошло» без этого набора не является доказательством.
\n.npmrc и digest текущего артефакта.npm ci, затем сохраните npm ls --all --omit=dev --json и npm explain PACKAGE. Разберите каждый путь и копию.Зелёный npm audit не означает, что внешний scanner ошибся: базы advisory, области установки и правила инструментов различаются. Красный scanner тоже не доказывает достижимость вызова. Зафиксируйте источник, версию базы, путь пакета и artifact, затем согласуйте решение с владельцем риска.
Установка с --ignore-scripts не проверяет install script, который нужен приложению. macOS-проверка не заменяет Linux-образ, если native dependency собирается в CI. Локальная папка не заменяет image digest. Эти ограничения пишутся рядом с результатом, иначе отчёт создаёт ложную уверенность.
Маршрут рассчитан на npm-проекты с package-lock.json. Для Yarn, pnpm, Cargo, Maven или системных пакетов команды и формат lockfile будут другими, хотя разделение manifest, resolved tree, artifact и runtime остаётся полезной моделью. Не переносите npm-команды в другой менеджер без сверки его документации.
Статья не определяет exploitability и не заменяет threat model, code review или расследование инцидента. Достижимость зависит от кода, конфигурации, прав, входных данных и внешних сервисов. Если не удалось получить точный digest, выполнить сценарий или сопоставить SBOM с build, статус должен быть unknown, а не safe.
Major-обновление требует отдельной оценки API-изменений. Для native addon добавьте проверку ABI и целевой платформы. Для private registry сохраняйте provenance, но не публикуйте токены и приватные URL в отчёте.
\nОбновление готово к выпуску, когда одна связанная запись содержит advisory и scope, baseline и candidate, все dependency paths, diff lockfile, clean install, тесты затронутого контракта, runtime smoke, SBOM или иной состав артефакта, сканирование того же digest и проверяемый rollback. У каждого результата есть команда, окружение и exit code либо наблюдаемый ответ.
\nЛюбой пропуск получает явную отметку: not run, not applicable с причиной или unknown. Пока не доказана связь между пакетом, образом и runtime-сценарием, предупреждение не закрыто. Так команда принимает не «самую новую версию», а изменение с понятной областью действия и обратным ходом.
package.json, очистке node_modules и неизменности lockfile во время CI-установки.--all, --json и ограничение: вывод не равен физическому расположению на диске.engines и его advisory-характер без engine-strict.Сканер сообщает об advisory для транзитивного пакета. Команда меняет строку в package.json, получает зелёный pull request и закрывает задачу. Через день выясняется, что lockfile не изменился, SBOM относится к предыдущему образу, а пакет присутствует только в optional-ветке для другой платформы. Ошибка стоит времени на ложную аварию или, хуже, оставляет настоящий риск без владельца. В обоих случаях команда не может ответить на простой вопрос: какой компонент попал в конкретный артефакт и где он может быть достигнут?
Тезис статьи прост: безопасность зависимости проверяют не по одному имени и не по одному файлу. Сначала связывают внешний сигнал с точной записью в resolved tree. Затем связывают эту запись с SBOM того же build. После этого отдельно проверяют достижимость и поведение в нужном runtime-сценарии. package.json, lockfile, SBOM и runtime evidence описывают разные границы. Совпадение двух списков полезно, но само по себе не закрывает риск.
Manifest отвечает на вопрос «что проект просит установить». Для прямой зависимости он хранит имя и допустимый диапазон версий. Запись \"demo-shell\": \"^1.0.0\" не говорит, какая версия окажется в текущем дереве.
Lockfile отвечает на другой вопрос: какое дерево выбрал установщик при конкретных правилах разрешения. Для npm это точное представление дерева, созданного установкой. В нём видны транзитивные пакеты, версии, источники и integrity-поля. Но lockfile не является журналом уже запущенного процесса. Его нужно связать с commit, командой установки и артефактом, который действительно собирает pipeline.
\nSBOM отвечает на вопрос «какие компоненты заявлены для named artifact и как они связаны». Он может быть независим от конкретного package manager и пригоден для анализа поставки. Но файл без build ID, digest или другой неизменяемой привязки не доказывает состав текущего образа. Runtime evidence отвечает на четвёртый вопрос: что загрузилось и выполнилось в выбранном сценарии. Ни один из этих слоёв не заменяет остальные.
\n| Артефакт | Главный вопрос | Проверка | Чего не доказывает |
|---|---|---|---|
package.json | Какую direct dependency просит проект? | Diff manifest и review диапазона | Точное resolved tree |
package-lock.json | Какое дерево выбрал installer? | Diff lockfile и clean install | Загрузку модуля в процессе |
| SBOM | Какие компоненты заявлены для артефакта? | Component records и artifact identity | Достижимость по коду и конфигурации |
| Runtime evidence | Что произошло в named scenario? | Тест, trace или controlled smoke | Все возможные пути приложения |
| Advisory | Какой внешний сигнал надо разобрать? | ID, источник, package и version scope | Воздействие на этот сервис |
Рассмотрим ограниченный fixture. demo-service зависит от demo-shell, а demo-shell — от demo-parser. Advisory указывает на demo-parser@1.0.0. В synthetic lockfile пакет есть. В synthetic SBOM есть запись для того же имени и версии. Это подтверждает пересечение двух заданных массивов. Это не подтверждает, что пакет установлен в production, достижим из реального entry point или уязвим именно в таком контексте.
const locked = packages.find(\n (item) => item.name === advisory.packageName\n && item.version === advisory.affectedVersion,\n);\n\nconst recorded = components.some(\n (item) => item.name === advisory.packageName\n && item.version === advisory.affectedVersion,\n);\n\nreturn {\n lockfile: locked ? 'entry-matched' : 'entry-not-found',\n sbom: recorded ? 'component-matched' : 'component-not-found',\n reachability: 'not-assessed',\n vulnerabilityStatus: 'not-determined',\n};\nКод намеренно не читает настоящий lockfile, не запускает scanner и не строит call graph. Он показывает важную границу: найденная строка — это факт о входных данных, а не вердикт о безопасности. Если имя отсутствует в lockfile, вопрос меняется на provenance advisory или на несовпадение версии. Если имя есть в lockfile, но отсутствует в SBOM нужного build, нужно проверять генерацию и identity артефакта. Если оба списка совпали, всё ещё остаётся путь использования.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Изменился package.json, но lockfile остался прежним | Проверили намерение, но не разрешённое дерево | Сравнить direct range, lockfile entry и install command | Пересобрать candidate tree и review diff |
| Lockfile и SBOM показывают разные версии | SBOM создан из другого commit или build | Сверить source revision, artifact digest и время генерации | Сгенерировать SBOM для того же candidate artifact |
| Пакет есть в SBOM, но не найден в пути вызова | Состав поставки смешан с reachability | Проверить import, entry point, flags и optional branch | Оставить риск в review до доказанного исключения |
| После patch update изменилось много строк lockfile | Resolver обновил транзитивное дерево | Разделить added, removed, version и integrity changes | Проверить каждую существенную ветку и тесты |
| Fixture завершился PASS | PASS проверяет только synthetic contract | Посмотреть статусы provenance, runtime и deployment | Не прикладывать PASS как доказательство production safety |
Patch update не ограничивается одной строкой manifest. Новый диапазон может выбрать другую транзитивную версию. Installer может иначе обработать optional dependency на другой платформе. Lifecycle script может изменить содержимое build. Поэтому после обновления смотрят lockfile diff, а не только diff package.json. Важны added и removed packages, version shifts, integrity и source fields.
Отдельная ловушка — SBOM, который лежит рядом с репозиторием, но не рядом с выпуском. Такой документ может быть полезен для разработки и одновременно бесполезен для ответа о deployed image. Минимальный критерий свежести задаёт сам pipeline: SBOM создан из того же source revision и относится к тому же artifact digest, что и release candidate. Это инженерный критерий, который нужно реализовать, а не свойство любого файла с названием SBOM.
\nУчебная модель не знает реальную advisory database, формат конкретного SBOM, private registry, зависимости ОС, native addons, bundle, generated source или runtime configuration. Она не вычисляет vulnerable range и не доказывает отсутствие эксплуатации. Даже реальный путь импорта не равен доказанной уязвимости: нужно понимать, достигается ли опасный код с контролируемыми входными данными и при каких настройках.
\nОтрицательный путь обязателен. Advisory может не совпасть с exact resolved version. Компонент может быть в lockfile, но отсутствовать в named artifact. SBOM может быть старше образа. Достижимость может зависеть от выключенного по умолчанию флага. В каждом случае нельзя подменять неизвестность уверенным исключением. Укажите, что именно не проверено, каким методом это проверят и кто владеет следующим действием.
\nОбновление готово к решению, когда для одного candidate release можно восстановить всю цепочку: advisory → exact package@version → lockfile commit → SBOM → artifact digest → проверенный runtime-сценарий. Для каждого перехода есть ссылка или сохранённый результат. Lockfile diff просмотрен. Clean install и проектные тесты завершились ожидаемо. Неизвестные пути явно перечислены. Rollback описывает, какой артефакт и какую версию возвращают.
\nЕсли хотя бы одно звено отсутствует, работа может быть готова к следующему этапу, но не к утверждению безопасности выпуска. Это не бюрократическая формальность. Такая запись позволяет отличить реальный риск от неполного inventory и не потерять его при следующем обновлении.
\nСканер сообщает об advisory для транзитивного пакета. Команда меняет строку в package.json, получает зелёный pull request и закрывает задачу. Позже выясняется, что lockfile не изменился, SBOM относится к предыдущему образу, а пакет присутствует только в optional-ветке другой платформы. Команда потратила время на ложную аварию или оставила настоящий риск без владельца. В обоих случаях не восстановлен ответ на главный вопрос: какой компонент попал в конкретный выпуск и при каких условиях он достижим?
Безопасность зависимости проверяют не по одному имени и не по одному файлу. Внешний сигнал связывают с точной записью в resolved tree, эту запись — с SBOM того же кандидата, а затем отдельно проверяют достижимость и поведение нужного runtime-сценария. package.json, lockfile, SBOM и runtime evidence описывают разные границы. Совпадение двух списков полезно, но не является вердиктом о безопасности.
Advisory — сообщение о проблеме в определённом пакете и диапазоне версий. Оно отвечает на вопрос «какой сигнал надо разобрать», но не на вопрос «затронут ли мой образ». Для этого нужны как минимум имя пакета, точная версия, источник пакета и путь, по которому компонент попал в кандидат.
\nРазличайте три утверждения. «Версия попала в дерево» — факт о разрешении зависимостей. «Компонент попал в артефакт» — факт о конкретном build и его SBOM. «Опасный код достижим с контролируемым входом» — вывод анализа кода, конфигурации и runtime. Эти утверждения могут быть истинны независимо друг от друга.
\nManifest фиксирует намерение проекта. Диапазон \"demo-shell\": \"^1.0.0\" задаёт допустимые версии, но не говорит, что установлено сегодня. Lockfile фиксирует resolved tree. В npm он содержит представление дерева, версии, источники и integrity-поля, чтобы повторная установка могла получить ту же структуру.
SBOM — inventory конкретного программного продукта и его компонентов. Он полезен для сопоставления с базами уязвимостей и лицензий, но файл без source revision, build ID или digest нельзя надёжно связать с deployed image. Runtime evidence отвечает на ещё один вопрос: что загрузилось и произошло в выбранном сценарии. Ни один слой не заменяет остальные.
\n| Слой | Вопрос | Проверка | Чего не доказывает |
|---|---|---|---|
package.json | Что проект просит установить? | Diff manifest и review диапазона | Точную resolved version |
package-lock.json | Какое дерево выбрал installer? | Diff lockfile и clean install | Загрузку модуля в процессе |
| SBOM | Какие компоненты заявлены для artifact? | Records, relations и identity артефакта | Достижимость по коду |
| Runtime evidence | Что произошло в named scenario? | Тест, trace или controlled smoke | Все возможные пути |
| Advisory | Какой внешний сигнал исследовать? | ID, package и affected range | Воздействие на сервис |
Возьмём небольшой учебный граф: demo-service зависит от demo-shell, а demo-shell — от demo-parser. Advisory указывает на demo-parser@1.0.0. Совпадение имени и версии в lockfile и SBOM подтверждает пересечение двух документов. Оно не подтверждает, что пакет попал в production-образ, вызывается из реального entry point или уязвим при фактической конфигурации.
const locked = packages.find(\n (item) => item.name === advisory.packageName\n && item.version === advisory.affectedVersion,\n);\n\nconst recorded = components.some(\n (item) => item.name === advisory.packageName\n && item.version === advisory.affectedVersion,\n);\n\nreturn {\n lockfile: locked ? 'entry-matched' : 'entry-not-found',\n sbom: recorded ? 'component-matched' : 'component-not-found',\n reachability: 'not-assessed',\n vulnerabilityStatus: 'not-determined',\n};\nЭтот фрагмент намеренно не читает настоящий lockfile и не запускает scanner. Он показывает границу доказательства: найденная строка — факт о входных данных, а не решение по уязвимости. Если exact version отсутствует в lockfile, проверяют provenance advisory или другой workspace. Если запись есть в lockfile, но отсутствует в SBOM кандидата, проверяют вход генератора и identity артефакта. Если оба списка совпали, остаётся анализ пути использования.
\nНиже — последовательность для проекта с package.json и package-lock.json. Запускайте её в том же commit и с той же версией Node/npm, которые использует сборка. npm ci удаляет существующий node_modules и завершается с ошибкой, если manifest и lockfile не согласованы. Это контроль входа, а не замена тестам приложения.
set -eu\n\n# 1. Чистое дерево кандидата.\nnpm ci\n\n# 2. Почему пакет установлен и где находятся его копии.\nnpm explain demo-parser\nnpm ls demo-parser --all --json > dependency-tree.json\n\n# 3. Известные advisory. Ненулевой код возможен при найденных проблемах.\nset +e\nnpm audit --json > audit.json\naudit_status=$?\nset -e\nprintf 'npm audit exit code: %s\\n' \"$audit_status\"\n\n# 4. SBOM именно после этой установки.\nnpm sbom --sbom-format=spdx > sbom.spdx.json\n\n# 5. Идентичность результатов фиксируется рядом с CI-артефактами.\ngit rev-parse HEAD\nsha256sum package-lock.json sbom.spdx.json\nnpm explain выводит цепочку зависимостей, из-за которой пакет установлен. npm ls даёт машинно читаемое дерево текущего node_modules, а не доказательство того, что именно это дерево ушло в выпуск. npm audit отправляет описание зависимостей в registry и может завершиться ненулевым кодом; такой статус показывает результат аудита, но не доказывает эксплуатацию. npm sbom создаёт SPDX или CycloneDX. Для production SBOM сохраняйте digest образа или другого выпускаемого артефакта рядом с документом.
Сверку начинайте с identity, а не с имени пакета. Один и тот же name@version в двух документах не означает один и тот же источник, если различаются registry, URL tarball, integrity, source revision или artifact digest. В lockfile эти поля помогают отличить запись от простого совпадения текста.
| Наблюдение | Что доказано | Что неизвестно | Следующий шаг |
|---|---|---|---|
| Advisory не совпадает с exact version | Сигнал не подтверждает affected version в этом lockfile | Корректность advisory, другой workspace или артефакт | Проверить источник и границы affected range |
| Exact version есть в lockfile, нет в SBOM | Кандидатное дерево содержит компонент | Другой вход генератора или неполный SBOM | Сверить commit, digest, omit и генератор |
| Компонент есть в SBOM, но omitted при install | Он разрешён и описан документом | Попал ли на диск и в runtime-образ | Проверить build-команду и финальный слой |
| Компонент достижим, но опасная ветка выключена | Путь существует при определённом условии | Флаг и входы в deployed окружении | Проверить условие smoke-тестом |
Есть только локальный npm audit | Registry вернул результат для локального дерева | Состояние конкретного deployed artifact | Повторить для candidate и привязать к digest |
SBOM — инвентарная модель компонентов и отношений между ними. Она помогает сопоставлять компонент с базой уязвимостей, лицензий или provenance. Но инвентарь не знает, вызывается ли экспорт, включён ли feature flag, доступен ли endpoint извне и может ли атакующий передать опасный вход. Эти вопросы относятся к анализу кода, конфигурации и runtime.
\nРаботает и обратная граница: успешный smoke-тест одного endpoint не доказывает отсутствие риска во всех путях. Native addon, bundled dependency, generated bundle, private registry и зависимости операционной системы могут выпасть из npm-сценария. Если инструмент не покрывает такой слой, результат помечают как unknown и назначают отдельную проверку.
\nРешение об обновлении можно принимать, когда для одного кандидата восстановлена цепочка advisory → exact package@version → lockfile commit → SBOM → artifact digest → runtime-сценарий. Для каждого перехода есть файл, ссылка или команда, которую другой инженер может повторить. Lockfile diff просмотрен, clean install завершился ожидаемо, проектные тесты прошли, а неизвестные пути перечислены отдельно.
Критерий не вычисляет вероятность эксплуатации и не отменяет ручную оценку. Он также не обещает полноту SBOM: она зависит от генератора, режима установки и того, что команда называет артефактом. Для монорепозитория отдельно проверяйте workspace, production-режим и образ, который публикуется. Для менеджеров, отличных от npm, переносите принцип слоёв, но не копируйте команды без сверки с документацией своего инструмента.
\nnpm explain и зафиксировать путь от direct dependency.Если отсутствует хотя бы одно звено, корректный статус — «требует дополнительной проверки». Такая формулировка сохраняет владельца и следующий шаг. Она точнее, чем «ложное срабатывание» при несовпавшей версии и чем «безопасно» при одном зелёном отчёте.
\nresolved и integrity, а также границы lockfile.package-lock-only.Сканер сообщает: пакет совпал с advisory. Команда видит имя и версию, ставит задачу «срочно обновить» и меняет dependency. Через час lockfile разрастается, сборка падает, а никто не может ответить на главный вопрос: этот пакет попал в поставленный артефакт и был ли достижим уязвимый код? Цена ошибки двойная. Ложная тревога задерживает релиз и создаёт шум. Непроверенный сигнал оставляет настоящий риск без владельца.
\nТезис простой: advisory — это вход для расследования, а не вердикт о приложении. Сначала разделите четыре факта: внешний advisory, точную запись в lockfile, компонент в SBOM и путь от entry point до нужного кода. Потом проверяйте обновление отдельными gates. Пока связь между этими фактами не доказана, корректный статус — «требует проверки», а не «безопасно» и не «уязвимо в production».
\nAdvisory сообщает, какие имена и диапазоны версий описывает источник. Он не знает конфигурацию вашего сервиса. Manifest показывает намерение автора: прямую зависимость и допустимый range. Lockfile показывает дерево, которое resolver выбрал для конкретного состояния проекта. Это важный снимок, но не журнал уже запущенного процесса.
\nSBOM описывает компоненты и связи выбранного артефакта. Он отвечает на вопрос «что заявлено в этом build», если документ действительно связан с commit и digest. Runtime evidence отвечает на другой вопрос: что загрузил конкретный процесс. Эти слои можно сопоставить, но нельзя заменить один другим. Наличие строки в lockfile не доказывает наличие строки в образе. Наличие компонента в SBOM не доказывает вызов уязвимой функции.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Advisory совпал с package@version | Внешний сигнал приняли за факт о сервисе | Сохранить URL, дату, диапазон и источник | Открыть triage, не объявляя production-уязвимость |
| Пакет есть в lockfile | Resolved tree смешали с deployed artifact | Найти путь в дереве и сверить build identity | Проверить SBOM того же артефакта |
| Пакет есть в SBOM | Inventory приняли за runtime trace | Сверить компонент с образом и режимом запуска | Проверить импорт или загрузку в нужной конфигурации |
| Прямого импорта нет | Забыли транзитивный, optional или dynamic path | Проверить graph, plugin registration и feature flag | Описать достижимость как гипотезу с методом проверки |
| После update изменилось много записей | Resolver пересобрал дерево, а scope не зафиксировали | Разобрать lockfile diff и peer/optional branches | Сузить изменение или отдельно подтвердить совместимость |
Начните с exact package и version. Запишите commit, в котором возник сигнал, и место записи в lockfile. Если пакет транзитивный, сохраните parent path: без него невозможно понять, какая прямая зависимость привела компонент в дерево. Затем найдите SBOM, созданный для candidate build. У него должен быть устойчивый идентификатор: commit, image digest или иной идентификатор артефакта. Файл с названием sbom.json без такой связи — только неподтверждённый документ.
После этого сформулируйте путь достижимости. Не пишите «пакет используется». Пишите: «entry point A при конфигурации B импортирует модуль C, который вызывает ветку D». Для статического графа это возможная связь. Для теста — путь выбранного сценария. Для trace — наблюдение одного запуска. У каждого метода есть границы. Ни один метод сам по себе не перечисляет все платформы, флаги и входы.
\nadvisory URL\n -> package@version\n -> lockfile path\n -> SBOM component + artifact digest\n -> entry point + configuration\n -> reachability evidence\n -> decision with owner\nЕсли звено отсутствует, обозначьте его как unknown. Не заполняйте пробел догадкой. Такой формат помогает review: следующий человек видит не только вывод, но и место, где доказательство заканчивается.
\nНиже приведён ограниченный учебный пример. Имена demo-service, demo-parser и SYNTHETIC-ADVISORY-001 вымышлены. Они не взяты из registry, scanner, lockfile, SBOM или production trace. Код только сравнивает заранее заданные значения в памяти. Он не устанавливает пакет, не строит image и не запускает приложение.
const input = {\n advisory: { id: 'SYNTHETIC-ADVISORY-001', package: 'demo-parser', version: '1.0.0' },\n lockfile: [{ package: 'demo-parser', version: '1.0.0' }],\n sbom: [{ package: 'demo-parser', version: '1.0.0' }],\n entryPoint: 'demo-http-handler',\n path: 'demo-http-handler -> demo-shell -> demo-parser'\n};\n\nconst listedInLockfile = input.lockfile.some((x) =>\n x.package === input.advisory.package && x.version === input.advisory.version\n);\nconst listedInSbom = input.sbom.some((x) =>\n x.package === input.advisory.package && x.version === input.advisory.version\n);\n\nconsole.log({ listedInLockfile, listedInSbom,\n reachability: 'not-assessed-by-example',\n vulnerability: 'not-determined-by-example'\n});\nДаже если обе проверки вернут true, пример доказывает только совпадение полей в двух массивах. Он не доказывает, что demo-parser попал в образ, был загружен процессом или достиг уязвимой функции. Отрицательный путь важнее положительного: отсутствие записи в lockfile не доказывает отсутствие компонента в другом build, а наличие записи не доказывает runtime-достижимость. Учебный PASS нельзя прикладывать к production ticket как результат сканирования.
Изменение одной строки в manifest может перестроить транзитивное дерево. Меняются peer dependencies, optional packages, integrity, module format и lifecycle scripts. Поэтому смотрите не только на целевую версию, но и на весь lockfile diff. Если diff неожиданно широк, сначала объясните каждое изменение. Большой diff не делает update неправильным, но увеличивает объём доказательств.
\nКоманда чистой установки проверяет install contract для конкретного lockfile. Она не доказывает старт сервиса, работу native addon, browser bundle или вызов нужной функции. Тест проверяет выбранные сценарии. Smoke показывает поведение названного окружения. Только вместе эти результаты дают основание принять выпуск. Если smoke выполнить нельзя, это ограничение процесса, а не доказательство совместимости.
\nОписанная схема не вычисляет exploitability и не заменяет security research. Она не знает private registry, ОС-зависимости, контейнерные слои, generated code, runtime flags и все возможные входы. SBOM может быть неполным. Статический граф может включать недостижимую ветку. Trace может пропустить редкий сценарий. Поэтому вывод всегда должен содержать scope: какой commit, build, режим и метод проверены.
\nНе закрывайте alert фразой «пакет не импортируется напрямую». Не закрывайте его и фразой «версия обновлена». В первом случае остаются транзитивные и динамические пути. Во втором остаются новый graph, совместимость и факт выпуска. Если доказательств не хватает, оставьте владельца и следующий проверяемый шаг.
\nРазбор готов, когда другой инженер без устного контекста может восстановить цепочку: advisory → exact package@version → lockfile path → SBOM и immutable artifact → entry point/configuration → evidence достижимости → результаты install, tests и smoke → решение и rollback. Каждый результат имеет ссылку или явно отмечен как unknown. Ни один учебный PASS не выдан за production-факт. Если хотя бы одно звено не подтверждено, задача не закрыта как безопасная: она остаётся ограниченным расследованием с назначенным владельцем.
\nСбой в CI сопровождается advisory: пакет совпал с уязвимой версией. Первая реакция понятна — поднять версию в package.json и закрыть тикет после зелёного pipeline. Но такой сигнал отвечает только на один вопрос: известна запись о компоненте с подходящим диапазоном версий. Он не отвечает, попал ли компонент в конкретный образ, откуда он пришёл, выполняется ли опасная ветка и относится ли найденный SBOM к этому выпуску.
Практический риск здесь двойной. Ложное «уязвимо» заставляет срочно менять большое дерево зависимостей и ломает unrelated-сценарии. Ложное «исправлено» оставляет старый образ или транзитивный путь без владельца. Поэтому advisory нужно разбирать как цепочку доказательств: внешний сигнал → exact package@version → запись в lockfile → компонент в том же артефакте → проверенный путь выполнения → решение с границами применимости.
\nНе формулируйте задачу как «проверить безопасность пакета». Это слишком широкое обещание. Запишите узкий вопрос: «Есть ли package@version из advisory в lockfile commit X, в SBOM artifact digest Y и в runtime-сценарии Z?» Если один из переходов пока неизвестен, это часть результата расследования.
Сначала сохраните идентификатор сигнала: URL или ID advisory, имя пакета, затронутый диапазон версий, источник и время получения. Не переносите в тикет более сильную формулировку, чем есть в источнике. «Совпала версия из диапазона» и «уязвимый код достижим из внешнего запроса» — разные утверждения и требуют разных проверок.
\n| Слой | Проверяемый вопрос | Подходящий evidence | Чего он не доказывает |
|---|---|---|---|
| Advisory | Какие package и version range описаны внешним источником? | URL или ID, дата, диапазон, severity и описание условия | Воздействие на конкретный сервис |
package.json | Какую direct dependency просит проект? | Commit и diff manifest | Точную транзитивную версию |
| Lockfile | Какое дерево выбрал установщик? | Запись пакета, parent path, resolved и integrity-поля | Загрузку модуля процессом |
| SBOM | Какие компоненты заявлены для артефакта? | Формат, component record и commit/digest артефакта | Достижимость опасной функции |
| Runtime evidence | Что произошло в названном сценарии? | Тест, trace или controlled smoke с build identity | Все возможные платформы и флаги |
Manifest описывает намерение: например, \"demo-shell\": \"^1.4.0\" разрешает установщику выбрать совместимую версию. Lockfile фиксирует результат разрешения для конкретного состояния проекта. В npm это точное дерево, из которого последующие установки могут восстановить те же версии, если соблюдены правила и версия инструмента.
Проверяйте не только наличие имени. Для транзитивной зависимости нужен путь: какая direct dependency привела к пакету, какая версия родителя была выбрана и не существует ли второй копии в другом поддереве. Две записи одного имени могут иметь разные версии и разные условия попадания в bundle. Удаление прямого импорта не исключает транзитивную, optional, plugin или dynamic-import ветку.
\nКоманда npm explain предназначена именно для восстановления цепочки, которая привела пакет в установленное дерево. Но она описывает установленное дерево в конкретной рабочей директории. Если проверяется другой build, сначала получите чистую установку из его lockfile и только затем интерпретируйте вывод.
set -eu\nPACKAGE='имя-пакета-из-advisory'\n\nprintf 'commit: '; git rev-parse HEAD\nnode --version\nnpm --version\n\n# Чистое дерево должно соответствовать lockfile candidate-коммита.\nnpm ci\n\n# Полный путь от root до транзитивного пакета.\nnpm explain \"$PACKAGE\"\n\n# Все найденные экземпляры и их типы зависимости.\nnpm ls \"$PACKAGE\" --all --json > artifacts/package-tree.json\nЭтот блок не выдаёт автоматически решение. Он сохраняет контекст инструмента и показывает, где искать parent path. Если npm ci не проходит, сначала разберите несовместимость manifest и lockfile. Нельзя считать отсутствие результата npm explain доказательством отсутствия компонента в уже опубликованном образе.
SBOM — это инвентарь компонентов, а не заключение о безопасности. NTIA описывает для него поля идентификации компонентов, поддержку автоматической обработки и процессы формирования. На практике этого достаточно, чтобы сопоставлять компонент с базой advisory, но недостаточно, чтобы утверждать его достижимость или отсутствие эксплуатации.
\nДля npm можно получить SBOM командой npm sbom. Режим --package-lock-only строит результат по lockfile, поэтому он полезен для проверки resolved tree, но не заменяет SBOM контейнерного образа: в нём могут быть системные библиотеки, native runtime и дополнительные файлы. Если вопрос относится к deployed image, SBOM нужно создавать в build-процессе для того же digest, который будет развёрнут.
# Современный npm CLI: проверить поддерживаемые опции перед запуском.\nnpm help sbom\n\n# SBOM по lockfile, без dev-зависимостей в установленном дереве.\nnpm sbom --package-lock-only --omit=dev --sbom-format=cyclonedx \\\n > artifacts/sbom-lockfile.cdx.json\n\n# Аудит npm использует lockfile; JSON сохраняет детали сигнала.\nnpm audit --omit=dev --json > artifacts/npm-audit.json\nУ команд есть границы. npm docs указывают, что npm audit отправляет описание зависимостей в настроенный registry для получения отчёта; проверьте правила обращения с именами private-пакетов и registry в своей организации. Опция --omit=dev меняет проверяемое установленное дерево, но не означает, что dev-записи исчезли из lockfile. Устаревший npm может не знать npm sbom; тогда используйте одобренный генератор SBOM и зафиксируйте его версию.
Рассмотрим воспроизводимую модель без реального registry. Входные массивы ниже заранее заданы в памяти: один имитирует lockfile, второй — SBOM. Имена synthetic, поэтому пример не подтверждает конкретный CVE, версию библиотеки, build или runtime. Он нужен, чтобы не смешивать факт присутствия с выводом о поведении.
\nconst advisory = {\n packageName: 'demo-parser',\n affectedVersion: '1.0.0',\n};\n\nconst lockfile = [\n { name: 'demo-parser', version: '1.0.0',\n path: 'demo-app > demo-shell > demo-parser' },\n];\n\nconst sbom = [\n { name: 'demo-parser', version: '1.0.0',\n artifactDigest: 'sha256:example' },\n];\n\nconst locked = lockfile.find((item) =>\n item.name === advisory.packageName\n && item.version === advisory.affectedVersion\n);\nconst recorded = sbom.find((item) =>\n item.name === advisory.packageName\n && item.version === advisory.affectedVersion\n);\n\nconsole.log({\n lockfileMatch: Boolean(locked),\n sbomMatch: Boolean(recorded),\n parentPath: locked?.path ?? 'unknown',\n artifact: recorded?.artifactDigest ?? 'unknown',\n reachability: 'not-assessed',\n decision: 'not-determined',\n});\nОжидаемый результат показывает true для двух совпадений и одновременно not-assessed для достижимости. Это правильный результат модели. Код не читает настоящий lockfile, не строит call graph, не запускает контейнер и не проверяет входные данные. Подменять его PASS-выводом production-сканера нельзя.
Достижимость отвечает на вопрос «может ли выполнение попасть в нужный модуль или функцию при заданном entry point и конфигурации». Ищите импорт, регистрацию plugin, conditional export, dynamic import, feature flag и платформенную ветку. Зафиксируйте, какой метод использован: статический граф показывает возможную связь, тест — выбранный сценарий, trace — один наблюдаемый запуск.
\nExploitability — ещё более сильный вывод. Даже если модуль достижим, нужно знать, достигается ли уязвимая ветка, какие входные данные до неё доходят и какие защитные условия действуют. Ни lockfile, ни SBOM, ни успешный npm audit не вычисляют это автоматически для вашего приложения. Для такого вывода нужны анализ advisory, кодовый review и подходящие security-тесты.
| Наблюдение | Допустимый вывод | Следующий шаг |
|---|---|---|
| Package/version совпали только в advisory и manifest | Есть сигнал и заявленный диапазон, resolved tree не доказан | Проверить lockfile candidate-коммита |
| Пакет найден в lockfile, parent path известен | Компонент выбран resolver для этого дерева | Сверить SBOM и artifact identity |
| Пакет найден в SBOM без digest | Есть неподтверждённая inventory-запись | Найти provenance или пересоздать SBOM в build |
| SBOM и digest совпали, путь не проверен | Компонент заявлен в конкретном артефакте | Проверить entry point, flags и runtime-сценарий |
| Путь найден, опасная ветка подтверждена входом | Риск относится к названному сценарию и scope | Оценить remediation, тесты и rollback |
Обновление одной direct dependency может перестроить всё дерево. Меняются peer dependencies, optional-пакеты, integrity, формат модулей и lifecycle scripts. Поэтому сначала сохраните baseline, затем создайте candidate и просмотрите полный diff lockfile. Широкий diff не означает, что обновление ошибочно; он означает, что область совместимости стала шире ожидаемой и требует объяснения.
\n# Перед изменением сохраните baseline. Не включайте секреты в artifacts.\ngit rev-parse HEAD > artifacts/baseline-commit.txt\ncp package-lock.json artifacts/baseline-package-lock.json\n\n# В отдельной ветке обновите только заявленный пакет.\nnpm install --save-exact PACKAGE@FIXED_VERSION\ngit diff -- package.json package-lock.json\n\n# Повторите установку и проверки в чистой среде candidate.\nnpm ci\nnpm test\nnpm audit --omit=dev --audit-level=high\nЗамените PACKAGE и FIXED_VERSION на значения из вашего advisory и учитывайте менеджер пакетов проекта. Не запускайте npm audit fix --force как универсальную кнопку: npm docs предупреждают, что --force разрешает изменения за пределами обычных ограничений и может привести к major-обновлениям. Если требуется major version, сначала нужен совместимый change plan и тестовый rollback.
npm explain и npm ls --all, сохраните parent path и все найденные версии.Зелёный путь показывает, что выбранная версия устанавливается и тестовый сценарий проходит. Он не показывает, что старая версия не поставляется другим job, не остаётся в старом образе и не приходит через optional branch. Проверьте как минимум четыре отрицательных условия: exact version не совпадает с advisory range; package отсутствует в candidate SBOM; SBOM относится к другому digest; entry point не может включить ветку при заданной конфигурации.
\nДля каждого отрицательного результата фиксируйте границу. «Не найдено в SBOM digest Y» не равно «пакет нигде не существует». «Не импортируется этим entry point» не равно «уязвимость невозможна во всех режимах». Если проверка не покрывает dynamic loading или native code, оставьте это как unknown и назначьте следующий способ проверки.
\nСхема рассчитана на проекты, где можно получить lockfile и идентичность build. Она не заменяет аудит private registry, зависимостей ОС, контейнерных слоёв, generated code, native addons, browser bundle, runtime flags, XSS или authorization. Формат SBOM и качество его генератора тоже влияют на результат. Для yarn, pnpm, Composer, Maven и других экосистем команды и поля будут другими; переносить npm-команды без адаптации нельзя.
\nСсылки на npm CLI относятся к текущей документации. Поведение и доступность команд зависят от версии npm, настроек registry и package-manager policy. Поэтому сохраняйте node --version, npm --version, параметры omit и источник SBOM рядом с результатом. Это не делает проверку вечной, но позволяет повторить её в том же контракте и увидеть, что изменилось.
Расследование готово к решению, когда другой инженер может восстановить цепочку для одного candidate release: advisory → exact package@version → lockfile commit → parent path → SBOM → artifact digest → entry point и configuration → evidence достижимости → install, tests и smoke → решение и rollback. Для каждого перехода есть ссылка или сохранённый результат. Неизвестные пути перечислены, а не скрыты под словом «безопасно».
\nТакой критерий не обещает отсутствие уязвимостей. Он делает вывод проверяемым и ограниченным: понятно, какой компонент и какой артефакт исследованы, какой сценарий покрыт и где требуется дополнительная работа. Это достаточная основа для инженерного решения и гораздо надёжнее, чем совпадение имени в отчёте сканера.
\nПосле переноса frontend на новый host интерфейс перестаёт читать ответ API. В консоли появляется CORS error. Другой запрос получает 403 от CSRF middleware. Иногда сервер уже выполнил mutation, а браузер только скрыл ответ. Ошибка стоит дорого: команда может открыть API для любого origin, отключить проверку токена или повторить действие пользователя, не понимая, был ли запрос принят.
\nТезис простой: CORS и CSRF проверяют разные свойства запроса. CORS ограничивает, какой origin может прочитать cross-origin response из браузера. CSRF защищает изменение состояния от запроса без доказательства намерения пользователя. Один заголовок CORS не заменяет CSRF-токен. CSRF-токен не чинит отсутствующий preflight contract. Сначала нужно восстановить request contract, потом исправлять ровно его нарушенную часть.
\nЗафиксируйте одну операцию. Запишите origin страницы, URL API, method, content type, режим credentials и имена пользовательских заголовков. Добавьте status, видимые response headers и безопасный корреляционный идентификатор. Запись «браузер ругается на CORS» слишком коротка: она не показывает, был ли preflight, дошёл ли actual request до приложения и выполнился ли side effect.
\nНе берите для проверки production cookie. В учебном или тестовом окружении создайте отдельную сессию, выберите тестовую запись и заранее опишите допустимый side effect. Если controlled browser check пока невозможен, так и напишите: контракт проверен статически, сеть и сессия не проверены. Недостающий evidence — это результат диагностики, а не повод объявлять защиту рабочей.
\nCORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.
Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.
CSRF. Cookie может автоматически приложиться к запросу, созданному другим сайтом. Серверу нужно отдельное доказательство, что запрос сформировало разрешённое приложение. В token-based схеме сервер выдаёт непредсказуемый токен, а затем сравнивает его с токеном в сессии или с корректно связанным double-submit значением до mutation. Отсутствующий или неверный токен должен остановить side effect.
\n// Учебный пример контракта. Он не является готовым middleware.\nconst allowedOrigin = 'https://app.example.test';\n\nfunction corsHeaders(requestOrigin) {\n if (requestOrigin !== allowedOrigin) return {};\n\n return {\n 'Access-Control-Allow-Origin': allowedOrigin,\n 'Access-Control-Allow-Credentials': 'true',\n 'Vary': 'Origin',\n };\n}\n\nfunction preflightHeaders(requestMethod, requestHeaders) {\n const methods = ['POST'];\n const headers = ['Content-Type', 'X-CSRF-Token'];\n\n if (!methods.includes(requestMethod)) return null;\n if (requestHeaders.some((name) => !headers.includes(name))) return null;\n\n return {\n 'Access-Control-Allow-Methods': 'POST',\n 'Access-Control-Allow-Headers': 'Content-Type, X-CSRF-Token',\n };\n}\n\nfunction authorizeMutation(session, token) {\n if (!token || token !== session.csrfToken) {\n return { status: 403, reason: 'csrf-token-mismatch' };\n }\n return { status: 204 };\n}\nЭтот код показывает только идею: exact origin, узкий preflight и проверка токена до изменения состояния. Он не решает rotation, хранение сессии, кэширование HTML, logout, права на ресурс, обработку прокси и защиту от XSS. В production используйте contract и тесты выбранного framework. Не переносите учебный фрагмент в приложение без проверки его жизненного цикла.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| JavaScript не читает response | Origin отсутствует в allow-list | Сверить полный origin с Access-Control-Allow-Origin | Добавить только нужный origin |
| Credentials response заблокирован | Wildcard или нет Allow-Credentials | Проверить пару заголовков и режим credentials | Вернуть exact origin и явный credentials contract |
| OPTIONS получает отказ | Method или custom header не разрешён | Сопоставить request headers с ответом preflight | Разрешить один нужный method/header |
| POST form получил 403 | CSRF-токен отсутствует или не совпал | Проверить server reason до mutation | Исправить выдачу и передачу токена |
| Mutation прошёл, UI увидел error | Server action и CORS visibility различаются | Сверить application log и browser evidence | Исправить CORS, не снимая CSRF |
| 403 после token check | Нет business permission | Разделить CSRF reason и authorization reason | Исправить policy ресурса |
Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.
\nЕсли frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.
Затем выпишите фактический method и имена заголовков из client code. Не разрешайте «все методы и все headers» ради того, чтобы прекратить ошибку. Широкий ответ ухудшает review и превращает ошибку клиента в незаметно разрешённый путь. Если используется form-shaped POST, проверьте его отдельно: отсутствие preflight не означает отсутствие CSRF-риска.
\nЕсли запрос требует preflight, actual request может не начаться после отказа OPTIONS. Для другого request shape сервер может принять HTTP-запрос, но браузер не даст JavaScript прочитать response. Поэтому нужны оба слоя: browser DevTools или HAR и proxy/application evidence с корреляционным идентификатором. Один слой не заменяет второй.
\nVary: Origin, если ответ зависит от входного origin.Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что origin с другим port не получает credentialed response, неизвестный header не проходит preflight, а POST без token не меняет состояние. Это assertions над моделью контракта. Они не запускают gateway, браузер, framework middleware или настоящую session store.
\nПосле PASS такого теста корректная формулировка звучит так: «проверены заданные правила контракта и отрицательные ветки». Нельзя писать «CORS и CSRF проверены в сети», если не было controlled browser/API evidence. Нельзя переносить в заметку production cookie, token values и идентификаторы реальных пользователей.
\nЭтот маршрут не заменяет XSS review, аудит cookie attributes, CSP, authorization test или penetration test. XSS на доверенном origin меняет картину: чужой скрипт может использовать доступные ему API и токены. CORS и CSRF не защищают от выполнения вредоносного JavaScript внутри собственного origin. Не существует универсального значения TTL токена, набора SameSite или списка trusted origins: решение зависит от framework, browser support, session model и threat model.
Endpoint готов к review, когда видны четыре доказательства: точный разрешённый origin; корректный credentialed response и preflight contract, если они нужны; server-side rejection без CSRF proof до side effect; отдельная проверка business permission. Если есть только CORS header, работа не готова. Если есть только token test, frontend всё ещё может не прочитать response. Если есть только fixture PASS, нет доказательства интеграции. Эти слои дополняют друг друга и не заменяют друг друга.
\nПосле переноса frontend на новый host интерфейс перестаёт читать ответ API. В консоли появляется CORS error. Другой запрос получает 403 от CSRF middleware. Иногда сервер уже выполнил mutation, а браузер только скрыл ответ. Ошибка стоит дорого: команда может открыть API для любого origin, отключить проверку токена или повторить действие пользователя, не понимая, был ли запрос принят.
\nТезис простой: CORS и CSRF проверяют разные свойства запроса. CORS ограничивает, какой origin может прочитать cross-origin response из браузера. CSRF защищает изменение состояния от запроса без доказательства намерения пользователя. Один заголовок CORS не заменяет CSRF-токен. CSRF-токен не чинит отсутствующий preflight contract. Сначала нужно восстановить request contract, потом исправлять ровно его нарушенную часть.
\nЗафиксируйте одну операцию. Запишите origin страницы, URL API, method, content type, режим credentials и имена пользовательских заголовков. Добавьте status, видимые response headers и безопасный корреляционный идентификатор. Запись «браузер ругается на CORS» слишком коротка: она не показывает, был ли preflight, дошёл ли actual request до приложения и выполнился ли side effect.
\nНе берите для проверки production cookie. В учебном или тестовом окружении создайте отдельную сессию, выберите тестовую запись и заранее опишите допустимый side effect. Если controlled browser check пока невозможен, так и напишите: контракт проверен статически, сеть и сессия не проверены. Недостающий evidence — это результат диагностики, а не повод объявлять защиту рабочей.
\nCORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.
Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.
CSRF. Cookie может автоматически приложиться к запросу, созданному другим сайтом. Серверу нужно отдельное доказательство, что запрос сформировало разрешённое приложение. В token-based схеме сервер выдаёт непредсказуемый токен, а затем сравнивает его с токеном в сессии или с корректно связанным double-submit значением до mutation. Отсутствующий или неверный токен должен остановить side effect.
\nAPI='https://api.example.test/v1/profile/email'\nORIGIN='https://app.example.test'\n\n# OPTIONS не использует реальные cookies и показывает preflight contract.\ncurl --include --request OPTIONS \"$API\" --header \"Origin: $ORIGIN\" --header \"Access-Control-Request-Method: POST\" --header \"Access-Control-Request-Headers: content-type,x-csrf-token\"\n\n# В ответе ожидаются exact origin и только нужные method/headers:\n# Access-Control-Allow-Origin: https://app.example.test\n# Access-Control-Allow-Credentials: true\n# Access-Control-Allow-Methods: POST\n# Access-Control-Allow-Headers: Content-Type, X-CSRF-Token\n# Vary: Origin\n\n# Отрицательный путь: токен отсутствует. Только тестовая сессия и запись.\ncurl --include --request POST \"$API\" --header \"Origin: $ORIGIN\" --header \"Content-Type: application/json\" --cookie \"session=TEST_SESSION_ONLY\" --data-raw '{\"email\":\"qa@example.test\"}'\n\n# Положительный путь: подставьте token из test setup, не production secret.\ncurl --include --request POST \"$API\" --header \"Origin: $ORIGIN\" --header \"Content-Type: application/json\" --header \"X-CSRF-Token: TEST_TOKEN_ONLY\" --cookie \"session=TEST_SESSION_ONLY\" --data-raw '{\"email\":\"qa@example.test\"}'\nЭти команды показывают HTTP-контракт, но не моделируют browser same-origin policy. Успешный ответ curl не доказывает, что JavaScript прочитает response. В логах дополнительно сопоставьте correlation ID, решение preflight, результат CSRF-проверки и факт mutation. POST запускайте только в изолированном окружении, с тестовой сессией и тестовой записью; реальные cookie и token values нельзя переносить в shell history или журналы.
Если OPTIONS завершился отказом, actual request обычно не начинается. Если же preflight не нужен, браузер может отправить запрос, который другой сайт способен инициировать без чтения ответа. Поэтому отрицательная проверка должна смотреть не только на CORS headers, но и на неизменность тестовой записи после запроса без CSRF-доказательства.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| JavaScript не читает response | Origin отсутствует в allow-list | Сверить полный origin с Access-Control-Allow-Origin | Добавить только нужный origin |
| Credentials response заблокирован | Wildcard или нет Allow-Credentials | Проверить пару заголовков и режим credentials | Вернуть exact origin и явный credentials contract |
| OPTIONS получает отказ | Method или custom header не разрешён | Сопоставить request headers с ответом preflight | Разрешить один нужный method/header |
| POST form получил 403 | CSRF-токен отсутствует или не совпал | Проверить server reason до mutation | Исправить выдачу и передачу токена |
| Mutation прошёл, UI увидел error | Server action и CORS visibility различаются | Сверить application log и browser evidence | Исправить CORS, не снимая CSRF |
| 403 после token check | Нет business permission | Разделить CSRF reason и authorization reason | Исправить policy ресурса |
Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.
\nЕсли frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.
Затем выпишите фактический method и имена заголовков из client code. Не разрешайте «все методы и все headers» ради того, чтобы прекратить ошибку. Широкий ответ ухудшает review и превращает ошибку клиента в незаметно разрешённый путь. Если используется form-shaped POST, проверьте его отдельно: отсутствие preflight не означает отсутствие CSRF-риска.
\nЕсли запрос требует preflight, actual request может не начаться после отказа OPTIONS. Для другого request shape сервер может принять HTTP-запрос, но браузер не даст JavaScript прочитать response. Поэтому нужны оба слоя: browser DevTools или HAR и proxy/application evidence с корреляционным идентификатором. Один слой не заменяет второй.
\nVary: Origin, если ответ зависит от входного origin.Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что origin с другим port не получает credentialed response, неизвестный header не проходит preflight, а POST без token не меняет состояние. Это assertions над моделью контракта. Они не запускают gateway, браузер, framework middleware или настоящую session store.
\nПосле PASS такого теста корректная формулировка звучит так: «проверены заданные правила контракта и отрицательные ветки». Нельзя писать «CORS и CSRF проверены в сети», если не было controlled browser/API evidence. Нельзя переносить в заметку production cookie, token values и идентификаторы реальных пользователей.
\nЭтот маршрут не заменяет XSS review, аудит cookie attributes, CSP, authorization test или penetration test. XSS на доверенном origin меняет картину: чужой скрипт может использовать доступные ему API и токены. CORS и CSRF не защищают от выполнения вредоносного JavaScript внутри собственного origin. Не существует универсального значения TTL токена, набора SameSite или списка trusted origins: решение зависит от framework, browser support, session model и threat model.
Endpoint готов к review, когда видны четыре доказательства: точный разрешённый origin; корректный credentialed response и preflight contract, если они нужны; server-side rejection без CSRF proof до side effect; отдельная проверка business permission. Если есть только CORS header, работа не готова. Если есть только token test, frontend всё ещё может не прочитать response. Если есть только fixture PASS, нет доказательства интеграции. Эти слои дополняют друг друга и не заменяют друг друга.
\n