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 файла и номер строки. Один инженер предлагает заблокировать слияние. Другой называет результат ложным срабатыванием и хочет отключить правило. Оба решения преждевременны: файл описывает наблюдение инструмента, но не объясняет, что происходит в приложении.

\n

Цена ошибки зависит от выбранного обхода. Глобальное отключение убирает сигнал и для следующих участков кода. Безусловное блокирование превращает каждое совпадение формы в аварию. Команда тратит время на споры, а важный результат может затеряться среди шумных комментариев. Нужна простая граница: формат хранит данные, правило формулирует гипотезу, результат указывает на совпадение, а решение требует контекста проекта.

\n

Что именно сообщает анализатор

\n

SARIF 2.1.0 — формат обмена результатами статического анализа. В нём можно передать версию формата, инструмент, набор правил, результат и позицию в артефакте. Это общий контейнер для CI, анализатора и просмотрщика. Он не знает, является ли участок достижимым в нужном релизе, кто владеет компонентом и разрешено ли исключение в конкретной команде.

\n

Правило задаёт проверяемую гипотезу. Например: «значение из условно недоверенного источника передали в построение команды». Совпадение с шаблоном показывает только то, что форма кода похожа на гипотезу. Оно не доказывает источник значения, исполнение ветки или наличие уязвимости.

\n

Результат связывает гипотезу с наблюдением. ruleId показывает, какое правило сработало. message объясняет, что заметил инструмент. fingerprint помогает сопоставить результат между запусками. location указывает на файл и строку. Эти поля нужны для навигации и повторной проверки. Они не заменяют проверку исходника и границ данных.

\n
Граница между наблюдением и решением
СлойПример данныхЧто это означаетЧего не доказывает
Форматversion: 2.1.0Как читать logКачество проверки
Правилоid, revision, levelКакая гипотеза заданаРиск именно в этом месте
РезультатruleId, message, fingerprintКакое совпадение найденоДостижимость и влияние
ПозицияURI и номер строкиГде искать наблюдениеЧто код исполняется
Контекстasset, boundary, owner, scopeВ каких условиях принимать решениеПолное покрытие сценариев
\n

Минимальный контекст для review

\n

Чтобы выбрать действие, добавьте к результату пять полей. asset называет компонент или поток данных. entryPoint показывает предполагаемую точку входа. trustBoundary фиксирует, почему значение считают недоверенным. owner указывает роль или человека, который может подтвердить устройство компонента. releaseScope связывает проверку с изменением, веткой или релизом.

\n

Поле может быть неизвестно. Тогда запишите это прямо. Если не найден entry point, статус должен быть «контекст неполный», а не «безопасно». Если неизвестна граница доверия, нельзя объявлять значение проверенным. Такая запись сохраняет отрицательный путь: отсутствие доказательств не превращается ни в finding, ни в false positive.

\n

Пример: результат не равен вердикту

\n

Ниже приведён искусственный объект в памяти. Он не читает файл, не запускает Semgrep, не вызывает shell и не описывает настоящий finding. Значения src/demo-command.js, строки и fingerprint нужны только для показа связей между полями.

\n
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
\"Схема
Правило задаёт гипотезу, result указывает на совпадение, а project context связывает его с конкретным решением. Иллюстрация не показывает реальный запуск анализатора.
\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
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Сохраните ruleId, revision анализатора, fingerprint, URI, строку и commit. Не добавляйте вывод о риске, которого нет в данных.
  2. Прочитайте intent правила. Определите, какую форму оно ищет, какие языки и файлы входят в scope, какие условия считаются исключением.
  3. Проверьте исходник. Откройте ту же ревизию файла. Найдите entry point, источник значения, преобразования и вызов, на котором сработало правило.
  4. Заполните контекст. Назовите asset, trust boundary, owner и release scope. Неизвестные значения пометьте как неизвестные.
  5. Выберите узкое действие. Keep оставляет правило без изменений. Tune меняет гипотезу и получает новую revision. Scoped suppress ограничивает конкретный идентифицируемый результат и хранит причину, owner и дату пересмотра.
  6. Проверьте отрицательный путь. Если контекст не собран, не отключайте правило и не называйте совпадение подтверждённой уязвимостью. Назначьте следующий проверяемый шаг.
  7. Зафиксируйте rollback. Для изменения policy сохраните прежнюю revision и область действия. Возврат должен быть отдельным изменением конфигурации, а не устной договорённостью.
\n

Почему location и severity недостаточны

\n

Строка в SARIF может устареть между анализом и review. Файл мог измениться, ветка могла не попасть в релиз, а код мог быть недостижимым при нужной конфигурации. Поэтому location — это адрес для проверки, а не доказательство runtime-пути.

\n

Severity тоже не является итоговой оценкой. Уровень правила задаёт ожидаемую реакцию инструмента. Он не учитывает бизнес-ценность asset, права вызывающего кода, компенсирующие проверки и область релиза. Переносить его напрямую в слово «критично» нельзя.

\n

Fingerprint полезен для повторного review, но это не score риска. Он помогает увидеть, что один результат сохранился, переместился или исчез. Причину изменения нужно искать в diff, версии правила и коде, а не в самом fingerprint.

\n

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

\n

Разделение слоёв не даёт гарантии, что анализатор найдёт все ошибки. SARIF может быть неполным или заполненным по-разному разными producer. Static analysis может не знать о динамической загрузке, feature flag, сгенерированном коде и runtime-конфигурации. Контекстная запись не заменяет тест, ручной data-flow review, проверку доступа или воспроизводимый запуск инструмента.

\n

Не каждое правило стоит расширять. Более широкий pattern может поднять шум и увеличить стоимость review. Не каждое исключение стоит запрещать. Узкое, временное исключение с понятным объектом иногда лучше, чем изменение общего правила ради одного безопасного участка. Важны область действия, владелец, причина и дата повторной проверки.

\n

Пример в этой статье синтетический. Он проверяет только смысл полей и порядок рассуждения. Он не сообщает число срабатываний, coverage, false-positive rate, production effect или факт запуска в каком-либо репозитории.

\n

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

\n

Результат можно передавать в review, когда выполнены четыре условия: правило и его revision известны; location проверена на актуальном commit; asset, trust boundary и owner записаны либо явно отмечены как неизвестные; выбранное действие имеет scope и способ отмены. Для tune должна существовать новая revision и описание изменённой гипотезы. Для scoped suppress нужны точный объект результата, причина и дата пересмотра.

\n

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

\n

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

\n" + "title": "Статический анализ: где заканчивается правило и начинается решение", + "excerpt": "Линтер и анализатор быстро находят совпадения в коде, но не выносят вердикт о работе приложения. Разбираем модель, воспроизводимую проверку и границы применимости.", + "contentHtml": "

В pull request статический анализатор показывает несколько ошибок. Один разработчик предлагает сразу заблокировать слияние, другой — отключить правило целиком. Оба решения могут быть ошибочными. Отчёт сообщает, где и по какому правилу найдено совпадение, но не доказывает, что ветка исполняется, вход пришёл из недоверенной зоны или дефект попадёт в релиз.

\n

Цена неверного действия практическая. Глобальное отключение убирает сигнал и для будущего кода, а безусловная блокировка превращает шум в очередь ручных исключений. Рабочая модель разделяет четыре слоя: правило формулирует гипотезу, анализатор фиксирует наблюдение, код и конфигурация дают контекст, а команда принимает решение с понятным scope. Ниже этот порядок можно повторить на обычном JavaScript-проекте.

\n

Что называют статическим анализом

\n

Статический анализ проверяет программу по исходному коду, его синтаксическому дереву, типам, зависимостям или правилам потока данных, не исполняя весь сценарий приложения как единое целое. Линтер обычно проверяет стиль и локальные конструкции. Проверка типов ищет несогласованные операции с типами. Security-анализатор может искать опасные источники, преобразования и точки использования данных.

\n

Разные инструменты отвечают на разные вопросы. Линтер может уверенно сказать, что переменная объявлена, но не используется. Он не может по одной такой записи определить, доступен ли сервис в production. Проверка типов может найти несовместимость интерфейсов, но не подтверждает, что сервер вернул ожидаемое поле. Анализатор потока данных может показать возможный путь к опасному вызову, но должен учитывать конфигурацию, права и достижимость.

\n
Четыре слоя одного результата
СлойПримерЧто подтверждаетЧего не подтверждает
Правилоno-unused-vars или security patternКакую гипотезу проверяет инструментЧто гипотеза истинна во всех путях выполнения
НаблюдениеФайл, строка, сообщение, уровеньГде инструмент увидел совпадениеЧто код достигнут в нужном окружении
КонтекстCommit, entry point, конфигурация, граница доверияНа какой ревизии и в каких условиях читать сигналПолноту runtime-карты без дополнительных проверок
РешениеИсправить, уточнить правило, оставить исключениеКакой ограниченный шаг принят и кто за него отвечаетГарантию отсутствия других дефектов
\n

Эта граница защищает от подмены понятий. Статический анализ — ранний и дешёвый слой обратной связи, а не замена тестам, ручному review, проверке разрешений и наблюдению за работающей системой.

\n

Механизм: от исходника до отчёта

\n

На вход инструмент получает выбранную ревизию, набор файлов, конфигурацию и правила. Сначала он разбирает файлы или строит представление программы. Затем применяет правила и создаёт результаты. Результат обычно содержит идентификатор правила, сообщение, уровень и координату в файле. Формат отчёта может быть человекочитаемым или машинным.

\n

Изменение любого входа меняет смысл результата. Тот же файл на другом commit может иметь иную строку. Новый parser может иначе понять синтаксис. Конфигурация может исключить generated-файлы или, наоборот, включить тестовые фикстуры. Поэтому в CI рядом с отчётом фиксируют commit, версию инструмента и конфигурацию. Без этих трёх значений повторная проверка превращается в догадку.

