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

Одна trace и три разных времени

\n

Рассмотрим учебный пример. Root span описывает путь запроса от входа до ответа. Внутри него идут три последовательных интервала: admission queue ждёт 520 условных единиц, вызов БД занимает 150, внешний каталог — 200. Эти единицы придуманы для примера. Они не являются миллисекундами, метрикой сервиса или результатом production-измерения.

\n
Что видно в учебной trace и что можно сказать
СегментРольИнтервалДопустимый вывод
fixed-admission-queuequeue-wait40–560, 520 unitsСамый длинный названный сегмент этого input
fixed-db-calldatabase-execution570–720, 150 unitsИнтервал вызова БД в этой trace
fixed-catalog-callexternal-dependency730–930, 200 unitsИнтервал внешнего вызова в этой trace
fixed-gatewayend-to-end0–1000, 1000 unitsГраница пути, но не объяснение причины
\n

Таблица не говорит, что очередь замедляет production. Она не говорит, что SQL нужно переписать. Она фиксирует структуру одного учебного input. Это важное различие. Длинный span можно ранжировать. Причину нужно проверять отдельным экспериментом с той же границей.

\n
\"Две
Рисунок. Более короткий root не доказывает улучшение, если cohort, concurrency или форма входа изменились.
\n

Почему root 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. Это не универсальный стандарт нагрузки. Это минимальный контракт конкретной проверки. В другом сценарии набор полей будет иным, но правило останется тем же: заранее назвать условия, которые должны совпасть.

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

\n

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

\n
Диагностическая таблица для разбора trace
СимптомВозможная причинаПроверкаДействие
Длинный интервал перед работойОжидание в admission queueЕсть отдельный span с ролью queue-wait и parent внутри rootОставить наблюдение; не называть очередь причиной production-задержки
Длинный span помечен только internalКласс задержки неизвестенПроверить роль и границы интервалаВернуть stop-hidden-queue и назвать ожидание unknown
Root стал корочеИзменилась нагрузка или форма входаСверить cohort, requests, concurrency и input shapeПри расхождении вернуть stop-incomparable-load
Дочерний span ссылается на отсутствующий parentTrace неполнаПроверить связность дерева и интервалыВернуть stop-incomplete-trace; не восстанавливать связь догадкой
В записи есть «стало быстрее»Заявлен эффект без контроляНайти baseline с той же границей и явный критерийСнять effect claim и оставить только наблюдение
\n

Fail-closed: отрицательный путь важнее красивого графика

\n

Неполный материал должен завершать разбор отказом. У учебного input incomplete-trace-v1 дочерний span указывает на отсутствующий parent. Нельзя определить, относится ли интервал к этому пути. Статус stop-incomplete-trace точнее, чем попытка соединить span по времени или имени.

\n

У hidden-queue-v1 длинный интервал называется fixed-unclassified-delay. Он похож на ожидание, но такого сходства недостаточно. Статус stop-hidden-queue означает: сначала назовите границу ожидания, затем продолжайте. Иначе команда переложит время на очередь только потому, что она удобна как объяснение.

\n

У incomparable-load-v1 root равен 800, но нагрузка отличается от baseline. Статус stop-incomparable-load не утверждает, что число 800 неверно. Он запрещает делать из него вывод об ускорении. Наконец, unsupported-effect-v1 содержит фразу faster-after-change без допустимого контрольного сравнения. Для него нужен stop-unsupported-effect.

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

Длительность не равна причинности

\n

Даже связная trace не сообщает автоматически, почему очередь заняла 520 units. Причиной может быть лимит ресурса, политика admission, форма учебного сценария или другая часть системы. Роль span CLIENT, SERVER или INTERNAL помогает описать операцию. Она не превращает интервал в диагноз и не назначает оптимизацию.

\n

Правильная цепочка выглядит так: наблюдение — queue-wait=520; гипотеза — правило admission создаёт ожидание; новая проверка — заранее определённый input с той же границей и одним изменённым условием; результат — сравнимое наблюдение или новый stop. Перескок от первого пункта к утверждению «очередь является корнем проблемы» нарушает границу доказательства.

\n

То же относится к БД. database-execution=150 не является SQL-профилем, индексом, бюджетом или обещанием. Это интервал вызова в учебной trace. Если нужен разбор SQL, он требует отдельного measurement contract, собственных входов и критерия сравнения.

\n

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

\n
  1. Выбрать одну trace и один root span. Не собирать критический путь из разрозненных логов и таймеров.
  2. Проверить дерево: у каждого дочернего span есть существующий parent, начало не позже конца, root покрывает выбранный путь.
  3. Разделить ожидание, исполнение БД и внешний вызов. Если роль неизвестна, остановить разбор.
  4. Записать контрольную границу до просмотра того, какой root короче: cohort, число запросов, concurrency и форма входа.
  5. Сравнить только совпадающие inputs. Различие хотя бы одного обязательного поля — отдельное наблюдение, а не эффект изменения.
  6. Сформулировать вывод ровно по данным: например, «queue-wait — самый длинный названный сегмент этого учебного input».
  7. Проверить отрицательный путь и сохранить status. Если вход не проходит проверку, передать stop reason, а не рекомендацию по оптимизации.
\n

Ограничения

\n

Учебная trace не даёт распределения latency, хвостов, throughput, variance, queue discipline или стоимости ресурсов. Условные units нельзя переводить в миллисекунды. Один root не заменяет серию измерений. Связь через trace-id не гарантирует полноту дерева. Пересекающиеся span нельзя бездумно складывать: они могут выполняться параллельно.

\n

Источники ниже описывают контекст trace, роли span и семантику HTTP. Они не доказывают bottleneck, не обещают latency и не подтверждают production-эффект. Поэтому материал ограничивает вывод учебным input и не предлагает rollout, изменение конфигурации или выбор индекса.

\n

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

\n

Разбор готов, если другой инженер получает тот же status на том же именованном input, видит связное дерево, понимает контрольную границу и может указать, какое условие приводит к stop. В принятом учебном случае вывод должен остаться наблюдением: queue wait — самый длинный названный сегмент в данной trace; причинность и эффект изменения не заявлены. Если для чтения вывода нужны устные пояснения, внешний дашборд или догадка автора, материал не готов.

\n

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

" + "excerpt": "Как читать длительность root span, отделять ожидание от работы и проверять сопоставимость нагрузки до заявления об улучшении.", + "contentHtml": "

Короткий trace выглядит как хороший результат, но сам по себе ничего не говорит об ускорении. В одном запросе root span может включать ожидание в очереди, обработку сервера, вызов базы и ответ внешней системы. Если во втором запуске изменилась нагрузка или потерялся фрагмент дерева, число стало меньше, но сравнение перестало быть честным. Цена ошибки — оптимизация SQL, сети или CPU без изменения времени, которое видит пользователь.

\n

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

\n

Что именно измеряет span

\n

В OpenTelemetry span представляет одну операцию внутри trace. Root span обычно описывает весь путь, а дочерние span — отдельные подоперации. Для request-response операции начало и конец должны охватывать обработку запроса, включая middleware, бизнес-логику, сборку и отправку ответа. Поэтому длина root — это интервал всей операции, а не время одного наиболее заметного дочернего вызова.

\n

У каждого интервала есть границы: имя, start, end, parent и тип операции. Поле SpanKind помогает различать входящую серверную обработку, исходящий клиентский вызов и внутреннюю работу. Оно описывает роль span, но не отвечает на вопрос, почему операция была долгой. Например, CLIENT означает вызов удалённого сервиса, ожидающий ответ; это не доказательство, что удалённый сервис — причина задержки.

\n
Минимальный контракт учебной trace
ПолеПримерЧто проверяетЧего не доказывает
traceIdtrace-aК какой записи относится spanЧто все компоненты действительно попали в запись
parentIdgatewayСвязь с родительской операциейПричину ожидания или порядок параллельных работ
start/end40/560Длительность конкретного интервалаЧто интервал был полезной работой, а не ожиданием
kindCLIENTРоль операции в модели traceВиновника latency и эффект изменения кода
\n

W3C Trace Context стандартизует перенос идентификаторов между HTTP-компонентами через traceparent и tracestate. Это позволяет связать записи разных участников, но не создаёт отсутствующие span и не гарантирует, что каждый запрос был записан: выборка и полнота зависят от конкретной системы наблюдения.

\n
\"Учебный
Рисунок. Более низкая задержка имеет смысл только вместе с одинаковой границей нагрузки и одинаковым составом входа.
\n

Учебная trace: наблюдение без диагноза

\n

Пусть root gateway длится от 0 до 1000 условных единиц. Внутри него последовательно расположены ожидание допуска в очередь от 40 до 560, вызов базы от 570 до 720 и вызов каталога от 730 до 930. Промежутки между дочерними span оставлены намеренно: они показывают, что сумма видимых частей не обязана совпадать с root.

\n
Разбор одной фиксированной записи
SpanГраницыДлительностьКорректный вывод
gateway0–10001000 unitsПолный интервал выбранного пути
admission-queue40–560520 unitsСамый длинный названный сегмент записи
db-call570–720150 unitsИнтервал вызова базы в этой trace
catalog-call730–930200 unitsИнтервал внешнего вызова в этой trace
\n

Из таблицы можно сделать два вывода. Первый: в этой записи длиннее всего назван admission-queue. Второй: root равен 1000 units. Нельзя сделать третий вывод — «очередь является корневой причиной» — без эксперимента, который изменяет только условие допуска и сохраняет остальную границу. Нельзя также перевести units в миллисекунды: единица времени не определена примером.

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

\n

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

\n

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

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

Как отделить ожидание от исполнения

\n

Время root складывается не обязательно как простая сумма дочерних span. Часть времени уходит на ожидание свободного worker, соединения или ответа; часть — на фактическое выполнение; несколько вызовов могут идти параллельно. Поэтому сначала нужно проверить дерево и интервалы, а уже затем обсуждать вклад отдельных сегментов.

\n
Симптом, проверка и безопасное действие
НаблюдениеГипотезаПроверкаДействие до доказательства
Длинный отдельный span перед обработкойЗапрос ждёт допускаПроверить имя, parent, интервалы и instrumented boundaryОписать ожидание как наблюдение; не переписывать SQL
Длинный INTERNAL без ясной операцииСкрыта неизвестная задержкаУточнить границу и добавить измерение подоперацииОстановить причинный вывод
Root короче в candidateИзменилась нагрузкаСверить comparison key и samplingНе считать разницу эффектом изменения
Child ссылается на отсутствующий parentTrace неполнаПроверить экспорт и propagation contextВернуть запись на доработку, не соединять span по времени
Заявлено «быстрее»Нет контрольной выборкиНайти baseline, размер серии и критерий успехаСузить формулировку до наблюдаемого факта
\n

Особенно осторожно читайте трассы с sampling. W3C описывает sampled flag как рекомендацию о том, что вызывающая сторона могла записать данные; он не означает, что вся система сохранила каждый span. Если сравнивать traces, попавшие в backend по разным правилам, пропавший span легко принять за исчезнувшую работу.

\n

Воспроизводимая проверка границы

\n

Полезная проверка должна возвращать не только «да» или «нет», но и причину отказа. Ниже — минимальный вариант для фиксированных объектов. Он проверяет сравнимость входа и явный флаг полноты, но не претендует на валидатор OpenTelemetry или на профилировщик.

\n
function 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. Это всё ещё не доказывает, что изменение ускорило систему: нужно повторить серию на заранее выбранном числе запросов и проверить распределение задержки, ошибки и побочные эффекты.

\n

От наблюдения к причинной гипотезе

\n

Хороший разбор меняет вопрос по шагам. Сначала есть факт: admission-queue занял 520 units в одной записи. Затем гипотеза: политика допуска или нехватка worker создаёт ожидание. Следующая проверка должна оставить comparison key неизменным и изменить только условие, которое относится к этой гипотезе. Если root и очередь меняются вместе, гипотеза получает поддержку; если меняется только root, а очередь нет, нужно искать другой участок.

