diff --git a/editorial/agent-rewrites/059.json b/editorial/agent-rewrites/059.json index 4371817..ffee675 100644 --- a/editorial/agent-rewrites/059.json +++ b/editorial/agent-rewrites/059.json @@ -2,6 +2,6 @@ "index": 59, "slug": "editorial-2026-05-mechanism-systems-performance", "title": "Почему короткий trace не доказывает ускорение системы", - "excerpt": "Как отделить ожидание от работы, проверить сопоставимость нагрузки и остановить разбор, когда trace не подтверждает причинный вывод.", - "contentHtml": "
Фраза «БД медленная» часто появляется после одного взгляда на trace. На ней виден длинный промежуток, но не видно, ждёт ли запрос очередь, выполняет ли БД работу или задерживается внешний вызов. Цена ошибки — недели оптимизации не того участка. Команда меняет SQL, а пользовательский путь не становится короче.
\nНадёжный разбор начинается с более узкого тезиса: длительность показывает интервал, но не его причину. Чтобы сравнить два результата, нужно одновременно сохранить связную trace, одинаковую границу нагрузки и ограниченный вывод. Если одно условие нарушено, правильное действие — остановиться и назвать недостающий факт.
\nРассмотрим учебный пример. Root span описывает путь запроса от входа до ответа. Внутри него идут три последовательных интервала: admission queue ждёт 520 условных единиц, вызов БД занимает 150, внешний каталог — 200. Эти единицы придуманы для примера. Они не являются миллисекундами, метрикой сервиса или результатом production-измерения.
\n| Сегмент | Роль | Интервал | Допустимый вывод |
|---|---|---|---|
| fixed-admission-queue | queue-wait | 40–560, 520 units | Самый длинный названный сегмент этого input |
| fixed-db-call | database-execution | 570–720, 150 units | Интервал вызова БД в этой trace |
| fixed-catalog-call | external-dependency | 730–930, 200 units | Интервал внешнего вызова в этой trace |
| fixed-gateway | end-to-end | 0–1000, 1000 units | Граница пути, но не объяснение причины |
Таблица не говорит, что очередь замедляет production. Она не говорит, что SQL нужно переписать. Она фиксирует структуру одного учебного input. Это важное различие. Длинный span можно ранжировать. Причину нужно проверять отдельным экспериментом с той же границей.
\nДопустим, второй root равен 800 вместо 1000. На графике он выглядит лучше. Но во втором input одновременно изменились cohort, число логических запросов с 12 до 24, concurrency с 3 до 6 и форма чтения. Такой результат нельзя приписать предполагаемой оптимизации. Он описывает другой сценарий.
\nКонтрольная граница должна быть записана до сравнения длительностей. Для этого учебного примера она состоит из четырёх полей: cohort=fixed-load-a, logicalRequests=12, concurrency=3 и inputShape=fixed-read-shape-a. Это не универсальный стандарт нагрузки. Это минимальный контракт конкретной проверки. В другом сценарии набор полей будет иным, но правило останется тем же: заранее назвать условия, которые должны совпасть.
const boundary = {\\n cohort: 'fixed-load-a',\\n logicalRequests: 12,\\n concurrency: 3,\\n inputShape: 'fixed-read-shape-a',\\n};\\n\\n// Учебный пример: отсутствие совпадающей границы\\n// запрещает называть разницу эффектом изменения.\\nconst comparable = sameBoundary(baseline, candidate, boundary);\nКод выше иллюстрирует только порядок проверки. Функция sameBoundary не измеряет latency и не находит bottleneck. Она отвечает на более простой вопрос: можно ли поставить два учебных результата рядом. В реальном проекте нужно явно определить сравниваемые поля, единицы измерения и правила обработки пропусков.
| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| Длинный интервал перед работой | Ожидание в admission queue | Есть отдельный span с ролью queue-wait и parent внутри root | Оставить наблюдение; не называть очередь причиной production-задержки |
| Длинный span помечен только internal | Класс задержки неизвестен | Проверить роль и границы интервала | Вернуть stop-hidden-queue и назвать ожидание unknown |
| Root стал короче | Изменилась нагрузка или форма входа | Сверить cohort, requests, concurrency и input shape | При расхождении вернуть stop-incomparable-load |
| Дочерний span ссылается на отсутствующий parent | Trace неполна | Проверить связность дерева и интервалы | Вернуть stop-incomplete-trace; не восстанавливать связь догадкой |
| В записи есть «стало быстрее» | Заявлен эффект без контроля | Найти baseline с той же границей и явный критерий | Снять effect claim и оставить только наблюдение |
Неполный материал должен завершать разбор отказом. У учебного input incomplete-trace-v1 дочерний span указывает на отсутствующий parent. Нельзя определить, относится ли интервал к этому пути. Статус stop-incomplete-trace точнее, чем попытка соединить span по времени или имени.
У hidden-queue-v1 длинный интервал называется fixed-unclassified-delay. Он похож на ожидание, но такого сходства недостаточно. Статус stop-hidden-queue означает: сначала назовите границу ожидания, затем продолжайте. Иначе команда переложит время на очередь только потому, что она удобна как объяснение.
У incomparable-load-v1 root равен 800, но нагрузка отличается от baseline. Статус stop-incomparable-load не утверждает, что число 800 неверно. Он запрещает делать из него вывод об ускорении. Наконец, unsupported-effect-v1 содержит фразу faster-after-change без допустимого контрольного сравнения. Для него нужен stop-unsupported-effect.
for (const id of [\\n 'hidden-queue-v1',\\n 'incomparable-load-v1',\\n 'unsupported-effect-v1',\\n]) {\\n const result = reviewFixedPerformanceInput(createInput(id));\\n console.log(id, result.status, result.nextAction);\\n}\\n\\n// Учебный результат: stop — нормальный исход проверки.\\n// Он не доказывает, что система медленная.\nОтказ должен быть машинно и текстово видимым. Он сохраняет конкретную причину остановки и следующее действие. Общая фраза «нужно больше данных» хуже: она не показывает, какого именно условия не хватает.
\nДаже связная trace не сообщает автоматически, почему очередь заняла 520 units. Причиной может быть лимит ресурса, политика admission, форма учебного сценария или другая часть системы. Роль span CLIENT, SERVER или INTERNAL помогает описать операцию. Она не превращает интервал в диагноз и не назначает оптимизацию.
Правильная цепочка выглядит так: наблюдение — queue-wait=520; гипотеза — правило admission создаёт ожидание; новая проверка — заранее определённый input с той же границей и одним изменённым условием; результат — сравнимое наблюдение или новый stop. Перескок от первого пункта к утверждению «очередь является корнем проблемы» нарушает границу доказательства.
То же относится к БД. database-execution=150 не является SQL-профилем, индексом, бюджетом или обещанием. Это интервал вызова в учебной trace. Если нужен разбор SQL, он требует отдельного measurement contract, собственных входов и критерия сравнения.
Учебная trace не даёт распределения latency, хвостов, throughput, variance, queue discipline или стоимости ресурсов. Условные units нельзя переводить в миллисекунды. Один root не заменяет серию измерений. Связь через trace-id не гарантирует полноту дерева. Пересекающиеся span нельзя бездумно складывать: они могут выполняться параллельно.
\nИсточники ниже описывают контекст trace, роли span и семантику HTTP. Они не доказывают bottleneck, не обещают latency и не подтверждают production-эффект. Поэтому материал ограничивает вывод учебным input и не предлагает rollout, изменение конфигурации или выбор индекса.
\nРазбор готов, если другой инженер получает тот же status на том же именованном input, видит связное дерево, понимает контрольную границу и может указать, какое условие приводит к stop. В принятом учебном случае вывод должен остаться наблюдением: queue wait — самый длинный названный сегмент в данной trace; причинность и эффект изменения не заявлены. Если для чтения вывода нужны устные пояснения, внешний дашборд или догадка автора, материал не готов.
\nКороткий trace выглядит как хороший результат, но сам по себе ничего не говорит об ускорении. В одном запросе root span может включать ожидание в очереди, обработку сервера, вызов базы и ответ внешней системы. Если во втором запуске изменилась нагрузка или потерялся фрагмент дерева, число стало меньше, но сравнение перестало быть честным. Цена ошибки — оптимизация SQL, сети или CPU без изменения времени, которое видит пользователь.
\nВ этой статье я использую небольшой детерминированный пример. Его цель — научиться формулировать ограниченный вывод: «в этой записи такой-то сегмент был самым длинным». Это не измерение production и не способ автоматически найти bottleneck. Чтобы назвать причину, нужна следующая проверка с тем же входом, понятной границей и одним изменённым условием.
\nВ OpenTelemetry span представляет одну операцию внутри trace. Root span обычно описывает весь путь, а дочерние span — отдельные подоперации. Для request-response операции начало и конец должны охватывать обработку запроса, включая middleware, бизнес-логику, сборку и отправку ответа. Поэтому длина root — это интервал всей операции, а не время одного наиболее заметного дочернего вызова.
\nУ каждого интервала есть границы: имя, start, end, parent и тип операции. Поле SpanKind помогает различать входящую серверную обработку, исходящий клиентский вызов и внутреннюю работу. Оно описывает роль span, но не отвечает на вопрос, почему операция была долгой. Например, CLIENT означает вызов удалённого сервиса, ожидающий ответ; это не доказательство, что удалённый сервис — причина задержки.
| Поле | Пример | Что проверяет | Чего не доказывает |
|---|---|---|---|
| traceId | trace-a | К какой записи относится span | Что все компоненты действительно попали в запись |
| parentId | gateway | Связь с родительской операцией | Причину ожидания или порядок параллельных работ |
| start/end | 40/560 | Длительность конкретного интервала | Что интервал был полезной работой, а не ожиданием |
| kind | CLIENT | Роль операции в модели trace | Виновника latency и эффект изменения кода |
W3C Trace Context стандартизует перенос идентификаторов между HTTP-компонентами через traceparent и tracestate. Это позволяет связать записи разных участников, но не создаёт отсутствующие span и не гарантирует, что каждый запрос был записан: выборка и полнота зависят от конкретной системы наблюдения.
Пусть root gateway длится от 0 до 1000 условных единиц. Внутри него последовательно расположены ожидание допуска в очередь от 40 до 560, вызов базы от 570 до 720 и вызов каталога от 730 до 930. Промежутки между дочерними span оставлены намеренно: они показывают, что сумма видимых частей не обязана совпадать с root.
| Span | Границы | Длительность | Корректный вывод |
|---|---|---|---|
gateway | 0–1000 | 1000 units | Полный интервал выбранного пути |
admission-queue | 40–560 | 520 units | Самый длинный названный сегмент записи |
db-call | 570–720 | 150 units | Интервал вызова базы в этой trace |
catalog-call | 730–930 | 200 units | Интервал внешнего вызова в этой trace |
Из таблицы можно сделать два вывода. Первый: в этой записи длиннее всего назван admission-queue. Второй: root равен 1000 units. Нельзя сделать третий вывод — «очередь является корневой причиной» — без эксперимента, который изменяет только условие допуска и сохраняет остальную границу. Нельзя также перевести units в миллисекунды: единица времени не определена примером.
const trace = {\n root: { name: 'gateway', start: 0, end: 1000, parent: null },\n spans: [\n { name: 'admission-queue', start: 40, end: 560, parent: 'gateway', kind: 'INTERNAL' },\n { name: 'db-call', start: 570, end: 720, parent: 'gateway', kind: 'CLIENT' },\n { name: 'catalog-call', start: 730, end: 930, parent: 'gateway', kind: 'CLIENT' },\n ],\n};\n\nconst duration = ({ start, end }) => end - start;\nconst longest = trace.spans\n .map((span) => ({ name: span.name, units: duration(span) }))\n .sort((a, b) => b.units - a.units)[0];\n\nconsole.log({ rootUnits: duration(trace.root), longest });\n// { rootUnits: 1000, longest: { name: 'admission-queue', units: 520 } }\n// Это наблюдение одной записи, а не диагноз и не замер production.\nСкрипт можно скопировать в файл trace-review.mjs и выполнить командой node trace-review.mjs. Он не обращается к сети, базе или системе трассировки. Значения в нём фиксированы, чтобы любой читатель получил одинаковый результат и увидел границу между вычислением длительности и интерпретацией.
Предположим, после изменения root стал равен 800 units. Это выглядит как улучшение на 20 процентов, но процент имеет смысл только при сопоставимом знаменателе. Во втором запуске могли измениться число логических запросов, concurrency, набор данных, cache state, cohort пользователей, доля ошибок или способ выборки trace. Тогда мы сравниваем два разных эксперимента.
\nПеред просмотром результата зафиксируйте comparison key — набор полей, который обязан совпасть. В учебном случае это cohort=fixed-load-a, logicalRequests=12, concurrency=3 и inputShape=fixed-read-shape-a. Это не универсальный стандарт нагрузки. Для другой системы в ключ придут размер ответа, регион, версия клиента, состояние кеша или доля холодных запросов.
const comparisonKey = {\n cohort: 'fixed-load-a',\n logicalRequests: 12,\n concurrency: 3,\n inputShape: 'fixed-read-shape-a',\n};\n\nfunction isComparable(baseline, candidate) {\n return Object.keys(comparisonKey).every(\n (key) => baseline[key] === candidate[key],\n );\n}\n\nconst baseline = { ...comparisonKey, rootUnits: 1000 };\nconst candidate = { ...comparisonKey, rootUnits: 800 };\nconsole.log(isComparable(baseline, candidate)); // true\nconsole.log(candidate.rootUnits < baseline.rootUnits); // true\n// Даже true не объясняет причину и не заменяет серию измерений.\nВ рабочем проекте функция должна сравнивать не только четыре поля из примера. Составьте ключ до эксперимента, включите в него условия, влияющие на путь, и отдельно решите, что делать с пропущенными значениями. Молчаливое превращение «нет данных» в «совпадает» создаёт ложную сопоставимость.
\nВремя root складывается не обязательно как простая сумма дочерних span. Часть времени уходит на ожидание свободного worker, соединения или ответа; часть — на фактическое выполнение; несколько вызовов могут идти параллельно. Поэтому сначала нужно проверить дерево и интервалы, а уже затем обсуждать вклад отдельных сегментов.
\n| Наблюдение | Гипотеза | Проверка | Действие до доказательства |
|---|---|---|---|
| Длинный отдельный span перед обработкой | Запрос ждёт допуска | Проверить имя, parent, интервалы и instrumented boundary | Описать ожидание как наблюдение; не переписывать SQL |
Длинный INTERNAL без ясной операции | Скрыта неизвестная задержка | Уточнить границу и добавить измерение подоперации | Остановить причинный вывод |
| Root короче в candidate | Изменилась нагрузка | Сверить comparison key и sampling | Не считать разницу эффектом изменения |
| Child ссылается на отсутствующий parent | Trace неполна | Проверить экспорт и propagation context | Вернуть запись на доработку, не соединять span по времени |
| Заявлено «быстрее» | Нет контрольной выборки | Найти baseline, размер серии и критерий успеха | Сузить формулировку до наблюдаемого факта |
Особенно осторожно читайте трассы с sampling. W3C описывает sampled flag как рекомендацию о том, что вызывающая сторона могла записать данные; он не означает, что вся система сохранила каждый span. Если сравнивать traces, попавшие в backend по разным правилам, пропавший span легко принять за исчезнувшую работу.
\nПолезная проверка должна возвращать не только «да» или «нет», но и причину отказа. Ниже — минимальный вариант для фиксированных объектов. Он проверяет сравнимость входа и явный флаг полноты, но не претендует на валидатор OpenTelemetry или на профилировщик.
\nfunction review(baseline, candidate) {\n const required = ['cohort', 'logicalRequests', 'concurrency', 'inputShape'];\n const changed = required.filter((key) => baseline[key] !== candidate[key]);\n\n if (changed.length > 0) {\n return { status: 'stop-incomparable-input', changed };\n }\n if (!candidate.traceComplete) {\n return { status: 'stop-incomplete-trace' };\n }\n if (candidate.rootUnits >= baseline.rootUnits) {\n return { status: 'observe-no-lower-root' };\n }\n return {\n status: 'comparable-lower-root',\n differenceUnits: baseline.rootUnits - candidate.rootUnits,\n };\n}\n\nconsole.log(review(\n { cohort: 'a', logicalRequests: 12, concurrency: 3, inputShape: 'read', rootUnits: 1000 },\n { cohort: 'b', logicalRequests: 12, concurrency: 3, inputShape: 'read', rootUnits: 800, traceComplete: true },\n));\n// { status: 'stop-incomparable-input', changed: [ 'cohort' ] }\nЧтобы положительный путь был воспроизводимым, добавьте в candidate те же четыре поля, traceComplete: true и rootUnits: 800. Затем функция вернёт comparable-lower-root и разницу 200 units. Это всё ещё не доказывает, что изменение ускорило систему: нужно повторить серию на заранее выбранном числе запросов и проверить распределение задержки, ошибки и побочные эффекты.
Хороший разбор меняет вопрос по шагам. Сначала есть факт: admission-queue занял 520 units в одной записи. Затем гипотеза: политика допуска или нехватка worker создаёт ожидание. Следующая проверка должна оставить comparison key неизменным и изменить только условие, которое относится к этой гипотезе. Если root и очередь меняются вместе, гипотеза получает поддержку; если меняется только root, а очередь нет, нужно искать другой участок.
Здесь важно различать корреляцию и эксперимент. То, что очередь длиннее базы, помогает выбрать следующий замер. Это не разрешение увеличить число worker, изменить лимит или переписать запрос. Операционный шаг должен иметь владельца, безопасный диапазон, критерий отката и сигнал результата; в статье мы ограничиваемся проектированием проверки и не выдаём учебные units за этот сигнал.
\nТакая осторожность относится и к базе. Span db-call=150 измеряет интервал клиентской операции, но не сообщает, сколько времени заняли планирование запроса, чтение страниц, блокировка или сериализация результата. Для SQL-вывода нужны план выполнения, собственные метрики базы и сопоставимые входы. Trace помогает выбрать запрос для исследования, но не заменяет профилирование базы.
Один trace не описывает распределение задержки. Значение root не заменяет p50/p95/p99, throughput, error rate и размер выборки. Условные units нельзя переводить в миллисекунды или использовать как SLA. Пересекающиеся span нельзя складывать без учёта параллельного выполнения. Неполный export может скрыть работу, а sampling — изменить состав наблюдаемых записей.
\nW3C Trace Context отвечает за перенос контекста, OpenTelemetry — за модель trace и роль span, а HTTP RFC 9110 — за семантику request/response. Ни один из этих документов не утверждает, что конкретная очередь, база или внешний сервис является bottleneck. Реальный вывод потребует данных вашей версии SDK, схемы экспорта, окружения, нагрузки и контрольного эксперимента.
\nРазбор можно передать коллеге, если он получает исходную запись, видит границу root, понимает единицы, проверяет parent/child и может повторить вычисление самого длинного сегмента. Для заявления об ускорении нужны дополнительно одинаковые входы, серия измерений, критерий успеха и описание побочных эффектов. Если есть только короткий trace и фраза «стало быстрее», честный результат — наблюдение или остановка проверки, а не рекомендация по оптимизации.
\ntraceparent/tracestate, trace-id, parent-id и ограничения контекста. Документ не доказывает полноту записи или причинность.SpanKind. Спецификация задаёт модель данных, а не результат конкретного сервиса.Пользователь ждёт ответ десять секунд, а CPU сервиса держится на двадцати процентах. Команда меняет SQL, увеличивает пул потоков или поднимает таймаут. Симптом иногда маскируется, но путь не становится быстрее. Цена ошибки — релиз без эффекта, дополнительная нагрузка и потеря исходного сигнала. Следующий инженер уже не видит, что именно сравнивали.
\nНизкая загрузка CPU не опровергает медленный запрос. End-to-end время включает ожидание, работу и вызовы зависимостей. Чтобы выбрать действие, нужно разложить один путь на связанные интервалы и удержать одну границу сравнения. Учебные значения ниже не являются измерениями production-системы. Они показывают способ рассуждать.
\nRoot span задаёт границу от приёма запроса до ответа. Дочерний span показывает названную операцию внутри этой границы. Запрос может ждать admission queue, свободное соединение, блокировку, диск, DNS, TLS или ответ удалённого сервиса. Пока он ждёт, CPU может почти не работать.
\nНазвание интервала ограничивает вывод. queue-wait означает отдельно записанное ожидание в очереди. database-execution означает интервал вызова базы в этой trace. external-dependency означает границу внешнего вызова. Ни одно из этих названий само по себе не доказывает причину задержки. Если ожидание не размечено, его нужно оставить неизвестным.
Критический путь — это не рейтинг сервисов и не сумма всех span. Это временная цепь внутри одного root span. Связность важнее красивого графика: у каждого дочернего span должен существовать parent, начало не должно быть позже конца, а единицы времени должны совпадать. Если два вызова идут параллельно, их длительности нельзя складывать как последовательные.
\nРассмотрим условную trace fixed-trace-01. Root длится 1 000 units. Очередь занимает 520, вызов БД — 150, внешний каталог — 200. Промежутки между интервалами не получили отдельного объяснения. Поэтому их нельзя автоматически назвать сетью или дополнительной работой.
| Сегмент | Роль | Интервал | Длительность | Что можно сказать |
|---|---|---|---|---|
| fixed-admission-queue | queue-wait | 40–560 | 520 | Самый длинный названный сегмент этой записи |
| fixed-db-call | database-execution | 570–720 | 150 | Интервал вызова БД в этой trace |
| fixed-catalog-call | external-dependency | 730–930 | 200 | Интервал внешнего вызова в этой trace |
| fixed-gateway | end-to-end | 0–1 000 | 1 000 | Граница пути, а не объяснение причины |
В этой записи fixed-admission-queue длиннее двух других названных сегментов. Это единственный прямой вывод о порядке длительностей. Нельзя из него заключить, что очередь является bottleneck при другой нагрузке, что изменение gateway ускорит пользователя или что БД не требует исследования. Для любого такого утверждения нужна отдельная проверка.
Код ниже работает с заранее заданным объектом. Он не обращается к сети, базе, часам, профайлеру или телеметрии. Числа условны. Пример проверяет связность и интервалы, а не показывает результат реального сервиса.
\nconst trace = {\n root: { id: 'root-01', start: 0, end: 1000 },\n spans: [\n { id: 'queue-01', parent: 'root-01', role: 'queue-wait', start: 40, end: 560 },\n { id: 'db-01', parent: 'root-01', role: 'database-execution', start: 570, end: 720 },\n { id: 'catalog-01', parent: 'root-01', role: 'external-dependency', start: 730, end: 930 }\n ],\n load: { cohort: 'fixed-load-a', requests: 12, concurrency: 3, shape: 'fixed-read-shape-a' }\n};\n\nfunction review(input) {\n const ids = new Set(input.spans.map((span) => span.id));\n const connected = input.spans.every((span) =>\n span.parent === input.root.id || ids.has(span.parent)\n );\n const timed = input.spans.every((span) =>\n Number.isFinite(span.start) && Number.isFinite(span.end) &&\n span.end >= span.start\n );\n\n if (!connected) return { status: 'stop-incomplete-trace' };\n if (!timed) return { status: 'stop-invalid-interval' };\n return { status: 'observation-ready', effect: 'not-claimed' };\n}\n\nconsole.log(review(trace));\nobservation-ready здесь означает только, что запись связна и содержит корректные условные интервалы. Если parent равен missing-01, результат должен быть stop-incomplete-trace. Если начало больше конца, функция должна остановиться. Отрицательный путь не является исключением из метода. Он показывает, что неполный материал нельзя превращать в уверенный диагноз.
| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| CPU низкий, запрос медленный | В end-to-end время вошло ожидание | Разделить queue-wait, локальную работу и дочерние вызовы | Назвать только покрытые span; остаток оставить unknown |
| Длинный span совпал с пиком latency | Span включает ожидание upstream или retry | Проверить parent/child, повторные вызовы и дочерние интервалы | Не объявлять span причиной без отдельного сигнала |
| Второй прогон короче первого | Изменилась нагрузка или форма входа | Сверить cohort, requests, concurrency и shape | Снять сравнение и повторить на общей границе |
| Есть root, но нет parent у дочернего span | Потеря записи или неверная связь ID | Проверить полный экспорт и уникальность идентификаторов | Вернуть stop; не дорисовывать дерево по времени |
| После изменения есть одна короткая запись | Нет baseline и распределения наблюдений | Повторить тот же сценарий и сохранить контроль | Назвать observation, а не improvement |
Длинный интервал сообщает, что в конкретной записи он длинный. Он не сообщает, почему это произошло и какое изменение его сократит. Очередь может зависеть от admission policy или ограниченного ресурса. Вызов БД может ждать соединение до начала исполнения. Внешний вызов может включать локальную подготовку. Одна trace не выбирает между этими объяснениями.
\nПолезно разделять три фразы. Наблюдение: «queue-wait занимает 520 units в fixed-trace-01». Гипотеза: «правило допуска создаёт часть ожидания». Проверка: «сравнить заранее определённые записи с теми же cohort, requests, concurrency и shape». Перескакивать от первой фразы к третьей нельзя. Тем более нельзя сразу объявлять эффект изменения.
Та же граница действует для базы. database-execution = 150 — не диагноз SQL, не рекомендация индекса и не оценка бюджета. Если команда хочет исследовать запрос, она формулирует новый вопрос и сохраняет текущую запись как baseline только после проверки сопоставимости. Уменьшение знакомого локального шага не становится правильным действием из-за того, что его проще измерить.
Baseline и candidate можно сравнивать только внутри явно названной контрольной границы. В учебном примере это fixed-load-a, 12 логических запросов, concurrency 3 и fixed-read-shape-a. Если второй прогон использует 24 запроса, concurrency 6 или другую форму входа, он отвечает на другой вопрос. Более короткий root не доказывает ускорение.
Смена одного поля уже важна. Если выросла concurrency, очередь может измениться без изменения кода. Если изменилась форма данных, база может выбрать другой план. Если другой cohort пришёл из другого окна, кэш и внешняя зависимость могли иметь иное состояние. Запись должна сделать эти условия видимыми, а не прятать их в подписи графика.
\nОтдельно проверяйте параллельность. Дочерние span могут пересекаться. В таком случае их сумма превысит время root и не покажет стоимость пути. Сначала определите временную зависимость. Если это невозможно, оставьте вывод на уровне «интервалы пересекаются» и не выбирайте самый большой span как причину.
\nЕсли trace неполная, остановитесь на stop-incomplete-trace. Если ожидание помечено только как unknown-delay, не называйте его очередью. Если нагрузка отличается, верните stop-incomparable-load. Если в записи уже есть утверждение «стало быстрее», но нет сопоставимого контроля, снимите claim и сохраните только наблюдение.
Такая остановка экономит время. Неполный trace легко вставить в убедительный рассказ и трудно разобрать после нескольких изменений. Именованная причина stop сохраняет недостающий факт: нужно восстановить parent, назвать ожидание или выровнять нагрузку. Отказ от вывода точнее, чем правдоподобное объяснение пустого места.
\nSampling может убрать нужный span. Collector может потерять событие или доставить его не по порядку. Асинхронный worker может продолжить работу после root. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезной, но снижают силу вывода. Ограничение нужно записать рядом с наблюдением.
\nWaterfall не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Он не задаёт SLA и не заменяет нагрузочный тест. RFC 9110 описывает семантику HTTP, а не бюджет latency приложения. Для эксплуатационного решения нужны отдельные измерения, контрольные группы и критерии остановки.
\nУчебный код нельзя подключать к реальной телеметрии без новой проверки. Он использует одну запись, фиксированные числа и заранее известные поля. Он не проверяет экспорт, прокси, клиентские повторы или права доступа. Production-результат появляется только после отдельного эксперимента с описанной средой.
\nРазбор готов, если другой инженер без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу, отличить названную задержку от unknown и воспроизвести stop на неполной trace или несопоставимой нагрузке. Это критерий качества evidence, а не обещание ускорения.
\nИзменение можно оценивать отдельно, когда baseline и candidate сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а результат не маскирует ошибку ростом таймаута или потерей сигнала. До этого корректный итог звучит так: «В fixed-trace-01 при fixed-load-a queue-wait — самый длинный названный сегмент. Эффект изменения не заявлен». Другой инженер должен получить тот же вывод из той же записи.
Пользователь ждёт страницу десять секунд, а CPU сервиса держится на двадцати процентах. В такой ситуации легко переписать SQL, увеличить пул потоков или поднять timeout. Эти изменения могут убрать симптом на одном прогоне и оставить причину нетронутой. Сначала нужно определить, где именно прошло время: в браузере, на прокси, в очереди, в коде сервиса или в зависимости.
\nНиже — практический маршрут для одного воспроизводимого HTTP-сценария. Он не обещает найти bottleneck по одной картинке. Он помогает собрать минимальный набор сигналов, связать его через идентификатор запроса и выбрать следующий эксперимент. Все числовые значения в примере учебные, если прямо не указано обратное.
\nДо изменения кода запишите ровно тот запрос, который хотите ускорить: метод и маршрут, статус, размер ответа, время начала, длительность, идентификатор запроса, версию приложения и состояние кэша. Для повторного прогона добавьте cohort — фиксированный набор входных данных, число запросов, concurrency и форму ответа. Эти поля нужны не для отчётности: без них два коротких ответа могут быть результатом разных условий.
\nСформулируйте исходный факт без диагноза: «GET /catalog/42 вернул 200 за 10 000 мс, CPU процесса — около 20%». Фраза «медленная база» уже является гипотезой. Её можно проверять, но нельзя прятать в поле симптома. Если trace отобрана sampling-правилом или часть заголовков удаляет прокси, запишите это рядом с измерением.
| Поле | Пример | Зачем фиксировать | Что ломает сравнение |
|---|---|---|---|
| Маршрут и метод | GET /catalog/42 | Определяют операцию и набор middleware | Сравнение с другим endpoint |
| Вход и форма ответа | item=42, JSON v2 | Влияют на размер данных и план запроса | Другой id или набор полей |
| Нагрузка | 12 запросов, concurrency 3 | Определяет очередь и конкуренцию за ресурсы | Другая параллельность или cohort |
| Кэш и версия | miss, build abc123 | Разделяют cold и warm путь | Попадание в кэш или иной код |
| Идентификаторы | request_id и traceparent | Связывают клиент, сервис и зависимости | Поиск только по timestamp |
У end-to-end измерения есть граница от начала навигации или HTTP-запроса до события, которое вы считаете результатом. Внутри неё могут быть последовательные и параллельные участки. Браузер измеряет навигацию и ресурсы, сервис создаёт root span, а дочерние span описывают отдельные операции. Запрос к базе или партнёру может быть дочерним client span, но его имя не доказывает, что именно он создал задержку.
\nСначала ищите разрыв между границами. Если браузерная запись показывает десять секунд, а root span сервиса — две, оставшиеся восемь секунд не следует называть «сетевыми» без отдельного сигнала. Это может быть очередь перед сервисом, прокси, повтор на клиенте или ожидание до отправки. Если root длится десять секунд, а названные дочерние операции занимают только две, восемь секунд остаются неизвестными.
\nНапример, учебный root длится 1 000 условных единиц. В нём есть очередь 520, запрос к базе 150 и внешний вызов 200. Названные интервалы не покрывают весь root: остаётся 130 единиц промежутков и неразмеченного времени. Можно сказать, что очередь — самый длинный названный участок этой записи. Нельзя сказать, что она является production bottleneck или что изменение очереди сократит ответ.
\nНебольшой скрипт ниже проверяет только то, что можно проверить по переданному объекту: parent/child-связи, интервалы и контрольную границу. Он не читает телеметрию и не симулирует latency. Сохраните его как временный фрагмент в консоли браузера или запустите через node, предварительно заменив HTML-сущности обратно на символы в обычном JS-файле.
const input = {\n root: { id: 'root-01', start: 0, end: 1000 },\n spans: [\n { id: 'queue-01', parent: 'root-01', role: 'queue-wait', start: 40, end: 560 },\n { id: 'db-01', parent: 'root-01', role: 'database-call', start: 570, end: 720 },\n { id: 'partner-01', parent: 'root-01', role: 'external-call', start: 730, end: 930 }\n ],\n boundary: {\n route: 'GET /catalog/42',\n cohort: 'fixed-catalog-a',\n requests: 12,\n concurrency: 3,\n cache: 'miss',\n build: 'abc123'\n }\n};\n\nfunction reviewTrace(trace) {\n const ids = new Set(trace.spans.map((span) => span.id));\n const connected = trace.spans.every((span) =>\n span.parent === trace.root.id || ids.has(span.parent)\n );\n const timed = [trace.root, ...trace.spans].every((span) =>\n Number.isFinite(span.start) &&\n Number.isFinite(span.end) &&\n span.end >= span.start\n );\n\n if (!connected) return { status: 'stop-incomplete-trace' };\n if (!timed) return { status: 'stop-invalid-interval' };\n return { status: 'observation-ready', namedSpans: trace.spans.length };\n}\n\nconsole.log(reviewTrace(input));\n// { status: 'observation-ready', namedSpans: 3 }\nТест с отсутствующим родителем должен вернуть stop-incomplete-trace, а с start > end — stop-invalid-interval. Это полезный отрицательный путь: он запрещает восстановить дерево по удобному совпадению времени. Статус observation-ready означает только «структуру можно читать», а не «причина найдена».
В браузере начните с Navigation Timing, а не с устного впечатления «страница открывается долго». API возвращает измерения текущей навигации. Для документа важны границы, которые соответствуют вашему критерию: например, время до первого байта, DOM construction или load event. Название метрики должно быть частью результата, иначе команда сравнит разные события под одним словом «загрузка».
\nconst navigation = performance.getEntriesByType('navigation')[0];\n\nif (!navigation) {\n console.log({ status: 'stop-no-navigation-entry' });\n} else {\n console.table({\n type: navigation.type,\n redirectMs: navigation.redirectEnd - navigation.redirectStart,\n ttfbMs: navigation.responseStart - navigation.requestStart,\n domContentLoadedMs:\n navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,\n loadEventMs: navigation.loadEventEnd - navigation.loadEventStart,\n totalMs: navigation.loadEventEnd - navigation.startTime\n });\n}\nЭти вычисления показывают интервалы браузерной навигации в миллисекундах. Они не раскрывают внутреннюю очередь сервера и не заменяют trace. В SPA, при переходе без полной навигации, нужный пользовательский сценарий может быть resource timing или собственным User Timing-маркером. Не переносите приведённые поля на любой сценарий без проверки типа навигации и момента, когда запись появилась.
\nНа стороне сервиса найдите root по traceparent или request id, затем проверьте parent/child-связи, начало и конец каждого span, статус, retry и ошибки. OpenTelemetry использует span как единицу работы и допускает root span с подоперациями; контекст trace связывается между границами через стандартизированные HTTP-заголовки. Ни один из этих механизмов не создаёт пропущенный span и не превращает корреляцию в доказательство причины.
Если подозрение остаётся на PostgreSQL, измерьте SQL отдельно на сопоставимом наборе данных. EXPLAIN показывает план, который выбрал планировщик. EXPLAIN ANALYZE дополнительно выполняет запрос, поэтому используйте его для безопасного SELECT в окружении, где нагрузка и данные контролируемы. Сравнивайте план и фактические строки, а не только число cost: оценка плана — условная величина, зависящая от статистики и среды.
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)\nSELECT id, title\nFROM catalog_items\nWHERE id = 42;\nЭтот запрос не доказывает, что индекс нужен. Он отвечает на более узкий вопрос: какой план и фактические чтения получены для конкретного SQL и конкретных данных. Если запрос изменяет данные, не запускайте EXPLAIN ANALYZE без отдельной безопасной процедуры: анализ выполняет оператор. Полученный план нужно связать с тем же request/trace, иначе это лишь похожий локальный тест.
| Наблюдение | Что оно подтверждает | Чего оно не подтверждает | Следующий шаг |
|---|---|---|---|
| CPU низкий, root длинный | В путь входит ожидание или работа вне CPU | Конкретную очередь, БД или сеть | Разделить root на named spans и проверить разрывы |
Есть длинный database-call | Долгий интервал вызова БД в этой trace | Неэффективный SQL или необходимость индекса | Снять SQL и сопоставимый EXPLAIN |
| Браузер дольше сервиса | Между границами есть неразобранный интервал | Что именно делал прокси или клиент | Проверить redirect, resource timing, retry и gateway |
| Второй прогон короче | Вторая запись имеет меньшую длительность | Эффект изменения кода | Сверить cohort, concurrency, кэш, build и метрику |
| Нет parent или единицы времени | Наблюдаемость неполна | Положение и причина сегмента | Вернуть именованный stop и восстановить сигнал |
Корректная цепочка выглядит так: наблюдение — «queue-wait=520 в root-01»; гипотеза — «часть времени создаёт ограничение admission»; проверка — «снять отдельный metric ожидания на той же нагрузке»; действие — «изменить один фактор и сравнить заранее выбранную метрику». Если проверка не может опровергнуть гипотезу, это не проверка, а подтверждение удобной истории.
Если отдельный сигнал подтвердил ожидание в пуле, следующим действием может быть проверка времени выдачи соединения и конкуренции. Увеличение пула без такого сигнала способно перенести очередь в базу. Если подтверждена локальная работа CPU, нужен профайлер или измерение конкретной функции. Если виден только unknown, сначала улучшите наблюдаемость. Выбор действия должен следовать границе доказательства.
\nВерните stop-incomplete-trace, если дочерний span ссылается на отсутствующего родителя. Верните stop-unknown-delay, если большой интервал не имеет подтверждённой роли. Верните stop-incomparable-load, если изменились cohort, concurrency, кэш или форма ответа. Эти статусы не означают, что система исправна или неисправна. Они фиксируют, почему причинный вывод пока запрещён и какой сигнал нужно добыть.
Sampling может исключить нужную запись. Collector может потерять событие. Асинхронная задача может продолжить работу после завершения root и потребовать span link, а не вложенного дочернего span. Retry создаёт несколько похожих операций. Часы разных узлов могут расходиться. Контекст через traceparent помогает связать границы, но посредник может не передать заголовок, а заголовок не гарантирует полноту сбора.
Одна trace не показывает p95, p99, throughput, распределение ошибок или поведение при насыщении. Одна browser timing-запись не описывает все устройства и сети. Один EXPLAIN не заменяет серию запросов на данных, похожих на production. Поэтому условные 520 и 1 000 units нельзя превращать в миллисекунды, SLA или обещание ускорения.
Разбор можно передавать следующему инженеру, если он без устных пояснений находит исходный симптом, видит контрольную границу, связывает request с trace, проверяет parent/child и отличает названный интервал от неизвестного. Он должен суметь запустить локальный отрицательный пример, понять, какую гипотезу проверяет следующий шаг, и увидеть, почему другая нагрузка отменяет сравнение.
\nФинальная формулировка должна возвращать границу: «При fixed-catalog-a, 12 запросах и concurrency 3 в root-01 самый длинный названный интервал — queue-wait, 520 условных единиц. Причина задержки и эффект изменения не доказаны; следующая проверка — отдельный сигнал ожидания». Это полезнее, чем уверенное «сервис тормозит из-за очереди».
traceparent и tracestate между HTTP-границами; наличие заголовка не гарантирует полноту записи.PerformanceNavigationTiming и его временных полей; это браузерный слой, а не серверный профайлер.EXPLAIN ANALYZE и ограничений оценочных cost; результат относится к конкретному SQL, данным и среде.Симптом выглядит обнадёживающе: API отвечает на запросы только с нужным Origin, а в DevTools чужой сайт получает ошибку CORS. Команда помечает проблему закрытой. Но endpoint всё ещё принимает cross-site POST с cookie. Браузер может отправить запрос и не показать ответ атакующему. Если запрос меняет адрес доставки, пароль или лимит, ошибки CORS не возвращают деньги и не отменяют изменение.
Цена такой подмены — ложное чувство защиты. CORS управляет чтением ответа из браузера. CSRF-защита управляет тем, может ли чужой сайт заставить браузер выполнить действие от имени пользователя. Эти механизмы стоят рядом, но решают разные задачи. Без явного пути атаки, контрольной точки и наблюдаемого evidence слово «проверено» слишком сильное.
\nТезис статьи простой: security review должен связывать asset, путь атаки, interruption и границу доказательства. Положительный результат подтверждает только эту связь. Он не разрешает deploy, не доказывает защиту production и не заменяет проверку входа, сессии, заголовков или поведения браузера.
\nПусть пользователь вошёл в bank.example. Браузер хранит cookie сессии и автоматически прикладывает её к запросу на этот origin. На странице evil.example размещена форма. Форма отправляет POST на банковский endpoint. Для простой формы браузер не обязан сначала выполнить CORS preflight. Сервер может получить cookie и изменить состояние.
<form action=\"https://bank.example/profile/email\" method=\"POST\">\n <input name=\"email\" value=\"attacker@example.net\">\n</form>\n<script>document.forms[0].submit()</script>\n\n// Учебный пример. Он не отправляется и не доказывает поведение\n// конкретного браузера или endpoint.\nЕсли сервер принимает такой POST только по cookie, запрос остаётся опасным. Проверка Origin или Referer может добавить условие. Synchronizer token или signed double-submit token связывает действие с формой приложения. Cookie с подходящим SameSite уменьшает поверхность, но его режим зависит от контекста браузера и схемы запроса. Без проверки на сервере нельзя считать один флаг достаточным.
Тот же endpoint может иметь корректный CORS и всё равно быть уязвимым к изменению состояния. И наоборот: endpoint может не разрешать чтение ответа чужому origin, но нуждаться в CSRF-токене для опасного действия. Сначала назовите действие. Потом проверьте, какой контроль его прерывает.
\nНачните с одного asset. Для примера это email пользователя. Путь атаки имеет порядок: чужая страница создаёт запрос, браузер добавляет cookie, endpoint принимает изменение, сервер сохраняет новый email. CORS находится на границе чтения ответа. Он не обязан останавливать первые три шага. CSRF-токен и серверная проверка origin находятся ближе к операции изменения.
\nУ каждого контроля должна быть одна фраза с глаголом. «CORS включён» ничего не говорит о действии. «Сервер отклоняет state-changing POST без валидного токена» описывает interruption. Такая запись проверяема: можно назвать вход, ответ и правило отказа. Если token проверяется только в JavaScript, контроль не стоит на серверной границе. Если endpoint разрешает запрос без cookie, нужно отдельно оценить анонимную операцию.
\nEvidence тоже имеет границу. Заголовок Access-Control-Allow-Origin показывает настройку чтения ответа. Он не показывает, что сервер отверг чужой POST. Ответ 403 на запрос без токена показывает одну отрицательную ветку. Он не доказывает, что все state-changing endpoints используют тот же middleware. Эти два наблюдения нельзя склеить в общий verdict.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой origin видит CORS error | Браузер не отдаёт ему response body | Отправить отдельный state-changing POST и проверить запись | Добавить серверную CSRF-защиту, если действие использует cookie |
| POST проходит без token | Endpoint доверяет cookie без дополнительного доказательства намерения | Повторить запрос без token в изолированной учебной среде | Отклонять запрос до изменения состояния; сохранить ответ и correlation id |
| Token есть в форме, но не проверяется | Контроль остался на клиенте | Вызвать endpoint напрямую без выполнения UI | Перенести проверку на сервер и покрыть отрицательным тестом |
| Один endpoint защищён, другие нет | Проверка привязана к странице, а не к классу операции | Составить список state-changing routes и найти общий middleware | Назначить владельца непокрытых маршрутов; не выдавать общий verdict |
| После изменения появился 403 | Изменился контракт запроса или cookie policy | Сверить token, origin, cookie и права в позитивном сценарии | Исправить конкретный контракт; не ослаблять правило глобально |
Ниже псевдокод для учебного review. Он показывает порядок условий, но не является готовым middleware. Реальный фреймворк должен сам определить, как извлекать cookie, хранить token, сравнивать origin и формировать ответ.
\nfunction updateEmail(request) {\n if (request.method !== 'POST') return allowMethod();\n if (!sameOrigin(request.headers.origin)) return reject(403);\n if (!validCsrfToken(request.cookie, request.body.csrf)) {\n return reject(403);\n }\n if (!validEmail(request.body.email)) return reject(400);\n return saveEmail(request.session.userId, request.body.email);\n}\n\n// Учебный пример: не содержит production storage, logging\n// или конкретную реализацию token.\nВажен не синтаксис, а место проверки. Запрос отклоняется до записи. Позитивная ветка требует действующую сессию и корректный token. Негативная ветка проверяет запрос без token, с чужим origin и с повторно использованным token. Если тест вызывает только функцию в памяти, он подтверждает порядок условий в примере. Он не подтверждает маршрутизацию, cookie flags, proxy и реальную базу.
\nДля CORS правило другое. Разрешайте конкретные origins, не отражайте произвольный заголовок Origin, не сочетайте wildcard с credentialed requests и проверяйте, нужен ли endpoint вообще для cross-origin чтения. Но даже строгий allowlist не заменяет CSRF-защиту. Это отрицательный путь статьи: исправление видимого CORS-симптома может не менять способность чужой формы отправить запрос.
Передача результата должна содержать четыре поля: threat id, control id, observed evidence и остаток. Например, threat — «чужая форма меняет email в сессии пользователя». Control — «сервер отклоняет POST без token и при несоответствующем origin». Evidence — «в учебном тесте запрос без token вернул 403 до вызова сохранения». Остаток — «не проверены другие маршруты и поведение production proxy».
\nТакой hand-off не означает, что система защищена. Он означает, что следующий человек видит, какую ветку повторить и где заканчивается наблюдение. Нельзя заменить остаток фразой «остальное стандартно». Нельзя перенести evidence с одного endpoint на весь API. Нельзя считать отсутствие ответа у атакующего доказательством отсутствия изменения в системе.
\nЭта проверка не измеряет вероятность атаки, размер ущерба, покрытие всех маршрутов, устойчивость к обходу proxy или состояние системы после deploy. Пример не обращается к реальному API, не запускает браузер и не использует production cookie. Официальные стандарты помогают выбрать вопросы, но не подтверждают конкретную конфигурацию. CSP, например, полезна как дополнительная граница для content injection, но не отменяет безопасную обработку данных. ASVS задаёт требования для верификации web-контролей, а не автоматический verdict. NIST описывает наборы техник проверки, а не единый сертификат.
\nКритерий готовности должен быть узким и воспроизводимым: для каждого state-changing endpoint существует один записанный attack path, серверная проверка стоит до изменения состояния, позитивный запрос проходит с корректным token, отрицательный запрос не создаёт запись, а evidence содержит маршрут, вход, статус и границу применимости. Непокрытый маршрут остаётся stop. Если нельзя показать, какой контроль прервал путь, review не завершён.
\nЕсли после проверки требуется изменить код или конфигурацию, это отдельная уполномоченная работа. Hand-off только указывает следующий шаг. Он не включает заголовки, не меняет cookie policy и не разрешает выпуск. Такое ограничение делает вывод уже, но честнее: команда знает, что проверила, чего не проверила и какую работу нельзя считать выполненной.
\nСимптом выглядит обнадёживающе: API отвечает только для нужного Origin, а в DevTools чужой сайт получает ошибку CORS. Команда закрывает задачу. Но тот же endpoint всё ещё может принять cross-site POST с cookie и изменить состояние. Браузер способен отправить запрос, даже если JavaScript на странице-источнике не прочитает ответ. Если действие меняет email, адрес доставки или лимит, ошибка CORS не отменяет запись.
Цена ошибки — неверный диагноз. CORS (Cross-Origin Resource Sharing) регулирует, какой ответ браузер отдаёт скрипту другого origin. CSRF (Cross-Site Request Forgery) защищает state-changing действие от запроса, который пользователь не намеревался выполнять. Это разные границы. Ниже — тестовый сценарий для локального или специально разрешённого стенда: он показывает, как отличить отказ в чтении ответа от отказа операции и не выдать один сигнал за доказательство всей защиты.
\nПредставим приложение https://app.example.test и страницу злоумышленника https://attacker.example.test. Пользователь уже вошёл в приложение. Браузер хранит session cookie и по правилам cookie может приложить её к запросу. На странице-источнике размещена форма, которая отправляет URL-encoded POST. Такой запрос похож на обычную HTML-форму и не обязан начинаться с CORS preflight.
<form action='https://app.example.test/profile/email' method='POST'>\n <input name='email' value='attacker@example.test'>\n</form>\n<script>document.forms[0].submit()</script>\n\n// Учебный пример: не открывать его против чужого сервиса.\n// Цель — проверить, что сервер делает до изменения состояния.\nВ этой форме нет JavaScript-операции чтения ответа. Поэтому запрет Access-Control-Allow-Origin для attacker.example.test не равен запрету самой отправки. Браузер может показать странице-источнику ошибку или перенаправление, но сервер уже мог получить запрос. Проверять нужно не консоль браузера, а серверный результат: статус, вызов обработчика и факт записи.
CORS — протокол согласования между браузером и сервером. В ответе сервер сообщает, какому origin можно предоставить response body, какие методы и заголовки разрешены, а при необходимости — можно ли раскрывать ответ с credentials. Для нестандартного запроса браузер обычно посылает предварительный OPTIONS, а затем отправляет основной запрос только после подходящего ответа.
Это полезная граница для API, которому действительно нужно cross-origin чтение. Но CORS не предназначен для универсальной остановки запросов, похожих на отправку формы. Спецификация Fetch прямо рассматривает такие запросы как существующую возможность платформы: сервер должен сам защищать state-changing операции от CSRF. Поэтому allowlist отвечает на вопрос «кто может прочитать ответ из браузерного JavaScript?», а не «кто вправе изменить данные?».
\n| Наблюдение | Поддерживаемый вывод | Чего оно не доказывает | Следующая проверка |
|---|---|---|---|
| В консоли отображается CORS error | Скрипт не получил доступ к ответу по этому запросу | Что сервер не получил запрос или не изменил данные | Проверить серверный журнал и тестовое хранилище |
OPTIONS получил отказ | Конкретный preflight не разрешил последующий сложный запрос | Что простой POST или HTML-форма остановлены | Отдельно проверить form-shaped POST |
POST вернул 403 без токена | Эта ветка endpoint отклоняет запрос без доказательства намерения | Что middleware подключён ко всем изменяющим маршрутам | Составить список state-changing маршрутов |
Cookie имеет SameSite=Lax | Браузер применяет ограничение cookie в части cross-site контекстов | Что subdomain, GET и старые клиенты не создают риск | Проверить site/origin и все изменяющие методы |
| Fetch с JSON не прошёл preflight | Браузер не отправил этот основной запрос после отказа preflight | Что другой клиент не отправит URL-encoded форму | Проверить серверную защиту независимо от клиента |
Таблица нужна как стоп-сигнал для ревью. Нельзя переносить вывод из первой колонки на соседнюю строку. Особенно опасна подмена «ответ не прочитан» на «операция не выполнена»: это разные наблюдения и разные журналы.
\nЗащита должна сработать до побочного эффекта. На сервере сначала проверяют метод, сессию и контекст запроса, затем CSRF-токен или иной выбранный механизм, потом права и данные, и только после этого вызывают сохранение. Порядок зависит от фреймворка, но отрицательная ветка должна завершиться до функции, которая меняет состояние.
\nfunction updateEmail(request) {\n if (request.method !== 'POST') return reject(405);\n if (!request.session?.userId) return reject(401);\n if (!sameOrigin(request.headers.origin)) return reject(403);\n if (!validCsrfToken(request.session, request.body.csrfToken)) {\n return reject(403);\n }\n if (!validEmail(request.body.email)) return reject(400);\n\n return saveEmail(request.session.userId, request.body.email);\n}\n\n// Псевдокод: storage, токены и ответы зависят от фреймворка.\n// В тесте нужно проверить, что saveEmail не был вызван.\nКод показывает контракт, а не готовый middleware. sameOrigin должен корректно обработать отсутствие заголовка и доверенные схемы. Проверка токена должна использовать серверное состояние либо безопасно связанный double-submit-механизм. Авторизация отвечает на другой вопрос: имеет ли пользователь право менять email. CSRF-токен не заменяет авторизацию, а авторизация не доказывает намерение запроса.
Поднимите тестовый обработчик с записью в памяти, журналом вызовов и двумя ветками: позитивной и отрицательной. Значение TARGET ниже замените адресом своего локального HTTPS-стенда или изолированного окружения. Cookie TEST_ONLY_SESSION должна быть фиктивной, а обработчик — не связан с реальными данными.
TARGET='https://app.example.test'\n\n# URL-encoded запрос с чужим Origin и тестовой cookie.\n# Не запускать против production или чужого сервиса.\ncurl --silent --show-error --include --request POST \\\n \"$TARGET/profile/email\" \\\n --header 'Origin: https://attacker.example.test' \\\n --header 'Content-Type: application/x-www-form-urlencoded' \\\n --cookie 'session=TEST_ONLY_SESSION' \\\n --data 'email=attacker%40example.test'\n\n# Позитивная ветка: тот же стенд, корректный тестовый токен.\ncurl --silent --show-error --include --request POST \\\n \"$TARGET/profile/email\" \\\n --header 'Origin: https://app.example.test' \\\n --header 'Content-Type: application/x-www-form-urlencoded' \\\n --cookie 'session=TEST_ONLY_SESSION' \\\n --data 'email=user%40example.test&csrfToken=TEST_ONLY_TOKEN'\nОжидаемый отрицательный результат — сервер отклоняет запрос до saveEmail, а тестовая запись остаётся прежней. Положительный запрос с корректным токеном проходит согласно контракту стенда. Сохраните статус, идентификатор запроса, причину отказа и состояние записи до и после. Если сервер вернул 403, но запись изменилась асинхронно, проверка не пройдена: один статус не описывает весь побочный эффект.
Команда с чужим Origin проверяет ветку origin, но не моделирует все варианты браузера. Для form-shaped POST важнее убрать токен и проверить факт записи. Для JSON-клиента добавьте preflight и убедитесь, что CORS не позволяет незапланированному origin выполнить основной запрос с credentials. Каждый сценарий должен иметь собственный идентификатор и ожидаемое состояние.
Для cookie-аутентифицированного приложения базовый выбор — встроенная защита фреймворка от CSRF либо серверный synchronizer token. Сервер выдаёт непредсказуемый токен, клиент возвращает его в форме или заголовке, а сервер сравнивает его с ожидаемым значением до записи. Для stateless-систем возможен signed double-submit cookie, но подпись должна быть связана с сессией; простое совпадение двух значений без такой связи не даёт того же свойства.
\nПроверка Origin или, если его нет, аккуратная проверка Referer — дополнительный барьер. Fetch Metadata, например Sec-Fetch-Site, тоже может помочь отсечь cross-site контекст, если продукт контролирует поддержку браузеров и предусмотрел fallback. Эти механизмы не освобождают от проверки endpoint-ов: на старом маршруте может отсутствовать middleware, а клиентская библиотека — иметь отдельную ветку.
SameSite=Strict уменьшает отправку cookie в cross-site контексте, но может ломать переходы по внешним ссылкам. Lax оставляет более мягкую модель и не должен становиться единственным доказательством защиты. SameSite описывает site, а не origin: два subdomain одного registrable domain могут считаться same-site. Если среди subdomain есть пользовательский контент, legacy-приложение или чужая управляемая зона, остаточный риск нужно оценивать отдельно.
В CORS-конфигурации задавайте явный список доверенных origin. Не отражайте произвольный входной Origin в Access-Control-Allow-Origin, если не проверили его по allowlist. Для credentialed cross-origin запросов wildcard * не подходит. Добавляйте Vary: Origin, когда ответ зависит от этого заголовка и проходит через кэш. Эти меры ограничивают чтение и отправку некоторых запросов, но не заменяют серверную CSRF-проверку для form-shaped POST.
Минимальный набор тестов должен ломать каждый предполагаемый барьер по отдельности. Уберите токен, подмените origin, удалите cookie, используйте неподдержанный метод, отправьте запрос через форму и повторите запрос с корректными данными. Для каждого случая зафиксируйте проверку, которая должна остановить путь. Если разные причины возвращают один 403, это допустимо для внешнего ответа, но внутренний лог должен различать ветки без записи секретов.
| Ветка | Изменение входа | Ожидаемое действие | Факт, который нужно сохранить |
|---|---|---|---|
| Позитивная | Действующая тестовая сессия и корректный токен | Вызвать сохранение один раз | Новая запись и correlation id |
| CSRF token missing | Убрать токен, оставить cookie | Отклонить до сохранения | 403 и неизменённая запись |
| Origin foreign | Передать чужой origin | Отклонить по правилу стенда | Решение origin-check и запись |
| Unauthenticated | Убрать сессию | Отклонить до операции | 401/403 и отсутствие эффекта |
| Form-shaped POST | URL-encoded body без preflight | Применить тот же CSRF-контроль | Результат этой ветки, а не OPTIONS |
| GET state change | Вызвать старый GET-маршрут | Не менять состояние | Маршрут найден или отмечен как риск |
Проверка «в браузере показалась CORS error» — только подсказка. Она не заменяет наблюдение за обработчиком и хранилищем. Если нет server-side evidence, пишите: «скрипт не прочитал ответ в этом сценарии». Нельзя добавлять к этому «данные не изменились» без отдельного сигнала.
\nЗапись проверки содержит пять полей: threat, endpoint, control, observed и not-proven. Например: threat — «чужая форма пытается изменить email»; endpoint — POST /profile/email; control — «сервер отклоняет запрос без токена до saveEmail»; observed — «тест без токена вернул 403, запись не изменилась»; not-proven — «другие маршруты, браузеры и production proxy не проверены».
Такая запись связывает наблюдение с конкретной операцией. Она не утверждает, что весь API защищён, что любой браузер ведёт себя одинаково или что проблема устранена в production. Если проверка проходила только на фиктивной функции, результат относится к этой функции и входу. Для стенда добавьте версию приложения, cookie policy и идентификатор сборки.
\nОписание предполагает cookie-аутентификацию и state-changing endpoint. Для API с bearer-токеном в заголовке модель CSRF отличается: браузер не прикладывает такой заголовок автоматически, но XSS, утечка токена, CORS и неверная клиентская маршрутизация остаются отдельными угрозами. Для OAuth, embedded webview, native-клиента, service worker и межсайтовых редиректов нужны свои сценарии. Не переносите этот вывод на них без новой проверки.
\nПоведение зависит от браузера, схемы, доменов, атрибутов cookie, reverse proxy и серверного фреймворка. SameSite не является абсолютной границей: он различает site, а не origin, и не исправляет state-changing GET. Preflight проверяет только запросы, которые квалифицируются для preflight. Кэш может отдать CORS-ответ без корректного варианта Origin, если конфигурация не учитывает Vary.
Учебные curl-команды не доказывают поведение реального браузера. Они помогают воспроизвести HTTP-вход и проверить серверный контракт. Для браузерного вывода добавьте автоматизированный тест в поддерживаемых браузерах и проверьте фактические cookie, response headers, preflight, запись и логи. Если не проверены другие state-changing endpoint-ы, честный результат — частичный, а не общий verdict.
\nOPTIONS и JSON-клиентом.Проверку можно считать завершённой для конкретного endpoint-а, когда известны его state-changing операции, позитивная ветка проходит с тестовой сессией и корректным токеном, а form-shaped запрос без токена не вызывает запись. Для каждой отрицательной ветки виден контроль и сохранён факт результата. CORS-конфигурация содержит явные доверенные origin и согласованные credential rules. Остальные маршруты и средовые ограничения перечислены.
\nЭто узкий критерий. Он не обещает отсутствие CSRF, не выдаёт сертификат безопасности и не превращает CORS error в доказательство. Он даёт следующему инженеру воспроизводимую последовательность: какой запрос повторить, где посмотреть эффект и какое утверждение пока запрещено. Если хотя бы один state-changing маршрут не прошёл такой путь, общий вывод нужно остановить.
\nПосле ревью в отчёте остаётся знакомый список: CSP включён, cookies имеют флаг HttpOnly, сканер не нашёл критических проблем. Через неделю в приложение попадает пользовательский фрагмент, а команда не может ответить, какой именно шаг атаки должен был остановиться. Ошибка стоит дорого: разработчики спорят о настройке заголовка, инцидент получает ложный статус «закрыт», а реальная проверка входных данных и места вывода остаётся без владельца.
\nТезис статьи простой: security control имеет смысл только внутри трассы threat model → interruption → evidence → residual risk. Сначала нужно назвать актив, условие и порядок шагов атаки. Затем — указать, какой control прерывает конкретный шаг. После этого — ограничить вывод наблюдаемым evidence. Всё, что осталось за границей наблюдения, записывают как residual risk. Если связь оборвалась, результатом должен быть отказ от вывода, а не зелёная отметка.
Перечень хранит существительные: CSP, sanitizer, SAST, review, SameSite. Он не хранит направление связи. Из строки «CSP настроен» не следует, что конкретный фрагмент не попадёт в опасный sink. Из строки «cookie защищена» не следует, что сервер проверяет намерение запроса. Из строки «сканер чист» не следует, что сканер видел нужную ветку, конфигурацию и версию приложения.
\nОдин control часто действует позднее источника проблемы. CSP может ограничить исполнение скрипта в документе. Он не исправляет неверную валидацию и не превращает небезопасный HTML-sink в безопасный. Поэтому связь надо записать глаголом: отклонить скрипт без известного nonce на границе документа. Такой текст уже можно сопоставить с шагом атаки и с проверкой.
\nРассмотрим учебный путь. Ненадёжный фрагмент достигает именованного sink, а sink формирует документ, в котором возможен исполняемый сценарий. В модели это три разных шага. Нельзя заменить их словом «XSS»: короткое название скрывает условие и место, где должна сработать защита.
\nasset: browser rendering context\nprecondition: untrusted fragment reaches named sink\npath:\n 1. untrusted-fragment\n 2. unsafe-render-sink\n 3. script-capable-document\ncontrol: reject-script-without-fixed-nonce\nevidence:\n - named-directives-present\n - synthetic-negative-script-is-not-authorized\nЭто учебная запись в памяти. Она не создаёт HTTP-ответ, не запускает браузер, не проверяет заголовок и не измеряет поведение сайта. Её задача — показать форму рассуждения. В реальном ревью такую модель надо связать с конкретным маршрутом, кодом рендера, способом доставки policy и воспроизводимой проверкой.
\nУ control есть узкое место действия. В примере policy может ограничить следующий шаг: браузер не авторизует сценарий без нужного nonce. Но evidence не говорит, откуда взялся фрагмент, корректно ли закодирован вывод и все ли sinks покрыты. Эти вопросы остаются открытыми. Наличие residual risk не означает провал всей защиты. Оно означает, что вывод не расширяют за пределы наблюдения.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| «CSP включён», но путь атаки не назван | Control записан без interruption | Попросить назвать шаг, который он прерывает | Добавить ordered path и точку отказа |
| Тест зелёный, но относится к другой странице | Evidence не связано с threat id | Сверить идентификаторы пути и наблюдения | Остановить вывод и привязать проверку заново |
| После исправления исчезла строка residual risk | Поздний control приняли за исправление источника | Проверить validation, encoding и все sinks | Вернуть открытые участки и следующий вопрос |
| В отчёте написано «защита гарантирована» | Ограниченное evidence расширили риторикой | Сопоставить каждое слово с наблюдением | Сузить утверждение до проверяемого факта |
| Неизвестно, что делать при разрыве связи | У модели нет отрицательной ветки | Подать запись без path или binding | Вернуть точный stop и недостающий вход |
Отрицательная ветка важнее красивого положительного результата. Если evidence содержит наблюдение, но ссылается на другой threat или control, система не должна искать «похожую» запись по тексту. Она возвращает ошибку связи. Если attack path пуст, нельзя считать policy доказанной. Если residual risk не содержит открытого участка и вопроса для следующей проверки, положительный hand-off также нельзя принимать.
\nconst review = {\n threatId: 'fixed-html-injection-path-v1',\n controlId: 'fixed-csp-nonce-boundary-v1',\n evidence: {\n bindsThreatId: 'other-path',\n bindsControlId: 'other-control'\n }\n};\n\nif (review.evidence.bindsThreatId !== review.threatId ||\n review.evidence.bindsControlId !== review.controlId) {\n return {\n status: 'stop-unbound-evidence',\n nextAction: 'bind-observation-to-named-threat-and-control'\n };\n}\nПример синтетический. Он проверяет только равенство идентификаторов в объекте. Он не доказывает свойства браузера и не подтверждает, что приложение послало нужный заголовок. В реальном коде эта проверка может быть частью валидатора review-записи. Проверку браузерного поведения, серверного ответа и входных данных проводят отдельно.
\nEvidence — не обязательно скриншот или лог. Это ограниченное наблюдение, которому заранее задан допустимый вывод. Для учебного объекта допустимы два факта: именованные директивы присутствуют в записи и отрицательный сценарий без nonce не авторизован в этой модели. Нельзя из них выводить отсутствие XSS, корректность всех HTML-преобразований, защиту сессии или безопасность каждого браузера.
\nГраницу пишут рядом с observation. Иначе при передаче она исчезает, а фраза «negative case не авторизован» превращается в «уязвимость устранена». Хорошая запись отвечает на четыре вопроса: какой threat проверялся, какой control к нему привязан, что именно наблюдалось и чего это наблюдение не доказывает.
\nЭта дисциплина помогает и при конфликте controls. Санитизация может менять вход, а CSP — ограничивать последствия в документе. У них разные interruption. Если evidence относится только к policy, нельзя выдать вывод о преобразовании данных. Если два controls действуют на разные шаги, их нельзя слить в одну зелёную строку только ради компактного отчёта.
\nТрассировка не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, тесты, статический анализ, сканирование и независимую оценку. Она только не даёт одной записи присвоить себе результаты всех этих методов.
\nCSP не заменяет валидацию входа и кодирование вывода. Cookie-флаги не заменяют проверку полномочий и намерения операции. CORS не является универсальной защитой от CSRF: если сервер принимает изменяющий запрос с cookie без отдельной проверки, исправление CORS может не закрыть путь. Этот отрицательный пример применим только при соответствующей схеме браузера, cookie и серверного endpoint; его надо проверять реальным запросом в тестовой среде.
\nОписанный JavaScript ограничен учебной моделью. Он не сообщает production-результаты и не даёт разрешения на релиз. Если нужен реальный вывод, потребуются отдельные входы: собранный response, конфигурация доставки policy, тестовый браузер, тестовые данные и зафиксированный scope. Нельзя подменить их одной записью в памяти.
\nМатериал ревью готов, когда независимый читатель может пройти цепочку от актива до residual risk без устных пояснений. Для каждого control видны threat id, ordered path и точка прерывания. Для каждого evidence видны два binding id и допустимый вывод. Открытые участки не скрыты. Отрицательные случаи возвращают определённый stop. Положительная ветка остаётся только ограниченной передачей на следующий review, а не разрешением на изменение системы.
\nПрактический тест занимает один проход: удалите из записи любое звено и снова запустите валидатор. Если запись всё ещё выглядит «зелёной», модель слишком либеральна. Добавьте проверку, которая останавливает её на конкретном разрыве. Так список controls превращается в проверяемый аргумент, а не в коллекцию обещаний.
\nПроблема security review часто обнаруживается не в отсутствии мер, а в слишком сильном выводе. В отчёте стоят галочки «CSP включён», «cookie с HttpOnly», «сканер чист», но никто не может показать, какой запрос или фрагмент данных остановил каждый control. Если после этого endpoint меняет профиль по чужому POST, список мер создаёт ложное ощущение закрытой уязвимости.
\nУдобнее разложить проверку на четыре связанных вопроса: какой asset защищаем, как выглядит ordered attack path, где control прерывает путь и что именно наблюдалось. Последняя часть — evidence, то есть воспроизводимое подтверждение с ограниченным выводом. Всё, что не проверено, остаётся residual risk. Такая схема не обещает безопасность всего приложения, зато не позволяет одной настройке присвоить себе результат чужого теста.
\nСтрока «CORS настроен» описывает настройку чтения ответа из браузера, но не отвечает, принял ли сервер изменяющий запрос. HttpOnly не даёт JavaScript прочитать cookie, но браузер по-прежнему может приложить её к запросу. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе, но не исправляет небезопасный HTML-sink. Даже хороший SAST-анализ говорит только о просмотренных файлах, правилах и версии запуска.
\nОдин и тот же control может быть полезен в одном месте и бесполезен в другом. Поэтому вместо существительного нужен глагол: «сервер отклоняет POST без действительного CSRF-токена до записи в профиль». В этой формулировке видны операция, условие отказа и момент, до которого должен сохраниться asset. Её можно сопоставить с тестом. «CSRF включён» сопоставить не с чем.
\nВозьмём изменение email. Пользователь вошёл в bank.example, браузер хранит cookie сессии, а endpoint принимает POST /api/profile/email. Внешняя страница может попытаться отправить такой запрос с cookie. CORS определяет, может ли внешний origin прочитать ответ через браузерный API. CSRF-защита должна не допустить нежелательное изменение состояния на сервере.
<form action="https://bank.example/api/profile/email" method="POST">\n <input name="email" value="attacker@example.net">\n</form>\n<script>document.forms[0].submit()</script>\n\n// Сценарий приведён для анализа потока.\n// Не запускайте его против чужого сервиса.\nЕсли endpoint доверяет только cookie, чужой запрос может дойти до операции записи независимо от того, увидит ли внешний сайт тело ответа. Проверка заголовка Origin, synchronizer token или другой серверный механизм может прервать путь. Свойство SameSite уменьшает часть cross-site-сценариев, но зависит от контекста запроса и политики cookie. Ни один из этих controls нельзя объявлять заменой аутентификации или авторизации.
Здесь полезно записать путь по шагам: external-page → browser-attaches-cookie → POST-email → profile-changed. CORS относится к чтению ответа, CSRF-проверка — к допуску изменяющего запроса, авторизация — к праву менять конкретный профиль. Если evidence проверяет только заголовок ответа, из него нельзя выводить, что запись не произошла.
Для каждой операции составьте маленькую карточку. asset — изменяемый объект. precondition — условие, при котором путь возможен. attackPath — упорядоченные действия. interruption — точный запрет. evidence — наблюдение, которое можно повторить. Поле notProven защищает от расширения вывода при пересказе.
{\n "asset": "profile.email",\n "precondition": "valid_session_cookie",\n "attackPath": [\n "external_page",\n "browser_attaches_cookie",\n "POST_profile_email",\n "profile_changed"\n ],\n "interruption": "reject_without_csrf_token_before_write",\n "evidence": "403_and_email_unchanged_in_test_environment",\n "notProven": [\n "all_other_write_endpoints",\n "authorization_for_other_profiles",\n "production_proxy_behavior"\n ]\n}\nКарточка не является security verdict. Она фиксирует область одного утверждения. Если проверка вернула 403, но после запроса email изменился, статус ответа не спасает вывод: контроль сработал слишком поздно или тест смотрит не на тот asset. Если идентификатор endpoint отсутствует, карточку нельзя переносить на весь API.
Проверять нужно не только ответ, но и состояние после отказа. Ниже — команда для локального или специально разрешённого тестового сервиса. Подставьте адрес своего стенда и тестовую cookie; домен из примера не является целью для запроса.
\nBASE=http://127.0.0.1:3000\n\ncurl --fail-with-body -i -X POST "$BASE/api/profile/email" \\\n -H 'Origin: https://attacker.example' \\\n -H 'Content-Type: application/x-www-form-urlencoded' \\\n -b 'session=TEST_SESSION' \\\n --data 'email=attacker@example.net'\n\n# Ожидание для защищённого тестового endpoint:\n# HTTP/1.1 403 Forbidden\n# и прежнее значение profile.email после запроса.\nКоманда воспроизводима только при известных маршруте, тестовой сессии и контракте ответа. TEST_SESSION не должен быть настоящим секретом. Если сервис возвращает 401, сначала не создана аутентифицированная тестовая сессия; это не доказательство CSRF-защиты. Для положительной ветки повторите запрос с действительным CSRF-токеном, сохраните новый ответ и отдельно подтвердите ожидаемое изменение email.
Минимальный псевдокод показывает место отказа. В реальном приложении названия функций, хранилище токенов и формат ошибок будут другими.
\napp.post('/api/profile/email', async (req, res) => {\n const session = await getSession(req);\n if (!session) return res.sendStatus(401);\n\n if (!sameOrigin(req.get('Origin')) ||\n !validCsrfToken(req.get('X-CSRF-Token'), session)) {\n return res.sendStatus(403);\n }\n\n const email = parseEmail(req.body.email);\n if (!email) return res.sendStatus(400);\n\n if (!(await canEditProfile(session.userId, req.body.profileId))) {\n return res.sendStatus(403);\n }\n\n await updateEmail(req.body.profileId, email);\n return res.sendStatus(204);\n});\nПорядок здесь важен: сессия, намерение запроса, формат значения, право на объект, затем запись. Это не универсальный middleware и не готовая библиотека. Он не показывает, как сравнивать секреты, ротировать токены, проводить логирование или защищать другие каналы. Эти детали нужно проверять по фреймворку и коду конкретного сервиса.
\nEvidence — это не обязательно скриншот. Им может быть HTTP-ответ вместе с проверкой состояния, тестовый лог с correlation id, результат статического анализа с зафиксированным scope или браузерное наблюдение для конкретной политики. У каждого наблюдения должен быть допустимый вывод. Например, ответ 403 на POST без токена подтверждает отказ этой ветки на данном endpoint в данном окружении. Он не подтверждает защиту всех mutations.
Записывайте границу рядом с результатом. «В тесте запрос без токена получил 403, запись не изменилась; не проверены фоновые задачи, соседние маршруты и production proxy» — полезное утверждение. «CSRF закрыт» — уже нет. Точно так же наличие Content-Security-Policy показывает доставку заголовка. Оно не доказывает, что каждый пользовательский фрагмент безопасно закодирован и ни один sink не исполняет данные.
| Симптом | Что он означает | Проверка | Следующее действие |
|---|---|---|---|
| Чужой origin получает CORS error | Браузер ограничил чтение ответа | Проверить, изменилось ли состояние после отдельного POST | Оставить серверную CSRF-проверку, если операция использует cookie |
| Cookie имеет HttpOnly | Скрипт не читает её значение через API браузера | Проверить фактический запрос и серверную проверку намерения | Не выдавать HttpOnly за защиту от CSRF |
| POST без токена вернул 403 | Одна отрицательная ветка остановлена | Сверить asset, endpoint, окружение и состояние после запроса | Записать scope; отдельно проверить другие write-маршруты |
| Токен есть, но меняется чужой профиль | CSRF не заменяет авторизацию | Повторить с объектом другого пользователя | Проверить владельца ресурса до записи |
| CSP есть, но пользовательский HTML исполняется | Защита документа не исправила источник или sink | Проверить вывод, кодирование и response policy для этой страницы | Исправить безопасный рендеринг; CSP оставить дополнительным слоем |
W3C описывает Content Security Policy как механизм управления ресурсами и выполнением, который снижает риск content injection и работает как defense-in-depth. Там же явно сказано, что CSP не заменяет внимательную валидацию входа и кодирование вывода. Поэтому в трассе CSP может прерывать попытку выполнить неразрешённый скрипт, но не доказывает, что строка безопасно попала в HTML, атрибут или DOM-sink.
\nДля одного пользовательского поля нужны разные проверки: допустимый формат на сервере, безопасный контекст вывода и отрицательный тест для конкретной точки рендера. Политика с script-src и nonce относится к браузерному исполнению. Она не даёт права убрать тесты для шаблона или клиентского кода. Если policy меняется, повторно проверьте доставленный заголовок и позитивные сценарии приложения: чрезмерно строгая политика может ломать legitimate scripts, а режим Report-Only сообщает о нарушениях, но не блокирует их.
Трассировка threat → control → evidence не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, динамические тесты, SAST, сканирование и независимую оценку. Её задача уже: не дать отчёту расширить узкое наблюдение до утверждения о всей системе.
\nПример с cookie относится к state-changing HTTP endpoint. Он не покрывает OAuth callback, WebSocket, GraphQL mutation, загрузку файла, очередь сообщений или действия, авторизованные не cookie, а другим способом. Для каждого канала нужно построить отдельный путь. Для файла дополнительно проверяют тип, содержимое, имя, место хранения и выдачу. Для очереди — отправителя, обработчика, повторы и идемпотентность.
\nКоманды и псевдокод выше не дают разрешения атаковать реальный сервис. Выполняйте проверки только на локальном стенде или при явном разрешении владельца. Если неизвестно, какой middleware обслуживает маршрут, или нет способа проверить состояние после отказа, результат должен быть «нужна отдельная проверка», а не «защищено».
\nДля выбранной операции читатель должен без устных пояснений увидеть asset, precondition, ordered path, interruption, evidence и residual risk. Отрицательный запрос должен получить ожидаемый отказ до изменения состояния, положительный — пройти по контракту. Запись должна назвать окружение и перечислить, что не проверялось. Только такой результат можно передать следующему инженеру как ограниченное evidence; это не сертификат безопасности всего приложения.
\nСимптом часто выглядит убедительно: сервер отдаёт заголовок CORS, cookie помечена HttpOnly, а endpoint изменения профиля проверяет авторизацию. Но чужая страница всё ещё может отправить POST с cookie пользователя. Команда видит несколько включённых controls и считает задачу закрытой. Цена ошибки — изменение данных от имени жертвы, инцидент без понятной точки отказа и долгий спор о том, какая настройка должна была остановить запрос.
Тезис статьи простой: контроль защищает не «веб вообще», а конкретный переход в маршруте атаки. Для каждой меры нужно назвать вход, условие, точку прерывания и проверяемый результат. CORS ограничивает чтение ответа браузером. CSRF-токен проверяет намерение для state-changing запроса. Проверка прав решает, может ли пользователь выполнить операцию. Эти меры дополняют друг друга, но одна не заменяет другую.
\nНачните с действия нарушителя, а не со списка заголовков. В учебном сценарии пользователь вошёл в приложение, браузер хранит сессионную cookie, а endpoint принимает POST /api/profile/email. Внешняя страница содержит форму или JavaScript, который отправляет запрос на этот адрес. Браузер может приложить cookie к запросу. Если сервер не требует отдельного доказательства намерения, запрос меняет email.
У пути есть четыре наблюдаемые точки: источник запроса, браузер, endpoint и операция записи. CORS не делает внешний POST невозможным. Он обычно мешает прочитать ответ из JavaScript. Это другая граница. HttpOnly не запрещает браузеру отправлять cookie. Он только скрывает cookie от JavaScript. SameSite может уменьшить риск для части кросс-сайтовых запросов, но режим зависит от контекста, способа навигации и политики cookie. Защита должна проверять контракт на сервере.
Аутентификация отвечает на вопрос «кто отправил запрос?». Авторизация отвечает на вопрос «может ли этот пользователь изменить этот объект?». CSRF-защита отвечает на вопрос «есть ли у запроса доказательство, которое внешний сайт не может получить и воспроизвести?». Валидация входа отвечает на вопрос «соответствует ли значение контракту поля?». Нельзя перенести ответ одного слоя на другой.
\nСервер может принять валидный токен CSRF от пользователя без права менять чужой профиль. Тогда токен доказал происхождение запроса, но не право на операцию. Обратный случай тоже возможен: сервер правильно проверяет право, но принимает запрос с cookie без CSRF-защиты. Злоумышленник не получает новые права, но заставляет уже авторизованного пользователя выполнить разрешённое действие.
\nКонтроль становится проверяемым, когда его условие видно в коде и в тесте. Фраза «CORS настроен» не сообщает, что именно проверяли. Формулировка «без заголовка X-CSRF-Token endpoint возвращает 403 и не меняет запись» задаёт границу. Она не доказывает безопасность всех endpoint-ов, но доказывает один отрицательный путь для одной операции.
Ниже — учебный фрагмент на Express-подобном API. Он не подключается к базе, не создаёт настоящую сессию и не показывает production-результат. Функции getSession, findUser и updateEmail обозначают границы приложения. В реальном сервисе их контракты нужно проверить отдельно.
app.post('/api/profile/email', async (req, res) => {\n const session = await getSession(req);\n if (!session) return res.sendStatus(401);\n\n const csrf = req.get('X-CSRF-Token');\n if (!csrf || !timingSafeEqual(csrf, session.csrfToken)) {\n return res.sendStatus(403);\n }\n\n const email = parseEmail(req.body.email);\n if (!email) return res.status(400).json({ error: 'invalid_email' });\n\n const user = await findUser(session.userId);\n if (!user || user.id !== session.userId) return res.sendStatus(403);\n\n await updateEmail(user.id, email);\n return res.sendStatus(204);\n});\nПорядок проверок здесь не является универсальным шаблоном. Он показывает цепочку решений: сессия допускает запрос в контекст пользователя, токен закрывает CSRF-переход, парсер ограничивает значение, а проверка идентификатора не даёт обновить чужую запись. Каждый отказ имеет отдельный статус и тестируемое условие. Если приложение использует другой способ передачи CSRF-токена, сохраняется тот же принцип: внешний сайт не должен получить нужное доказательство из обычной сессии.
\nОтрицательный путь обязателен. Уберите заголовок, оставьте cookie и отправьте тот же POST. Ожидаемый результат — 403, а значение email не изменилось. Если сервер вернул 204 или запись изменилась, CORS, HttpOnly и наличие формы входа не имеют значения: граница endpoint пропускает запрос. В учебном примере это проверка логики, а не свидетельство поведения конкретного production-сервиса.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Есть CORS, но чужая форма меняет данные | CORS ограничивает чтение ответа, а не сам state-changing запрос | Отправить POST без CSRF-токена и проверить статус и запись | Добавить серверную проверку CSRF или иной эквивалентный механизм |
| Cookie имеет HttpOnly | Браузер всё ещё может приложить cookie к запросу | Проверить фактический запрос во внешнем контексте | Не считать HttpOnly защитой от CSRF; оставить его для защиты от чтения cookie скриптом |
| Токен проверяется, но меняется чужой объект | CSRF-токен не заменяет авторизацию | С токеном пользователя запросить объект другого пользователя | Сверить владельца объекта с субъектом сессии и вернуть 403 |
| Валидация поля есть только в браузере | Клиентский код не является доверенной границей | Отправить запрос напрямую с неверным или лишним полем | Повторить валидацию на сервере до записи |
| CSP включена, но XSS-тест проходит | Политика не исправляет небезопасный sink и неверное происхождение HTML | Проверить response header и отрицательный сценарий для конкретного sink | Исправить источник и вывод данных; использовать CSP как дополнительный слой |
Для каждой меры заведите короткую карточку. В поле asset назовите защищаемый объект. В precondition запишите условие, при котором атака возможна. В interruption укажите запрещаемый переход. В evidence положите наблюдение, которое можно повторить. Последнее поле должно иметь границу: оно отвечает только на свой вопрос.
{\n "asset": "profile.email",\n "precondition": "session_cookie_present",\n "attackPath": [\n "external_page",\n "browser_attaches_cookie",\n "POST_profile_email",\n "profile_changed"\n ],\n "interruption": "reject_without_csrf_token",\n "evidence": "same_request_returns_403_and_value_is_unchanged",\n "notProven": [\n "authorization_for_other_objects",\n "all_other_write_endpoints",\n "XSS_protection"\n ]\n}\nТакая запись полезнее поля security: enabled. Она показывает, что именно проверяли и что осталось за пределами проверки. Если evidence не связывает asset, endpoint и отрицательный результат, его нельзя переносить на другой маршрут. Если в карте нет residual risk, это не означает нулевой риск. Это означает, что карту заполнили неполно.
Content Security Policy (CSP) задаёт браузеру правила для ресурсов и выполнения скриптов. Она может уменьшить последствия инъекции контента. Но CSP не знает, имеет ли пользователь право менять email, и не добавляет секретный токен в POST. Поэтому политика может быть полезной дополнительной защитой, но не заменяет серверную проверку намерения и прав.
\nОбратное ограничение тоже важно. Хорошая CSRF-защита не исправляет HTML-инъекцию. Если пользовательский текст попадает в небезопасный DOM-sink, скрипт может выполнить действие уже из доверенного контекста страницы и прочитать доступные данные. В этом случае нужны безопасный вывод, кодирование, ограничения источников и тесты для конкретного sink. Нельзя закрыть XSS, добавив заголовок к endpoint изменения профиля.
\nCSRF-токен защищает конкретный контракт, если сервер действительно проверяет его до изменения состояния. Он не защищает от украденной сессии, вредоносного скрипта внутри доверенного origin или компрометации сервера. SameSite-cookie снижает риск для части сценариев, но её поведение зависит от браузера и контекста. Не делайте из свойства cookie универсальное доказательство.
\nКод примера не покрывает OAuth callback, загрузку файлов, WebSocket, GraphQL mutations и фоновые очереди. У каждого канала свои границы. Для GraphQL нужно проверить mutation и resolver. Для файла — имя, тип, содержимое, место хранения и выдачу. Для очереди — кто помещает сообщение, кто его обрабатывает и повторяется ли операция безопасно.
\nНе объявляйте защиту готовой по одному зелёному тесту. Тест может подтвердить отказ без токена, но не проверит права на чужой объект или другой endpoint. Проверка готова, когда исходный отрицательный запрос возвращает ожидаемый отказ, состояние ресурса не изменяется, а запись связывает результат с конкретным маршрутом и перечисляет непроверенные соседние пути.
\nДля выбранной операции у команды есть карта из asset, precondition, attack path, interruption и evidence. Есть автоматизированный или воспроизводимый тест без нужного доказательства. Он получает отказ, а запись остаётся неизменной. Отдельный тест проверяет права на чужой объект. В документе явно указано, что CORS, HttpOnly и CSP не заменяют эти проверки. Если хотя бы одного пункта нет, результат — не «защищено», а «нужна следующая проверка».
\nСимптом знакомый: в ответе есть CSP, cookie помечена HttpOnly, API настроил CORS, а endpoint изменения профиля проверяет сессию. В отчёте появляется фраза «защита включена». Но команда не может ответить на более узкий вопрос: какой контроль должен прервать конкретный путь атаки и какое наблюдение это подтверждает? Без такого ответа тест заголовка легко принять за доказательство защиты данных.
Цена ошибки — не только уязвимость. Во время инцидента приходится заново выяснять, проходили ли входные данные через небезопасный sink (место вывода или выполнения), отправлялась ли cookie, менялось ли состояние и на каком слое ожидали отказ. Практичнее разделить четыре объекта: asset — что защищаем, attack path — как к нему добираются, control — какой переход запрещаем, и evidence — что можно повторить и увидеть.
\nВозьмём учебный маршрут профиля. Пользователь вводит имя, сервер сохраняет его и выводит на странице. Если значение попадает в HTML без экранирования, атакующий может передать фрагмент, который браузер воспримет как разметку или скрипт. Последовательность выглядит так: fragment → render sink → document → script execution. Здесь asset — страница профиля, а опасный побочный эффект — выполнение скрипта в контексте приложения.
У каждого шага свой владелец. Валидатор и экранирование отвечают за смысл и безопасный вывод значения. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе. Браузер применяет политику, но не исправляет серверный шаблон. Поэтому ответ «в заголовке есть Content-Security-Policy» подтверждает доставку политики, а не безопасность каждого sink.
Для CSRF путь другой: внешняя страница создаёт запрос, браузер может приложить cookie, сервер принимает операцию записи. CORS управляет тем, сможет ли чужой скрипт прочитать ответ; он не является универсальным запретом на отправку запроса. Нельзя перенести evidence из одного маршрута на другой только потому, что оба проходят через браузер.
\nControl полезно описывать глаголом: «экранирует значение перед вставкой», «отклоняет скрипт без разрешённого nonce», «отказывает в mutation без CSRF-токена», «не разрешает субъекту изменить чужой объект». Такая формулировка сразу показывает место проверки. Формула «CSP настроена» скрывает и переход, и ожидаемый результат.
\nEvidence должно содержать вход, маршрут, наблюдение и границу вывода. Например: «на тестовом стенде страница с маркером xss_probe вернула CSP-заголовок; браузер заблокировал inline script без nonce; маркер не появился в журнале выполнения». Это подтверждает одну политику и один сценарий. Оно не доказывает безопасный вывод в другом шаблоне, отсутствие XSS во всём приложении или правильность конфигурации после прокси.
| Контроль | Какой переход ограничивает | Минимальное наблюдение | Что не доказано |
|---|---|---|---|
| Экранирование и безопасный sink | Недоверенное значение превращается в HTML или код | В DOM появляется текст, а не узел, который выполняет код | Другие шаблоны, sink и данные из другого источника |
| CSP с nonce | Скрипт без разрешённого nonce выполняется в документе | Браузер блокирует отрицательный сценарий и фиксирует нарушение политики | Что значение безопасно сформировали до вывода; поведение неподдерживаемого клиента |
| CSRF-токен | Чужой сайт отправляет state-changing запрос с cookie без доказательства намерения | Запрос без токена получает отказ, запись не меняется | XSS, права на чужой объект и маршруты без этого middleware |
| CORS | Чужой скрипт читает ответ cross-origin запроса | Браузер не отдаёт response body скрипту при запрещённом origin | Запрос без preflight не дошёл до сервера и состояние не изменилось |
| Авторизация | Субъект изменяет объект вне своей области прав | Запрос с валидной сессией к чужому объекту получает 403, запись неизменна | Наличие CSRF-защиты и безопасность клиентского кода |
HttpOnly / SameSite | Чтение cookie скриптом или отправка cookie в части cross-site-контекстов | Фактические атрибуты cookie и запрос в проверяемом контексте | Полная защита от CSRF, XSS и украденной сессии |
Таблица не заменяет тест-план. Она не говорит, что любой control обязателен для любого endpoint. Её задача — не дать одному зелёному наблюдению ответить сразу на пять разных вопросов.
\nНиже — фрагмент в стиле Express для страницы, которая выводит имя. Он показывает порядок границ, но не является готовым middleware: escapeHtml, генерация nonce, шаблонизатор, заголовки прокси и обработка ошибок должны иметь реальные реализации и тесты. Важна последовательность: данные обезвреживаются до вывода, а политика передаётся браузеру вместе с документом.
import { randomBytes } from 'node:crypto';\n\napp.get('/profile', (req, res) => {\n const nonce = randomBytes(16).toString('base64');\n const safeName = escapeHtml(String(req.query.name ?? ''));\n\n res.set('Content-Security-Policy', [\n "default-src 'self'",\n "script-src 'nonce-" + nonce + "'",\n "object-src 'none'",\n "base-uri 'none'",\n ].join('; '));\n\n const html = '<h1>' + safeName + '</h1>' +\n '<script nonce="' + nonce + '">' +\n 'window.profilePageReady = true;' +\n '</script>';\n res.send(html);\n});\nВ этом коде nonce разрешает только явно помеченный скрипт текущего документа. Он не делает safeName безопасным: значение всё равно нужно корректно экранировать или передавать через безопасный API шаблонизатора. Если убрать экранирование, оставить только CSP и считать задачу решённой, доказательство будет неполным. W3C прямо описывает CSP как defence-in-depth для снижения последствий инъекции, а не замену проверке входа и безопасному выводу.
Есть и практическое ограничение: пример не показывает, как фреймворк выставляет заголовок при ошибке, как политика проходит через CDN и как приложение обрабатывает inline-обработчики, сторонние скрипты и динамическую загрузку. Эти решения могут потребовать другие директивы и отдельные тесты. Нельзя добавлять 'unsafe-inline' только ради того, чтобы старый тест перестал падать: это меняет саму границу, которую вы хотели проверить.
Проверка должна отделять HTTP-наблюдение от поведения браузера. Команды ниже ничего не меняют: они скачивают страницу и заголовки с тестового URL. Подставьте адрес своего стенда, где разрешена проверка, а не production-сайта. Имя файла в примере — локальный временный артефакт ревью.
\nBASE_URL='https://staging.example.test'\nPAGE_PATH='/profile?name=xss_probe'\n\ncurl --fail-with-body -sS \\\n -D /tmp/profile.headers \\\n "$BASE_URL$PAGE_PATH" \\\n -o /tmp/profile.html\n\nrg -n -i '^content-security-policy:' /tmp/profile.headers\nrg -n 'xss_probe|nonce=' /tmp/profile.html\n\n# Ожидаем заголовок CSP и текстовый маркер.\n# Этот curl-запуск не доказывает, что браузер заблокировал код.\n! rg -n -i '<script[^>]*>[^<]*(xss_probe|alert)\\b' /tmp/profile.html\nДля отрицательного сценария нужен браузерный тест. В него передают безопасный маркер, слушают событие нарушения CSP и проверяют, что код с неправильным nonce не создал наблюдаемого эффекта. Если тест запускается на странице с nonce, значение nonce нельзя зашивать в ожидание: сначала получите реальный документ, затем отдельно сформируйте намеренно неверный вариант. Иначе тест подтвердит только наличие атрибута, а не работу политики.
\nconst violations = [];\npage.on('console', message => {\n if (message.type() === 'error') violations.push(message.text());\n});\n\nawait page.goto(baseUrl + '/profile?name=xss_probe');\nawait page.evaluate(() => {\n const script = document.createElement('script');\n script.textContent = 'window.__xssProbe = true';\n script.setAttribute('nonce', 'wrong-nonce');\n document.body.append(script);\n});\n\nconst executed = await page.evaluate(() => window.__xssProbe === true);\nif (executed) throw new Error('unexpected script execution');\nconsole.log({ executed, consoleErrors: violations.length });\nРезультат нужно записать точнее, чем «XSS закрыт»: «в браузере версии, использованной в тесте, скрипт с неверным nonce не выполнился; HTTP-ответ содержал ожидаемый CSP; безопасный вывод маркера проверен только на маршруте /profile». Если браузерный тест не поймал ошибку консоли, это ещё не повод ослаблять политику: консольные сообщения зависят от браузера. Надёжнее проверять отсутствие эффекта и, при необходимости, событие securitypolicyviolation.
Для cross-origin fetch браузер применяет CORS к чтению ответа. Некоторые запросы с безопасными для CORS методом, заголовками и типом содержимого не требуют preflight. Исторически HTML-форма и без этого механизма могла отправить запрос на другой origin, поэтому отсутствие ответа у чужого скрипта не означает отсутствие побочного эффекта на сервере. Если операция использует cookie и меняет состояние, серверу нужна отдельная CSRF-защита или эквивалентная проверка.
CSRF-токен отвечает на другой вопрос: есть ли у запроса доказательство, которое внешний сайт не может получить из обычного cross-site сценария. Он не решает, имеет ли субъект право изменить объект. Для этого сервер сравнивает субъект сессии и владельца ресурса. HttpOnly мешает JavaScript прочитать cookie, но браузер всё равно может отправить её по правилам cookie. SameSite ограничивает часть отправок в cross-site-контексте, однако результат зависит от атрибута, браузера, схемы и типа навигации.
Так же CSP не заменяет ни CSRF, ни авторизацию. Скрипт, который уже выполняется в доверенном контексте приложения, может вызвать разрешённую операцию; политика ресурсов не определяет права пользователя. Верный review не спрашивает «включена ли безопасность», а сопоставляет asset, угрозу, контроль, негативный тест и непроверенные соседние маршруты.
\nЭто метод локальной проверки одного маршрута, а не сертификат безопасности приложения. Он не покрывает автоматически WebSocket, GraphQL, загрузку файлов, OAuth callback, Service Worker, фоновые очереди и native-клиенты. Для каждого канала нужно построить собственный путь и назвать границу, которая действительно применяется.
\nNonce и CSP зависят от того, как формируется документ и какие скрипты нужны странице. Политика из примера может сломать легитимную аналитику или inline-код. Не переносите её в другой сервис без инвентаризации ресурсов. curl не исполняет HTML и потому не подтверждает браузерную защиту. Браузерный тест не доказывает, что сервер безопасно выводит все данные. Тест на тестовом стенде не доказывает конфигурацию после deploy.
CSRF-токен не защищает от украденной сессии или скрипта, который уже выполняется в доверенном origin. CORS не является заменой контролю записи. HttpOnly и SameSite уменьшают отдельные риски, но не являются универсальной защитой. Если команда не может назвать непроверенный sink, маршрут или контекст cookie, карта слишком широкая для честного вывода.
Для выбранной операции готово не «всё защищено», а более узкое утверждение: путь атаки записан; control стоит до опасного эффекта; позитивный сценарий работает; негативный сценарий получает ожидаемый отказ и не меняет состояние; HTTP- и браузерное наблюдения разделены; список непроверенных маршрутов сохранён. После изменения шаблона, middleware, cookie-политики, прокси или браузера этот набор нужно повторить.
\nТакой критерий даёт команде полезный результат: видно, что остановлено, где осталось residual risk и кто должен выполнить следующий review. Если evidence подтверждает только заголовок, пишите только про заголовок. Если тест подтверждает один sink, не называйте его защитой всего приложения.
\nHttpOnly и SameSite и их влияние на доступ скрипта и отправку cookie. Фактический результат нужно проверять в целевом браузерном контексте.После изменения JSON один consumer продолжает читать сообщения, и команда ставит схеме зелёный статус. Через день другой reader начинает отбрасывать объект: он запрещает новые поля. Ещё один consumer ждёт обязательный state, а producer уже отправляет только phase. Поле называется похоже, тест на sample проходит, но смысл и правила чтения различаются. Ошибка стоит дорого: сбой обнаруживается после раскатки, владелец находится вручную, а команда откатывает уже связанное изменение.
Тезис простой: совместимость нельзя присвоить схеме в целом. Её проверяют для конкретной пары producer → consumer, конкретной версии и конкретного направления чтения. Для каждой пары нужно назвать contract family, baseline, candidate и capability reader. Неизвестный consumer не получает зелёный статус. Отсутствующая связь означает остановку и уточнение.
Список интеграций помогает найти владельцев, но не отвечает на вопрос о совместимости. Нужна одна строка review. В ней producer создаёт candidate schema, consumer читает эту форму, а gate сравнивает только заранее названные свойства. Такой scope ограничивает вывод и делает причину отказа адресной.
\nconst review = {\n family: 'orders-v1',\n baseline: { id: 'string', state: 'string', note: 'optional' },\n candidate: { id: 'string', state: 'string', note: 'optional', priority: 'integer' },\n producerId: 'producer-1.1',\n consumerId: 'reader-tolerant-v1',\n direction: 'producer-writes-consumer-reads',\n policy: 'declared-additions-accepted'\n};\nЭто учебный объект. Он не описывает реальный сервис, registry, сообщение или deployment. Он показывает форму решения: у comparison есть family, две формы, направление и правило reader. Если убрать любое из этих звеньев, результат нельзя расширять до общего обещания.
\nСначала gate проверяет structural слой. Обязательное поле baseline не должно исчезнуть из candidate. Его тип не должен измениться без отдельного решения. В учебном примере замена state на phase — не безопасный rename. Reader, который ищет state, видит удалённое required field. Близость слов не доказывает совпадение семантики.
Затем gate проверяет объявленные additions. Добавление необязательного priority сохраняет старую обязательную поверхность, но всё равно требует проверки consumer. Tolerant reader может принимать declared additions. Strict reader может отвергать любое дополнительное поле. Тип данных сам по себе не говорит, какая политика действует на границе.
Третья проверка связывает diff с manifest. Если candidate содержит routingHint, но карточка change его не называет, это не повод угадать намерение. Gate возвращает stop-undocumented-schema-field. Скрытое поле может влиять на маршрутизацию, размер сообщения или безопасность. Сначала его нужно объявить и проверить.
Наконец, gate проверяет relation. Поля двух JSON-объектов нельзя сравнивать только потому, что оба объекта выглядят одинаково. Нужны family, producer, consumer и direction. Если направление не задано, sample не превращается в compatibility verdict. Результат — stop-implicit-comparison.
function reviewCompatibility(item) {\n if (!item.family || !item.producerId || !item.consumerId || !item.direction) {\n return {\n status: 'stop-implicit-comparison',\n nextAction: 'name-contract-relation'\n };\n }\n\n const removed = requiredFields(item.baseline)\n .filter((field) => !(field in item.candidate));\n\n if (removed.length > 0) {\n return {\n status: 'stop-backward-incompatible-schema',\n removedRequiredFields: removed\n };\n }\n\n if (hasUndeclaredAddedFields(item)) {\n return {\n status: 'stop-undocumented-schema-field',\n nextAction: 'update-change-manifest'\n };\n }\n\n if (item.policy === 'declared-additions-rejected' && hasAddedFields(item)) {\n return {\n status: 'stop-incompatible-consumer',\n nextAction: 'hold-addition-or-migrate-reader'\n };\n }\n\n return {\n status: 'synthetic-compatibility-review-hand-off'\n };\n}\nПример синтетический. Он не читает сеть, не вызывает schema registry, не ищет consumers и не подтверждает результат в production. Функция демонстрирует порядок отказов. Сначала она требует relation, потом проверяет обязательную поверхность, затем manifest и только после этого применяет policy reader. Реальная система должна дополнить эти шаги своей схемой, тестами и наблюдаемыми входами.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один consumer прочитал sample, схема объявлена совместимой | Проверили одну пару и свернули результат в общий статус | Перечислить named consumers и их policy | Разбить review на отдельные строки producer → consumer |
Reader перестал находить state | Required field заменили на похожее имя phase | Сравнить обязательные поля baseline и candidate | Вернуть поле или оформить отдельную migration |
Новый reader падает на поле priority | Strict policy не принимает additions | Проверить capability reader, а не только тип поля | Удержать addition или расширить границу reader |
В candidate есть routingHint, но в change его нет | Schema diff не связан с manifest | Сверить все добавленные поля с declared list | Остановить review и описать поле явно |
| В отчёте написано «совместимо», но direction пуст | Сравнение сделано по внешнему сходству JSON | Проверить family, producerId, consumerId и direction | Вернуть работу на описание relation |
У change может быть пять consumers. Один принимает addition, второй запрещает его, третий относится к другой family, а четвёртый неизвестен. Общий статус «compatible» скрывает владельца решения и стирает отрицательные ветки. Такой статус допустим только как агрегат после того, как каждая известная пара получила собственный результат. Даже тогда рядом должны остаться причины stop и несопоставимые отношения.
\nStrict reader не является неисправным. Его policy — часть контракта. Gate не должен менять её ради удобства producer. Если producer добавляет поле, есть три честных варианта: не добавлять его, мигрировать reader или выпустить отдельную форму. Пока выбор не сделан, stop полезнее зелёного предположения.
\nОтдельно храните неизвестность. Отсутствие карточки consumer не означает tolerance. Скрытый reader нельзя объявить совместимым по умолчанию. Если связь только предполагается, сначала нужен владелец, подтверждение family и направление чтения. Это отрицательный путь механизма, а не исключение из него.
\nstate, со скрытым routingHint и со strict reader. Каждый случай должен остановиться на своей причине.Этот механизм не обнаруживает неизвестных consumers. Он не знает, кто хранит старую форму в архиве, какой proxy меняет payload и как асинхронная доставка обрабатывает повтор. Для этого нужны inventory, наблюдаемая маршрутизация и отдельные проверки. Compatibility gate не заменяет schema registry, consumer contract tests, миграцию данных и план возврата.
\nПроверка required fields не покрывает всю семантику. Два поля могут иметь один тип и разные единицы измерения, часовые пояса или правила округления. Название family не доказывает значение поля. Такие условия нужно добавить в контракт отдельными правилами и тестовыми случаями. Нельзя получить полноту из короткой функции.
\nУчебные literals не дают production-результата. Успешный вызов функции не говорит, что реальный consumer обработал candidate, что registry содержит нужную версию или что deployment завершился. Для реального изменения потребуется привязать проверку к фактическим схемам, версиям, данным и владельцам. Если вход невозможно подтвердить, результат должен остаться stop.
\nПроверка готова, когда независимый читатель без устных пояснений видит одну relation, baseline, candidate, required surface и policy consumer. Для каждого addition есть запись в manifest. Для каждого verdict есть причина и следующий адрес действия. Удаление обязательного поля, строгий reader, скрытое поле и пустое направление дают определённые stop-результаты. Ни один synthetic hand-off не назван разрешением на production.
\nПрактический тест готовности короткий: возьмите положительный case, удалите из него по одному звену и повторите проверку. Если объект без consumer или direction всё ещё получает зелёный результат, gate слишком либерален. Если state → phase проходит как косметическое изменение, structural слой слишком слаб. Если скрытый addition проходит, manifest не связан с diff. Готовность выражается не числом зелёных строк, а тем, что каждый разрыв даёт понятный stop.
После изменения JSON один сервис продолжает читать сообщения, и команда ставит контракту зелёный статус. Затем другой consumer начинает отбрасывать тот же объект: он запрещает неизвестные поля. Третий ждёт status: paid как признак завершённой оплаты, а producer использует paid для промежуточного состояния после авторизации. Форма объекта похожа, sample проходит, а решение всё равно ломает процесс.
Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары producer → consumer, версии, направления передачи и набора правил чтения.
Контракт данных — это не перечень ключей. Он отвечает как минимум на пять вопросов: какие поля обязательны, какие типы и единицы измерения допустимы, какие значения имеют деловой смысл, разрешены ли дополнительные поля и как долго сообщение остаётся читаемым. Если в карточке изменения написано только «добавили status», consumer не получил достаточного описания.
В учебном кейсе producer отправляет заказ. В первой версии поле status означает жизненный цикл заказа: new, paid, cancelled. Другой сервис использует такое же имя для результата платежной операции: authorized, captured, refunded. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.
Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.
\nJSON Schema хорошо описывает первый слой. В спецификации есть type, required, enum и additionalProperties. Но сама структурная проверка не знает, означает ли paid захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.
{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"orderId\", \"status\", \"occurredAt\"],\n \"properties\": {\n \"orderId\": {\"type\": \"string\", \"minLength\": 1},\n \"status\": {\"enum\": [\"new\", \"paid\", \"cancelled\"]},\n \"occurredAt\": {\"type\": \"string\", \"format\": \"date-time\"}\n }\n}\nВ этом фрагменте additionalProperties: false — сознательная строгая политика для данного объекта. Она защищает от опечатки и скрытого расширения, но делает добавление поля изменением, которое требует координации. В расширяемом событии можно выбрать другую политику: разрешать дополнительные поля, документировать их и не использовать их как обязательные для старого reader.
Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки status.
Отдельно проверяйте направление. Старый producer → новый consumer и новый producer → старый consumer — разные случаи. Добавление необязательного поля часто безопасно для старого tolerant reader, но не для старого strict reader. Удаление обязательного поля опасно в обратную сторону: новый consumer может ожидать его у сообщений, которые ещё пишет старый producer.
\n| Изменение | Риск | Что проверяем | Решение |
|---|---|---|---|
| Добавили необязательное поле | Strict reader отвергает extra property | Политику неизвестных полей у каждого consumer | Мигрировать reader, не добавлять поле или выпускать версию |
| Удалили обязательное поле | Reader не может собрать объект | Все producer и накопленные сообщения старой версии | Сначала период совместной поддержки, затем удаление |
Переименовали status в phase | Это удаление и добавление, а не косметика | Ссылки на старое имя и смысл каждого значения | Добавить новое поле с явной миграцией или новый контракт |
| Добавили значение enum | Код consumer может иметь закрытый switch | Поведение на неизвестном значении и запасную ветку | Расширить reader до отправки нового значения |
| Изменили секунды на миллисекунды | Число валидно, результат неверен в 1000 раз | Единицу измерения, диапазон и тест на границе | Назвать единицу в поле или выпустить отдельную форму |
Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения status и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.
// contract-gate.mjs\nconst baseline = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status'])\n};\n\nconst candidate = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string', priority: 'integer' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status', 'priority'])\n};\n\nfunction check({ producer, consumer, direction, reader }) {\n if (!producer || !consumer || !direction) {\n return 'STOP relation is not named';\n }\n\n for (const field of baseline.required) {\n if (!candidate.required.has(field)) {\n return `STOP required field removed: ${field}`;\n }\n }\n\n for (const [field, type] of Object.entries(baseline.types)) {\n if (candidate.types[field] !== type) {\n return `STOP type changed: ${field}`;\n }\n }\n\n const added = [...candidate.properties]\n .filter((field) => !baseline.properties.has(field));\n if (added.length && !reader.allowUnknownProperties) {\n return `STOP unknown fields: ${added.join(', ')}`;\n }\n\n const oldStatuses = baseline.values.status;\n const newStatuses = candidate.values.status;\n const introduced = [...newStatuses].filter((value) => !oldStatuses.has(value));\n if (introduced.some((value) => !reader.statusValues.has(value))) {\n return `STOP unknown status value: ${introduced.join(', ')}`;\n }\n\n return 'PASS compatible for this named reader';\n}\n\nconst strictReader = {\n allowUnknownProperties: false,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\nconst tolerantReader = {\n allowUnknownProperties: true,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\n\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'billing-worker',\n direction: 'producer-writes-consumer-reads',\n reader: strictReader\n}));\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'analytics-loader',\n direction: 'producer-writes-consumer-reads',\n reader: tolerantReader\n}));\nconsole.log(check({ producer: 'orders-api', consumer: '', direction: '', reader: strictReader }));\nСохраните код в contract-gate.mjs и выполните:
node --check contract-gate.mjs\nnode contract-gate.mjs\nОжидаемый вывод:
\nSTOP unknown fields: priority\nPASS compatible for this named reader\nSTOP relation is not named\nЭто не готовый schema registry и не доказательство успешной доставки. Gate демонстрирует порядок: сначала связь, затем breaking change, затем политика reader. В реальном проекте типы и значения следует брать из версионируемой схемы, а список reader — из inventory интеграций, конфигурации маршрутов и consumer contract tests.
\nСтруктура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, paid → new может быть запрещённым переходом, а повторное paid — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.
Не прячьте единицу времени в описании команды. Поля occurredAt и receivedAt отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.
Для бинарных схем риск может выглядеть иначе. В Protocol Buffers номер поля участвует в wire format и не должен меняться или переиспользоваться; удалённые номера и имена рекомендуется резервировать, чтобы позднее не создать конфликт. В Avro reader и writer schema сопоставляются по правилам schema resolution. Это полезные модели, но их правила нельзя механически переносить на произвольный JSON API.
\nОписанный gate проверяет ограниченную структурную и перечислимую часть контракта. Он не обнаруживает скрытых consumers, не анализирует код всех клиентов, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку или корректность бизнес-переходов. Для этого нужны отдельные тесты, наблюдаемость и владелец интеграции.
\nДаже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что status относится к заказу, а не к платежу. Поле format также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.
Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.
\nИзменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.
\nПрактическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение status. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает status: refunded для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.
type, required, enum, объектные ограничения и различие format-annotation/format-assertion.