\n
Схема статического анализа: правило задаёт гипотезу, SARIF хранит структуру и результат, а проектный контекст добавляет границу доверия, владельца и область изменения перед решением
Формат отчёта переносит наблюдение между инструментами, но проектный контекст всё равно должен быть проверен отдельно.
\n

Воспроизводимый запуск на JavaScript

\n

Возьмём ESLint как понятный пример локального статического анализа. Команда выполняется в проекте, где ESLint уже указан в package.json и зависимости установлены по lock-файлу. Это ограничение принципиально: запуск произвольной версии через сетевой загрузчик не даёт той же воспроизводимости.

\n
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. Команда не должна считать нулевой результат доказательством корректности бизнес-сценария.

\n

Для честного сравнения запусков сохраняйте не только число ошибок, но и commit, версию Node.js, версию ESLint, конфигурацию и список исключений. Иначе уменьшение количества результатов может означать не исправление, а изменение области проверки.

\n

SARIF: переносимый отчёт, а не вердикт

\n

SARIF 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 и контекста — напоминание о том, что эти сведения нельзя восстановить из одной строки.

\n

При загрузке SARIF в платформу нужно отдельно проверить адреса файлов. Например, GitHub сопоставляет относительный URI с файлом репозитория и учитывает partialFingerprints для повторных результатов. Несколько наборов результатов для одного commit требуют уникальной категории. Это правила конкретной платформы, а не универсальное свойство самого анализа.

\n

Как отличить полезный сигнал от неверного решения

\n
Диагностика по схеме «симптом — причина — проверка — действие»
СимптомВероятная причинаПроверкаОграниченное действие
Результат указывает на строку, которой уже нетОтчёт относится к другому commitСверить commit отчёта и текущую ревизию, затем повторить запускНе переносить finding на новую строку вручную
Одинаковое правило срабатывает в тестах и production-кодеScope правила шире предполагаемой границыСравнить назначение файлов и путь от входа до вызоваУточнить pattern или разделить конфигурацию, не выключать правило глобально
После обновления инструмента результатов стало меньшеИзменились parser, правила или область файловСравнить версии, конфигурацию и diff отчётовНазвать причину изменения до принятия нового baseline
Найден возможный опасный вызовАнализатор не знает проверку или праваПроследить источник, преобразования, allowlist, entry point и праваИсправить код либо оставить сигнал до подтверждения контекста
Команда просит добавить исключениеКонкретный участок признан допустимым или правило шумитОпределить точный объект, владельца, причину и срок пересмотраСделать узкое исключение с rollback, а не менять весь проект
\n

Термин «ложное срабатывание» тоже требует доказательства. Им можно назвать результат после проверки, которая показала, что условие правила не соответствует реальному риску в этом конкретном участке. Пока источник данных или путь выполнения неизвестны, точнее использовать статус «контекст не собран».

\n

Порядок проверки одного результата

\n
  1. Зафиксируйте входы. Сохраните commit, версию инструмента, конфигурацию, идентификатор правила, файл, строку и полный текст сообщения.
  2. Прочитайте правило. Определите, какую конструкцию оно ищет, какие файлы входят в scope и какие исключения уже предусмотрены.
  3. Откройте ту же ревизию. Проверьте, что URI и строка относятся к тому же исходнику, который анализировал CI.
  4. Восстановите путь. Для security-сигнала найдите источник значения, преобразования, проверки, sink и entry point. Для lint-сигнала проверьте локальную конструкцию и намерение кода.
  5. Добавьте контекст. Запишите компонент, границу доверия, конфигурацию, владельца и область изменения. Неизвестное поле оставьте явно неизвестным.
  6. Выберите действие. Исправьте дефект, уточните правило для будущих результатов или примените узкое исключение только к доказанно допустимому месту.
  7. Проверьте отрицательный путь. Убедитесь, что другой commit, соседний файл и тот же источник данных не исчезли из проверки из-за широкого исключения.
  8. Сохраните повторяемый результат. Оставьте команду запуска, expected outcome и условие отката. Следующий инженер должен получить тот же вопрос и увидеть, что изменилось.
\n

Где проходит граница применимости

\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 остаётся полезной, но её нужно сверить с документацией платформы.

\n

Критерий готовности

\n