\n

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

\n

Такая осторожность относится и к базе. Span db-call=150 измеряет интервал клиентской операции, но не сообщает, сколько времени заняли планирование запроса, чтение страниц, блокировка или сериализация результата. Для SQL-вывода нужны план выполнения, собственные метрики базы и сопоставимые входы. Trace помогает выбрать запрос для исследования, но не заменяет профилирование базы.

\n

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

\n
  1. Выбрать один root span и определить, какую пользовательскую или сервисную границу он покрывает.
  2. Проверить связность дерева: parent существует, start не позже end, дочерние интервалы относятся к тому же trace.
  3. Разметить тип операции: ожидание, внутренняя работа, база, HTTP-клиент или обработка ответа. Не угадывать роль по одному имени.
  4. Зафиксировать comparison key до сравнения baseline и candidate. Пропуск обязательного поля считать отказом от сравнения.
  5. Отделить наблюдение от гипотезы: «длиннее всех» не равно «является причиной».
  6. Изменять в следующей проверке одно условие, сохранить серию и заранее выбрать метрику успеха: например, p95 root при той же ошибочности и throughput.
  7. Проверить отрицательный путь: неполное дерево, изменившуюся нагрузку, другой sampling и отсутствие baseline должны возвращать явную причину остановки.
  8. Только после этого обсуждать изменение конфигурации или кода, его откат и дальнейшее наблюдение.
\n

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

\n

Один trace не описывает распределение задержки. Значение root не заменяет p50/p95/p99, throughput, error rate и размер выборки. Условные units нельзя переводить в миллисекунды или использовать как SLA. Пересекающиеся span нельзя складывать без учёта параллельного выполнения. Неполный export может скрыть работу, а sampling — изменить состав наблюдаемых записей.

\n

W3C Trace Context отвечает за перенос контекста, OpenTelemetry — за модель trace и роль span, а HTTP RFC 9110 — за семантику request/response. Ни один из этих документов не утверждает, что конкретная очередь, база или внешний сервис является bottleneck. Реальный вывод потребует данных вашей версии SDK, схемы экспорта, окружения, нагрузки и контрольного эксперимента.

\n

Критерий готовности вывода

\n

Разбор можно передать коллеге, если он получает исходную запись, видит границу root, понимает единицы, проверяет parent/child и может повторить вычисление самого длинного сегмента. Для заявления об ускорении нужны дополнительно одинаковые входы, серия измерений, критерий успеха и описание побочных эффектов. Если есть только короткий trace и фраза «стало быстрее», честный результат — наблюдение или остановка проверки, а не рекомендация по оптимизации.

\n

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

" } diff --git a/editorial/agent-rewrites/060.json b/editorial/agent-rewrites/060.json index 64555b3..b2696c4 100644 --- a/editorial/agent-rewrites/060.json +++ b/editorial/agent-rewrites/060.json @@ -1,7 +1,7 @@ { "index": 60, "slug": "editorial-2026-05-practice-systems-performance", - "title": "Критический путь запроса: как найти задержку и не перепутать её с причиной", - "excerpt": "Запрос медленный, хотя CPU свободен. Разбираем end-to-end путь, отделяем очередь от работы и задаём проверку, после которой оптимизацию можно обсуждать без догадок.", - "contentHtml": "

Пользователь ждёт ответ десять секунд, а CPU сервиса держится на двадцати процентах. Команда меняет SQL, увеличивает пул потоков или поднимает таймаут. Симптом иногда маскируется, но путь не становится быстрее. Цена ошибки — релиз без эффекта, дополнительная нагрузка и потеря исходного сигнала. Следующий инженер уже не видит, что именно сравнивали.

\n

Низкая загрузка CPU не опровергает медленный запрос. End-to-end время включает ожидание, работу и вызовы зависимостей. Чтобы выбрать действие, нужно разложить один путь на связанные интервалы и удержать одну границу сравнения. Учебные значения ниже не являются измерениями production-системы. Они показывают способ рассуждать.

\n

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

\n

Root span задаёт границу от приёма запроса до ответа. Дочерний span показывает названную операцию внутри этой границы. Запрос может ждать admission queue, свободное соединение, блокировку, диск, DNS, TLS или ответ удалённого сервиса. Пока он ждёт, CPU может почти не работать.

\n

Название интервала ограничивает вывод. queue-wait означает отдельно записанное ожидание в очереди. database-execution означает интервал вызова базы в этой trace. external-dependency означает границу внешнего вызова. Ни одно из этих названий само по себе не доказывает причину задержки. Если ожидание не размечено, его нужно оставить неизвестным.

\n

Критический путь — это не рейтинг сервисов и не сумма всех span. Это временная цепь внутри одного root span. Связность важнее красивого графика: у каждого дочернего span должен существовать parent, начало не должно быть позже конца, а единицы времени должны совпадать. Если два вызова идут параллельно, их длительности нельзя складывать как последовательные.

\n

Учебный пример: один root, три названных интервала

\n

Рассмотрим условную trace fixed-trace-01. Root длится 1 000 units. Очередь занимает 520, вызов БД — 150, внешний каталог — 200. Промежутки между интервалами не получили отдельного объяснения. Поэтому их нельзя автоматически назвать сетью или дополнительной работой.

\n
Состав одного учебного end-to-end пути
СегментРольИнтервалДлительностьЧто можно сказать
fixed-admission-queuequeue-wait40–560520Самый длинный названный сегмент этой записи
fixed-db-calldatabase-execution570–720150Интервал вызова БД в этой trace
fixed-catalog-callexternal-dependency730–930200Интервал внешнего вызова в этой trace
fixed-gatewayend-to-end0–1 0001 000Граница пути, а не объяснение причины
\n
\"Учебный
Учебная схема показывает порядок проверки: сначала root и названные интервалы, затем границы вывода. Она не изображает production latency.
\n

В этой записи fixed-admission-queue длиннее двух других названных сегментов. Это единственный прямой вывод о порядке длительностей. Нельзя из него заключить, что очередь является bottleneck при другой нагрузке, что изменение gateway ускорит пользователя или что БД не требует исследования. Для любого такого утверждения нужна отдельная проверка.

\n

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

\n

Код ниже работает с заранее заданным объектом. Он не обращается к сети, базе, часам, профайлеру или телеметрии. Числа условны. Пример проверяет связность и интервалы, а не показывает результат реального сервиса.

\n
const 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));
\n

observation-ready здесь означает только, что запись связна и содержит корректные условные интервалы. Если parent равен missing-01, результат должен быть stop-incomplete-trace. Если начало больше конца, функция должна остановиться. Отрицательный путь не является исключением из метода. Он показывает, что неполный материал нельзя превращать в уверенный диагноз.

\n

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

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
CPU низкий, запрос медленныйВ end-to-end время вошло ожиданиеРазделить queue-wait, локальную работу и дочерние вызовыНазвать только покрытые span; остаток оставить unknown
Длинный span совпал с пиком latencySpan включает ожидание upstream или retryПроверить parent/child, повторные вызовы и дочерние интервалыНе объявлять span причиной без отдельного сигнала
Второй прогон короче первогоИзменилась нагрузка или форма входаСверить cohort, requests, concurrency и shapeСнять сравнение и повторить на общей границе
Есть root, но нет parent у дочернего spanПотеря записи или неверная связь IDПроверить полный экспорт и уникальность идентификаторовВернуть stop; не дорисовывать дерево по времени
После изменения есть одна короткая записьНет baseline и распределения наблюденийПовторить тот же сценарий и сохранить контрольНазвать observation, а не improvement
\n

Как не спутать наблюдение с причинностью

\n

Длинный интервал сообщает, что в конкретной записи он длинный. Он не сообщает, почему это произошло и какое изменение его сократит. Очередь может зависеть от admission policy или ограниченного ресурса. Вызов БД может ждать соединение до начала исполнения. Внешний вызов может включать локальную подготовку. Одна trace не выбирает между этими объяснениями.

\n

Полезно разделять три фразы. Наблюдение: «queue-wait занимает 520 units в fixed-trace-01». Гипотеза: «правило допуска создаёт часть ожидания». Проверка: «сравнить заранее определённые записи с теми же cohort, requests, concurrency и shape». Перескакивать от первой фразы к третьей нельзя. Тем более нельзя сразу объявлять эффект изменения.

\n

Та же граница действует для базы. database-execution = 150 — не диагноз SQL, не рекомендация индекса и не оценка бюджета. Если команда хочет исследовать запрос, она формулирует новый вопрос и сохраняет текущую запись как baseline только после проверки сопоставимости. Уменьшение знакомого локального шага не становится правильным действием из-за того, что его проще измерить.

\n

Что значит сопоставимая нагрузка

\n

Baseline и candidate можно сравнивать только внутри явно названной контрольной границы. В учебном примере это fixed-load-a, 12 логических запросов, concurrency 3 и fixed-read-shape-a. Если второй прогон использует 24 запроса, concurrency 6 или другую форму входа, он отвечает на другой вопрос. Более короткий root не доказывает ускорение.

\n

Смена одного поля уже важна. Если выросла concurrency, очередь может измениться без изменения кода. Если изменилась форма данных, база может выбрать другой план. Если другой cohort пришёл из другого окна, кэш и внешняя зависимость могли иметь иное состояние. Запись должна сделать эти условия видимыми, а не прятать их в подписи графика.

\n

Отдельно проверяйте параллельность. Дочерние span могут пересекаться. В таком случае их сумма превысит время root и не покажет стоимость пути. Сначала определите временную зависимость. Если это невозможно, оставьте вывод на уровне «интервалы пересекаются» и не выбирайте самый большой span как причину.

\n

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

\n
  1. Зафиксируйте исходный симптом: маршрут, метод, статус, длительность, размер ответа, время и идентификатор запроса.
  2. Сохраните один trace до изменения кода или конфигурации. Отметьте root, дочерние операции, пропуски и неизвестные интервалы.
  3. Проверьте parent/child-связи, границы интервалов и единицы времени. Отдельно отметьте overlap.
  4. Выпишите контрольную границу: cohort, число логических запросов, concurrency и форму входных данных.
  5. Отделите ожидание от исполнения БД и внешнего вызова. Не называйте неразмеченный остаток причиной.
  6. Сформулируйте гипотезу и проверку, которая может её опровергнуть. Один длинный span не заменяет такую проверку.
  7. Прогоните отрицательный случай: отсутствующий parent, некорректный интервал, unknown-delay или другая нагрузка должны вернуть точный stop.
  8. Измените один фактор только после фиксации baseline. Повторите тот же сценарий на той же контрольной границе.
  9. Сравните исходный симптом с candidate и проверьте соседние сигналы: ошибки, таймауты, очередь, throughput и потребление ресурсов.
\n

Что делать с отрицательным путём

\n

Если trace неполная, остановитесь на stop-incomplete-trace. Если ожидание помечено только как unknown-delay, не называйте его очередью. Если нагрузка отличается, верните stop-incomparable-load. Если в записи уже есть утверждение «стало быстрее», но нет сопоставимого контроля, снимите claim и сохраните только наблюдение.

\n

Такая остановка экономит время. Неполный trace легко вставить в убедительный рассказ и трудно разобрать после нескольких изменений. Именованная причина stop сохраняет недостающий факт: нужно восстановить parent, назвать ожидание или выровнять нагрузку. Отказ от вывода точнее, чем правдоподобное объяснение пустого места.

\n

Ограничения

\n

Sampling может убрать нужный span. Collector может потерять событие или доставить его не по порядку. Асинхронный worker может продолжить работу после root. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезной, но снижают силу вывода. Ограничение нужно записать рядом с наблюдением.

\n

Waterfall не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Он не задаёт SLA и не заменяет нагрузочный тест. RFC 9110 описывает семантику HTTP, а не бюджет latency приложения. Для эксплуатационного решения нужны отдельные измерения, контрольные группы и критерии остановки.

\n

Учебный код нельзя подключать к реальной телеметрии без новой проверки. Он использует одну запись, фиксированные числа и заранее известные поля. Он не проверяет экспорт, прокси, клиентские повторы или права доступа. Production-результат появляется только после отдельного эксперимента с описанной средой.

\n

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

\n

Разбор готов, если другой инженер без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу, отличить названную задержку от unknown и воспроизвести stop на неполной trace или несопоставимой нагрузке. Это критерий качества evidence, а не обещание ускорения.

\n

Изменение можно оценивать отдельно, когда baseline и candidate сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а результат не маскирует ошибку ростом таймаута или потерей сигнала. До этого корректный итог звучит так: «В fixed-trace-01 при fixed-load-a queue-wait — самый длинный названный сегмент. Эффект изменения не заявлен». Другой инженер должен получить тот же вывод из той же записи.

\n

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

\n" + "title": "Медленный запрос при низком CPU: практический маршрут от браузера до базы", + "excerpt": "Пошаговый способ разобрать долгий end-to-end запрос: зафиксировать одну границу, связать браузерный timing с trace, проверить базу отдельно и остановиться там, где данных недостаточно.", + "contentHtml": "

Пользователь ждёт страницу десять секунд, а CPU сервиса держится на двадцати процентах. В такой ситуации легко переписать SQL, увеличить пул потоков или поднять timeout. Эти изменения могут убрать симптом на одном прогоне и оставить причину нетронутой. Сначала нужно определить, где именно прошло время: в браузере, на прокси, в очереди, в коде сервиса или в зависимости.

\n

Ниже — практический маршрут для одного воспроизводимого HTTP-сценария. Он не обещает найти bottleneck по одной картинке. Он помогает собрать минимальный набор сигналов, связать его через идентификатор запроса и выбрать следующий эксперимент. Все числовые значения в примере учебные, если прямо не указано обратное.

\n

Начать с одной контрольной границы

\n

До изменения кода запишите ровно тот запрос, который хотите ускорить: метод и маршрут, статус, размер ответа, время начала, длительность, идентификатор запроса, версию приложения и состояние кэша. Для повторного прогона добавьте cohort — фиксированный набор входных данных, число запросов, concurrency и форму ответа. Эти поля нужны не для отчётности: без них два коротких ответа могут быть результатом разных условий.

\n

Сформулируйте исходный факт без диагноза: «GET /catalog/42 вернул 200 за 10 000 мс, CPU процесса — около 20%». Фраза «медленная база» уже является гипотезой. Её можно проверять, но нельзя прятать в поле симптома. Если trace отобрана sampling-правилом или часть заголовков удаляет прокси, запишите это рядом с измерением.

\n
Минимальная контрольная граница для сравнения
ПолеПримерЗачем фиксироватьЧто ломает сравнение
Маршрут и методGET /catalog/42Определяют операцию и набор middlewareСравнение с другим endpoint
Вход и форма ответаitem=42, JSON v2Влияют на размер данных и план запросаДругой id или набор полей
Нагрузка12 запросов, concurrency 3Определяет очередь и конкуренцию за ресурсыДругая параллельность или cohort
Кэш и версияmiss, build abc123Разделяют cold и warm путьПопадание в кэш или иной код
Идентификаторыrequest_id и traceparentСвязывают клиент, сервис и зависимостиПоиск только по timestamp
\n

Разложить end-to-end путь по владельцам времени

\n

У end-to-end измерения есть граница от начала навигации или HTTP-запроса до события, которое вы считаете результатом. Внутри неё могут быть последовательные и параллельные участки. Браузер измеряет навигацию и ресурсы, сервис создаёт root span, а дочерние span описывают отдельные операции. Запрос к базе или партнёру может быть дочерним client span, но его имя не доказывает, что именно он создал задержку.

\n

Сначала ищите разрыв между границами. Если браузерная запись показывает десять секунд, а root span сервиса — две, оставшиеся восемь секунд не следует называть «сетевыми» без отдельного сигнала. Это может быть очередь перед сервисом, прокси, повтор на клиенте или ожидание до отправки. Если root длится десять секунд, а названные дочерние операции занимают только две, восемь секунд остаются неизвестными.

\n
Схема диагностики задержки: полный trace с названными сегментами сравнивается с одинаковой контрольной нагрузкой, проходит контрпример и приводит к следующей проверке либо именованной остановке
Наблюдение становится действием только после проверки полноты trace и сопоставимости нагрузки. Контрпример с пропущенным сегментом или другой нагрузкой должен остановить причинный вывод.
\n

Например, учебный root длится 1 000 условных единиц. В нём есть очередь 520, запрос к базе 150 и внешний вызов 200. Названные интервалы не покрывают весь root: остаётся 130 единиц промежутков и неразмеченного времени. Можно сказать, что очередь — самый длинный названный участок этой записи. Нельзя сказать, что она является production bottleneck или что изменение очереди сократит ответ.

\n

Воспроизвести проверку на локальном снимке

\n

Небольшой скрипт ниже проверяет только то, что можно проверить по переданному объекту: parent/child-связи, интервалы и контрольную границу. Он не читает телеметрию и не симулирует latency. Сохраните его как временный фрагмент в консоли браузера или запустите через node, предварительно заменив HTML-сущности обратно на символы в обычном JS-файле.

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

\n

Проверить браузерный участок отдельно

\n

В браузере начните с Navigation Timing, а не с устного впечатления «страница открывается долго». API возвращает измерения текущей навигации. Для документа важны границы, которые соответствуют вашему критерию: например, время до первого байта, DOM construction или load event. Название метрики должно быть частью результата, иначе команда сравнит разные события под одним словом «загрузка».

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

Проверить сервис и базу по отдельным сигналам

\n

На стороне сервиса найдите root по traceparent или request id, затем проверьте parent/child-связи, начало и конец каждого span, статус, retry и ошибки. OpenTelemetry использует span как единицу работы и допускает root span с подоперациями; контекст trace связывается между границами через стандартизированные HTTP-заголовки. Ни один из этих механизмов не создаёт пропущенный span и не превращает корреляцию в доказательство причины.

\n

Если подозрение остаётся на PostgreSQL, измерьте SQL отдельно на сопоставимом наборе данных. EXPLAIN показывает план, который выбрал планировщик. EXPLAIN ANALYZE дополнительно выполняет запрос, поэтому используйте его для безопасного SELECT в окружении, где нагрузка и данные контролируемы. Сравнивайте план и фактические строки, а не только число cost: оценка плана — условная величина, зависящая от статистики и среды.

\n
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)\nSELECT id, title\nFROM catalog_items\nWHERE id = 42;
\n

Этот запрос не доказывает, что индекс нужен. Он отвечает на более узкий вопрос: какой план и фактические чтения получены для конкретного SQL и конкретных данных. Если запрос изменяет данные, не запускайте EXPLAIN ANALYZE без отдельной безопасной процедуры: анализ выполняет оператор. Полученный план нужно связать с тем же request/trace, иначе это лишь похожий локальный тест.

\n

Не выбирать действие по самому длинному span

\n
Как перейти от симптома к следующей проверке
НаблюдениеЧто оно подтверждаетЧего оно не подтверждаетСледующий шаг
CPU низкий, root длинныйВ путь входит ожидание или работа вне CPUКонкретную очередь, БД или сетьРазделить root на named spans и проверить разрывы
Есть длинный database-callДолгий интервал вызова БД в этой traceНеэффективный SQL или необходимость индексаСнять SQL и сопоставимый EXPLAIN
Браузер дольше сервисаМежду границами есть неразобранный интервалЧто именно делал прокси или клиентПроверить redirect, resource timing, retry и gateway
Второй прогон корочеВторая запись имеет меньшую длительностьЭффект изменения кодаСверить cohort, concurrency, кэш, build и метрику
Нет parent или единицы времениНаблюдаемость неполнаПоложение и причина сегментаВернуть именованный stop и восстановить сигнал
\n

Корректная цепочка выглядит так: наблюдение — «queue-wait=520 в root-01»; гипотеза — «часть времени создаёт ограничение admission»; проверка — «снять отдельный metric ожидания на той же нагрузке»; действие — «изменить один фактор и сравнить заранее выбранную метрику». Если проверка не может опровергнуть гипотезу, это не проверка, а подтверждение удобной истории.

\n

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

\n
  1. Зафиксируйте baseline: маршрут, статус, метрику, cohort, число запросов, concurrency, кэш, build и sampling.
  2. Сохраните исходные браузерные timings, root span, дочерние span и ошибки под одним идентификатором.
  3. Проверьте связность дерева и единицы времени. Все пропуски назовите явно, не заполняйте их догадкой.
  4. Сформулируйте одну гипотезу и условие, при котором вы её отклоните.
  5. Выберите одно изменение: например, добавить измерение выдачи соединения, а не одновременно увеличить пул и переписать SQL.
  6. Повторите тот же сценарий с той же контрольной границей. Укажите, что изменилось и что осталось прежним.
  7. Сравните медиану и хвост распределения, если наблюдений достаточно; отдельно проверьте ошибки, timeout, throughput и потребление ресурса.
  8. Запишите результат как observation, improvement или stop. Improvement допустим только при сопоставимом baseline и проверенных побочных сигналах.
\n

Если отдельный сигнал подтвердил ожидание в пуле, следующим действием может быть проверка времени выдачи соединения и конкуренции. Увеличение пула без такого сигнала способно перенести очередь в базу. Если подтверждена локальная работа CPU, нужен профайлер или измерение конкретной функции. Если виден только unknown, сначала улучшите наблюдаемость. Выбор действия должен следовать границе доказательства.

\n

Когда остановиться и назвать ограничение

\n

Верните stop-incomplete-trace, если дочерний span ссылается на отсутствующего родителя. Верните stop-unknown-delay, если большой интервал не имеет подтверждённой роли. Верните stop-incomparable-load, если изменились cohort, concurrency, кэш или форма ответа. Эти статусы не означают, что система исправна или неисправна. Они фиксируют, почему причинный вывод пока запрещён и какой сигнал нужно добыть.

\n

Sampling может исключить нужную запись. Collector может потерять событие. Асинхронная задача может продолжить работу после завершения root и потребовать span link, а не вложенного дочернего span. Retry создаёт несколько похожих операций. Часы разных узлов могут расходиться. Контекст через traceparent помогает связать границы, но посредник может не передать заголовок, а заголовок не гарантирует полноту сбора.

\n

Одна trace не показывает p95, p99, throughput, распределение ошибок или поведение при насыщении. Одна browser timing-запись не описывает все устройства и сети. Один EXPLAIN не заменяет серию запросов на данных, похожих на production. Поэтому условные 520 и 1 000 units нельзя превращать в миллисекунды, SLA или обещание ускорения.

\n

Критерий готового разбора

\n

Разбор можно передавать следующему инженеру, если он без устных пояснений находит исходный симптом, видит контрольную границу, связывает request с trace, проверяет parent/child и отличает названный интервал от неизвестного. Он должен суметь запустить локальный отрицательный пример, понять, какую гипотезу проверяет следующий шаг, и увидеть, почему другая нагрузка отменяет сравнение.

\n

Финальная формулировка должна возвращать границу: «При fixed-catalog-a, 12 запросах и concurrency 3 в root-01 самый длинный названный интервал — queue-wait, 520 условных единиц. Причина задержки и эффект изменения не доказаны; следующая проверка — отдельный сигнал ожидания». Это полезнее, чем уверенное «сервис тормозит из-за очереди».

\n

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

\n" } diff --git a/editorial/agent-rewrites/061.json b/editorial/agent-rewrites/061.json index fbfffd2..0356ab3 100644 --- a/editorial/agent-rewrites/061.json +++ b/editorial/agent-rewrites/061.json @@ -2,6 +2,6 @@ "index": 61, "slug": "editorial-2026-04-field-modern-web-security", "title": "Почему CORS не защищает POST: как проверить границы веб-безопасности", - "excerpt": "Чужой сайт может отправить запрос с cookie даже после настройки CORS. Разбираем механизм, связываем угрозу с контролем и evidence, а затем получаем проверяемый stop или ограниченный hand-off.", - "contentHtml": "