Результат можно передать в review, когда другой инженер может повторить проверку на той же ревизии и ответить на четыре вопроса: какое правило сработало, где именно, в каком контексте и почему выбрано это действие. Для исправления должны быть видны тест или повторный запуск. Для изменения правила — новая версия гипотезы и её scope. Для исключения — точный объект, причина, владелец, срок пересмотра и способ отката.

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/168.json b/editorial/agent-rewrites/168.json index edb8fb6..86f8c2e 100644 --- a/editorial/agent-rewrites/168.json +++ b/editorial/agent-rewrites/168.json @@ -1,7 +1,7 @@ { "index": 168, "slug": "editorial-2023-05-practice-static-analysis", - "title": "Шумное правило статического анализа: как принять решение по одному сигналу", - "excerpt": "Статический анализ показывает совпадение, а не готовый вердикт. Разбираем один сигнал по rule, result, location и контексту, затем выбираем проверяемое действие без глобального отключения защиты.", - "contentHtml": "

В pull request появляется предупреждение: правило увидело передачу значения в функцию, которая строит команду. Строка выглядит безопасно. Значение приходит из внутреннего объекта, ветка закрыта проверкой, а правило повторяется в десятках файлов. После нескольких таких комментариев команда просит выключить его целиком.

\n

Симптом понятен: статический анализ тормозит review и смешивает полезные находки с шумом. Цена ошибки выше, чем время на один комментарий. Глобальное отключение убирает сигнал для следующего участка, который никто ещё не видел. Автоматическое объявление каждой строки уязвимостью создаёт другую проблему: инженеры перестают различать риск и форму совпадения.

\n

Рабочий тезис простой: результат анализатора — это начало проверки, а не её конец. Сначала нужно отделить правило, результат, позицию и контекст. Потом выбрать одно из трёх действий: оставить сигнал, уточнить правило или временно ограничить один результат. Если контекст не собран, сигнал остаётся видимым.

\n

Что именно сообщает анализатор

\n

Правило описывает синтаксическую или семантическую гипотезу. Например, оно ищет передачу условно недоверенного значения в runShell. Результат сообщает, что гипотеза совпала в конкретном месте. Позиция даёт URI, строку и иногда отпечаток. Ни одно из этих полей не говорит само по себе, что ветка исполняется, значение действительно приходит извне или команда достигнет production.

\n

SARIF 2.1.0 полезен как формат обмена этими фактами. В нём можно связать инструмент, версию правила, result, location и fingerprint. Формат не добавляет сведения, которых инструмент не собирал. Поэтому ruleId нельзя читать как готовый security verdict, а startLine — как доказательство достижимости.

\n
Как читать один сигнал статического анализа
СлойСимптомПричинаПроверкаДействие
ПравилоОдинаковый ruleId повторяется в разных модуляхПаттерн шире ожидаемого сценарияПрочитать intent, revision и diff правилаОставить или уточнить pattern
ResultЕсть сообщение и строка, но нет решенияСовпадение приняли за вывод о кодеСверить fingerprint и версию инструментаДобавить контекст, не ставить verdict
LocationУказан URI, но файл уже изменилсяРезультат относится к другой ревизииПроверить commit, строку и entry pointПовторить анализ на актуальной ревизии
КонтекстНепонятно, откуда пришло значениеTrust boundary не записанаНазначить владельца и назвать источникОставить сигнал видимым
РешениеПредлагают выключить правило глобальноТочечный результат смешали с политикойПроверить scope, срок и rollbackВыбрать keep, tune или scoped suppress
\n

Учебный пример: форма совпадения

\n

Ниже приведён синтетический фрагмент. Он нужен, чтобы показать границу между совпадением и выводом. Имена файла, строки и правило вымышлены. Пример не читает репозиторий, не запускает анализатор и не доказывает наличие уязвимости.

\n
function exportReport(request) {\n  const command = request.options.command;\n\n  if (!ALLOWED_COMMANDS.has(command)) {\n    throw new Error('unsupported command');\n  }\n\n  return runShell(command);\n}
\n

Правило demo.untrusted-command-construction может отметить вызов runShell(command). Синтаксически это разумный сигнал: функция получает значение, которое прошло через объект запроса. Но по одному совпадению нельзя установить, что request контролирует внешний пользователь, что проверка ALLOWED_COMMANDS корректна или что функция вызывается в интересующем артефакте.

\n

Проверка должна идти по цепочке данных. Нужно найти источник request, определить границу доверия, проверить содержимое allowlist и проследить вызов до entry point. Если любое звено неизвестно, запись должна сказать «контекст неполный». Это точнее, чем «ложное срабатывание»: отсутствие данных не доказывает безопасность.

\n

В учебной модели результат можно представить так: ruleId связывает совпадение с правилом, revision фиксирует его версию, uri и startLine указывают место, а fingerprint помогает сопоставить тот же результат после повторного запуска. Fingerprint не является оценкой риска. Он не заменяет чтение актуального исходника.

\n
\"Воронка
Учебная воронка разбора одного сигнала. Она показывает порядок классификации и не утверждает наличие findings, coverage или production-эффекта.
\n

Контекст, без которого решение преждевременно

\n

Для одного сигнала достаточно короткой context record. Поле asset называет компонент или артефакт. entryPoint показывает, откуда начинается путь. trustBoundary объясняет, почему значение считают недоверенным. owner называет человека или роль, которая может подтвердить устройство компонента. releaseScope связывает решение с ревизией или изменением, а не со всем продуктом.

\n

Эти поля не обязаны быть заполнены сразу. Но неизвестное нужно записать как неизвестное. Если не найден entry point, нельзя утверждать, что код недостижим. Если неясен источник данных, нельзя утверждать, что значение безопасно. Если результат относится к generated code, сначала нужно выяснить, какой исходный файл владеет поведением. Контекст не превращает сигнал в уязвимость, но делает следующий вопрос проверяемым.

\n

Три действия после классификации

\n

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

\n

Tune. Правило меняют, когда сама гипотеза слишком широка. Например, pattern можно ограничить известным небезопасным sink или потребовать явного признака внешнего источника. Изменение должно получить новую revision и описание того, какие будущие совпадения оно перестанет показывать. «Стало меньше шума» не объясняет trade-off.

\n

Scoped suppress. Один результат временно исключают, когда правило нужно сохранить, а конкретный участок уже проверен. Исключение должно ссылаться на точный fingerprint или другую устойчивую идентификацию, иметь scope, владельца, причину и дату пересмотра. Срок не должен превращать временное решение в бессрочное разрешение.

\n

Действия по порядку

\n
  1. Сохраните ruleId, revision правила, fingerprint, URI, строку и ревизию исходника. Не добавляйте в запись вывод о безопасности.
  2. Прочитайте intent правила и его diff. Уточните язык, область файлов и условие, которое вызывает совпадение.
  3. Восстановите путь данных: источник, преобразования, проверка, sink и entry point. Для каждого шага отметьте подтверждённое и неизвестное.
  4. Заполните asset, trust boundary, owner и release scope. Если поле неизвестно, поставьте статус context-incomplete.
  5. Выберите keep, tune или точечный suppress. Запишите причину, scope, срок пересмотра и требуемый rollback.
  6. Повторите анализ на актуальной ревизии. Проверьте, что tune изменил ожидаемую форму, а suppress не скрыл соседние результаты.
  7. Проверьте отрицательный путь: глобальное действие disable-globally должно быть отклонено политикой, а неполный контекст не должен превращаться в «безопасно».
\n

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

\n

Suppression решает вопрос об одном уже идентифицированном результате. Tune решает вопрос о гипотезе, которую правило применяет к будущим участкам. Если команда раздаёт исключения там, где pattern неправильно понимает boundary, она сохраняет старую ошибку и постепенно теряет карту покрытия. Если команда переписывает правило ради одного проверенного участка, она может скрыть реальные сигналы в других модулях.

\n

Не стоит путать и другой отрицательный путь. Если анализатор показал результат на synthetic fixture, это доказывает только то, что учебный объект соответствует заданной форме. PASS у такого fixture не означает, что scanner читал файл, запускал ветку или получил finding в реальном проекте. Код примера ограничен учебной задачей и не является production-рецептом.

\n

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

\n

Статический анализ не видит автоматически весь runtime-контекст. Feature flag может скрыть путь. Generated code может отличаться от исходного шаблона. Динамический импорт, конфигурация окружения и права доступа могут изменить достижимость. SARIF сохраняет результат инструмента, но не подтверждает корректность правила, полноту проекта и отсутствие других путей к sink.

\n

Метод также не даёт production-метрику. Он не сообщает precision, recall, coverage, число предотвращённых инцидентов или время до исправления. Для таких утверждений нужны отдельные данные: запуски на определённых ревизиях, правила подсчёта и независимая проверка. В этой статье таких измерений нет.

\n

Критерий готовности

\n

Разбор одного сигнала готов, если другой инженер может повторить решение без устного контекста. В записи есть ruleId и revision, точный result, проверенная ревизия исходника, путь от источника до sink, владелец, scope и выбранное действие. Для tune виден diff правила. Для suppress видны идентификатор результата, причина и срок пересмотра. Для keep ясно, какая проверка ещё не выполнена.

\n

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

\n

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

\n" + "title": "Шумное правило статического анализа: как разобрать сигнал и выбрать действие", + "excerpt": "Статический анализ сообщает о совпадении, а не выносит готовый вердикт. Разбираем SARIF-результат, восстанавливаем путь данных и выбираем keep, tune или точечное suppress без глобального отключения правила.", + "contentHtml": "

В pull request появляется предупреждение об ошибке: правило увидело значение рядом с функцией, которая запускает команду. Строка выглядит безопасной: значение приходит из внутреннего объекта, выше стоит проверка, а такое сообщение повторяется в десятках файлов. Команда предлагает выключить правило целиком, чтобы review снова стал быстрым.

\n

Это плохой выбор по двум причинам. Статический анализ мог заметить настоящий путь к опасному sink, а мог увидеть только форму кода, не зная источника данных. Глобальное отключение смешивает эти случаи и убирает следующий сигнал вместе с текущим. Полезная единица работы — не «правило шумное» и не «строка уязвима», а один результат с проверяемым контекстом.

\n

Ниже — схема разбора для JavaScript и других языков. Она отвечает на один вопрос: что нужно проверить, прежде чем оставить результат, уточнить правило или ограниченно подавить совпадение. Названия анализатора и внутренней системы в примерах условны; поля SARIF соответствуют формату версии 2.1.0.

\n

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

\n

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

\n

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

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

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

\n

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

\n
{\n  "version": "2.1.0",\n  "runs": [{\n    "tool": {\n      "driver": {\n        "name": "demo-scanner",\n        "rules": [{ "id": "demo.untrusted-command" }]\n      }\n    },\n    "results": [{\n      "ruleId": "demo.untrusted-command",\n      "level": "warning",\n      "message": { "text": "value reaches a command sink" },\n      "locations": [{\n        "physicalLocation": {\n          "artifactLocation": { "uri": "src/export.js" },\n          "region": { "startLine": 8 }\n        }\n      }],\n      "partialFingerprints": {\n        "demo.untrusted-command/v1": "example-fingerprint"\n      }\n    }]\n  }]\n}
\n

Здесь level — уровень, который выбрал инструмент, а не универсальная шкала ущерба. startLine помогает открыть место, но не подтверждает достижимость. Частичный отпечаток помогает системе сопоставлять результаты; он не доказывает, что два похожих сообщения относятся к одной причине. Официальная спецификация SARIF отдельно предупреждает, что абсолютный номер строки плохо подходит для устойчивого fingerprint.

\n

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

\n

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

\n
node -e "const fs=require('node:fs'); const x=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const rs=(x.runs||[]).flatMap(r=>r.results||[]); for (const r of rs) { const p=r.locations?.[0]?.physicalLocation; console.log([r.ruleId || '<no-rule>', p?.artifactLocation?.uri || '<no-uri>', p?.region?.startLine || '?'].join('\t')); }" result.sarif
\n

Ожидаемый вывод для примера — demo.untrusted-command src/export.js 8. Команда проверяет только читаемость и наличие нескольких полей. Она не запускает анализатор, не открывает исходник и не устанавливает безопасность. Для настоящего результата дополнительно зафиксируйте commit, имя инструмента, версию правил и команду запуска. Иначе при повторе можно незаметно сравнить разные ревизии.

\n

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

\n

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

\n
function exportReport(request) {\n  const command = request.options.command;\n\n  if (!ALLOWED_COMMANDS.has(command)) {\n    throw new Error('unsupported command');\n  }\n\n  return runShell(command);\n}
\n

Правило вправе отметить последний вызов. Но для решения нужно проверить, кто создаёт request, что именно входит в ALLOWED_COMMANDS, можно ли изменить объект после проверки и вызывается ли функция из внешнего entry point. Если хотя бы одно звено неизвестно, статус должен быть «контекст не собран», а не «безопасно».

\n

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

\n
const commands = new Map([\n  ['weekly', { file: '/usr/bin/report-export', args: ['--weekly'] }],\n]);\n\nconst selected = commands.get(request.options.command);\nif (!selected) throw new Error('unsupported command');\nreturn spawn(selected.file, selected.args, { shell: false });
\n

Это только иллюстрация для Node.js: абсолютный путь, фиксированные аргументы и shell: false не заменяют проверку прав, окружения, таймаута и обработки ошибок. Документация Node.js предупреждает не передавать непроверенный ввод при включённом shell. На другой платформе и для другого API нужны собственные ограничения.

\n

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

\n

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

\n

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

\n

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

\n

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

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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/169.json b/editorial/agent-rewrites/169.json index 13ba5fe..49a619c 100644 --- a/editorial/agent-rewrites/169.json +++ b/editorial/agent-rewrites/169.json @@ -1,7 +1,7 @@ { "index": 169, "slug": "editorial-2023-04-field-dependency-security", - "title": "Уязвимая зависимость в дереве: как доказать безопасное обновление", - "excerpt": "Почему предупреждение сканера не равно доказанной уязвимости в runtime и как проверить обновление зависимости по дереву, окружению, сценарию запуска и откату.", - "contentHtml": "

Сканер сообщает об уязвимой версии пакета. Пакет не указан в package.json, поэтому команда решает, что он не используется. Через несколько часов обновление ломает сборку: изменилось транзитивное дерево, peer-зависимость перестала разрешаться, а новый пакет требует другой Node.js. Цена ошибки — остановленный деплой, срочный откат и неясный ответ на вопрос, какой артефакт уже попал в окружение.

\n

Обратная ошибка тоже дорогая. Команда удаляет пакет из манифеста, получает зелёный install и закрывает предупреждение. Но уязвимый модуль остаётся в lockfile или в другом production-артефакте. Исправление должно отвечать на два разных вопроса: входит ли компонент в поставляемый артефакт и что изменится после его обновления.

\n

Тезис: версия не является доказательством

\n

Безопасное обновление — это не замена одной строки в манифесте. Это проверяемая цепочка: идентифицированный компонент, зафиксированное дерево, воспроизводимая установка, проверка приложения в целевом runtime и готовый путь возврата.

\n

Уязвимость обычно приходит через несколько уровней. Приложение зависит от http-client. Он зависит от parser. Advisory указывает на старую версию parser. Вызов метода может находиться далеко от корневого кода, но пакет всё равно входит в установленное дерево. Обратное также верно: запись в lockfile ещё не доказывает, что компонент вошёл в собранный образ или реально загружен процессом.

\n

Как устроена проверка

\n

Сначала отделите четыре объекта. Манифест описывает намерение проекта. 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 и условие остановки
\n

Пример с транзитивным пакетом

\n

Следующий фрагмент — учебный. Он не читает настоящий lockfile и не устанавливает пакеты. Он показывает, почему проверка должна искать не только прямые зависимости. В реальном проекте результат нужно получить командой package manager и сопоставить с образом, который будет выпущен.

\n
const 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 артефакта.

\n

Иллюстрация цепочки доказательств

\n
\"Цепочка
Порядок проверок отделяет состав дерева от поведения приложения. Зелёный install не открывает выпуск без runtime smoke и проверки итогового артефакта.
\n

Симптом → причина → проверка → действие

\n
Диагностика типичных ложных выводов
СимптомПричинаПроверкаДействие
Пакет не виден в 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 изменил формат или внешний протоколПроверить обратную совместимость миграцииРазделить изменение формата и замену зависимости
\n

Порядок действий

\n
  1. Скопируйте advisory и укажите источник, пакет, затронутый диапазон и дату проверки. Не называйте компонент уязвимым для приложения, пока не проверили его путь в артефакте.
  2. Зафиксируйте baseline: commit, lockfile, package manager, Node.js, platform и digest текущего кандидата на выпуск.
  3. Постройте dependency tree для production-команды. Найдите прямой путь к каждой копии компонента и проверьте optional и peer-ветки.
  4. Сформируйте минимальный candidate update. Измените только необходимые записи, затем прочитайте весь diff lockfile: версии, integrity, источники, scripts и соседние разрешения.
  5. В чистом окружении выполните установку с теми же флагами, которые использует pipeline. Сохраните команду и exit code. Не превращайте warning о runtime в PASS.
  6. Запустите тесты границ, которых касается пакет: startup, обработка входных данных, сетевой вызов, сборка, CLI или browser bundle. Название теста должно объяснять проверяемый контракт.
  7. Сделайте smoke в образе-кандидате. Проверьте старт, один безопасный сценарий и отрицательный путь. Например, некорректный вход должен получить ожидаемый отказ, а не попасть в обработчик.
  8. Сканируйте образ или архив по immutable digest. Сверьте состав скана с lockfile и SBOM, если SBOM создаёт ваш pipeline. Различие источников — сигнал к расследованию, а не повод выбрать удобный результат.
  9. Перед публикацией проверьте rollback на уровне артефакта. Если изменение затрагивает миграцию, формат данных или внешний протокол, остановите независимый откат версии и подготовьте совместимый план.
\n

Почему engines и lockfile недостаточны

\n

Поле engines выражает заявленный диапазон. Оно не запускает приложение, не проверяет native addon и не показывает, какая ветка разрешилась в конкретной платформе. При мягкой настройке package manager несовпадение может остаться предупреждением. Поэтому engine check полезен как ранний фильтр, но не как итог.

\n

Lockfile даёт воспроизводимую точку для установки. Он не является снимком уже работающего процесса. Сборка может исключить пакет, добавить его в другой слой, заменить optional dependency или использовать иной lockfile. Состав проверяйте на выходном артефакте, а поведение — в целевом runtime.

\n

Ограничения и отрицательный путь

\n

Dependency 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

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

\n

Обновление готово к выпуску, когда команда может предъявить один набор связанных доказательств: advisory и scope, baseline и candidate, полный diff дерева, успешную чистую установку, тесты затронутого контракта, smoke в целевом образе, результат сканирования того же digest и проверяемый rollback. Каждый результат имеет команду, окружение и exit status или наблюдаемый ответ.

\n

Любой пропущенный элемент помечается явно: not run, not applicable с причиной или unknown. Слово PASS допустимо только для реально выполненной проверки. Если доказательства относятся к другому commit, образу или runtime, обновление не готово.

\n

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

\n" + "title": "Безопасность зависимостей: как проверить транзитивный пакет до релиза", + "excerpt": "Сканер нашёл пакет, которого нет в package.json. Разбираем путь через npm-дерево, lockfile, production-артефакт и runtime, затем проверяем обновление и откат без ложного PASS.", + "contentHtml": "

Сканер сообщает об уязвимой версии пакета, но в package.json этого имени нет. Команда удаляет случайную строку, получает зелёный install и закрывает задачу. Позже выясняется, что пакет пришёл транзитивно, остался в другом production-артефакте или уже был упакован в образ. Обратный сценарий тоже опасен: обновление без проверки меняет peer-зависимость, требует другой Node.js и останавливает деплой.

\n

Проверяемый вывод должен быть уже: какой пакет найден, кто его привёл, в каком артефакте он оказался, достигается ли уязвимая ветка в названном сценарии и можно ли вернуться к исходному digest. Предупреждение scanner — вход в расследование, а не доказательство ни безопасности, ни эксплуатации.

\n

Не начинайте с удаления строки

\n

Зависимость может отсутствовать в корневом манифесте и всё равно входить в установленное дерево. Приложение зависит от http-client, тот — от parser, а advisory указывает на старую версию parser. Нужно проверить не только имя и версию, но и путь от production root до компонента.

\n

Есть и обратная граница. Запись в lockfile описывает результат разрешения, но не доказывает, что пакет физически попал в конкретный образ. Сборка может исключить dev-зависимость, заменить optional-ветку, собрать другой workspace или использовать другой lockfile. Поэтому после дерева проверяют именно выходной артефакт и его digest.

\n

Четыре слоя доказательств

\n

Разделите объекты до первого изменения. Манифест описывает намерение проекта и диапазоны прямых зависимостей. Lockfile фиксирует разрешённое дерево. SBOM (Software Bill of Materials) перечисляет компоненты и отношения поставки для названного артефакта. Runtime evidence показывает наблюдение конкретного процесса и сценария. Каждый слой отвечает на свой вопрос и не заменяет соседний.

\n

Сначала зафиксируйте baseline: commit, package manager, Node.js, настройки .npmrc, команду установки и digest текущего артефакта. Затем назовите candidate: пакет, исходную и целевую версии, advisory и предполагаемый путь обновления. Без baseline нельзя отличить удаление уязвимого узла от его перемещения в другую ветку.

\n
Что именно проверяет каждый слой
СлойВопросЧто сохранить
package.jsonКакие direct dependencies и диапазоны объявлены?Commit и diff манифеста
package-lock.jsonКакое дерево разрешено зафиксированным проектом?Полный diff, integrity и настройки resolver
node_modules / imageЧто физически установлено в проверенном артефакте?Имя образа, digest и состав слоя
SBOMКакие компоненты заявлены для named artifact?Формат, источник генерации и связь с digest
RuntimeЧто произошло в названном entry point?Команду, вход, ответ, лог и отрицательный путь
\n

Воспроизводимый маршрут для npm

\n

Ниже команды для проекта на npm. Подставьте в PACKAGE имя из advisory и выполняйте их в том workspace, который собирается в production. Если pipeline использует --legacy-peer-deps, workspaces или иной .npmrc, повторите те же настройки: разрешение зависит не только от текста манифеста.

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

npm ci — контрольная точка. По официальной документации ему нужен существующий lockfile; при расхождении с package.json команда завершается ошибкой, удаляет имеющийся node_modules и не переписывает манифест или lockfile. Если установка упала, это отдельный результат: сначала разберите peer-диапазоны, версию npm и флаги, с которыми lockfile был создан.

\n

Не заменяйте npm ci на npm install ради зелёного exit code: обычная установка может пересчитать lockfile. Флаг --omit=dev убирает dev-зависимости с диска, но записи о них остаются разрешёнными в lockfile. Если build использует dev-инструменты, описывайте build-артефакт отдельной проверкой.

\n

npm ls --all --omit=dev --json показывает логическое дерево установленных пакетов и может отметить missing, invalid или extraneous узлы. npm explain даёт обратный путь — почему пакет присутствует. Нулевой вывод не закрывает advisory, пока не проверены правильные workspace, lockfile, режим установки и artifact.

\n

Найдите каждый путь в дереве

\n

Одна строка parser@1.0.0 отвечает только на вопрос о версии. Один пакет может присутствовать несколько раз из-за несовместимых диапазонов. Логическое дерево npm также не равно физическому расположению на диске: deduplication и peer-зависимости меняют картину. Для решения нужны все пути от корня.

\n
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 и что опасная ветка вызывается. Учитывайте это ограничение при формулировке результата.

\n

Сопоставьте дерево с артефактом

\n
Схема границ manifest, lockfile, SBOM и runtime evidence: каждый следующий слой требует отдельной проверки и не является автоматической гарантией предыдущего
Manifest выражает намерение, lockfile — разрешённое дерево, SBOM — состав названного артефакта, runtime evidence — наблюдение конкретного сценария. Стрелки означают проверку связи, а не доказанную безопасность.
\n

SBOM полезен только вместе с идентичностью сборки: commit, digest, временем и понятным источником генерации. Файл с названием sbom.json без связи с release candidate может относиться к соседнему образу. Сверяйте компонент, версию, источник и relationship; расхождение с lockfile — сигнал расследования, а не повод выбрать более удобный результат.

\n

Сканируйте immutable digest именно публикуемого образа или архива. Локальный node_modules, staging-образ и предыдущий digest не являются заменой. Если сборка создаёт несколько image layers, выясните, где лежит компонент и не остаётся ли старая копия в другом слое.

\n
Типовой симптом и следующий тест
СимптомГипотезаПроверкаДействие
Пакета нет в package.jsonОн транзитивный или пришёл из другого workspacenpm ls --all и npm explain в production rootНазвать родителя и проверить каждый путь
Lockfile изменился, advisory осталсяОсталась другая копия или веткаСравнить все записи и версии в дереве и SBOMОбновить каждого владельца либо обосновать исключение
npm ci зелёный, сервис не стартуетНе совпали Node.js, peer range, ABI или module formatSmoke в том же image и runtimeОстановить выпуск и сузить candidate
Сканер не видит старую версиюПроверен другой digest или слойСопоставить digest отчёта и releaseПересканировать публикуемый artifact
Rollback вернул версию, но данные не читаютсяИзменился формат или внешний протоколПроверить обратную совместимость миграцииРазделить security update и изменение данных
\n

Отделите наличие от достижимости

\n

Наличие пакета и достижимость уязвимого кода — разные утверждения. Назовите entry point, импорт или вызов, входные данные и режим исполнения. Для сервера это HTTP-обработчик, для CLI — команда, для worker — сообщение из очереди. Формулировка «ветка не достигнута в проверенном сценарии» честнее, чем «уязвимости нет».

\n

Проверьте optional-зависимости на целевой платформе, peer-зависимости, bundled packages, worker, cron и отдельный CLI. Сборщик может исключить статический импорт, а динамический require сохранить путь. Строковый поиск находит имя, но не учитывает условие, экспорт, bundler и конфигурацию.

\n

Минимальное evidence — связка dependency path, содержимого production-артефакта и названного сценария с ожидаемым результатом. Добавьте отрицательный путь: некорректный вход должен быть отклонён ожидаемым способом, а недоступная optional-часть не должна молча создавать видимость исправности.

\n

Обновление как контролируемое изменение

\n

Сформируйте небольшой candidate. Обновляйте прямого родителя, если он выпускает совместимую версию, или добавляйте явное разрешение с объяснением владельца риска. Не меняйте одновременно Node.js, package manager и несколько крупных библиотек: rollback перестанет показывать причину отказа.

\n

Прочитайте весь diff lockfile: resolved URL, integrity, peer и optional-поля, количество копий и новые install scripts. Поле engines выражает заявленный диапазон совместимости. Без engine-strict npm может оставить предупреждение и продолжить, а строгая проверка всё равно не запускает приложение и не проверяет native addon.

\n

Повторите clean install с параметрами pipeline. Затем запустите startup, импорт компонента, валидный и невалидный вход, сборку, сетевой вызов и затронутый CLI. У каждого теста должны быть команда, окружение и exit code. Фраза «всё прошло» без этого набора не является доказательством.

\n

Порядок действий перед выпуском

\n
  1. Скопируйте advisory: пакет, затронутый диапазон, исправленную версию и источник. Отделите утверждение advisory от гипотезы о вашем приложении.
  2. Определите production root и baseline: commit, lockfile, Node.js, npm, .npmrc и digest текущего артефакта.
  3. Выполните npm ci, затем сохраните npm ls --all --omit=dev --json и npm explain PACKAGE. Разберите каждый путь и копию.
  4. Соберите candidate с минимальным diff. Проверьте манифест, lockfile, integrity, peer/optional-ветки и install scripts.
  5. Повторите clean install и тесты на том же runtime. Проверьте успешный и отрицательный сценарий затронутого entry point.
  6. Соберите image или архив. Сканируйте его immutable digest и сопоставьте результат с SBOM, lockfile и commit.
  7. Сделайте runtime smoke в том же окружении: старт, безопасный запрос, ожидаемый отказ и нужные worker/CLI paths.
  8. Проверьте rollback к baseline. При изменении формата данных, миграции или протокола отдельно подтвердите обратную совместимость.
\n

Ложные зелёные результаты

\n

Зелёный npm audit не означает, что внешний scanner ошибся: базы advisory, области установки и правила инструментов различаются. Красный scanner тоже не доказывает достижимость вызова. Зафиксируйте источник, версию базы, путь пакета и artifact, затем согласуйте решение с владельцем риска.

\n

Установка с --ignore-scripts не проверяет install script, который нужен приложению. macOS-проверка не заменяет Linux-образ, если native dependency собирается в CI. Локальная папка не заменяет image digest. Эти ограничения пишутся рядом с результатом, иначе отчёт создаёт ложную уверенность.

\n

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

\n

Маршрут рассчитан на npm-проекты с package-lock.json. Для Yarn, pnpm, Cargo, Maven или системных пакетов команды и формат lockfile будут другими, хотя разделение manifest, resolved tree, artifact и runtime остаётся полезной моделью. Не переносите npm-команды в другой менеджер без сверки его документации.

\n

Статья не определяет exploitability и не заменяет threat model, code review или расследование инцидента. Достижимость зависит от кода, конфигурации, прав, входных данных и внешних сервисов. Если не удалось получить точный digest, выполнить сценарий или сопоставить SBOM с build, статус должен быть unknown, а не safe.

\n

Major-обновление требует отдельной оценки API-изменений. Для native addon добавьте проверку ABI и целевой платформы. Для private registry сохраняйте provenance, но не публикуйте токены и приватные URL в отчёте.

\n

Критерий готовности

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

\n

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

" } diff --git a/editorial/agent-rewrites/170.json b/editorial/agent-rewrites/170.json index a17345f..e4ad853 100644 --- a/editorial/agent-rewrites/170.json +++ b/editorial/agent-rewrites/170.json @@ -1,7 +1,7 @@ { "index": 170, "slug": "editorial-2023-04-mechanism-dependency-security", - "title": "Безопасность зависимостей: как связать advisory, lockfile и SBOM", - "excerpt": "Совпадение имени пакета с advisory ещё не доказывает риск для выпуска. Разбираем, что фиксируют manifest, lockfile и SBOM, как проверить путь зависимости и когда обновление можно считать готовым.", - "contentHtml": "

Сканер сообщает об advisory для транзитивного пакета. Команда меняет строку в package.json, получает зелёный pull request и закрывает задачу. Через день выясняется, что lockfile не изменился, SBOM относится к предыдущему образу, а пакет присутствует только в optional-ветке для другой платформы. Ошибка стоит времени на ложную аварию или, хуже, оставляет настоящий риск без владельца. В обоих случаях команда не может ответить на простой вопрос: какой компонент попал в конкретный артефакт и где он может быть достигнут?

\n

Тезис статьи прост: безопасность зависимости проверяют не по одному имени и не по одному файлу. Сначала связывают внешний сигнал с точной записью в resolved tree. Затем связывают эту запись с SBOM того же build. После этого отдельно проверяют достижимость и поведение в нужном runtime-сценарии. package.json, lockfile, SBOM и runtime evidence описывают разные границы. Совпадение двух списков полезно, но само по себе не закрывает риск.

\n

Четыре вопроса вместо одного

\n

Manifest отвечает на вопрос «что проект просит установить». Для прямой зависимости он хранит имя и допустимый диапазон версий. Запись \"demo-shell\": \"^1.0.0\" не говорит, какая версия окажется в текущем дереве.

\n

Lockfile отвечает на другой вопрос: какое дерево выбрал установщик при конкретных правилах разрешения. Для npm это точное представление дерева, созданного установкой. В нём видны транзитивные пакеты, версии, источники и integrity-поля. Но lockfile не является журналом уже запущенного процесса. Его нужно связать с commit, командой установки и артефактом, который действительно собирает pipeline.

\n

SBOM отвечает на вопрос «какие компоненты заявлены для 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Воздействие на этот сервис
\n

Учебный graph: почему одного совпадения мало

\n

Рассмотрим ограниченный fixture. demo-service зависит от demo-shell, а demo-shell — от demo-parser. Advisory указывает на demo-parser@1.0.0. В synthetic lockfile пакет есть. В synthetic SBOM есть запись для того же имени и версии. Это подтверждает пересечение двух заданных массивов. Это не подтверждает, что пакет установлен в production, достижим из реального entry point или уязвим именно в таком контексте.

\n
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
\"Схема
Схема разделяет намерение проекта, resolved tree, инвентарь named artifact и наблюдение runtime. Это иллюстрация учебной модели, а не SBOM или запись production-запуска.
\n

Симптом → причина → проверка → действие

\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 изменилось много строк lockfileResolver обновил транзитивное деревоРазделить added, removed, version и integrity changesПроверить каждую существенную ветку и тесты
Fixture завершился PASSPASS проверяет только synthetic contractПосмотреть статусы provenance, runtime и deploymentНе прикладывать PASS как доказательство production safety
\n

Порядок проверки advisory

\n
  1. Сохраните immutable источник сигнала: URL или ID advisory, имя пакета, affected range и дату получения. Не называйте совпадение имени подтверждённой уязвимостью.
  2. Найдите exact package и version в lockfile того commit, из которого собирается candidate. Запишите путь в дереве: direct dependency, parent и транзитивная ветка.
  3. Сопоставьте компонент с SBOM, сгенерированным для того же артефакта. Зафиксируйте format, generator, source revision и artifact digest.
  4. Проверьте достижимость отдельным методом. Ищите entry point, imports, conditional exports, feature flags, optional dependencies и платформенные ветки. Если метод не покрывает часть пути, запишите unknown.
  5. Сформируйте candidate update и просмотрите полный lockfile diff. Убедитесь, что изменение не добавило другой риск и не изменило дерево шире, чем ожидалось.
  6. Выполните clean install с правилами pipeline, затем проектные тесты и короткий runtime smoke для сценария, где используется затронутая ветка. Результат должен ссылаться на конкретный build.
  7. Запишите решение и rollback. Если доказательств не хватает, статус должен быть «требует проверки проекта», а не «ложное срабатывание» и не «безопасно».
\n

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

\n

Patch update не ограничивается одной строкой manifest. Новый диапазон может выбрать другую транзитивную версию. Installer может иначе обработать optional dependency на другой платформе. Lifecycle script может изменить содержимое build. Поэтому после обновления смотрят lockfile diff, а не только diff package.json. Важны added и removed packages, version shifts, integrity и source fields.

\n

Отдельная ловушка — SBOM, который лежит рядом с репозиторием, но не рядом с выпуском. Такой документ может быть полезен для разработки и одновременно бесполезен для ответа о deployed image. Минимальный критерий свежести задаёт сам pipeline: SBOM создан из того же source revision и относится к тому же artifact digest, что и release candidate. Это инженерный критерий, который нужно реализовать, а не свойство любого файла с названием SBOM.

\n

Ограничения и отрицательный путь

\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

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

\n

Обновление готово к решению, когда для одного candidate release можно восстановить всю цепочку: advisory → exact package@version → lockfile commit → SBOM → artifact digest → проверенный runtime-сценарий. Для каждого перехода есть ссылка или сохранённый результат. Lockfile diff просмотрен. Clean install и проектные тесты завершились ожидаемо. Неизвестные пути явно перечислены. Rollback описывает, какой артефакт и какую версию возвращают.

\n

Если хотя бы одно звено отсутствует, работа может быть готова к следующему этапу, но не к утверждению безопасности выпуска. Это не бюрократическая формальность. Такая запись позволяет отличить реальный риск от неполного inventory и не потерять его при следующем обновлении.

\n

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

" + "title": "Advisory, lockfile и SBOM: как доказать состав выпуска", + "excerpt": "Имя пакета в advisory ещё не доказывает риск для конкретного выпуска. Разбираем границы manifest, lockfile, SBOM и runtime, показываем проверку npm-командами и критерий, при котором неизвестность не маскируют под «безопасно».", + "contentHtml": "

Сканер сообщает об advisory для транзитивного пакета. Команда меняет строку в package.json, получает зелёный pull request и закрывает задачу. Позже выясняется, что lockfile не изменился, SBOM относится к предыдущему образу, а пакет присутствует только в optional-ветке другой платформы. Команда потратила время на ложную аварию или оставила настоящий риск без владельца. В обоих случаях не восстановлен ответ на главный вопрос: какой компонент попал в конкретный выпуск и при каких условиях он достижим?

\n

Безопасность зависимости проверяют не по одному имени и не по одному файлу. Внешний сигнал связывают с точной записью в resolved tree, эту запись — с SBOM того же кандидата, а затем отдельно проверяют достижимость и поведение нужного runtime-сценария. package.json, lockfile, SBOM и runtime evidence описывают разные границы. Совпадение двух списков полезно, но не является вердиктом о безопасности.

\n

Сигнал advisory не равен факту о выпуске

\n

Advisory — сообщение о проблеме в определённом пакете и диапазоне версий. Оно отвечает на вопрос «какой сигнал надо разобрать», но не на вопрос «затронут ли мой образ». Для этого нужны как минимум имя пакета, точная версия, источник пакета и путь, по которому компонент попал в кандидат.

\n

Различайте три утверждения. «Версия попала в дерево» — факт о разрешении зависимостей. «Компонент попал в артефакт» — факт о конкретном build и его SBOM. «Опасный код достижим с контролируемым входом» — вывод анализа кода, конфигурации и runtime. Эти утверждения могут быть истинны независимо друг от друга.

\n

Что фиксирует каждый слой

\n

Manifest фиксирует намерение проекта. Диапазон \"demo-shell\": \"^1.0.0\" задаёт допустимые версии, но не говорит, что установлено сегодня. Lockfile фиксирует resolved tree. В npm он содержит представление дерева, версии, источники и integrity-поля, чтобы повторная установка могла получить ту же структуру.

\n

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Воздействие на сервис
\n

Граф зависимости: почему одного совпадения мало

\n

Возьмём небольшой учебный граф: demo-service зависит от demo-shell, а demo-shell — от demo-parser. Advisory указывает на demo-parser@1.0.0. Совпадение имени и версии в lockfile и SBOM подтверждает пересечение двух документов. Оно не подтверждает, что пакет попал в production-образ, вызывается из реального entry point или уязвим при фактической конфигурации.

\n
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
\"Схема
Схема разделяет намерение проекта, resolved tree, инвентарь связанного артефакта и наблюдаемое выполнение. Это учебная модель, а не SBOM или запись production-запуска.
\n

Воспроизводимая проверка в npm

\n

Ниже — последовательность для проекта с package.json и package-lock.json. Запускайте её в том же commit и с той же версией Node/npm, которые использует сборка. npm ci удаляет существующий node_modules и завершается с ошибкой, если manifest и lockfile не согласованы. Это контроль входа, а не замена тестам приложения.

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

npm explain выводит цепочку зависимостей, из-за которой пакет установлен. npm ls даёт машинно читаемое дерево текущего node_modules, а не доказательство того, что именно это дерево ушло в выпуск. npm audit отправляет описание зависимостей в registry и может завершиться ненулевым кодом; такой статус показывает результат аудита, но не доказывает эксплуатацию. npm sbom создаёт SPDX или CycloneDX. Для production SBOM сохраняйте digest образа или другого выпускаемого артефакта рядом с документом.

\n

Как читать расхождения

\n

Сверку начинайте с identity, а не с имени пакета. Один и тот же name@version в двух документах не означает один и тот же источник, если различаются registry, URL tarball, integrity, source revision или artifact digest. В lockfile эти поля помогают отличить запись от простого совпадения текста.

\n
Решение по результату сверки
НаблюдениеЧто доказаноЧто неизвестноСледующий шаг
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 auditRegistry вернул результат для локального дереваСостояние конкретного deployed artifactПовторить для candidate и привязать к digest
\n

Почему SBOM не закрывает достижимость

\n

SBOM — инвентарная модель компонентов и отношений между ними. Она помогает сопоставлять компонент с базой уязвимостей, лицензий или provenance. Но инвентарь не знает, вызывается ли экспорт, включён ли feature flag, доступен ли endpoint извне и может ли атакующий передать опасный вход. Эти вопросы относятся к анализу кода, конфигурации и runtime.

\n

Работает и обратная граница: успешный smoke-тест одного endpoint не доказывает отсутствие риска во всех путях. Native addon, bundled dependency, generated bundle, private registry и зависимости операционной системы могут выпасть из npm-сценария. Если инструмент не покрывает такой слой, результат помечают как unknown и назначают отдельную проверку.

\n

Критерий готовности и ограничения

\n

Решение об обновлении можно принимать, когда для одного кандидата восстановлена цепочка advisory → exact package@version → lockfile commit → SBOM → artifact digest → runtime-сценарий. Для каждого перехода есть файл, ссылка или команда, которую другой инженер может повторить. Lockfile diff просмотрен, clean install завершился ожидаемо, проектные тесты прошли, а неизвестные пути перечислены отдельно.

\n

Критерий не вычисляет вероятность эксплуатации и не отменяет ручную оценку. Он также не обещает полноту SBOM: она зависит от генератора, режима установки и того, что команда называет артефактом. Для монорепозитория отдельно проверяйте workspace, production-режим и образ, который публикуется. Для менеджеров, отличных от npm, переносите принцип слоёв, но не копируйте команды без сверки с документацией своего инструмента.

\n

Порядок действий для команды

\n
  1. Сохранить ID advisory, источник, affected range, время получения и имя пакета.
  2. Проверить exact package и version в lockfile кандидата, затем выполнить npm explain и зафиксировать путь от direct dependency.
  3. Убедиться, что кандидат собирается теми же Node/npm и установочными флагами, что и pipeline.
  4. Сгенерировать SBOM после clean install, связать его с commit и digest выпуска, сохранить формат и версию генератора.
  5. Сравнить lockfile, SBOM и финальный образ: версии, integrity, registry, optional/dev-границы и bundled-файлы.
  6. Отдельно проверить достижимость: entry point, import, conditional export, feature flag, конфигурацию и контролируемый вход.
  7. Запустить тесты и runtime smoke для затронутого сценария, затем записать решение, оставшиеся unknown и rollback-артефакт.
\n

Если отсутствует хотя бы одно звено, корректный статус — «требует дополнительной проверки». Такая формулировка сохраняет владельца и следующий шаг. Она точнее, чем «ложное срабатывание» при несовпавшей версии и чем «безопасно» при одном зелёном отчёте.

\n

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

" } diff --git a/editorial/agent-rewrites/171.json b/editorial/agent-rewrites/171.json index d164f35..761568f 100644 --- a/editorial/agent-rewrites/171.json +++ b/editorial/agent-rewrites/171.json @@ -2,6 +2,6 @@ "index": 171, "slug": "editorial-2023-04-practice-dependency-security", "title": "Advisory зависимости: как доказать, что сигнал относится к вашему выпуску", - "excerpt": "Совпадение package и версии с advisory ещё не доказывает риск в приложении. Разбираем lockfile, SBOM, достижимость, обновление и критерий готовности без выдуманных production-выводов.", - "contentHtml": "

Сканер сообщает: пакет совпал с advisory. Команда видит имя и версию, ставит задачу «срочно обновить» и меняет dependency. Через час lockfile разрастается, сборка падает, а никто не может ответить на главный вопрос: этот пакет попал в поставленный артефакт и был ли достижим уязвимый код? Цена ошибки двойная. Ложная тревога задерживает релиз и создаёт шум. Непроверенный сигнал оставляет настоящий риск без владельца.

\n

Тезис простой: advisory — это вход для расследования, а не вердикт о приложении. Сначала разделите четыре факта: внешний advisory, точную запись в lockfile, компонент в SBOM и путь от entry point до нужного кода. Потом проверяйте обновление отдельными gates. Пока связь между этими фактами не доказана, корректный статус — «требует проверки», а не «безопасно» и не «уязвимо в production».

\n

Что именно фиксирует каждый артефакт

\n

Advisory сообщает, какие имена и диапазоны версий описывает источник. Он не знает конфигурацию вашего сервиса. Manifest показывает намерение автора: прямую зависимость и допустимый range. Lockfile показывает дерево, которое resolver выбрал для конкретного состояния проекта. Это важный снимок, но не журнал уже запущенного процесса.

\n

SBOM описывает компоненты и связи выбранного артефакта. Он отвечает на вопрос «что заявлено в этом build», если документ действительно связан с commit и digest. Runtime evidence отвечает на другой вопрос: что загрузил конкретный процесс. Эти слои можно сопоставить, но нельзя заменить один другим. Наличие строки в lockfile не доказывает наличие строки в образе. Наличие компонента в SBOM не доказывает вызов уязвимой функции.

\n
Разделяйте наблюдение, вывод и действие
СимптомПричинаПроверкаДействие
Advisory совпал с package@versionВнешний сигнал приняли за факт о сервисеСохранить URL, дату, диапазон и источникОткрыть triage, не объявляя production-уязвимость
Пакет есть в lockfileResolved tree смешали с deployed artifactНайти путь в дереве и сверить build identityПроверить SBOM того же артефакта
Пакет есть в SBOMInventory приняли за runtime traceСверить компонент с образом и режимом запускаПроверить импорт или загрузку в нужной конфигурации
Прямого импорта нетЗабыли транзитивный, optional или dynamic pathПроверить graph, plugin registration и feature flagОписать достижимость как гипотезу с методом проверки
После update изменилось много записейResolver пересобрал дерево, а scope не зафиксировалиРазобрать lockfile diff и peer/optional branchesСузить изменение или отдельно подтвердить совместимость
\n

Минимальная цепочка доказательств

\n

Начните с exact package и version. Запишите commit, в котором возник сигнал, и место записи в lockfile. Если пакет транзитивный, сохраните parent path: без него невозможно понять, какая прямая зависимость привела компонент в дерево. Затем найдите SBOM, созданный для candidate build. У него должен быть устойчивый идентификатор: commit, image digest или иной идентификатор артефакта. Файл с названием sbom.json без такой связи — только неподтверждённый документ.

\n

После этого сформулируйте путь достижимости. Не пишите «пакет используется». Пишите: «entry point A при конфигурации B импортирует модуль C, который вызывает ветку D». Для статического графа это возможная связь. Для теста — путь выбранного сценария. Для trace — наблюдение одного запуска. У каждого метода есть границы. Ни один метод сам по себе не перечисляет все платформы, флаги и входы.

\n
advisory 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

Учебный пример: совпадение строк не равно уязвимости

\n

Ниже приведён ограниченный учебный пример. Имена demo-service, demo-parser и SYNTHETIC-ADVISORY-001 вымышлены. Они не взяты из registry, scanner, lockfile, SBOM или production trace. Код только сравнивает заранее заданные значения в памяти. Он не устанавливает пакет, не строит image и не запускает приложение.

\n
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 как результат сканирования.

\n
\"Схема
Схема разделяет внешний advisory, инвентарь компонентов и гипотезу достижимости. Иллюстрация не содержит данных production и не заменяет evidence проекта.
\n

Порядок проверки

\n
  1. Зафиксируйте сигнал. Сохраните advisory URL или идентификатор базы, дату, package, точную версию и затронутый диапазон. Не переписывайте формулировку источника в более сильный вывод.
  2. Найдите baseline. Назовите commit и lockfile, которые относятся к проверяемому build. Для транзитивного пакета сохраните полный путь от direct dependency.
  3. Сверьте инвентарь. Найдите SBOM candidate artifact и проверьте его provenance: commit, digest, формат и генератор. Если связь с артефактом не доказана, статус SBOM — «не подтверждён».
  4. Опишите путь. Укажите entry point, режим, feature flag, dynamic import или plugin registration. Для каждого утверждения назовите метод: граф, тест, trace или ручная проверка.
  5. Проверьте отрицательную ветку. Убедитесь, что пакет не только отсутствует в прямом импорте, но и не приходит через другой parent, optional dependency, build-time branch или старый образ.
  6. Сформируйте candidate update. Запишите from/to version, ожидаемый lockfile diff, peer dependencies, optional branches, lifecycle scripts и rollback на baseline.
  7. Пройдите gates. Выполните чистую установку по lockfile, targeted tests и контролируемый runtime smoke в названном окружении. Сохраните ссылки на результаты каждого gate.
  8. Примите решение. Закройте задачу только с доказанными границами: исправлено и проверено, не найдено в конкретном артефакте или требует дополнительного project review.
\n

Почему быстрое обновление часто не является исправлением

\n

Изменение одной строки в 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

Ограничения

\n

Описанная схема не вычисляет exploitability и не заменяет security research. Она не знает private registry, ОС-зависимости, контейнерные слои, generated code, runtime flags и все возможные входы. SBOM может быть неполным. Статический граф может включать недостижимую ветку. Trace может пропустить редкий сценарий. Поэтому вывод всегда должен содержать scope: какой commit, build, режим и метод проверены.

\n

Не закрывайте alert фразой «пакет не импортируется напрямую». Не закрывайте его и фразой «версия обновлена». В первом случае остаются транзитивные и динамические пути. Во втором остаются новый graph, совместимость и факт выпуска. Если доказательств не хватает, оставьте владельца и следующий проверяемый шаг.

\n

Критерий готовности

\n

Разбор готов, когда другой инженер без устного контекста может восстановить цепочку: advisory → exact package@version → lockfile path → SBOM и immutable artifact → entry point/configuration → evidence достижимости → результаты install, tests и smoke → решение и rollback. Каждый результат имеет ссылку или явно отмечен как unknown. Ни один учебный PASS не выдан за production-факт. Если хотя бы одно звено не подтверждено, задача не закрыта как безопасная: она остаётся ограниченным расследованием с назначенным владельцем.

\n

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

\n" + "excerpt": "Совпадение package и версии с advisory ещё не доказывает риск в приложении. Разбираем lockfile, SBOM, путь зависимости и безопасный порядок обновления с воспроизводимыми командами.", + "contentHtml": "

Сбой в CI сопровождается advisory: пакет совпал с уязвимой версией. Первая реакция понятна — поднять версию в package.json и закрыть тикет после зелёного pipeline. Но такой сигнал отвечает только на один вопрос: известна запись о компоненте с подходящим диапазоном версий. Он не отвечает, попал ли компонент в конкретный образ, откуда он пришёл, выполняется ли опасная ветка и относится ли найденный SBOM к этому выпуску.

\n

Практический риск здесь двойной. Ложное «уязвимо» заставляет срочно менять большое дерево зависимостей и ломает unrelated-сценарии. Ложное «исправлено» оставляет старый образ или транзитивный путь без владельца. Поэтому advisory нужно разбирать как цепочку доказательств: внешний сигнал → exact package@version → запись в lockfile → компонент в том же артефакте → проверенный путь выполнения → решение с границами применимости.

\n

Начните с вопроса, который можно проверить

\n

Не формулируйте задачу как «проверить безопасность пакета». Это слишком широкое обещание. Запишите узкий вопрос: «Есть ли package@version из advisory в lockfile commit X, в SBOM artifact digest Y и в runtime-сценарии Z?» Если один из переходов пока неизвестен, это часть результата расследования.

\n

Сначала сохраните идентификатор сигнала: 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Все возможные платформы и флаги
\n

Почему lockfile важнее одной строки manifest

\n

Manifest описывает намерение: например, \"demo-shell\": \"^1.4.0\" разрешает установщику выбрать совместимую версию. Lockfile фиксирует результат разрешения для конкретного состояния проекта. В npm это точное дерево, из которого последующие установки могут восстановить те же версии, если соблюдены правила и версия инструмента.

\n

Проверяйте не только наличие имени. Для транзитивной зависимости нужен путь: какая direct dependency привела к пакету, какая версия родителя была выбрана и не существует ли второй копии в другом поддереве. Две записи одного имени могут иметь разные версии и разные условия попадания в bundle. Удаление прямого импорта не исключает транзитивную, optional, plugin или dynamic-import ветку.

\n

Команда npm explain предназначена именно для восстановления цепочки, которая привела пакет в установленное дерево. Но она описывает установленное дерево в конкретной рабочей директории. Если проверяется другой build, сначала получите чистую установку из его lockfile и только затем интерпретируйте вывод.

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

\n

Свяжите lockfile с конкретным SBOM

\n

SBOM — это инвентарь компонентов, а не заключение о безопасности. NTIA описывает для него поля идентификации компонентов, поддержку автоматической обработки и процессы формирования. На практике этого достаточно, чтобы сопоставлять компонент с базой advisory, но недостаточно, чтобы утверждать его достижимость или отсутствие эксплуатации.

\n

Для npm можно получить SBOM командой npm sbom. Режим --package-lock-only строит результат по lockfile, поэтому он полезен для проверки resolved tree, но не заменяет SBOM контейнерного образа: в нём могут быть системные библиотеки, native runtime и дополнительные файлы. Если вопрос относится к deployed image, SBOM нужно создавать в build-процессе для того же digest, который будет развёрнут.

\n
# Современный 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 и зафиксируйте его версию.

\n
\"Схема
Advisory запускает расследование: после lockfile и SBOM остаётся отдельный вопрос о достижимости. Схема учебная и не содержит данных production.
\n

Совпадение двух списков не равно достижимости

\n

Рассмотрим воспроизводимую модель без реального registry. Входные массивы ниже заранее заданы в памяти: один имитирует lockfile, второй — SBOM. Имена synthetic, поэтому пример не подтверждает конкретный CVE, версию библиотеки, build или runtime. Он нужен, чтобы не смешивать факт присутствия с выводом о поведении.

\n
const 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-сканера нельзя.

\n

Разделите reachability и exploitability

\n

Достижимость отвечает на вопрос «может ли выполнение попасть в нужный модуль или функцию при заданном entry point и конфигурации». Ищите импорт, регистрацию plugin, conditional export, dynamic import, feature flag и платформенную ветку. Зафиксируйте, какой метод использован: статический граф показывает возможную связь, тест — выбранный сценарий, trace — один наблюдаемый запуск.

\n

Exploitability — ещё более сильный вывод. Даже если модуль достижим, нужно знать, достигается ли уязвимая ветка, какие входные данные до неё доходят и какие защитные условия действуют. Ни lockfile, ни SBOM, ни успешный npm audit не вычисляют это автоматически для вашего приложения. Для такого вывода нужны анализ advisory, кодовый review и подходящие security-тесты.

\n
Как интерпретировать результаты расследования
НаблюдениеДопустимый выводСледующий шаг
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
\n

Безопасное обновление — это отдельная проверка

\n

Обновление одной 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.

\n

Порядок triage от сигнала до решения

\n
  1. Зафиксируйте advisory. Сохраните источник, ID, package, affected range и дату. Отдельно отметьте, что является фактом источника, а что пока гипотеза.
  2. Назовите baseline. Привяжите проверку к commit, lockfile и правилам установки. Для монорепозитория назовите workspace и тип зависимости.
  3. Восстановите dependency path. Выполните npm explain и npm ls --all, сохраните parent path и все найденные версии.
  4. Сверьте артефакт. Найдите SBOM с commit и digest candidate-образа. Если документ относится к другому build, вернитесь к шагу генерации.
  5. Проверьте runtime scope. Назовите entry point, режим, флаг и входные данные. Отметьте, что покрывает статический анализ, тест и trace.
  6. Сформируйте candidate update. Укажите from/to, ожидаемый lockfile diff, peer/optional branches и способ rollback. Не смешивайте unrelated upgrades.
  7. Пройдите gates. Выполните clean install, targeted tests, security checks и controlled smoke. Каждый результат должен ссылаться на этот candidate.
  8. Запишите решение. Формулировки «исправлено», «не входит в этот артефакт» и «требует project review» допустимы только с указанным scope и оставшимися неизвестными.
\n

Отрицательный путь нельзя пропускать

\n

Зелёный путь показывает, что выбранная версия устанавливается и тестовый сценарий проходит. Он не показывает, что старая версия не поставляется другим 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

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

\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 рядом с результатом. Это не делает проверку вечной, но позволяет повторить её в том же контракте и увидеть, что изменилось.

\n

Критерий готовности

\n

Расследование готово к решению, когда другой инженер может восстановить цепочку для одного candidate release: advisory → exact package@version → lockfile commit → parent path → SBOM → artifact digest → entry point и configuration → evidence достижимости → install, tests и smoke → решение и rollback. Для каждого перехода есть ссылка или сохранённый результат. Неизвестные пути перечислены, а не скрыты под словом «безопасно».

\n

Такой критерий не обещает отсутствие уязвимостей. Он делает вывод проверяемым и ограниченным: понятно, какой компонент и какой артефакт исследованы, какой сценарий покрыт и где требуется дополнительная работа. Это достаточная основа для инженерного решения и гораздо надёжнее, чем совпадение имени в отчёте сканера.

\n

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

\n" } diff --git a/editorial/agent-rewrites/172.json b/editorial/agent-rewrites/172.json index dbb174a..148c02c 100644 --- a/editorial/agent-rewrites/172.json +++ b/editorial/agent-rewrites/172.json @@ -3,5 +3,5 @@ "slug": "editorial-2023-03-field-csrf-cors", "title": "CORS, preflight и CSRF: как найти настоящую причину ошибки cookie API", "excerpt": "Интерфейс теряет ответ, OPTIONS получает отказ, а mutation заканчивается 403. Разбираем границы CORS и CSRF, проверяем контракт по фактам и не снимаем защиту ради быстрого зелёного теста.", - "contentHtml": "

После переноса 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

Начните с наблюдаемого симптома

\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 — это результат диагностики, а не повод объявлять защиту рабочей.

\n

Механизм: три границы, которые нельзя склеивать

\n

CORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.

\n

Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.

\n

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

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
JavaScript не читает responseOrigin отсутствует в 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 получил 403CSRF-токен отсутствует или не совпалПроверить server reason до mutationИсправить выдачу и передачу токена
Mutation прошёл, UI увидел errorServer action и CORS visibility различаютсяСверить application log и browser evidenceИсправить CORS, не снимая CSRF
403 после token checkНет business permissionРазделить CSRF reason и authorization reasonИсправить policy ресурса
\n

Иллюстрация маршрута

\n
\"Маршрут
Схема разделяет origin policy, credentialed response, preflight и серверную проверку CSRF. Это учебная иллюстрация, а не trace реального запроса.
\n

Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.

\n

Разберите credentials и preflight отдельно

\n

Если frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.

\n

Затем выпишите фактический 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 с корреляционным идентификатором. Один слой не заменяет второй.

\n

Маршрут проверки

\n
  1. Зафиксируйте симптом. Выберите одну failing операцию и назовите цену ошибки: потерянный response, отклонённый mutation или возможный side effect без видимого результата.
  2. Опишите request contract. Запишите source origin, target URL, method, content type, credentials и custom headers. Отдельно отметьте, что пока неизвестно.
  3. Проверьте CORS. Сопоставьте origin с ответом. Для credentials проверьте exact value, а не wildcard. Проверьте Vary: Origin, если ответ зависит от входного origin.
  4. Проверьте OPTIONS. Если request shape требует preflight, сравните фактические method и header names с узким allow-list. Не делайте вывод о CSRF по результату OPTIONS.
  5. Проверьте server-side proof. Убедитесь, что missing или mismatch token отклоняется до mutation. Причину CSRF не смешивайте с причиной отсутствия права.
  6. Исправьте одну причину. Добавьте exact origin, один method/header или недостающую передачу token. Не меняйте одновременно CORS на глобально permissive и CSRF на disabled.
  7. Повторите положительный и отрицательный путь. Разрешённый запрос должен получить ожидаемый response. Запрос без token, с неверным token и с чужим origin должен остаться запрещённым в соответствующем слое.
  8. Оставьте проверку. Закрепите route test для missing/mismatch token и проверяемую конфигурацию CORS. Для нового frontend origin нужен отдельный review, а не копия существующей строки.
\n

Отрицательный путь важнее зелёного запроса

\n

Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что 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

Ограничения и критерий готовности

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

\n

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

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

\n" + "contentHtml": "

После переноса 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

Начните с наблюдаемого симптома

\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 — это результат диагностики, а не повод объявлять защиту рабочей.

\n

Механизм: три границы, которые нельзя склеивать

\n

CORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.

\n

Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.

\n

CSRF. Cookie может автоматически приложиться к запросу, созданному другим сайтом. Серверу нужно отдельное доказательство, что запрос сформировало разрешённое приложение. В token-based схеме сервер выдаёт непредсказуемый токен, а затем сравнивает его с токеном в сессии или с корректно связанным double-submit значением до mutation. Отсутствующий или неверный токен должен остановить side effect.

\n
API='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 или журналы.

\n

Если OPTIONS завершился отказом, actual request обычно не начинается. Если же preflight не нужен, браузер может отправить запрос, который другой сайт способен инициировать без чтения ответа. Поэтому отрицательная проверка должна смотреть не только на CORS headers, но и на неизменность тестовой записи после запроса без CSRF-доказательства.

\n

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
JavaScript не читает responseOrigin отсутствует в 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 получил 403CSRF-токен отсутствует или не совпалПроверить server reason до mutationИсправить выдачу и передачу токена
Mutation прошёл, UI увидел errorServer action и CORS visibility различаютсяСверить application log и browser evidenceИсправить CORS, не снимая CSRF
403 после token checkНет business permissionРазделить CSRF reason и authorization reasonИсправить policy ресурса
\n

Иллюстрация маршрута

\n
\"Маршрут
Схема разделяет origin policy, credentialed response, preflight и серверную проверку CSRF. Это учебная иллюстрация, а не trace реального запроса.
\n

Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.

\n

Разберите credentials и preflight отдельно

\n

Если frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.

\n

Затем выпишите фактический 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 с корреляционным идентификатором. Один слой не заменяет второй.

\n

Маршрут проверки

\n
  1. Зафиксируйте симптом. Выберите одну failing операцию и назовите цену ошибки: потерянный response, отклонённый mutation или возможный side effect без видимого результата.
  2. Опишите request contract. Запишите source origin, target URL, method, content type, credentials и custom headers. Отдельно отметьте, что пока неизвестно.
  3. Проверьте CORS. Сопоставьте origin с ответом. Для credentials проверьте exact value, а не wildcard. Проверьте Vary: Origin, если ответ зависит от входного origin.
  4. Проверьте OPTIONS. Если request shape требует preflight, сравните фактические method и header names с узким allow-list. Не делайте вывод о CSRF по результату OPTIONS.
  5. Проверьте server-side proof. Убедитесь, что missing или mismatch token отклоняется до mutation. Причину CSRF не смешивайте с причиной отсутствия права.
  6. Исправьте одну причину. Добавьте exact origin, один method/header или недостающую передачу token. Не меняйте одновременно CORS на глобально permissive и CSRF на disabled.
  7. Повторите положительный и отрицательный путь. Разрешённый запрос должен получить ожидаемый response. Запрос без token, с неверным token и с чужим origin должен остаться запрещённым в соответствующем слое.
  8. Оставьте проверку. Закрепите route test для missing/mismatch token и проверяемую конфигурацию CORS. Для нового frontend origin нужен отдельный review, а не копия существующей строки.
\n

Отрицательный путь важнее зелёного запроса

\n

Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что 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

Ограничения и критерий готовности

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

\n

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

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

\n" }