Симптом выглядит обнадёживающе: API отвечает на запросы только с нужным Origin, а в DevTools чужой сайт получает ошибку CORS. Команда помечает проблему закрытой. Но endpoint всё ещё принимает cross-site POST с cookie. Браузер может отправить запрос и не показать ответ атакующему. Если запрос меняет адрес доставки, пароль или лимит, ошибки CORS не возвращают деньги и не отменяют изменение.

\n

Цена такой подмены — ложное чувство защиты. CORS управляет чтением ответа из браузера. CSRF-защита управляет тем, может ли чужой сайт заставить браузер выполнить действие от имени пользователя. Эти механизмы стоят рядом, но решают разные задачи. Без явного пути атаки, контрольной точки и наблюдаемого evidence слово «проверено» слишком сильное.

\n

Тезис статьи простой: security review должен связывать asset, путь атаки, interruption и границу доказательства. Положительный результат подтверждает только эту связь. Он не разрешает deploy, не доказывает защиту production и не заменяет проверку входа, сессии, заголовков или поведения браузера.

\n

Один запрос показывает разницу между CORS и CSRF

\n

Пусть пользователь вошёл в bank.example. Браузер хранит cookie сессии и автоматически прикладывает её к запросу на этот origin. На странице evil.example размещена форма. Форма отправляет POST на банковский endpoint. Для простой формы браузер не обязан сначала выполнить CORS preflight. Сервер может получить cookie и изменить состояние.

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

\n

Тот же endpoint может иметь корректный CORS и всё равно быть уязвимым к изменению состояния. И наоборот: endpoint может не разрешать чтение ответа чужому origin, но нуждаться в CSRF-токене для опасного действия. Сначала назовите действие. Потом проверьте, какой контроль его прерывает.

\n

Механизм проверки: путь, контроль, evidence

\n

Начните с одного asset. Для примера это email пользователя. Путь атаки имеет порядок: чужая страница создаёт запрос, браузер добавляет cookie, endpoint принимает изменение, сервер сохраняет новый email. CORS находится на границе чтения ответа. Он не обязан останавливать первые три шага. CSRF-токен и серверная проверка origin находятся ближе к операции изменения.

\n

У каждого контроля должна быть одна фраза с глаголом. «CORS включён» ничего не говорит о действии. «Сервер отклоняет state-changing POST без валидного токена» описывает interruption. Такая запись проверяема: можно назвать вход, ответ и правило отказа. Если token проверяется только в JavaScript, контроль не стоит на серверной границе. Если endpoint разрешает запрос без cookie, нужно отдельно оценить анонимную операцию.

\n
\"Цикл
Граница hand-off должна быть видна. Учебный цикл передаёт только названный scope проверки и не превращается в решение о выпуске.
\n

Evidence тоже имеет границу. Заголовок Access-Control-Allow-Origin показывает настройку чтения ответа. Он не показывает, что сервер отверг чужой POST. Ответ 403 на запрос без токена показывает одну отрицательную ветку. Он не доказывает, что все state-changing endpoints используют тот же middleware. Эти два наблюдения нельзя склеить в общий verdict.

\n
От симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Чужой origin видит CORS errorБраузер не отдаёт ему response bodyОтправить отдельный state-changing POST и проверить записьДобавить серверную CSRF-защиту, если действие использует cookie
POST проходит без tokenEndpoint доверяет cookie без дополнительного доказательства намеренияПовторить запрос без token в изолированной учебной средеОтклонять запрос до изменения состояния; сохранить ответ и correlation id
Token есть в форме, но не проверяетсяКонтроль остался на клиентеВызвать endpoint напрямую без выполнения UIПеренести проверку на сервер и покрыть отрицательным тестом
Один endpoint защищён, другие нетПроверка привязана к странице, а не к классу операцииСоставить список state-changing routes и найти общий middlewareНазначить владельца непокрытых маршрутов; не выдавать общий verdict
После изменения появился 403Изменился контракт запроса или cookie policyСверить token, origin, cookie и права в позитивном сценарииИсправить конкретный контракт; не ослаблять правило глобально
\n

Пример серверной границы

\n

Ниже псевдокод для учебного review. Он показывает порядок условий, но не является готовым middleware. Реальный фреймворк должен сам определить, как извлекать cookie, хранить token, сравнивать origin и формировать ответ.

\n
function 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-симптома может не менять способность чужой формы отправить запрос.

\n

Как передавать результат без ложного допуска

\n

Передача результата должна содержать четыре поля: threat id, control id, observed evidence и остаток. Например, threat — «чужая форма меняет email в сессии пользователя». Control — «сервер отклоняет POST без token и при несоответствующем origin». Evidence — «в учебном тесте запрос без token вернул 403 до вызова сохранения». Остаток — «не проверены другие маршруты и поведение production proxy».

\n

Такой hand-off не означает, что система защищена. Он означает, что следующий человек видит, какую ветку повторить и где заканчивается наблюдение. Нельзя заменить остаток фразой «остальное стандартно». Нельзя перенести evidence с одного endpoint на весь API. Нельзя считать отсутствие ответа у атакующего доказательством отсутствия изменения в системе.

\n

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

\n
  1. Назовите asset и действие. Запишите, что может изменить чужой запрос: email, пароль, заказ или иной объект.
  2. Нарисуйте путь. Укажите страницу-источник, cookie, endpoint, проверку и запись. Не объединяйте чтение ответа с изменением состояния.
  3. Разделите controls. Отдельно опишите CORS, CSRF token, origin check, SameSite и авторизацию. Для каждого назовите interruption.
  4. Проверьте отрицательный запрос. В учебной или специально разрешённой среде уберите token, измените origin и убедитесь, что запись не произошла.
  5. Проверьте позитивный запрос. С действующей сессией и корректным token операция должна пройти. Сохраните только наблюдаемые поля.
  6. Сверьте покрытие. Найдите все state-changing маршруты и убедитесь, что правило применяет общий серверный слой, а не одну форму.
  7. Запишите остаток. Назовите непроверенные proxy, браузеры, cookie-режимы, фоновые операции и endpoints.
  8. Передайте ограниченный результат. Если связка неполна, верните stop с точным следующим вопросом. Не превращайте учебный output в release approval.
\n

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

\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

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

" + "excerpt": "Чужой сайт может отправить запрос с cookie даже после настройки CORS. Разбираем механизм на тестовом endpoint, проверяем отрицательный путь и фиксируем границы вывода.", + "contentHtml": "

Симптом выглядит обнадёживающе: API отвечает только для нужного Origin, а в DevTools чужой сайт получает ошибку CORS. Команда закрывает задачу. Но тот же endpoint всё ещё может принять cross-site POST с cookie и изменить состояние. Браузер способен отправить запрос, даже если JavaScript на странице-источнике не прочитает ответ. Если действие меняет email, адрес доставки или лимит, ошибка CORS не отменяет запись.

\n

Цена ошибки — неверный диагноз. CORS (Cross-Origin Resource Sharing) регулирует, какой ответ браузер отдаёт скрипту другого origin. CSRF (Cross-Site Request Forgery) защищает state-changing действие от запроса, который пользователь не намеревался выполнять. Это разные границы. Ниже — тестовый сценарий для локального или специально разрешённого стенда: он показывает, как отличить отказ в чтении ответа от отказа операции и не выдать один сигнал за доказательство всей защиты.

\n

Сначала назовите действие, а не заголовок

\n

Представим приложение https://app.example.test и страницу злоумышленника https://attacker.example.test. Пользователь уже вошёл в приложение. Браузер хранит session cookie и по правилам cookie может приложить её к запросу. На странице-источнике размещена форма, которая отправляет URL-encoded POST. Такой запрос похож на обычную HTML-форму и не обязан начинаться с CORS preflight.

\n
<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 не равен запрету самой отправки. Браузер может показать странице-источнику ошибку или перенаправление, но сервер уже мог получить запрос. Проверять нужно не консоль браузера, а серверный результат: статус, вызов обработчика и факт записи.

\n

Что именно делает CORS

\n

CORS — протокол согласования между браузером и сервером. В ответе сервер сообщает, какому origin можно предоставить response body, какие методы и заголовки разрешены, а при необходимости — можно ли раскрывать ответ с credentials. Для нестандартного запроса браузер обычно посылает предварительный OPTIONS, а затем отправляет основной запрос только после подходящего ответа.

\n

Это полезная граница для 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

Таблица нужна как стоп-сигнал для ревью. Нельзя переносить вывод из первой колонки на соседнюю строку. Особенно опасна подмена «ответ не прочитан» на «операция не выполнена»: это разные наблюдения и разные журналы.

\n

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

\n

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

\n
function 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-токен не заменяет авторизацию, а авторизация не доказывает намерение запроса.

\n

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

\n

Поднимите тестовый обработчик с записью в памяти, журналом вызовов и двумя ветками: позитивной и отрицательной. Значение TARGET ниже замените адресом своего локального HTTPS-стенда или изолированного окружения. Cookie TEST_ONLY_SESSION должна быть фиктивной, а обработчик — не связан с реальными данными.

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

\n

Команда с чужим Origin проверяет ветку origin, но не моделирует все варианты браузера. Для form-shaped POST важнее убрать токен и проверить факт записи. Для JSON-клиента добавьте preflight и убедитесь, что CORS не позволяет незапланированному origin выполнить основной запрос с credentials. Каждый сценарий должен иметь собственный идентификатор и ожидаемое состояние.

\n
Цикл проверки веб-безопасности: назвать действие, связать запрос с серверным контролем, проверить отрицательный путь, записать границу evidence и остаточный риск
Проверка движется от действия к серверной записи. Ошибка чтения ответа не закрывает цикл, пока не проверено, что изменилось на сервере.
\n

Какие защиты сочетать

\n

Для cookie-аутентифицированного приложения базовый выбор — встроенная защита фреймворка от CSRF либо серверный synchronizer token. Сервер выдаёт непредсказуемый токен, клиент возвращает его в форме или заголовке, а сервер сравнивает его с ожидаемым значением до записи. Для stateless-систем возможен signed double-submit cookie, но подпись должна быть связана с сессией; простое совпадение двух значений без такой связи не даёт того же свойства.

\n

Проверка Origin или, если его нет, аккуратная проверка Referer — дополнительный барьер. Fetch Metadata, например Sec-Fetch-Site, тоже может помочь отсечь cross-site контекст, если продукт контролирует поддержку браузеров и предусмотрел fallback. Эти механизмы не освобождают от проверки endpoint-ов: на старом маршруте может отсутствовать middleware, а клиентская библиотека — иметь отдельную ветку.

\n

SameSite=Strict уменьшает отправку cookie в cross-site контексте, но может ломать переходы по внешним ссылкам. Lax оставляет более мягкую модель и не должен становиться единственным доказательством защиты. SameSite описывает site, а не origin: два subdomain одного registrable domain могут считаться same-site. Если среди subdomain есть пользовательский контент, legacy-приложение или чужая управляемая зона, остаточный риск нужно оценивать отдельно.

\n

В CORS-конфигурации задавайте явный список доверенных origin. Не отражайте произвольный входной Origin в Access-Control-Allow-Origin, если не проверили его по allowlist. Для credentialed cross-origin запросов wildcard * не подходит. Добавляйте Vary: Origin, когда ответ зависит от этого заголовка и проходит через кэш. Эти меры ограничивают чтение и отправку некоторых запросов, но не заменяют серверную CSRF-проверку для form-shaped POST.

\n

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

\n

Минимальный набор тестов должен ломать каждый предполагаемый барьер по отдельности. Уберите токен, подмените origin, удалите cookie, используйте неподдержанный метод, отправьте запрос через форму и повторите запрос с корректными данными. Для каждого случая зафиксируйте проверку, которая должна остановить путь. Если разные причины возвращают один 403, это допустимо для внешнего ответа, но внутренний лог должен различать ветки без записи секретов.

\n
Минимальная матрица тестов endpoint-а
ВеткаИзменение входаОжидаемое действиеФакт, который нужно сохранить
ПозитивнаяДействующая тестовая сессия и корректный токенВызвать сохранение один разНовая запись и correlation id
CSRF token missingУбрать токен, оставить cookieОтклонить до сохранения403 и неизменённая запись
Origin foreignПередать чужой originОтклонить по правилу стендаРешение origin-check и запись
UnauthenticatedУбрать сессиюОтклонить до операции401/403 и отсутствие эффекта
Form-shaped POSTURL-encoded body без preflightПрименить тот же CSRF-контрольРезультат этой ветки, а не OPTIONS
GET state changeВызвать старый GET-маршрутНе менять состояниеМаршрут найден или отмечен как риск
\n

Проверка «в браузере показалась CORS error» — только подсказка. Она не заменяет наблюдение за обработчиком и хранилищем. Если нет server-side evidence, пишите: «скрипт не прочитал ответ в этом сценарии». Нельзя добавлять к этому «данные не изменились» без отдельного сигнала.

\n

Как оформить ограниченный результат

\n

Запись проверки содержит пять полей: threat, endpoint, control, observed и not-proven. Например: threat — «чужая форма пытается изменить email»; endpoint — POST /profile/email; control — «сервер отклоняет запрос без токена до saveEmail»; observed — «тест без токена вернул 403, запись не изменилась»; not-proven — «другие маршруты, браузеры и production proxy не проверены».

\n

Такая запись связывает наблюдение с конкретной операцией. Она не утверждает, что весь API защищён, что любой браузер ведёт себя одинаково или что проблема устранена в production. Если проверка проходила только на фиктивной функции, результат относится к этой функции и входу. Для стенда добавьте версию приложения, cookie policy и идентификатор сборки.

\n

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

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

\n

Учебные curl-команды не доказывают поведение реального браузера. Они помогают воспроизвести HTTP-вход и проверить серверный контракт. Для браузерного вывода добавьте автоматизированный тест в поддерживаемых браузерах и проверьте фактические cookie, response headers, preflight, запись и логи. Если не проверены другие state-changing endpoint-ы, честный результат — частичный, а не общий verdict.

\n

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

\n
  1. Назовите asset и операцию. Укажите, какие данные меняются и каким HTTP-маршрутом.
  2. Зафиксируйте модель входа. Запишите cookie, origin, метод, content type, токен и ожидаемый побочный эффект.
  3. Разделите CORS и CSRF. Для CORS проверяйте доступ к response body, для CSRF — отказ до изменения состояния.
  4. Проверьте form-shaped POST. Не ограничивайтесь OPTIONS и JSON-клиентом.
  5. Запустите позитивный и отрицательный тест. Сравните статус, вызов обработчика и запись.
  6. Проверьте покрытие. Найдите все POST, PUT, PATCH, DELETE и старые GET, которые меняют состояние.
  7. Добавьте глубинные барьеры. Оцените token, origin, Fetch Metadata, SameSite и CORS по отдельности.
  8. Запишите границы. Укажите стенд, браузеры, версии, proxy, непроверенные маршруты и остаточный риск.
  9. Сформулируйте вывод по evidence. Если доказана только ветка чтения ответа, так и напишите; изменение системы требует отдельного решения.
\n

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

\n

Проверку можно считать завершённой для конкретного endpoint-а, когда известны его state-changing операции, позитивная ветка проходит с тестовой сессией и корректным токеном, а form-shaped запрос без токена не вызывает запись. Для каждой отрицательной ветки виден контроль и сохранён факт результата. CORS-конфигурация содержит явные доверенные origin и согласованные credential rules. Остальные маршруты и средовые ограничения перечислены.

\n

Это узкий критерий. Он не обещает отсутствие CSRF, не выдаёт сертификат безопасности и не превращает CORS error в доказательство. Он даёт следующему инженеру воспроизводимую последовательность: какой запрос повторить, где посмотреть эффект и какое утверждение пока запрещено. Если хотя бы один state-changing маршрут не прошёл такой путь, общий вывод нужно остановить.

\n

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

\n" } diff --git a/editorial/agent-rewrites/062.json b/editorial/agent-rewrites/062.json index ab61c86..ea6a6a7 100644 --- a/editorial/agent-rewrites/062.json +++ b/editorial/agent-rewrites/062.json @@ -2,6 +2,6 @@ "index": 62, "slug": "editorial-2026-04-mechanism-modern-web-security", "title": "Почему список security controls не доказывает защиту веб-приложения", - "excerpt": "Один CSP-заголовок не объясняет, какой шаг атаки он прерывает. Разбираем трассировку threat model → control → evidence → residual risk на учебном примере и фиксируем границу, после которой нужен отдельный тест.", - "contentHtml": "

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

\n

Тезис статьи простой: security control имеет смысл только внутри трассы threat model → interruption → evidence → residual risk. Сначала нужно назвать актив, условие и порядок шагов атаки. Затем — указать, какой control прерывает конкретный шаг. После этого — ограничить вывод наблюдаемым evidence. Всё, что осталось за границей наблюдения, записывают как residual risk. Если связь оборвалась, результатом должен быть отказ от вывода, а не зелёная отметка.

\n

Почему перечень controls вводит в заблуждение

\n

Перечень хранит существительные: CSP, sanitizer, SAST, review, SameSite. Он не хранит направление связи. Из строки «CSP настроен» не следует, что конкретный фрагмент не попадёт в опасный sink. Из строки «cookie защищена» не следует, что сервер проверяет намерение запроса. Из строки «сканер чист» не следует, что сканер видел нужную ветку, конфигурацию и версию приложения.

\n

Один control часто действует позднее источника проблемы. CSP может ограничить исполнение скрипта в документе. Он не исправляет неверную валидацию и не превращает небезопасный HTML-sink в безопасный. Поэтому связь надо записать глаголом: отклонить скрипт без известного nonce на границе документа. Такой текст уже можно сопоставить с шагом атаки и с проверкой.

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

Минимальная модель пути

\n

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

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

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

\n
Диагностика разорванной трассировки
СимптомПричинаПроверкаДействие
«CSP включён», но путь атаки не названControl записан без interruptionПопросить назвать шаг, который он прерываетДобавить ordered path и точку отказа
Тест зелёный, но относится к другой страницеEvidence не связано с threat idСверить идентификаторы пути и наблюденияОстановить вывод и привязать проверку заново
После исправления исчезла строка residual riskПоздний control приняли за исправление источникаПроверить validation, encoding и все sinksВернуть открытые участки и следующий вопрос
В отчёте написано «защита гарантирована»Ограниченное evidence расширили риторикойСопоставить каждое слово с наблюдениемСузить утверждение до проверяемого факта
Неизвестно, что делать при разрыве связиУ модели нет отрицательной веткиПодать запись без path или bindingВернуть точный stop и недостающий вход
\n

Как выглядит отрицательный путь

\n

Отрицательная ветка важнее красивого положительного результата. Если evidence содержит наблюдение, но ссылается на другой threat или control, система не должна искать «похожую» запись по тексту. Она возвращает ошибку связи. Если attack path пуст, нельзя считать policy доказанной. Если residual risk не содержит открытого участка и вопроса для следующей проверки, положительный hand-off также нельзя принимать.

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

\n

Что считается evidence

\n

Evidence — не обязательно скриншот или лог. Это ограниченное наблюдение, которому заранее задан допустимый вывод. Для учебного объекта допустимы два факта: именованные директивы присутствуют в записи и отрицательный сценарий без nonce не авторизован в этой модели. Нельзя из них выводить отсутствие XSS, корректность всех HTML-преобразований, защиту сессии или безопасность каждого браузера.

\n

Границу пишут рядом с observation. Иначе при передаче она исчезает, а фраза «negative case не авторизован» превращается в «уязвимость устранена». Хорошая запись отвечает на четыре вопроса: какой threat проверялся, какой control к нему привязан, что именно наблюдалось и чего это наблюдение не доказывает.

\n

Эта дисциплина помогает и при конфликте controls. Санитизация может менять вход, а CSP — ограничивать последствия в документе. У них разные interruption. Если evidence относится только к policy, нельзя выдать вывод о преобразовании данных. Если два controls действуют на разные шаги, их нельзя слить в одну зелёную строку только ради компактного отчёта.

\n

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

\n
  1. Назовите актив. Запишите, что защищаете: например, контекст рендера браузера, профиль пользователя или изменение счёта.
  2. Опишите условие. Укажите, при каком входе или состоянии путь становится возможным.
  3. Разложите маршрут. Дайте шагам порядок и действующие глаголы. Не заменяйте маршрут названием класса уязвимости.
  4. Назовите interruption. Укажите, какой control и каким решением прерывает конкретный шаг.
  5. Привяжите evidence. Свяжите наблюдение одновременно с threat id и control id.
  6. Запишите предел. Явно перечислите утверждения, которых наблюдение не поддерживает.
  7. Оставьте residual risk. Назовите открытые участки и вопрос для отдельной проверки.
  8. Проверьте отрицательную ветку. Убедитесь, что неизвестный путь, чужая связь и чрезмерный положительный вывод дают stop.
\n

Ограничения механизма

\n

Трассировка не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, тесты, статический анализ, сканирование и независимую оценку. Она только не даёт одной записи присвоить себе результаты всех этих методов.

\n

CSP не заменяет валидацию входа и кодирование вывода. Cookie-флаги не заменяют проверку полномочий и намерения операции. CORS не является универсальной защитой от CSRF: если сервер принимает изменяющий запрос с cookie без отдельной проверки, исправление CORS может не закрыть путь. Этот отрицательный пример применим только при соответствующей схеме браузера, cookie и серверного endpoint; его надо проверять реальным запросом в тестовой среде.

\n

Описанный JavaScript ограничен учебной моделью. Он не сообщает production-результаты и не даёт разрешения на релиз. Если нужен реальный вывод, потребуются отдельные входы: собранный response, конфигурация доставки policy, тестовый браузер, тестовые данные и зафиксированный scope. Нельзя подменить их одной записью в памяти.

\n

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

\n

Материал ревью готов, когда независимый читатель может пройти цепочку от актива до residual risk без устных пояснений. Для каждого control видны threat id, ordered path и точка прерывания. Для каждого evidence видны два binding id и допустимый вывод. Открытые участки не скрыты. Отрицательные случаи возвращают определённый stop. Положительная ветка остаётся только ограниченной передачей на следующий review, а не разрешением на изменение системы.

\n

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

\n

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

\n" + "excerpt": "CSP, HttpOnly, CORS и сканер отвечают на разные вопросы. Разбираем путь от угрозы к точке прерывания, связываем его с проверяемым evidence и оставляем остаточный риск там, где данных недостаточно.", + "contentHtml": "

Проблема security review часто обнаруживается не в отсутствии мер, а в слишком сильном выводе. В отчёте стоят галочки «CSP включён», «cookie с HttpOnly», «сканер чист», но никто не может показать, какой запрос или фрагмент данных остановил каждый control. Если после этого endpoint меняет профиль по чужому POST, список мер создаёт ложное ощущение закрытой уязвимости.

\n

Удобнее разложить проверку на четыре связанных вопроса: какой asset защищаем, как выглядит ordered attack path, где control прерывает путь и что именно наблюдалось. Последняя часть — evidence, то есть воспроизводимое подтверждение с ограниченным выводом. Всё, что не проверено, остаётся residual risk. Такая схема не обещает безопасность всего приложения, зато не позволяет одной настройке присвоить себе результат чужого теста.

\n

Список мер не хранит причинность

\n

Строка «CORS настроен» описывает настройку чтения ответа из браузера, но не отвечает, принял ли сервер изменяющий запрос. HttpOnly не даёт JavaScript прочитать cookie, но браузер по-прежнему может приложить её к запросу. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе, но не исправляет небезопасный HTML-sink. Даже хороший SAST-анализ говорит только о просмотренных файлах, правилах и версии запуска.

\n

Один и тот же control может быть полезен в одном месте и бесполезен в другом. Поэтому вместо существительного нужен глагол: «сервер отклоняет POST без действительного CSRF-токена до записи в профиль». В этой формулировке видны операция, условие отказа и момент, до которого должен сохраниться asset. Её можно сопоставить с тестом. «CSRF включён» сопоставить не с чем.

\n
\"Схематичное
Безопасность веба пересекает несколько границ. Иллюстрация перечисляет соседние механизмы, но сама по себе не доказывает их настройку; доказательством становится только привязанный к конкретной операции тест.
\n

Один путь показывает разницу между CORS и CSRF

\n

Возьмём изменение email. Пользователь вошёл в bank.example, браузер хранит cookie сессии, а endpoint принимает POST /api/profile/email. Внешняя страница может попытаться отправить такой запрос с cookie. CORS определяет, может ли внешний origin прочитать ответ через браузерный API. CSRF-защита должна не допустить нежелательное изменение состояния на сервере.

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

\n

Здесь полезно записать путь по шагам: external-page → browser-attaches-cookie → POST-email → profile-changed. CORS относится к чтению ответа, CSRF-проверка — к допуску изменяющего запроса, авторизация — к праву менять конкретный профиль. Если evidence проверяет только заголовок ответа, из него нельзя выводить, что запись не произошла.

\n

Как связать threat, control и evidence

\n

Для каждой операции составьте маленькую карточку. asset — изменяемый объект. precondition — условие, при котором путь возможен. attackPath — упорядоченные действия. interruption — точный запрет. evidence — наблюдение, которое можно повторить. Поле notProven защищает от расширения вывода при пересказе.

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

\n

Воспроизводимый тест серверной границы

\n

Проверять нужно не только ответ, но и состояние после отказа. Ниже — команда для локального или специально разрешённого тестового сервиса. Подставьте адрес своего стенда и тестовую cookie; домен из примера не является целью для запроса.

\n
BASE=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.

\n

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

\n
app.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 и не готовая библиотека. Он не показывает, как сравнивать секреты, ротировать токены, проводить логирование или защищать другие каналы. Эти детали нужно проверять по фреймворку и коду конкретного сервиса.

\n

Что именно считать evidence

\n

Evidence — это не обязательно скриншот. Им может быть HTTP-ответ вместе с проверкой состояния, тестовый лог с correlation id, результат статического анализа с зафиксированным scope или браузерное наблюдение для конкретной политики. У каждого наблюдения должен быть допустимый вывод. Например, ответ 403 на POST без токена подтверждает отказ этой ветки на данном endpoint в данном окружении. Он не подтверждает защиту всех mutations.

\n

Записывайте границу рядом с результатом. «В тесте запрос без токена получил 403, запись не изменилась; не проверены фоновые задачи, соседние маршруты и production proxy» — полезное утверждение. «CSRF закрыт» — уже нет. Точно так же наличие Content-Security-Policy показывает доставку заголовка. Оно не доказывает, что каждый пользовательский фрагмент безопасно закодирован и ни один sink не исполняет данные.

\n
От наблюдаемого симптома к следующему действию
СимптомЧто он означаетПроверкаСледующее действие
Чужой origin получает CORS errorБраузер ограничил чтение ответаПроверить, изменилось ли состояние после отдельного POSTОставить серверную CSRF-проверку, если операция использует cookie
Cookie имеет HttpOnlyСкрипт не читает её значение через API браузераПроверить фактический запрос и серверную проверку намеренияНе выдавать HttpOnly за защиту от CSRF
POST без токена вернул 403Одна отрицательная ветка остановленаСверить asset, endpoint, окружение и состояние после запросаЗаписать scope; отдельно проверить другие write-маршруты
Токен есть, но меняется чужой профильCSRF не заменяет авторизациюПовторить с объектом другого пользователяПроверить владельца ресурса до записи
CSP есть, но пользовательский HTML исполняетсяЗащита документа не исправила источник или sinkПроверить вывод, кодирование и response policy для этой страницыИсправить безопасный рендеринг; CSP оставить дополнительным слоем
\n

Почему CSP не исправляет XSS в одиночку

\n

W3C описывает Content Security Policy как механизм управления ресурсами и выполнением, который снижает риск content injection и работает как defense-in-depth. Там же явно сказано, что CSP не заменяет внимательную валидацию входа и кодирование вывода. Поэтому в трассе CSP может прерывать попытку выполнить неразрешённый скрипт, но не доказывает, что строка безопасно попала в HTML, атрибут или DOM-sink.

\n

Для одного пользовательского поля нужны разные проверки: допустимый формат на сервере, безопасный контекст вывода и отрицательный тест для конкретной точки рендера. Политика с script-src и nonce относится к браузерному исполнению. Она не даёт права убрать тесты для шаблона или клиентского кода. Если policy меняется, повторно проверьте доставленный заголовок и позитивные сценарии приложения: чрезмерно строгая политика может ломать legitimate scripts, а режим Report-Only сообщает о нарушениях, но не блокирует их.

\n

Порядок проверки без ложного зелёного статуса

\n
  1. Выберите одну операцию. Назовите endpoint, метод, asset и изменяемое поле.
  2. Опишите угрозу. Запишите precondition и цену ошибки, если запись пройдёт.
  3. Разложите путь. Перечислите источник запроса, cookie, endpoint, проверки и побочный эффект.
  4. Разделите controls. Для CORS, CSRF, авторизации, валидации и CSP укажите разные interruption.
  5. Сделайте отрицательный запрос. Уберите токен, смените origin или выберите чужой объект в разрешённом стенде.
  6. Проверьте состояние. Статус отказа недостаточен: убедитесь, что asset не изменился.
  7. Проверьте положительную ветку. С действительными данными операция должна пройти согласно контракту.
  8. Сверьте покрытие. Найдите остальные state-changing маршруты, фоновые обработчики и альтернативные протоколы.
  9. Запишите остаток. Перечислите непроверенные браузеры, proxy, cookie-контексты, sinks и каналы.
\n

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

\n

Трассировка threat → control → evidence не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, динамические тесты, SAST, сканирование и независимую оценку. Её задача уже: не дать отчёту расширить узкое наблюдение до утверждения о всей системе.

\n

Пример с cookie относится к state-changing HTTP endpoint. Он не покрывает OAuth callback, WebSocket, GraphQL mutation, загрузку файла, очередь сообщений или действия, авторизованные не cookie, а другим способом. Для каждого канала нужно построить отдельный путь. Для файла дополнительно проверяют тип, содержимое, имя, место хранения и выдачу. Для очереди — отправителя, обработчика, повторы и идемпотентность.

\n

Команды и псевдокод выше не дают разрешения атаковать реальный сервис. Выполняйте проверки только на локальном стенде или при явном разрешении владельца. Если неизвестно, какой middleware обслуживает маршрут, или нет способа проверить состояние после отказа, результат должен быть «нужна отдельная проверка», а не «защищено».

\n

Критерий готового security review

\n

Для выбранной операции читатель должен без устных пояснений увидеть asset, precondition, ordered path, interruption, evidence и residual risk. Отрицательный запрос должен получить ожидаемый отказ до изменения состояния, положительный — пройти по контракту. Запись должна назвать окружение и перечислить, что не проверялось. Только такой результат можно передать следующему инженеру как ограниченное evidence; это не сертификат безопасности всего приложения.

\n

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

\n" } diff --git a/editorial/agent-rewrites/063.json b/editorial/agent-rewrites/063.json index 73fd27d..95a0adf 100644 --- a/editorial/agent-rewrites/063.json +++ b/editorial/agent-rewrites/063.json @@ -2,6 +2,6 @@ "index": 63, "slug": "editorial-2026-04-practice-modern-web-security", "title": "Безопасность веба начинается с границы: как доказать, что контроль прерывает атаку", - "excerpt": "CSP, CSRF-токен и проверка прав решают разные задачи. Разбираем путь атаки, точку прерывания, отрицательный тест и критерий, по которому защиту можно проверить.", - "contentHtml": "

Симптом часто выглядит убедительно: сервер отдаёт заголовок CORS, cookie помечена HttpOnly, а endpoint изменения профиля проверяет авторизацию. Но чужая страница всё ещё может отправить POST с cookie пользователя. Команда видит несколько включённых controls и считает задачу закрытой. Цена ошибки — изменение данных от имени жертвы, инцидент без понятной точки отказа и долгий спор о том, какая настройка должна была остановить запрос.

\n

Тезис статьи простой: контроль защищает не «веб вообще», а конкретный переход в маршруте атаки. Для каждой меры нужно назвать вход, условие, точку прерывания и проверяемый результат. CORS ограничивает чтение ответа браузером. CSRF-токен проверяет намерение для state-changing запроса. Проверка прав решает, может ли пользователь выполнить операцию. Эти меры дополняют друг друга, но одна не заменяет другую.

\n

Сначала опишите путь атаки

\n

Начните с действия нарушителя, а не со списка заголовков. В учебном сценарии пользователь вошёл в приложение, браузер хранит сессионную cookie, а endpoint принимает POST /api/profile/email. Внешняя страница содержит форму или JavaScript, который отправляет запрос на этот адрес. Браузер может приложить cookie к запросу. Если сервер не требует отдельного доказательства намерения, запрос меняет email.

\n

У пути есть четыре наблюдаемые точки: источник запроса, браузер, endpoint и операция записи. CORS не делает внешний POST невозможным. Он обычно мешает прочитать ответ из JavaScript. Это другая граница. HttpOnly не запрещает браузеру отправлять cookie. Он только скрывает cookie от JavaScript. SameSite может уменьшить риск для части кросс-сайтовых запросов, но режим зависит от контекста, способа навигации и политики cookie. Защита должна проверять контракт на сервере.

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

Механизм: разные controls закрывают разные переходы

\n

Аутентификация отвечает на вопрос «кто отправил запрос?». Авторизация отвечает на вопрос «может ли этот пользователь изменить этот объект?». CSRF-защита отвечает на вопрос «есть ли у запроса доказательство, которое внешний сайт не может получить и воспроизвести?». Валидация входа отвечает на вопрос «соответствует ли значение контракту поля?». Нельзя перенести ответ одного слоя на другой.

\n

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

\n

Контроль становится проверяемым, когда его условие видно в коде и в тесте. Фраза «CORS настроен» не сообщает, что именно проверяли. Формулировка «без заголовка X-CSRF-Token endpoint возвращает 403 и не меняет запись» задаёт границу. Она не доказывает безопасность всех endpoint-ов, но доказывает один отрицательный путь для одной операции.

\n

Учебный пример: серверная граница для записи

\n

Ниже — учебный фрагмент на Express-подобном API. Он не подключается к базе, не создаёт настоящую сессию и не показывает production-результат. Функции getSession, findUser и updateEmail обозначают границы приложения. В реальном сервисе их контракты нужно проверить отдельно.

\n
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-сервиса.

\n

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

\n
Как отличить настройку от работающей границы
СимптомПричинаПроверкаДействие
Есть CORS, но чужая форма меняет данныеCORS ограничивает чтение ответа, а не сам state-changing запросОтправить POST без CSRF-токена и проверить статус и записьДобавить серверную проверку CSRF или иной эквивалентный механизм
Cookie имеет HttpOnlyБраузер всё ещё может приложить cookie к запросуПроверить фактический запрос во внешнем контекстеНе считать HttpOnly защитой от CSRF; оставить его для защиты от чтения cookie скриптом
Токен проверяется, но меняется чужой объектCSRF-токен не заменяет авторизациюС токеном пользователя запросить объект другого пользователяСверить владельца объекта с субъектом сессии и вернуть 403
Валидация поля есть только в браузереКлиентский код не является доверенной границейОтправить запрос напрямую с неверным или лишним полемПовторить валидацию на сервере до записи
CSP включена, но XSS-тест проходитПолитика не исправляет небезопасный sink и неверное происхождение HTMLПроверить response header и отрицательный сценарий для конкретного sinkИсправить источник и вывод данных; использовать CSP как дополнительный слой
\n

Как связывать control и evidence

\n

Для каждой меры заведите короткую карточку. В поле asset назовите защищаемый объект. В precondition запишите условие, при котором атака возможна. В interruption укажите запрещаемый переход. В evidence положите наблюдение, которое можно повторить. Последнее поле должно иметь границу: оно отвечает только на свой вопрос.

\n
{\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, это не означает нулевой риск. Это означает, что карту заполнили неполно.

\n

Почему CSP и CSRF нельзя смешивать

\n

Content Security Policy (CSP) задаёт браузеру правила для ресурсов и выполнения скриптов. Она может уменьшить последствия инъекции контента. Но CSP не знает, имеет ли пользователь право менять email, и не добавляет секретный токен в POST. Поэтому политика может быть полезной дополнительной защитой, но не заменяет серверную проверку намерения и прав.

\n

Обратное ограничение тоже важно. Хорошая CSRF-защита не исправляет HTML-инъекцию. Если пользовательский текст попадает в небезопасный DOM-sink, скрипт может выполнить действие уже из доверенного контекста страницы и прочитать доступные данные. В этом случае нужны безопасный вывод, кодирование, ограничения источников и тесты для конкретного sink. Нельзя закрыть XSS, добавив заголовок к endpoint изменения профиля.

\n

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

\n
  1. Выберите одну операцию записи: endpoint, HTTP-метод, ресурс и изменяемое поле.
  2. Опишите симптом и цену ошибки: что изменится, если внешний запрос пройдёт.
  3. Нарисуйте упорядоченный путь от источника запроса до побочного эффекта.
  4. Для каждого control назовите один переход, который он должен прервать.
  5. Проверьте серверную аутентификацию, авторизацию, CSRF и валидацию независимо.
  6. Сделайте отрицательный запрос: уберите токен, поменяйте владельца или подставьте неверное поле.
  7. Проверьте не только статус ответа, но и состояние ресурса после отказа.
  8. Запишите evidence и отдельный список того, что тест не доказывает.
  9. Повторите проверку после изменения middleware, cookie-политики, маршрута или формата запроса.
\n

Ограничения

\n

CSRF-токен защищает конкретный контракт, если сервер действительно проверяет его до изменения состояния. Он не защищает от украденной сессии, вредоносного скрипта внутри доверенного origin или компрометации сервера. SameSite-cookie снижает риск для части сценариев, но её поведение зависит от браузера и контекста. Не делайте из свойства cookie универсальное доказательство.

\n

Код примера не покрывает OAuth callback, загрузку файлов, WebSocket, GraphQL mutations и фоновые очереди. У каждого канала свои границы. Для GraphQL нужно проверить mutation и resolver. Для файла — имя, тип, содержимое, место хранения и выдачу. Для очереди — кто помещает сообщение, кто его обрабатывает и повторяется ли операция безопасно.

\n

Не объявляйте защиту готовой по одному зелёному тесту. Тест может подтвердить отказ без токена, но не проверит права на чужой объект или другой endpoint. Проверка готова, когда исходный отрицательный запрос возвращает ожидаемый отказ, состояние ресурса не изменяется, а запись связывает результат с конкретным маршрутом и перечисляет непроверенные соседние пути.

\n

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

\n

Для выбранной операции у команды есть карта из asset, precondition, attack path, interruption и evidence. Есть автоматизированный или воспроизводимый тест без нужного доказательства. Он получает отказ, а запись остаётся неизменной. Отдельный тест проверяет права на чужой объект. В документе явно указано, что CORS, HttpOnly и CSP не заменяют эти проверки. Если хотя бы одного пункта нет, результат — не «защищено», а «нужна следующая проверка».

\n

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

" + "excerpt": "CSP, CSRF-токен, CORS и проверка прав останавливают разные шаги атаки. Разбираем карту пути, рабочий пример с nonce, отрицательный тест и границы того, что действительно доказано.", + "contentHtml": "

Симптом знакомый: в ответе есть CSP, cookie помечена HttpOnly, API настроил CORS, а endpoint изменения профиля проверяет сессию. В отчёте появляется фраза «защита включена». Но команда не может ответить на более узкий вопрос: какой контроль должен прервать конкретный путь атаки и какое наблюдение это подтверждает? Без такого ответа тест заголовка легко принять за доказательство защиты данных.

\n

Цена ошибки — не только уязвимость. Во время инцидента приходится заново выяснять, проходили ли входные данные через небезопасный sink (место вывода или выполнения), отправлялась ли cookie, менялось ли состояние и на каком слое ожидали отказ. Практичнее разделить четыре объекта: asset — что защищаем, attack path — как к нему добираются, control — какой переход запрещаем, и evidence — что можно повторить и увидеть.

\n

Начните с наблюдаемого пути, а не со списка заголовков

\n

Возьмём учебный маршрут профиля. Пользователь вводит имя, сервер сохраняет его и выводит на странице. Если значение попадает в HTML без экранирования, атакующий может передать фрагмент, который браузер воспримет как разметку или скрипт. Последовательность выглядит так: fragment → render sink → document → script execution. Здесь asset — страница профиля, а опасный побочный эффект — выполнение скрипта в контексте приложения.

\n

У каждого шага свой владелец. Валидатор и экранирование отвечают за смысл и безопасный вывод значения. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе. Браузер применяет политику, но не исправляет серверный шаблон. Поэтому ответ «в заголовке есть Content-Security-Policy» подтверждает доставку политики, а не безопасность каждого sink.

\n
Карта проверки веб-атаки: недоверенный фрагмент проходит к render sink и документу, CSP nonce прерывает выполнение, а источник фрагмента и непокрытые sink остаются остаточным риском
Карта связывает один маршрут с одной точкой прерывания и одним наблюдением. Остаточный риск не исчезает из-за положительного теста: источник фрагмента и другие sink нужно проверять отдельно.
\n

Для CSRF путь другой: внешняя страница создаёт запрос, браузер может приложить cookie, сервер принимает операцию записи. CORS управляет тем, сможет ли чужой скрипт прочитать ответ; он не является универсальным запретом на отправку запроса. Нельзя перенести evidence из одного маршрута на другой только потому, что оба проходят через браузер.

\n

Разведите контроль и доказательство

\n

Control полезно описывать глаголом: «экранирует значение перед вставкой», «отклоняет скрипт без разрешённого nonce», «отказывает в mutation без CSRF-токена», «не разрешает субъекту изменить чужой объект». Такая формулировка сразу показывает место проверки. Формула «CSP настроена» скрывает и переход, и ожидаемый результат.

\n

Evidence должно содержать вход, маршрут, наблюдение и границу вывода. Например: «на тестовом стенде страница с маркером xss_probe вернула CSP-заголовок; браузер заблокировал inline script без nonce; маркер не появился в журнале выполнения». Это подтверждает одну политику и один сценарий. Оно не доказывает безопасный вывод в другом шаблоне, отсутствие XSS во всём приложении или правильность конфигурации после прокси.

\n
Какой вопрос отвечает каждый контроль
КонтрольКакой переход ограничиваетМинимальное наблюдениеЧто не доказано
Экранирование и безопасный 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 и украденной сессии
\n

Таблица не заменяет тест-план. Она не говорит, что любой control обязателен для любого endpoint. Её задача — не дать одному зелёному наблюдению ответить сразу на пять разных вопросов.

\n

Учебный пример: nonce не отменяет экранирование

\n

Ниже — фрагмент в стиле Express для страницы, которая выводит имя. Он показывает порядок границ, но не является готовым middleware: escapeHtml, генерация nonce, шаблонизатор, заголовки прокси и обработка ошибок должны иметь реальные реализации и тесты. Важна последовательность: данные обезвреживаются до вывода, а политика передаётся браузеру вместе с документом.

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

\n

Есть и практическое ограничение: пример не показывает, как фреймворк выставляет заголовок при ошибке, как политика проходит через CDN и как приложение обрабатывает inline-обработчики, сторонние скрипты и динамическую загрузку. Эти решения могут потребовать другие директивы и отдельные тесты. Нельзя добавлять 'unsafe-inline' только ради того, чтобы старый тест перестал падать: это меняет саму границу, которую вы хотели проверить.

\n

Воспроизводимая проверка на разрешённом стенде

\n

Проверка должна отделять HTTP-наблюдение от поведения браузера. Команды ниже ничего не меняют: они скачивают страницу и заголовки с тестового URL. Подставьте адрес своего стенда, где разрешена проверка, а не production-сайта. Имя файла в примере — локальный временный артефакт ревью.

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

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

\n

Почему CORS, CSRF и права нельзя объединять в один verdict

\n

Для cross-origin fetch браузер применяет CORS к чтению ответа. Некоторые запросы с безопасными для CORS методом, заголовками и типом содержимого не требуют preflight. Исторически HTML-форма и без этого механизма могла отправить запрос на другой origin, поэтому отсутствие ответа у чужого скрипта не означает отсутствие побочного эффекта на сервере. Если операция использует cookie и меняет состояние, серверу нужна отдельная CSRF-защита или эквивалентная проверка.

\n

CSRF-токен отвечает на другой вопрос: есть ли у запроса доказательство, которое внешний сайт не может получить из обычного cross-site сценария. Он не решает, имеет ли субъект право изменить объект. Для этого сервер сравнивает субъект сессии и владельца ресурса. HttpOnly мешает JavaScript прочитать cookie, но браузер всё равно может отправить её по правилам cookie. SameSite ограничивает часть отправок в cross-site-контексте, однако результат зависит от атрибута, браузера, схемы и типа навигации.

\n

Так же CSP не заменяет ни CSRF, ни авторизацию. Скрипт, который уже выполняется в доверенном контексте приложения, может вызвать разрешённую операцию; политика ресурсов не определяет права пользователя. Верный review не спрашивает «включена ли безопасность», а сопоставляет asset, угрозу, контроль, негативный тест и непроверенные соседние маршруты.

\n

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

\n
  1. Выберите один asset и один побочный эффект: например, имя в странице профиля или изменение email.
  2. Опишите attack path глаголами: откуда приходит значение, через какой sink проходит, где появляется документ и какое действие возможно.
  3. Назначьте один control на один переход. Для CSP это выполнение запрещённого скрипта, для CSRF — запись без токена, для авторизации — доступ к чужому объекту.
  4. Запишите позитивный и негативный сценарии. Позитивный показывает разрешённый контракт, негативный должен доходить до точки отказа и не менять состояние.
  5. Проверьте HTTP отдельно: статус, заголовок, cookie-атрибуты и тело ответа. Не выдавайте отсутствие response body за отсутствие серверного эффекта.
  6. Проверьте браузер отдельно: реальное применение CSP, выполнение скрипта, preflight и отправка credentials зависят от контекста и реализации клиента.
  7. Сверьте покрытие: найдите соседние шаблоны, mutation, формы, фоновые обработчики и прокси, которые не участвовали в тесте.
  8. Передайте результат четырьмя полями: asset, interruption, evidence и not-proven. Если одного поля нет, оставьте следующий шаг, а не общий verdict.
\n

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

\n

Это метод локальной проверки одного маршрута, а не сертификат безопасности приложения. Он не покрывает автоматически WebSocket, GraphQL, загрузку файлов, OAuth callback, Service Worker, фоновые очереди и native-клиенты. Для каждого канала нужно построить собственный путь и назвать границу, которая действительно применяется.

\n

Nonce и CSP зависят от того, как формируется документ и какие скрипты нужны странице. Политика из примера может сломать легитимную аналитику или inline-код. Не переносите её в другой сервис без инвентаризации ресурсов. curl не исполняет HTML и потому не подтверждает браузерную защиту. Браузерный тест не доказывает, что сервер безопасно выводит все данные. Тест на тестовом стенде не доказывает конфигурацию после deploy.

\n

CSRF-токен не защищает от украденной сессии или скрипта, который уже выполняется в доверенном origin. CORS не является заменой контролю записи. HttpOnly и SameSite уменьшают отдельные риски, но не являются универсальной защитой. Если команда не может назвать непроверенный sink, маршрут или контекст cookie, карта слишком широкая для честного вывода.

\n

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

\n

Для выбранной операции готово не «всё защищено», а более узкое утверждение: путь атаки записан; control стоит до опасного эффекта; позитивный сценарий работает; негативный сценарий получает ожидаемый отказ и не меняет состояние; HTTP- и браузерное наблюдения разделены; список непроверенных маршрутов сохранён. После изменения шаблона, middleware, cookie-политики, прокси или браузера этот набор нужно повторить.

\n

Такой критерий даёт команде полезный результат: видно, что остановлено, где осталось residual risk и кто должен выполнить следующий review. Если evidence подтверждает только заголовок, пишите только про заголовок. Если тест подтверждает один sink, не называйте его защитой всего приложения.

\n

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

" } diff --git a/editorial/agent-rewrites/064.json b/editorial/agent-rewrites/064.json index f3aecf2..3f4c311 100644 --- a/editorial/agent-rewrites/064.json +++ b/editorial/agent-rewrites/064.json @@ -1,7 +1,7 @@ { "index": 64, "slug": "editorial-2026-03-field-data-contracts", - "title": "Почему один verdict не описывает совместимость контракта данных", - "excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем проверку пары producer → consumer, строгие границы чтения и отказ при неизвестном сравнении.", - "contentHtml": "

После изменения JSON один consumer продолжает читать сообщения, и команда ставит схеме зелёный статус. Через день другой reader начинает отбрасывать объект: он запрещает новые поля. Ещё один consumer ждёт обязательный state, а producer уже отправляет только phase. Поле называется похоже, тест на sample проходит, но смысл и правила чтения различаются. Ошибка стоит дорого: сбой обнаруживается после раскатки, владелец находится вручную, а команда откатывает уже связанное изменение.

\n

Тезис простой: совместимость нельзя присвоить схеме в целом. Её проверяют для конкретной пары producer → consumer, конкретной версии и конкретного направления чтения. Для каждой пары нужно назвать contract family, baseline, candidate и capability reader. Неизвестный consumer не получает зелёный статус. Отсутствующая связь означает остановку и уточнение.

\n

Минимальная единица решения

\n

Список интеграций помогает найти владельцев, но не отвечает на вопрос о совместимости. Нужна одна строка review. В ней producer создаёт candidate schema, consumer читает эту форму, а gate сравнивает только заранее названные свойства. Такой scope ограничивает вывод и делает причину отказа адресной.

\n
const 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
\"Матрица
Одна и та же candidate schema даёт разные результаты: tolerant reader принимает объявленное поле, strict reader останавливает проверку, неизвестная пара требует сначала назвать связь.
\n

Что именно проверяет gate

\n

Сначала gate проверяет structural слой. Обязательное поле baseline не должно исчезнуть из candidate. Его тип не должен измениться без отдельного решения. В учебном примере замена state на phase — не безопасный rename. Reader, который ищет state, видит удалённое required field. Близость слов не доказывает совпадение семантики.

\n

Затем gate проверяет объявленные additions. Добавление необязательного priority сохраняет старую обязательную поверхность, но всё равно требует проверки consumer. Tolerant reader может принимать declared additions. Strict reader может отвергать любое дополнительное поле. Тип данных сам по себе не говорит, какая политика действует на границе.

\n

Третья проверка связывает diff с manifest. Если candidate содержит routingHint, но карточка change его не называет, это не повод угадать намерение. Gate возвращает stop-undocumented-schema-field. Скрытое поле может влиять на маршрутизацию, размер сообщения или безопасность. Сначала его нужно объявить и проверить.

\n

Наконец, gate проверяет relation. Поля двух JSON-объектов нельзя сравнивать только потому, что оба объекта выглядят одинаково. Нужны family, producer, consumer и direction. Если направление не задано, sample не превращается в compatibility verdict. Результат — stop-implicit-comparison.

\n

Пример fail-closed проверки

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

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

\n
Диагностика неверного verdict
СимптомПричинаПроверкаДействие
Один consumer прочитал sample, схема объявлена совместимойПроверили одну пару и свернули результат в общий статусПеречислить named consumers и их policyРазбить review на отдельные строки producer → consumer
Reader перестал находить stateRequired field заменили на похожее имя phaseСравнить обязательные поля baseline и candidateВернуть поле или оформить отдельную migration
Новый reader падает на поле priorityStrict policy не принимает additionsПроверить capability reader, а не только тип поляУдержать addition или расширить границу reader
В candidate есть routingHint, но в change его нетSchema diff не связан с manifestСверить все добавленные поля с declared listОстановить review и описать поле явно
В отчёте написано «совместимо», но direction пустСравнение сделано по внешнему сходству JSONПроверить family, producerId, consumerId и directionВернуть работу на описание relation
\n

Почему общий зелёный статус опасен

\n

У change может быть пять consumers. Один принимает addition, второй запрещает его, третий относится к другой family, а четвёртый неизвестен. Общий статус «compatible» скрывает владельца решения и стирает отрицательные ветки. Такой статус допустим только как агрегат после того, как каждая известная пара получила собственный результат. Даже тогда рядом должны остаться причины stop и несопоставимые отношения.

\n

Strict reader не является неисправным. Его policy — часть контракта. Gate не должен менять её ради удобства producer. Если producer добавляет поле, есть три честных варианта: не добавлять его, мигрировать reader или выпустить отдельную форму. Пока выбор не сделан, stop полезнее зелёного предположения.

\n

Отдельно храните неизвестность. Отсутствие карточки consumer не означает tolerance. Скрытый reader нельзя объявить совместимым по умолчанию. Если связь только предполагается, сначала нужен владелец, подтверждение family и направление чтения. Это отрицательный путь механизма, а не исключение из него.

\n

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

\n
  1. Зафиксируйте одну пару. Назовите producer, consumer, family и направление: кто пишет candidate и кто его читает.
  2. Сохраните baseline. Выпишите обязательные поля, типы и версию формы до изменения.
  3. Опишите candidate. Отделите сохранённые поля, удалённые поля и additions. Не трактуйте rename по сходству имён.
  4. Проверьте manifest. Каждое добавленное поле должно быть объявлено. Необъявленное поле возвращает stop.
  5. Назовите capability reader. Зафиксируйте supported version и policy для declared additions. Не выводите policy из того, что reader однажды прочитал sample.
  6. Запустите structural check. Сначала остановите удалённое required field и изменение типа. Только потом проверяйте additions.
  7. Сохраните отдельный verdict. Запишите точную причину: backward break, undocumented field, incompatible consumer или implicit comparison.
  8. Проверьте отрицательные случаи. Подайте объект без relation, с удалённым state, со скрытым routingHint и со strict reader. Каждый случай должен остановиться на своей причине.
  9. Передайте ограниченный результат. Успешная synthetic-проверка означает только hand-off на следующий review. Она не означает публикацию, раскатку или работоспособность внешней системы.
\n

Ограничения

\n

Этот механизм не обнаруживает неизвестных consumers. Он не знает, кто хранит старую форму в архиве, какой proxy меняет payload и как асинхронная доставка обрабатывает повтор. Для этого нужны inventory, наблюдаемая маршрутизация и отдельные проверки. Compatibility gate не заменяет schema registry, consumer contract tests, миграцию данных и план возврата.

\n

Проверка required fields не покрывает всю семантику. Два поля могут иметь один тип и разные единицы измерения, часовые пояса или правила округления. Название family не доказывает значение поля. Такие условия нужно добавить в контракт отдельными правилами и тестовыми случаями. Нельзя получить полноту из короткой функции.

\n

Учебные literals не дают production-результата. Успешный вызов функции не говорит, что реальный consumer обработал candidate, что registry содержит нужную версию или что deployment завершился. Для реального изменения потребуется привязать проверку к фактическим схемам, версиям, данным и владельцам. Если вход невозможно подтвердить, результат должен остаться stop.

\n

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

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

\n

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

\n" + "title": "Контракты данных: как не принять похожее поле за совместимое", + "excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем смысл полей, политику reader и fail-closed проверку producer → consumer.", + "contentHtml": "

После изменения JSON один сервис продолжает читать сообщения, и команда ставит контракту зелёный статус. Затем другой consumer начинает отбрасывать тот же объект: он запрещает неизвестные поля. Третий ждёт status: paid как признак завершённой оплаты, а producer использует paid для промежуточного состояния после авторизации. Форма объекта похожа, sample проходит, а решение всё равно ломает процесс.

\n

Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары producer → consumer, версии, направления передачи и набора правил чтения.

\n

Что именно является контрактом

\n

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

\n

В учебном кейсе producer отправляет заказ. В первой версии поле status означает жизненный цикл заказа: new, paid, cancelled. Другой сервис использует такое же имя для результата платежной операции: authorized, captured, refunded. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.

\n
\"Матрица
Одна candidate schema даёт разные результаты для разных reader. Поэтому verdict относится к названной связи, а не ко всей системе без исключений.
\n

Сначала разделяем форму и смысл

\n

Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.

\n

JSON Schema хорошо описывает первый слой. В спецификации есть type, required, enum и additionalProperties. Но сама структурная проверка не знает, означает ли paid захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.

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

\n

Почему один успешный consumer ничего не доказывает

\n

Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки status.

\n

Отдельно проверяйте направление. Старый 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 разЕдиницу измерения, диапазон и тест на границеНазвать единицу в поле или выпустить отдельную форму
\n

Воспроизводимый fail-closed gate

\n

Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения status и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.

\n
// 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 и выполните:

\n
node --check contract-gate.mjs\nnode contract-gate.mjs
\n

Ожидаемый вывод:

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

Отдельно проверяем жизненный цикл status

\n

Структура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, paid → new может быть запрещённым переходом, а повторное paid — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.

\n

Не прячьте единицу времени в описании команды. Поля occurredAt и receivedAt отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.

\n

Для бинарных схем риск может выглядеть иначе. В Protocol Buffers номер поля участвует в wire format и не должен меняться или переиспользоваться; удалённые номера и имена рекомендуется резервировать, чтобы позднее не создать конфликт. В Avro reader и writer schema сопоставляются по правилам schema resolution. Это полезные модели, но их правила нельзя механически переносить на произвольный JSON API.

\n

Порядок проверки изменения

\n
  1. Назовите отношение. Запишите contract family, producer, consumer, направление, транспорт и версию. Если consumer неизвестен, результат — остановка и поиск владельца.
  2. Сохраните baseline. Зафиксируйте обязательные поля, типы, enum, единицы измерения, ограничения и примеры до изменения.
  3. Опишите candidate. Разделите сохранённые, удалённые, переименованные и добавленные поля. Переименование рассматривайте как изменение формы и смысла, пока обратное не доказано.
  4. Проверьте reader policy. Узнайте, принимает ли consumer дополнительные поля и значения enum. Не выводите это из того, что один sample однажды прочитался.
  5. Проверьте отрицательные случаи. Удалите обязательное поле, добавьте неизвестное поле, передайте новое значение enum, смените единицу времени и отправьте сообщение в обратном направлении.
  6. Проверьте накопленные данные. Очередь, retry, архив и offline-клиенты могут доставить старую форму после релиза. Укажите срок совместного чтения и способ удаления старой версии.
  7. Сохраните адресный verdict. Запишите не только PASS или STOP, но и reader, входную версию, причину отказа и следующее действие. Общий итог можно вычислять только после результатов всех названных пар.
\n

Границы применимости

\n

Описанный gate проверяет ограниченную структурную и перечислимую часть контракта. Он не обнаруживает скрытых consumers, не анализирует код всех клиентов, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку или корректность бизнес-переходов. Для этого нужны отдельные тесты, наблюдаемость и владелец интеграции.

\n

Даже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что status относится к заказу, а не к платежу. Поле format также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.

\n

Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.

\n

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

\n

Изменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.

\n

Практическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение status. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает status: refunded для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.

\n

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

\n" }