diff --git a/editorial/agent-rewrites/161.json b/editorial/agent-rewrites/161.json index a8810a9..69753c2 100644 --- a/editorial/agent-rewrites/161.json +++ b/editorial/agent-rewrites/161.json @@ -2,6 +2,6 @@ "index": 161, "slug": "editorial-2023-07-mechanism-contract-tests", "title": "Почему schema match не равен совместимости API", - "excerpt": "Ответ API может пройти схему и всё равно сломать действие на экране. Разбираем три уровня контракта: форму JSON, ожидание consumer и проверку provider.", - "contentHtml": "

Экран получает HTTP 200, обязательные ключи на месте, типы совпадают с OpenAPI. Но кнопка продления не появляется: ответ содержит state: \"active\" и renewalAt: null. Схема допускает оба значения. Consumer ожидал дату, по которой можно показать действие. Пользователь видит неполный сценарий, поддержка получает жалобу, а команда спорит, был ли релиз совместимым.

\n

Цена ошибки растёт из-за ложного зелёного сигнала. Проверка JSON подтверждает форму, но не подтверждает, что consumer сможет закончить свой сценарий. Provider считает, что поле не менялось. Consumer видит изменение смысла. Владелец релиза видит успешный job и не получает основания остановить выкладку.

\n

Тезис статьи простой: контракт API состоит как минимум из трёх разных доказательств. Schema match проверяет структуру. Consumer expectation проверяет нужное поведение. Provider verification проверяет, что конкретная версия provider действительно отвечает опубликованному interaction. Один результат нельзя выдавать за другой.

\n

Три вопроса к одному ответу

\n

Сначала отделите форму от смысла. OpenAPI описывает интерфейс, который могут использовать люди и инструменты. Schema Object задаёт типы, обязательность, перечисления и допустимые варианты. Это хороший барьер против пропавшего ключа, числа вместо строки и неизвестного значения enum.

\n

Но схема не знает, какое действие должен показать конкретный экран. Поле renewalAt может быть nullable для одного клиента и обязательным условием для другого сценария. Поэтому второй уровень должен принадлежать consumer: «для экрана продления активная подписка должна иметь применимую дату». Это уже не только свойство JSON. Это правило принятия решения.

\n

Третий уровень связывает ожидание с provider. Provider verification исполняет interaction на согласованном состоянии provider и сравнивает фактический ответ с контрактом. Без такого результата у команды есть описание ожидания, но нет доказательства, что provider его выполнил.

\n
Что означает каждый результат
СимптомПричинаПроверкаДействие
Ответ 200, экран не показывает действиеСмысл поля шире ожидания consumerВоспроизвести decision rule на ответе active + nullУточнить state, добавить явное поле или изменить consumer
Пропал ключ или изменился типНарушена schema-границаПроверить required, type, enum и nullable для версии схемыИсправить provider либо согласовать версионное изменение
Consumer contract зелёный, provider не проверенПроверили только mock-ответНайти результат verification для версии provider и provider stateНе называть выпуск совместимым до реального результата
Один interaction зелёный, старый клиент сломанContract покрывает не всех consumersСверить список клиентов и поддерживаемые версииДобавить interaction или ограничить решение областью проверки
\n

Учебный пример: active не обещает дату

\n

Рассмотрим искусственный сценарий GET /v1/subscriptions/sub-42. Имена synthetic-portal-web и synthetic-billing-api нужны только для объяснения механизма. Это не лог реального сервиса, не результат запуска и не утверждение о production.

\n

Общая schema может разрешать такой ответ:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

На уровне формы ответ выглядит допустимым. На уровне consumer он не подходит экрану продления: у экрана нет даты и он не должен выдумывать её. Правило можно записать рядом с interaction:

\n
const expectation = ({ state, renewalAt }) =>\n  state === 'active' && isFutureIsoDate(renewalAt);\n\nexpect(expectation(response)).toBe(true);
\n

Этот код показывает только идею decision rule. Он не валидирует OpenAPI-документ, не вызывает HTTP, не поднимает provider и не запускает Pact. В реальном тесте надо определить формат даты, часовой пояс, момент отсчёта и provider state. Если эти условия не названы, тест может пройти на случайных данных и не защищать нужный сценарий.

\n

Теперь различие видно на трёх ответах. active с будущей датой может пройти форму и ожидание. active + null может пройти форму, но нарушить ожидание consumer. Число в state должно остановиться уже на схеме. Такая классификация полезнее единого флага compatible: true: она показывает, где именно возникло расхождение.

\n
\"Матрица
Матрица разделяет форму ответа, смысл для consumer и фактическую проверку provider. Отметки относятся к учебной модели и не являются результатом production-запуска.
\n

Как записать contract, который помогает принять решение

\n

Начните с одного действия consumer, а не со всего API. Назовите метод, путь, вход, ожидаемый статус и минимальный ответ. Затем запишите provider state. Формулировка «подписка активна» слишком общая, если экрану нужна именно дата продления после текущего момента. State должен объяснять, почему provider обязан вернуть нужные данные.

\n

Отдельно укажите отрицательный путь. Например: если state равен active, но renewalAt отсутствует или уже прошёл, consumer не показывает кнопку продления и сообщает, что действие недоступно. Это не означает, что поле надо сделать non-null для всех клиентов. Отрицательная ветка фиксирует решение одного сценария.

\n

Храните рядом версии. Укажите версию schema, consumer contract, provider и provider state. Версия OpenAPI не заменяет версию API-сборки. Версия consumer не доказывает, какую сборку provider проверяли. Эти указатели нужны, чтобы зелёный результат можно было воспроизвести и связать с конкретным изменением.

\n

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

\n
  1. Зафиксируйте симптом. Запишите request, response, статус и пользовательское действие, которое не завершилось. Не называйте причину до проверки.
  2. Проверьте форму. Сверьте версию schema, media type, required-поля, типы, enum и nullable-границы. Если форма нарушена, исправляйте её прежде, чем обсуждать смысл.
  3. Опишите решение consumer. Укажите, какое поле запускает действие и что должен сделать клиент при его отсутствии, просрочке или неизвестном значении.
  4. Назовите provider state. Определите состояние данных, в котором interaction должен быть выполнен. Не заменяйте его удобным mock-ответом без объяснения.
  5. Запустите provider verification. Исполните опубликованный contract против согласованной версии provider. Сохраните результат, версию и список проверенных interactions.
  6. Примите ограниченное решение. Разрешайте выпуск только для проверенных consumers, states и версий. Для остальных оставьте явный риск или остановите изменение.
\n

Почему schema match недостаточен

\n

Schema проверяет допустимость значения, а не его полезность для каждого клиента. Nullable-поле может быть корректным по общему договору и непригодным для конкретного действия. Enum может сохранить прежний набор строк, но поменять бизнес-смысл каждой строки. HTTP 200 может сообщать об успешной обработке запроса, но не о готовности пользовательского шага.

\n

Consumer-driven contract помогает сузить проверку до реальной потребности клиента. Он не пытается описать все возможные ответы provider. Это достоинство для быстрого feedback, но и ограничение: неохваченный consumer остаётся неохваченным. Список interactions надо поддерживать вместе со списком клиентов, иначе команда легко перенесёт результат одного экрана на весь API.

\n

Provider verification тоже не даёт универсальной гарантии. Она подтверждает конкретные interactions в подготовленных состояниях. Она не заменяет авторизацию, миграцию данных, нагрузочные проверки, совместимость старых мобильных версий и наблюдение после выкладки. Эти проверки отвечают на другие вопросы.

\n

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

\n

Учебный код выше не доказывает совместимость реальных версий. В нём нет сети, broker, Pact, авторизации, зависимостей provider и production-данных. Даже корректный локальный результат означает только то, что правило примера отделяет форму от смысла. Нельзя писать в release-описании «provider verified», если запуск provider verification не состоялся.

\n

Если verification не прошла, сначала сохраните исходный contract и ответ. Затем решите, где находится граница изменения. Иногда provider должен вернуть прежний смысл. Иногда consumer должен перестать трактовать active слишком узко. Иногда безопаснее добавить новое поле и временно поддержать оба варианта. Автоматически делать nullable-поле обязательным нельзя: это может сломать другие сценарии.

\n

Rollback также требует конкретики. Назовите версии, которые можно вернуть, данные, уже записанные новым кодом, и consumer, который ещё читает старый ответ. Snapshot JSON не откатывает endpoint, базу, флаг или опубликованный артефакт. Если эти условия неизвестны, готовность к rollback не доказана.

\n

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

\n

Изменение готово к выпуску, когда выполнены все четыре условия: schema проверена для нужной версии; consumer expectation содержит положительную и отрицательную ветки; provider state и версия provider названы; provider verification дала сохранённый результат для каждого consumer, которого затрагивает изменение. Если хотя бы одного пункта нет, вывод должен звучать точнее: «форма проверена», «ожидание записано» или «verification не запускалась». Слово «совместимо» оставляйте только для доказанной области.

\n

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

\n" + "excerpt": "Ответ может соответствовать OpenAPI и всё равно сломать действие на экране. Разбираем границу между формой JSON, ожиданием consumer и проверкой provider на конкретном состоянии.", + "contentHtml": "

Сбой интеграции часто выглядит обманчиво: HTTP-ответ имеет статус 200, обязательные поля присутствуют, а валидатор схемы сообщает PASS. При этом кнопка продления не появляется. Ответ содержит state: \"active\" и renewalAt: null. Общая схема допускает null, но экрану нужна дата, по которой он может предложить действие. Пользователь получает неполный сценарий, а команда видит зелёный тест и поздно замечает расхождение.

\n

Причина не в том, что schema validation бесполезна. Она отвечает на один вопрос: допустима ли форма сообщения по описанию? Совместимость интеграции требует ещё двух ответов: получил ли consumer данные, необходимые его сценарию, и проверил ли provider этот конкретный обмен на согласованном состоянии. Эти уровни нельзя сворачивать в один флаг compatible.

\n

Три разных значения слова «контракт»

\n

В этой статье schema — формальное описание структуры сообщения. В OpenAPI Schema Object задаёт типы, обязательность, перечисления, форматы и другие ограничения. OpenAPI 3.1 опирается на JSON Schema Draft 2020-12, но само описание не знает, какое поле нужно конкретной кнопке или какой порядок действий ожидает пользователь.

\n

Consumer expectation — проверяемое ожидание клиента. Оно связывает ответ с действием: для активной подписки с датой в будущем экран показывает продление, а для отсутствующей или просроченной даты не показывает его и объясняет недоступность. Это правило принадлежит сценарию клиента, а не общей схеме ресурса.

\n

Provider verification — запуск опубликованного набора взаимодействий против provider в заданном состоянии данных. Такой запуск показывает, что конкретная версия provider действительно отвечает ожидаемым запросам. Он не доказывает поведение клиентов, которых в наборе нет, и не заменяет проверки авторизации, нагрузки или миграции данных.

\n
Что доказывает каждый уровень проверки
УровеньВопросПоложительный результатЧто остаётся неизвестным
SchemaСообщение имеет допустимую форму?Ключи, типы, enum и ограничения соответствуют схеме.Подходит ли значение действию конкретного consumer.
Consumer expectationКлиент сможет принять решение?Положительная и отрицательная ветки сценария определены.Отвечает ли им реальный provider.
Provider verificationProvider выполняет interaction?Запрос и ответ прошли на указанном provider state.Поведение неохваченных клиентов, нагрузку и весь production-контур.
\n

Контрпример: допустимый JSON, бесполезный для действия

\n

Предположим, endpoint возвращает сведения о подписке. Ниже — минимальная схема OpenAPI 3.1 в YAML. Запись type: [string, 'null'] намеренно допускает отсутствие даты: это может быть корректно для отменённой подписки или другого потребителя.

\n
type: object\nrequired: [id, state, renewalAt]\nproperties:\n  id:\n    type: string\n  state:\n    type: string\n    enum: [active, canceled]\n  renewalAt:\n    type: [string, 'null']\n    format: date-time
\n

Ответ active + null соответствует этой форме. Но для сценария продления он недостаточен: экран не знает, когда можно выполнить операцию. Отсюда следует важное разделение: расширение схемы или ужесточение renewalAt для всех клиентов может быть неправильным исправлением. Нужно сначала выяснить, какой смысл требуется именно этому consumer.

\n

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

\n
\"Последовательность
Каждый блок добавляет отдельное доказательство: схема не заменяет ожидание consumer, состояние provider и сохранённый результат verification.
\n

Как превратить ожидание в проверяемое правило

\n

Начинайте не с полного API, а с одного пользовательского действия. Зафиксируйте метод, путь, статус, минимальный ответ, условие показа действия и отрицательную ветку. Например: «если state равен active, а renewalAt — дата в будущем относительно часов теста, экран показывает продление; иначе действие скрыто».

\n

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

\n
function canRenew(subscription, now) {\n  if (subscription.state !== 'active') return false;\n  if (typeof subscription.renewalAt !== 'string') return false;\n\n  const renewalAt = Date.parse(subscription.renewalAt);\n  return Number.isFinite(renewalAt) && renewalAt > now.getTime();\n}\n\nconst now = new Date('2023-07-15T10:00:00Z');\nif (!canRenew({\n  state: 'active',\n  renewalAt: '2023-07-16T10:00:00Z',\n}, now)) throw new Error('future renewal must be available');\n\nif (canRenew({ state: 'active', renewalAt: null }, now)) {\n  throw new Error('null renewal must not enable the action');\n}
\n

Это полностью локальная проверка decision rule: сохраните фрагмент в contract-rule.mjs и запустите node contract-rule.mjs. Скрипт не обращается к сети, не валидирует OpenAPI и не запускает provider. Он доказывает только две ветки выбранного правила. Прежде чем переносить его в проект, добавьте тесты на отменённое состояние, прошедшую дату, некорректную дату и другой часовой пояс.

\n

Где заканчивается consumer contract

\n

Consumer-driven contract описывает взаимодействие, которое действительно использует клиент. Для него важны сформированный запрос, заголовки, статус, нужные поля и обработка ответа. Такой тест полезен именно своей узкой областью: он быстро показывает, что provider перестал удовлетворять конкретному клиенту.

\n

Узкая область одновременно создаёт риск. Если в наборе есть только веб-экран, результат нельзя автоматически распространить на мобильное приложение, партнёрский API или старую версию клиента. Список consumers и их interactions должен быть явным. Если неизвестно, кто ещё читает поле, вывод ограничивается проверенным набором.

\n

Не помещайте в contract test всю бизнес-логику экрана. Проверка «кнопка имеет зелёный цвет» относится к UI или функциональному тесту. Контракт должен зафиксировать, какие данные и ответ нужны для связи между consumer и provider. Решение интерфейса можно проверять отдельным тестом, используя тот же набор граничных ответов.

\n

Что именно проверяет provider verification

\n

Provider state — это не комментарий «в базе есть активная подписка», а воспроизводимая подготовка данных, при которой interaction имеет смысл. Для примера назовите его так: subscription sub-42 is active and renews after 2023-07-15T10:00:00Z. В описании состояния должны быть известны идентификатор фикстуры, версия provider и момент времени, относительно которого проверяется дата.

\n

Проверка provider должна выполняться против локально запускаемого экземпляра или экземпляра в CI с контролируемыми зависимостями. Проверка уже развёрнутого общего окружения хуже отвечает задаче быстрого feedback: там труднее подготовить состояние, изолировать внешние сервисы и понять, какая версия обработала запрос. Это не запрет на smoke-тесты в окружении, а граница между ними и provider verification.

\n

Результат записывайте не только как PASS/FAIL. Нужны версия provider, идентификатор contract, provider state, список interactions, commit или сборка и время запуска. Если результат не найден, корректная формулировка — «consumer contract опубликован, verification не подтверждена», а не «API совместим».

\n
Минимальная запись для расследования расхождения
ПолеПримерЗачем нужно
Consumerweb-renewalПонимать, чьё ожидание проверялось.
InteractionGET /v1/subscriptions/sub-42Связать ошибку с конкретным обменом.
Provider stateactive, renewal after fixed timeВоспроизвести входные данные.
Provider versionbuild-2023-07-15.2Отличить код, который реально проверяли.
Verification resultPASS: 1 interactionНе принять существование contract за его выполнение.
\n

Диагностика: симптом → проверка → решение

\n

При отказе не начинайте с изменения nullable-поля. Сначала определите уровень, на котором возникло расхождение. Один и тот же экранный симптом может быть следствием формы ответа, семантики данных, часов, provider state или отсутствия самого запуска.

\n
Карта разбора проблемного ответа
СимптомПервая проверкаОграниченное действие
Нет обязательного ключа или изменился типСверить версию схемы, required, type и media type.Исправить provider или согласовать версионное изменение.
Форма верна, но действие скрытоПроверить decision rule, значение поля и фиксированные часы.Уточнить смысл consumer или добавить явное поле.
Consumer зелёный, provider неизвестенНайти запись provider verification для той же версии.Не расширять область вывода за проверенный consumer.
Один consumer зелёный, другой сломанСверить список interactions и версий клиентов.Добавить отдельный сценарий или ограничить изменение.
Тест нестабилен у границы датыПроверить источник времени, формат и часовой пояс.Передавать часы в правило и фиксировать момент в state.
\n

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

\n
  1. Зафиксируйте наблюдение. Сохраните безопасные request, response, статус, consumer и действие, которое не завершилось. Не называйте причину до проверки.
  2. Проверьте форму. Укажите точную версию OpenAPI или другой schema contract. Сверьте required, type, enum, format, null и media type.
  3. Запишите решение consumer. Назовите положительную и отрицательную ветки. Для дат зафиксируйте формат, часовой пояс и источник времени.
  4. Определите scope. Составьте список consumers, версий и interactions, которых касается изменение. Не переносите результат одного клиента на весь API.
  5. Опишите provider state. Укажите фикстуру, состояние данных, внешние зависимости и версию provider, на которой должен выполняться обмен.
  6. Запустите два независимых слоя. Выполните consumer contract и provider verification. Сохраните не только contract, но и результат его проверки.
  7. Проверьте отрицательный путь. Прогоните null, просроченную дату, неизвестный enum, ошибку авторизации и отсутствие записи там, где это входит в сценарий.
  8. Примите решение в границах доказательств. Для неподтверждённых клиентов оставьте риск, добавьте проверку или остановите несовместимое изменение.
\n

Ограничения и безопасный вывод

\n

Контрактные тесты не заменяют функциональные, end-to-end, нагрузочные и security-тесты. Они не доказывают корректность бизнес-расчёта для всех данных, доступность базы, задержку сети или работоспособность каждого UI-перехода. Они также не обнаружат consumer, о котором команда не знает и который не попал в набор interactions.

\n

Учебный endpoint и фикстура в этой статье вымышлены. Команда node contract-rule.mjs проверяет локальный инвариант на двух значениях и не вызывает реальный API. Если проект использует OpenAPI 3.0, правило для nullable оформляется иначе, чем в OpenAPI 3.1; сверяйте версию спецификации и поведение конкретного валидатора. Формат date-time сам по себе не говорит, какую бизнес-зону времени выбрать.

\n

Безопасный вывод должен быть узким: «ответ соответствует schema», «consumer rule проходит для этих fixtures» или «provider verification прошла для такого-то state». Формулировку «изменение совместимо» оставляйте только тогда, когда перечислены все затронутые consumers и для них есть соответствующие результаты. Если не хватает версии, state или verification result, это пробел в доказательстве, а не зелёный статус.

\n

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

\n" } diff --git a/editorial/agent-rewrites/162.json b/editorial/agent-rewrites/162.json index e8084c0..e595dab 100644 --- a/editorial/agent-rewrites/162.json +++ b/editorial/agent-rewrites/162.json @@ -1,7 +1,7 @@ { "index": 162, "slug": "editorial-2023-07-practice-contract-tests", - "title": "Контрактные тесты API: как поймать совместимое на вид изменение", - "excerpt": "HTTP 200 и валидная схема ещё не означают, что consumer сможет продолжить сценарий. Разбираем смысловой контракт, provider verification и критерий готовности.", - "contentHtml": "

API возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.

\n

Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.

\n

Тезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.

\n

Где ломается обычная проверка схемы

\n

OpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.

\n

Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

Схема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.

\n

Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.

\n

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

\n
Что доказывает каждая проверка
СлойЧто проверяемЧто означает PASSЧего PASS не означает
Schema matchПоля, типы, enum, обязательность и nullable-границы.Ответ соответствует описанной форме.Consumer может завершить свой пользовательский сценарий.
Semantic expectationМинимальное значение, нужное конкретному consumer.Ответ содержит предусловие выбранного действия.Provider действительно обработал запрос.
Provider verificationInteraction исполняется на provider в названном состоянии.Запущенный provider вернул ожидаемый ответ для этого contract.Проверены все клиенты, методы и варианты данных.
\n

Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.

\n

Как записать смысловой контракт

\n

Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.

\n

Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.

\n
const response = {\n  id: 'sub-42',\n  state: 'active',\n  renewalAt: '2026-09-30T00:00:00Z',\n};\n\nexpect(response.state).toBe('active');\nexpect(response.renewalAt).toMatch(\n  /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/\n);\nexpect(Date.parse(response.renewalAt)).toBeGreaterThan(Date.now());
\n

Код выше — учебная проверка значения. Она не является готовым Pact-тестом: здесь нет consumer client, mock server, contract broker и provider verification. В рабочем тесте assertion должен проходить через реальный код доступа consumer, чтобы contract отражал его запрос и его решение, а не отдельно созданный объект.

\n

Почему нужен provider verification

\n

Consumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.

\n

У provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.

\n

Проверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.

\n
\"Схема
Consumer формулирует потребность, contract фиксирует request и expectation, provider verification исполняет interaction в названном состоянии. Схема учебная: она не является сетевой трассой и не доказывает запуск конкретного сервиса.
\n

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

\n
Карта разбора расхождения consumer и provider
СимптомПричинаПроверкаДействие
Схема зелёная, экран не показывает действие.Смысловое предусловие не записано: допустимый null стал непригодным для consumer.Назвать решение, которое принимает экран, и минимальное значение для него.Добавить semantic expectation для конкретного сценария или изменить общий контракт после согласования владельцев.
Provider verification падает на пустом поле.Provider state не создаёт данные, обещанные interaction.Проверить подготовку состояния, идентификатор записи и фактический response.Исправить state setup или уточнить contract; не добавлять случайный default в assertion.
Consumer-тест проходит, provider verification не запускался.Проверили mock или сохранённый JSON, но не реальный provider.Найти результат verifier, версию contract, версию provider и номер interaction.Запустить проверку на управляемом provider и опубликовать результат рядом с contract.
Один contract прошёл, другой consumer сломался.Общее поле использовалось с разными ожиданиями.Составить список consumer и сравнить их semantic expectations.Разделить endpoint или поле, версионировать изменение либо добавить совместимое новое поле.
Тест падает только на старых данных.Новый смысл поля не поддерживает исторические записи.Проверить варианты данных до миграции и после неё.Добавить миграцию, fallback с явным сроком или запрет выпуска до готовности данных.
\n

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

\n
  1. Зафиксируйте симптом. Запишите endpoint, статус, поля ответа, consumer-сценарий и цену отказа. Формулировка «API несовместим» слишком широка.
  2. Отделите форму от смысла. Проверьте schema match отдельно. Укажите, какие значения схема разрешает и какое из них не подходит выбранному consumer.
  3. Назовите предусловие. Запишите действие пользователя и минимальный response, без которого действие должно исчезнуть или перейти в понятный отрицательный путь.
  4. Определите provider state. Укажите идентификатор данных, состояние зависимостей и способ подготовки. Не ссылайтесь на «обычную тестовую базу» без воспроизводимого описания.
  5. Сформируйте interaction. Включите только нужный request и response, но сохраните все поля, по которым consumer принимает решение. Привяжите contract к версии consumer.
  6. Запустите provider verification. Исполните interaction на конкретной версии provider. Сохраните результат, версию, состояние и номер проверки.
  7. Проверьте отрицательный путь. Ответ с active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.
  8. Примите решение о выпуске. Разрешайте изменение только для перечисленных consumer и проверенных состояний. Для остальных клиентов оставьте совместимое поле, новую версию или план миграции.
\n

Ограничения

\n

Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.

\n

Provider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.

\n

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

\n

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

\n

Изменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.

\n

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

\n" + "title": "Контрактные тесты API: почему зелёная схема не гарантирует совместимость", + "excerpt": "Контракт ловит не только исчезнувшее поле, но и расхождение между допустимым ответом и действием consumer. Разбираем interaction, provider state, verification и безопасный release gate.", + "contentHtml": "

API возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.

\n

Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.

\n

Тезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.

\n

Где ломается обычная проверка схемы

\n

OpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.

\n

Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

Схема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.

\n

Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.

\n

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

\n
Что доказывает каждая проверка
СлойЧто проверяемЧто означает PASSЧего PASS не означает
Schema matchПоля, типы, enum, обязательность и nullable-границы.Ответ соответствует описанной форме.Consumer может завершить свой пользовательский сценарий.
Semantic expectationМинимальное значение, нужное конкретному consumer.Ответ содержит предусловие выбранного действия.Provider действительно обработал запрос.
Provider verificationInteraction исполняется на provider в названном состоянии.Запущенный provider вернул ожидаемый ответ для этого contract.Проверены все клиенты, методы и варианты данных.
\n

Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.

\n

Как записать смысловой контракт

\n

Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.

\n

Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.

\n
const response = {\n  id: 'sub-42',\n  state: 'active',\n  renewalAt: '2023-10-01T00:00:00Z',\n};\n\nexpect(response.state).toBe('active');\nexpect(response.renewalAt).toMatch(\n  /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/\n);\nconst now = Date.parse('2023-09-01T00:00:00Z');\nexpect(Date.parse(response.renewalAt)).toBeGreaterThan(now);
\n

Код выше — учебная проверка значения. Она не является готовым Pact-тестом: здесь нет consumer client, mock server, contract broker и provider verification. В рабочем тесте assertion должен проходить через реальный код доступа consumer, чтобы contract отражал его запрос и его решение, а не отдельно созданный объект.

\n

Почему нужен provider verification

\n

Consumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.

\n

У provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.

\n

Проверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.

\n
\"Схема
Consumer формулирует потребность, contract фиксирует request и expectation, provider verification исполняет interaction в названном состоянии. Схема учебная: она не является сетевой трассой и не доказывает запуск конкретного сервиса.
\n

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

\n
Карта разбора расхождения consumer и provider
СимптомПричинаПроверкаДействие
Схема зелёная, экран не показывает действие.Смысловое предусловие не записано: допустимый null стал непригодным для consumer.Назвать решение, которое принимает экран, и минимальное значение для него.Добавить semantic expectation для конкретного сценария или изменить общий контракт после согласования владельцев.
Provider verification падает на пустом поле.Provider state не создаёт данные, обещанные interaction.Проверить подготовку состояния, идентификатор записи и фактический response.Исправить state setup или уточнить contract; не добавлять случайный default в assertion.
Consumer-тест проходит, provider verification не запускался.Проверили mock или сохранённый JSON, но не реальный provider.Найти результат verifier, версию contract, версию provider и номер interaction.Запустить проверку на управляемом provider и опубликовать результат рядом с contract.
Один contract прошёл, другой consumer сломался.Общее поле использовалось с разными ожиданиями.Составить список consumer и сравнить их semantic expectations.Разделить endpoint или поле, версионировать изменение либо добавить совместимое новое поле.
Тест падает только на старых данных.Новый смысл поля не поддерживает исторические записи.Проверить варианты данных до миграции и после неё.Добавить миграцию, fallback с явным сроком или запрет выпуска до готовности данных.
\n

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

\n
  1. Зафиксируйте симптом. Запишите endpoint, статус, поля ответа, consumer-сценарий и цену отказа. Формулировка «API несовместим» слишком широка.
  2. Отделите форму от смысла. Проверьте schema match отдельно. Укажите, какие значения схема разрешает и какое из них не подходит выбранному consumer.
  3. Назовите предусловие. Запишите действие пользователя и минимальный response, без которого действие должно исчезнуть или перейти в понятный отрицательный путь.
  4. Определите provider state. Укажите идентификатор данных, состояние зависимостей и способ подготовки. Не ссылайтесь на «обычную тестовую базу» без воспроизводимого описания.
  5. Сформируйте interaction. Включите только нужный request и response, но сохраните все поля, по которым consumer принимает решение. Привяжите contract к версии consumer.
  6. Запустите provider verification. Исполните interaction на конкретной версии provider. Сохраните результат, версию, состояние и номер проверки.
  7. Проверьте отрицательный путь. Ответ с active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.
  8. Примите решение о выпуске. Разрешайте изменение только для перечисленных consumer и проверенных состояний. Для остальных клиентов оставьте совместимое поле, новую версию или план миграции.
\n

Где поставить gate в CI

\n

Consumer-тест и provider verification отвечают на разные вопросы, поэтому их результаты нельзя свести к одному зелёному job. Сначала consumer публикует pact-файл с версией, которая однозначно указывает на сборку. Затем provider проверяет этот pact и публикует результат в Pact Broker. Только после этого release pipeline может спросить, существует ли успешная пара версий в нужном окружении.

\n
export PACT_BROKER_BASE_URL='https://pact-broker.example.test'\nexport APP_VERSION='7f3a1d2'\n\n# До deployment: проверяем Matrix и завершаем job при несовместимости\npact-broker can-i-deploy \\\n  --pacticipant renewal-web \\\n  --version \"$APP_VERSION\" \\\n  --to-environment staging \\\n  --broker-base-url \"$PACT_BROKER_BASE_URL\"\n\n# После успешного deployment: записываем факт в Broker\npact-broker record-deployment \\\n  --pacticipant renewal-web \\\n  --version \"$APP_VERSION\" \\\n  --environment staging
\n

Эти команды воспроизводимы только в проекте с настроенным Pact Broker, credentials, опубликованным pact и provider verification. can-i-deploy запускайте до выкладки, а record-deployment — только после успешной выкладки. Для production подставляйте конкретный commit или другой уникальный номер сборки; запрос «последнюю версию» может дать другой ответ при повторном запуске.

\n

Ограничения

\n

Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.

\n

Provider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.

\n

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

\n

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

\n

Изменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.

\n

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

\n" } diff --git a/editorial/agent-rewrites/163.json b/editorial/agent-rewrites/163.json index 6942994..d507a8a 100644 --- a/editorial/agent-rewrites/163.json +++ b/editorial/agent-rewrites/163.json @@ -1,7 +1,7 @@ { "index": 163, "slug": "editorial-2023-06-field-secrets-supply-chain", - "title": "Когда deploy виден, а происхождение нет: проверяем цепочку поставки", - "excerpt": "Практический разбор разрыва между исходным кодом, сборкой, digest, декларацией и deploy input. Секретная граница, отрицательный путь и критерий, после которого выпуск можно проверять дальше.", - "contentHtml": "

В отчёте CI есть успешная сборка. В registry лежит образ. Deploy указывает на digest. Но команда не может ответить, из какого commit собран этот digest, какой builder его выпустил и какая декларация относится именно к нему. Ошибка проявляется поздно: релиз уже обсуждают, а provenance приходится восстанавливать по разным системам.

\n

Цена разрыва — не только задержка. Команда может принять чужой образ за результат доверенной сборки. Она может отозвать не тот credential, откатить не тот digest или назвать подписанную декларацию доказательством факта, которого она не проверяет. Секрет при этом способен попасть в логи, слой образа, кеш или артефакт CI.

\n

Тезис простой: deploy сам по себе показывает только выбранный вход. Без сопоставления source revision → builder identity → secret boundary → artifact digest → declaration → deploy input нельзя утверждать происхождение результата. Каждый переход требует своего evidence. Отсутствующий переход переводит выпуск в ручной review, а не в подтверждённое provenance.

\n

Механизм разрыва

\n

Цепочка поставки состоит из разных утверждений. Commit отвечает на вопрос о входном коде. Builder и его identity отвечают на вопрос о процессе сборки. Secret boundary показывает, какие данные могли попасть в процесс и куда им запрещено уходить. Digest связывает байты артефакта с конкретным output. Declaration описывает claims о сборке. Deploy input показывает, что именно пытались применить.

\n

Соседнее утверждение не заменяет пропущенное. Имя job не доказывает, что job выполнила сборку. Digest не доказывает commit. Декларация не доказывает, что её subject попал в deploy. Подпись подтверждает целостность подписанного объекта при корректной проверке, но не превращает любой текст в наблюдение среды.

\n

Такой разбор нужен и для секретов. Секрет не должен проходить через Dockerfile, командную строку, переменную, которую печатает shell, или общий кеш. Значение может быть скрыто в логе, но остаться в слое образа. Оно может исчезнуть из образа, но сохраниться в артефакте или history. Поэтому проверяют не только содержимое файла, но и границы процесса.

\n

Минимальный контракт проверки

\n

Начните с одной карточки выпуска. В ней достаточно шести полей: commit, builder, digest, declaration subject, deploy input и граница секрета. Для каждого поля запишите источник, время получения и допустимый способ просмотра. Не копируйте значение секрета. Нужен факт его отсутствия или контролируемого использования, а не само значение.

\n
const release = {\n  sourceRevision: 'abc123',\n  builderIdentity: 'ci.example/build-prod',\n  artifactDigest: 'sha256:...',\n  declarationSubject: 'sha256:...',\n  deployInput: 'sha256:...'\n};\n\nconst sameArtifact =\n  release.artifactDigest === release.declarationSubject &&\n  release.artifactDigest === release.deployInput;\n\nif (!sameArtifact) {\n  throw new Error('manual review: artifact links do not match');\n}\n\n// Учебный пример. Он не проверяет подпись, CI, registry или production.\n// Реальные форматы полей и правила доверия задаёт конкретная среда.\n
\n

Код показывает только одну проверяемую связь: одинаковый digest в артефакте, декларации и deploy input. Он не доказывает, что commit действительно участвовал в сборке. Он не проверяет identity builder, подпись, policy или содержание секрета. Если хотя бы одно поле недоступно, безопасный результат этого примера — ручной review.

\n

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

\n
Как сузить разрыв в цепочке
СимптомПричинаПроверкаДействие
Deploy содержит digest, но нет commitАртефакт отделён от записи сборкиСопоставьте digest с output конкретной jobОстановите вывод о provenance до появления связи
Declaration есть, subject не совпадаетВыбрана декларация другого артефактаСравните subject digest и deploy inputПереведите выпуск в manual review
Builder указан именем jobНет проверяемой identity и доверенной границыНайдите issuer, workflow и policy проверкиНазначьте отдельную проверку builder
Секрет исчез из лога, но попал в image historyЗначение передали в Dockerfile или командной строкеПроверьте слои, history, cache и exportУдалите секрет из build input и смените credential
Есть digest и подпись, но нет deploy linkПодписали объект, не проверив его использованиеСверьте точный digest с manifest deployНе называйте подпись доказательством release
\n

Секретная граница начинается до сборки

\n

Сборка должна получать секрет только там, где он нужен, и только на время операции. Не задавайте его через ARG, если значение может попасть в историю слоёв. Не выводите окружение командой вроде env в диагностический лог. Не сохраняйте рабочий каталог с credential в артефакт CI. Не передавайте секрет в шаг, который собирает публичный output.

\n

Учебный фрагмент ниже показывает безопасную мысль, а не готовую конфигурацию конкретного CI:

\n
# Учебный пример: имя секрета передаётся в действие, значение не печатается.\n# Фактический синтаксис зависит от CI и secret store.\nrun: ./publish.sh\nenv:\n  REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}\n\n# publish.sh не выполняет: set -x, env, printenv, cat /proc/*/environ\n# и не складывает каталог с credential в артефакты.
\n

Этот пример не доказывает отсутствие утечки. Его нужно дополнить проверкой логов, временных файлов, кеша, image history и прав доступа. Если токен уже появился в публичном output или общем registry, удаление строки из конфигурации не закрывает инцидент. Сначала ограничьте доступ и смените credential по процедуре владельца.

\n

Digest связывает байты, но не всю историю

\n

Digest полезен потому, что связывает имя output с конкретным содержимым. Поэтому release должен хранить полный digest, а не только tag вроде latest. Tag может указывать на другой объект после публикации. Но digest остаётся только якорем. Он не сообщает, кто собрал образ, с каким исходным кодом и какие входы получил builder.

\n

Проверяйте цепочку в прямом порядке, даже если проблема обнаружилась на deploy. Найдите commit. Найдите запись builder. Получите digest output. Сверьте subject декларации. Сверьте manifest deploy. После этого отдельно проверьте, какие данные видел процесс сборки и где они могли сохраниться. Такой порядок не позволяет начать с красивой декларации и подогнать под неё остальные факты.

\n
\"Дерево
Схема показывает порядок сопоставления. Это учебная иллюстрация процедуры, а не CI-отчёт, policy или доказательство конкретного выпуска.
\n

Отрицательный путь

\n

Проверка должна явно описывать отказ. Если declaration subject отличается от deploy digest, не выбирайте ближайший digest по времени. Если builder не имеет проверяемой identity, не принимайте название workflow за identity. Если secret попал в слой, не ограничивайтесь удалением тега: образ и связанные кеши уже требуют отдельной обработки.

\n

Неудача проверки не всегда означает компрометацию. Она означает, что текущих данных недостаточно для заявленного вывода. Это важное различие. Статус not-verified честнее, чем pass, построенный на совпадении имён. Дальнейшее действие выбирают по риску: остановка выпуска, получение evidence, смена credential или rollback к известному digest.

\n

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

\n
  1. Зафиксируйте симптом. Запишите, какая связь отсутствует: commit → build, build → digest, digest → declaration или declaration → deploy.
  2. Определите спорный output. Используйте полный digest и точный manifest. Не заменяйте их tag или названием job.
  3. Проверьте источник. Сопоставьте commit, workflow, builder identity и время выполнения с одной записью сборки.
  4. Проверьте subject. Сравните digest декларации с digest артефакта и deploy input посимвольно.
  5. Проверьте secret boundary. Ищите значение и его следы в логах, слоях, history, cache, временных файлах и exported artifacts.
  6. Проверьте отрицательный путь. Подставьте другой digest, пропустите declaration или отзовите доступ к secret store. Проверка должна остановиться, а не выбрать ближайший объект.
  7. Выберите действие. При отсутствии связи остановите следующий шаг. При подтверждённой утечке ограничьте доступ и смените credential. Rollback выполняйте только к известному и совместимому кандидату.
  8. Запишите критерий. Укажите, какой новый evidence переводит статус из ручного review в проверенный результат.
\n

Ограничения

\n

Provenance не заменяет сканирование уязвимостей, контроль доступа, защиту registry и проверку содержания артефакта. Подпись не заменяет проверку subject и trusted identity. Digest не гарантирует безопасный исходный код. Secret store не защищает от вывода значения в лог, если build step печатает окружение.

\n

Учебные примеры в статье не выполняют криптографическую проверку, не обращаются к CI, registry или production и не дают production-результатов. Формат declaration, issuer, policy и процедура отзыва зависят от ваших инструментов. Не объявляйте соответствие SLSA или SSDF по одному найденному полю. Сначала проверьте применимый профиль и границы заявленного уровня.

\n

Rollback тоже имеет границу. Он может вернуть известный deploy input, но не удаляет уже скачанный образ, не отзывает credential и не исправляет запись в чужом кеше. Эти действия требуют отдельной операционной процедуры и владельцев. Если известного кандидата нет, безопаснее остановить выпуск и сохранить минимальное evidence.

\n

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

\n

Проверка готова, если команда показывает одну карточку выпуска и отвечает на пять вопросов: какой commit вошёл в сборку, какой builder выполнил её, какой digest получен, какой declaration subject с ним совпадает и какой digest указан в deploy. Дополнительно команда показывает, где проверена секретная граница и какой отрицательный сценарий остановил выпуск.

\n

Критерий не требует утверждать больше, чем доказано. Если любой ответ опирается на имя, tag, текст в ticket или декларацию без независимого сопоставления, статус остаётся not-verified. Если все связи проверены допустимыми evidence, отрицательный путь блокирует несоответствие, а план обработки секрета известен, следующий шаг можно принимать в рамках policy конкретной системы.

\n

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

\n" + "title": "Токен в образе: как проверить цепочку поставки до deploy", + "excerpt": "Разбираем случай, когда токен тестовой среды оказался в контейнерном образе: как найти след, заменить небезопасный способ сборки, сопоставить digest с provenance и не объявить релиз проверенным без доказательств.", + "contentHtml": "

В исходном случае токен тестовой среды случайно сохранился в контейнерном образе. Такой дефект заметен не всегда: сборка проходит, registry принимает image, а deploy запускает именно тот digest, который ожидала команда. Проблема обнаруживается позже, когда нужно ответить на два разных вопроса: где оказался секрет и из какого commit получился запущенный образ.

\n

Эти вопросы нельзя закрыть одной проверкой. Строка в Dockerfile может оставить секрет в слое, но отсутствие строки в Dockerfile не доказывает отсутствие значения в кеше, логе или артефакте CI. Тег образа показывает удобное имя, но не фиксирует его содержимое. Подписанная декларация подтверждает подписанный объект только после проверки доверия и subject, а не сам факт deploy.

\n

Практический критерий такой: выпуск можно считать проверенным только после сопоставления source revision, build platform, digest образа, subject attestation и deploy input. Если одно звено недоступно, статус должен остаться not-verified, а следующий шаг — ручной проверкой или остановкой выпуска.

\n

Симптом и границы расследования

\n

Начните с точного образа, который был выбран для deploy. Запишите полное имя registry, тег, digest, идентификатор запуска сборки и окружение. Не скачивайте неизвестный образ на рабочую машину без разрешения владельца registry: для расследования лучше использовать изолированный runner или копию с ограниченным доступом.

\n

Затем разделите наблюдения и выводы. Запись deploy доказывает, какое значение передали оркестратору. Она не доказывает commit и не объясняет, кто собрал image. Запись CI показывает запуск job, но её имя не является удостоверением build platform. Provenance (аттестация происхождения) описывает claims о сборке, однако доверие к ней требует проверки подписи, идентичности builder и digest subject.

\n
Что именно доказывает каждое свидетельство
EvidenceЧто подтверждаетЧего не подтверждает
Deploy manifestКакой input передали на развёртываниеКак получен image и где хранился секрет
Registry digestКонкретное содержимое image objectCommit, builder и безопасность содержимого
Build log и run IDФакт запуска конкретной jobЧто все шаги выполнила доверенная платформа
Provenance attestationЗаявленные builder, параметры и subjectИстину claims без проверки доверия
Image history и логиВозможные следы команд и выводаОтсутствие секрета во всех кешах и артефактах
\n

Как секрет попадает в образ

\n

Самый опасный путь — передать credential как ARG, записать его через ENV или подставить в команду RUN. Значение может оказаться в истории инструкций или в слое, который позже попадёт в registry. Даже если финальный файл удалить, предыдущий слой не исчезает автоматически.

\n

Небезопасный фрагмент выглядит так:

\n
ARG REGISTRY_TOKEN\nRUN curl -H \"Authorization: Bearer $REGISTRY_TOKEN\" \\\n    https://registry.example.invalid/private.tar.gz \\\n    -o /tmp/private.tar.gz\nRUN rm /tmp/private.tar.gz
\n

Это не доказательство того, что конкретный token уже утёк, а пример механизма риска. Проверять нужно image history, логи CI, экспортированные артефакты, кеш сборки и права доступа к registry. Если credential действительно был доступен посторонним, удаление строки из Dockerfile не отзывает его: сначала ограничьте доступ, затем замените секрет по процедуре владельца.

\n

Безопасное воспроизведение

\n

Для локальной проверки используйте заведомо фиктивную строку. BuildKit предоставляет секрет только инструкции сборки и не добавляет его автоматически в финальный слой. Важно, чтобы сама команда не печатала окружение и не копировала каталог с mounted secret в output.

\n
# Dockerfile: секрет читается только внутри одной RUN-инструкции\n# syntax=docker/dockerfile:1\nFROM alpine:3.20\nRUN --mount=type=secret,id=demo_token \\\n    test \"$(cat /run/secrets/demo_token)\" = \"dummy-for-local-check\"\nCMD [\"sh\", \"-c\", \"echo image-ok\"]\n\n# Запуск из каталога с этим Dockerfile; значение намеренно фиктивное\nDEMO_TOKEN=dummy-for-local-check \\\n  docker buildx build --progress=plain \\\n  --secret id=demo_token,env=DEMO_TOKEN \\\n  --tag supply-chain-demo:secret-mount --load .\n\n# После сборки: проверить историю, но не считать её полным аудитом\ndocker image history --no-trunc supply-chain-demo:secret-mount
\n

Ожидаемая проверка — образ собирается, контейнер не содержит файл /run/secrets/demo_token, а в истории нет фиктивного значения. Этот пример воспроизводит границу secret mount, но не проверяет ваш CI, registry или production image. В CI синтаксис передачи секрета зависит от платформы; принцип остаётся тем же: минимум прав, короткое время доступа и отсутствие значения в выводе.

\n

Digest связывает артефакт с deploy

\n

Тег вроде release или latest — это изменяемое имя. Для расследования нужен digest, то есть контентный идентификатор вида sha256:.... Он позволяет сравнить один и тот же image object в registry и deploy, но не рассказывает его историю.

\n
IMAGE=registry.example.invalid/team/app:release\n\n# Получить digest и метаданные из доступной копии image\ndocker image inspect \"$IMAGE\" \\\n  --format '{{json .RepoDigests}}'\ndocker image history --no-trunc \"$IMAGE\"\n\n# Для deploy используйте тот же digest, а не только тег\n# registry.example.invalid/team/app@sha256:<полный-digest>
\n

Сравните значение в deploy manifest посимвольно с digest, который вы получили для того же registry и платформы. Для multi-platform image уточните, сравниваете ли вы digest manifest list или digest конкретного platform image. Смешение этих уровней создаёт ложное несовпадение или, хуже, проверяет не тот объект.

\n
\"Дерево
Порядок проверки не превращает deploy-событие в provenance: сначала нужен общий digest, затем доверенная аттестация и отдельная проверка secret boundary.
\n

Как сопоставить provenance

\n

В модели SLSA attestation описывает, что build platform произвела subject через заданное определение сборки. Для практической проверки нужны как минимум четыре поля: digest subject, идентификатор builder, внешние параметры сборки и зафиксированные зависимости. Commit должен быть представлен в подходящем поле или зависимости именно той аттестации, которую вы проверяете.

\n

Сначала проверьте подпись и корень доверия. Затем убедитесь, что subject совпадает с digest образа из deploy. После этого сравните builder identity и commit с ожидаемыми значениями. Нельзя менять порядок на «нашли удобную декларацию и подогнали под неё deploy»: декларация другого digest может быть корректной сама по себе и бесполезной для текущего выпуска.

\n
# Общий шаблон Sigstore; укажите policy вашей организации\ncosign verify-attestation \\\n  --certificate-oidc-issuer https://issuer.example.invalid \\\n  --certificate-identity-regexp 'https://ci.example.invalid/.*' \\\n  oci://ghcr.io/ORG/IMAGE:release-123 \\\n  -R ORG/REPO\n\n# Если attestation найдена, отдельно сверить её subject с тем же digest.\n# Команда сама по себе не доказывает соответствие вашему release policy.
\n

Точные флаги зависят от способа подписи: key-based, keyless, корпоративный root или другой trust policy. Если verification не может проверить identity или subject, результат — не «сборка вредоносна», а «данных недостаточно для заявленного вывода». Это важная граница: отрицательный audit и доказанная компрометация — разные события.

\n

Отрицательный путь должен останавливать выпуск

\n

Хорошая проверка полезна именно в момент отказа. Подставьте в тестовый manifest другой digest и убедитесь, что policy отклоняет его. Возьмите attestation от другого образа и проверьте, что subject не принимается. Запустите сборку без доступного secret store: она должна завершиться контролируемой ошибкой, а не тихо собрать публичный output без нужного шага.

\n

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

\n
  1. Зафиксируйте симптом. Сохраните run ID, полное имя образа, deploy manifest и время, не копируя значение секрета в ticket.
  2. Изолируйте копию. Работайте с образом и логами в окружении с ограниченным доступом; не публикуйте подозрительный tarball как артефакт.
  3. Найдите след. Проверьте Dockerfile, build args, environment, команды RUN, history, кеши, логи и exported artifacts.
  4. Заморозьте спорный выпуск. Пока digest, subject или builder identity не совпадают, не продвигайте образ дальше.
  5. Замените credential. При подтверждённой утечке отзовите старый токен и проверьте, где ещё использовалась его копия.
  6. Исправьте сборку. Перенесите секрет в механизм secret mount или аналог конкретного CI и запретите печать окружения.
  7. Повторите проверку. Сравните новый digest с deploy input, provenance и expected commit, затем прогоните отрицательные сценарии.
\n

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

\n

Расследование можно закрывать, когда одна запись выпуска отвечает на пять вопросов: какой commit вошёл в build, какая доверенная platform выполнила его, какой digest получен, какой subject attestation совпадает с этим digest и какой digest использовал deploy. Отдельно должна быть запись о границе секрета: где он был разрешён, какие места проверены и какой credential остался действующим.

\n

Этот критерий не означает, что image безопасен во всех смыслах. Он лишь делает происхождение и секретный риск проверяемыми. Сканирование уязвимостей, лицензий, зависимостей, прав registry и runtime policy остаётся отдельными контролями. Если не хватает одного поля, честный результат — manual review required, а не зелёная галочка по совпадению тега.

\n

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

\n

Пример рассчитан на Linux-контейнер и BuildKit с поддержкой secret mounts. Legacy builder, Windows-контейнеры, другой CI или multi-platform registry могут иметь иной синтаксис и другую семантику кеша. Команды с доменом example.invalid — шаблон: они не обращаются к реальному registry и не дают production-результата.

\n

Image history — полезный источник следов, но не доказательство чистоты: значение могло попасть в кеш, артефакт, рабочий каталог runner или внешний сервис. Provenance — утверждение, которое нужно проверять с выбранными корнями доверия и policy. Подпись гарантирует целостность подписанного объекта в рамках модели доверия, но не безопасность исходного кода и не факт его deploy.

\n

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

" } diff --git a/editorial/agent-rewrites/164.json b/editorial/agent-rewrites/164.json index 84e452f..89786e7 100644 --- a/editorial/agent-rewrites/164.json +++ b/editorial/agent-rewrites/164.json @@ -1,7 +1 @@ -{ - "index": 164, - "slug": "editorial-2023-06-mechanism-secrets-supply-chain", - "title": "Attestation не доказывает provenance: как связать секрет, сборку и артефакт", - "excerpt": "Файл attestation рядом с образом ещё не подтверждает его происхождение. Разбираем границы секрета, digest, builder identity и проверку, которая связывает provenance с тем, что действительно попадает в deploy.", - "contentHtml": "

После выкладки в системе лежит образ и файл attestation. Команда открывает файл, видит source revision и builder, затем помечает релиз как проверенный. Позже выясняется, что statement ссылается на другой digest, identity сборщика никто не проверял, а deploy получил образ по тегу latest. Ошибка стоит дорого: нельзя уверенно определить затронутый артефакт, выбрать безопасный rollback и объяснить аудитору, какой факт подтверждён.

\n

Проблема усиливается, когда в ту же декларацию добавляют сведения о секрете. Доступ CI к секрету не доказывает, что значение не попало в слой образа, cache, metadata или журнал. Provenance отвечает за происхождение output. Secret boundary отвечает за путь доступа к чувствительному значению. Это разные утверждения с разными проверками.

\n

Тезис статьи простой: attestation становится полезным evidence только после независимого сопоставления subject с artifact digest, проверки доверенной identity и связи digest с входом deploy. Наличие файла, подписи или знакомого названия инструмента не заменяет эти операции.

\n

Механизм цепочки

\n

Разложите delivery-поток на отдельные факты. Source revision обозначает вход сборки. Builder identity обозначает исполнителя, которому разрешено выпускать результат. Secret boundary задаёт этап, который может получить ссылку или значение, и этапы, которым оно недоступно. Artifact digest обозначает конкретный набор байтов. Attestation statement заявляет свойства этого output. Verification проверяет statement с заданными правилами доверия. Deploy input показывает, что именно система пыталась запустить.

\n

Нельзя вывести один факт из соседнего. Digest не рассказывает, кто собрал образ. Source revision не доказывает, что именно он попал в output. Подписанная attestation не подтверждает provenance, пока проверка не установила доверенную identity, допустимый формат, claims и тот же subject. Тег образа тоже не заменяет digest: тег может указывать на новый результат.

\n
Уровень утверждения и следующий способ проверки
УровеньЧто можно записатьЧего это не доказываетСледующая проверка
SourceВыбрана ревизия abc123.Сборка использовала именно её.Сопоставить revision с invocation build.
BuilderУказана identity CI.Identity доверена и реально запускала build.Проверить identity и контекст запуска.
ArtifactИзвестен digest образа.Этот digest отправили в deploy.Сверить digest с release input.
AttestationStatement содержит subject.Statement подписан и правдив.Проверить подпись, signer и claims.
Secret boundaryСборка получает секрет на названном этапе.Значение не попало в output.Проверить конкретный путь передачи и места хранения.
\n

Почему секрет нельзя смешивать с provenance

\n

Секрет должен жить внутри ограниченной границы. Например, job получает короткоживущий токен через secret manager, использует его для чтения зависимости и не записывает значение в environment, артефакт сборки или лог. Даже такая схема описывает только ожидаемый путь. Она не доказывает отсутствие утечки без проверки конкретного pipeline и его output.

\n

Особенно опасны аргументы командной строки, переменные, которые CI печатает при ошибке, кеши package manager и Docker layers. Секрет может исчезнуть из финального файла, но остаться в промежуточном слое. Поэтому вопрос «секрет есть в образе?» слишком широк. Сначала назовите образ, digest, слой или metadata и способ проверки. Если evidence нет, статус должен быть not-observed, а не «утечки нет».

\n
\"Схема
Учебная схема разделяет declaration и verification. Она не является журналом CI, результатом подписи или доказательством отсутствия секрета в образе.
\n

Минимальный пример сопоставления

\n

Ниже учебный пример. Он работает только с заранее заданными строками, не читает CI, registry или secret manager и не выполняет deploy. Его задача — показать отрицательный путь: statement про другой subject нельзя принять.

\n
const artifact = {\n  digest: 'sha256:artifact-a',\n  deployInput: 'sha256:artifact-a',\n};\n\nconst statement = {\n  subjectDigest: 'sha256:artifact-b',\n  builder: 'ci.example/build',\n};\n\nconst sameArtifact =\n  artifact.digest === statement.subjectDigest &&\n  artifact.digest === artifact.deployInput;\n\nif (!sameArtifact) {\n  throw new Error('manual review: subject is not the deploy artifact');\n}
\n

Проверка выше не устанавливает, что builder доверенный, подпись действительна или сборка использовала указанную ревизию. Она ловит только несоответствие subject и deploy input. В рабочей системе нужен проверяемый формат attestation, доверенная политика identity, источник digest и результат запуска verifier. Если хотя бы одно звено не наблюдалось, не повышайте статус до verified.

\n

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

\n
Карта диагностики разрыва в цепочке поставки
СимптомПричинаПроверкаДействие
Attestation есть, но deploy использует тег.Релиз не сохранил digest как вход.Найти фактический digest, переданный в deploy.Остановить вывод о provenance и привязать release к digest.
Subject statement не равен digest образа.Statement собран для другого output или перепутан.Сравнить subject, digest registry и deploy input.Не использовать statement; запросить корректное evidence.
Builder указан, но signer не проверен.Identity смешали с результатом verification.Проверить signer, trust policy и контекст запуска.Оставить статус not-verified до отдельной проверки.
Секрет доступен build, но нет сведений о слоях.Граница доступа описана общо.Проверить command line, logs, cache и layers выбранного output.Сузить исследование до одного digest и не публиковать значение секрета.
Локальная декларация выглядит полной.Текст приняли за наблюдаемый факт среды.Для каждого поля найти источник и время проверки.Отделить declaration от evidence и назначить владельца проверки.
Нужен срочный rollback.Неизвестно, какой output был применён.Связать release record с digest и известным кандидатом.Сначала остановить следующий шаг; rollback выполнять только при известном безопасном кандидате.
\n

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

\n
  1. Зафиксируйте симптом. Запишите конкретный разрыв: тег вместо digest, другой subject, непроверенная identity или неизвестный путь секрета.
  2. Назовите предмет проверки. Укажите source revision, builder, artifact digest и deploy input. Не копируйте в задачу секреты, токены и полные журналы.
  3. Сверьте артефакт. Сравните digest из registry, statement и release record. Любое расхождение переводит решение в ручной review.
  4. Проверьте attestation. Установите формат, subject, signer, trust policy и обязательные claims. Отметьте отдельно, что действительно проверил verifier.
  5. Проверьте границу секрета. Назовите job, этап, разрешённый способ доступа и места, где значение могло сохраниться. Не заменяйте проверку списком общих запретов.
  6. Проверьте отрицательный путь. Для другого subject, неизвестной identity или не подтверждённой secret boundary должно быть понятное действие: остановка, ручной review или безопасный отказ.
  7. Свяжите решение с deploy. Разрешайте выпуск только для digest, который прошёл требуемые проверки и совпадает с фактическим входом релиза.
  8. Сохраните минимальное evidence. Зафиксируйте версии, идентификаторы, время, результат проверки и владельца. Чувствительные значения оставьте в контролируемом хранилище.
\n

Ограничения

\n

Provenance не доказывает отсутствие уязвимостей, добросовестность исходного кода или безопасность всех зависимостей. Она описывает происхождение и условия получения output в пределах выбранной модели. Если builder записывает неверные сведения, downstream-проверка должна учитывать доверие к builder и его identity.

\n

Attestation не заменяет сканирование, review зависимостей, контроль доступа, ротацию секретов, тесты и наблюдение после выпуска. Подпись подтверждает целостность statement относительно ключа или identity. Она не делает claims истинными сама по себе. Digest связывает байты, но не объясняет, почему эти байты допустимы.

\n

Учебный код и таблица не запускались против production и не сообщают результат конкретного pipeline. Источники ниже дают официальные модели и спецификации, но не доказывают соответствие вашего проекта. При отсутствии реального verifier корректная формулировка — «не проверено».

\n

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

\n

Цепочка готова к решению о выпуске, когда source revision, builder identity, artifact digest и deploy input связаны конкретными записями; subject attestation совпадает с digest; signer и claims проверены по названной trust policy; путь секрета ограничен и проверен для выбранного output; отрицательные случаи переводят решение в ручной review. Если есть только файл attestation, зелёный CI или тег образа, доказательство не завершено.

\n

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

\n" -} +{"index":164,"slug":"editorial-2023-06-mechanism-secrets-supply-chain","title":"Attestation не доказывает provenance: как связать секрет, сборку и артефакт","excerpt":"Файл attestation рядом с образом ещё не подтверждает его происхождение. Разбираем границы секрета, digest, builder identity и проверку, которая связывает provenance с тем, что действительно попадает в deploy.","contentHtml":"

Симптом появляется после сборки: в registry лежат образ и attestation. В ней видны revision и builder, поэтому релиз помечают как проверенный. Но statement может ссылаться на другой digest, identity сборщика может не входить в доверенную политику, а deploy может получить образ по тегу latest. В этом случае команда не знает, какой именно набор байтов запущен, и не может уверенно выбрать rollback.

\n

Секрет добавляет ещё один разрыв. Токен, доступный CI, не становится безопасным только потому, что его нет в финальном файле: он мог попасть в командную строку, лог, cache или промежуточный слой. Provenance отвечает на вопрос «как получен output», а secret boundary — «где чувствительное значение могло быть доступно». Это разные утверждения и разные проверки.

\n

Практический критерий такой: attestation — это evidence только после проверки подписи и доверенной identity, сопоставления subject с digest образа, проверки ожидаемых claims и связи того же digest с входом deploy. Если одного звена нет, корректный статус — «не проверено», а не «безопасно».

\n

Сначала разделим факты цепочки

\n

В цепочке поставки полезно хранить не один большой флаг, а несколько наблюдаемых полей. Source revision — неизменяемая ревизия исходного кода. Builder identity — идентификатор платформы или workflow, которому доверяют выпуск результата. Artifact digest — криптографический отпечаток конкретных байтов. Attestation statement — подписанное утверждение о subject и свойствах сборки. Deploy input — digest, который фактически передал релизный механизм.

\n

Секретная граница описывается отдельно: какая job получает ссылку или значение, на каком шаге, в каком виде и где оно может оказаться после шага. Не следует выводить один факт из соседнего. Digest не говорит, кто собрал образ. Revision не доказывает, что сборщик использовал её. Наличие подписи не доказывает, что signer разрешён именно для этого репозитория.

\n
Что утверждает поле и чем его проверять
ФактДопустимое утверждениеЧто ещё не доказаноПроверка
SourceВходом названа ревизия abc123.Эта ревизия действительно попала в output.Сверить source в build invocation и provenance.
BuilderВ statement указан builder.id.Identity входит в доверенный список.Сопоставить signer и builder с trust policy.
ArtifactИзвестен digest образа.Именно он ушёл в deploy.Прочитать immutable release input.
AttestationЕсть statement с subject.Подпись, claims и формат проверены.Запустить verifier с заданными ожиданиями.
Secret boundaryШаг получает секрет через временный mount.Значение не сохранилось в output и журналах.Проверить Dockerfile, логи, cache и слои выбранного digest.
\n
\"Схема
Схема показывает границу между данными декларации и результатом verification. Это учебная иллюстрация, а не журнал CI и не доказательство для конкретного образа.
\n

Почему subject и digest должны совпасть

\n

В attestation subject идентифицирует artifact, к которому относится statement. Для контейнерного образа таким идентификатором обычно служит digest манифеста, а не подвижный тег. Тег удобен человеку, но может быть перепривязан к другой версии. Поэтому release record должен сохранять запись вроде registry.example/api@sha256:... и передавать в deploy именно её.

\n

Расхождение subject и deploy input — достаточная причина остановить автоматический выпуск. Даже если revision и builder выглядят знакомо, statement про образ B ничего не доказывает для образа A. Сверка должна быть буквальной: алгоритм и полное значение digest должны совпадать после нормализации формата, принятой вашим registry.

\n

Следующий уровень — ожидания к provenance. Помимо подписи и builder.id, проверяют канонический репозиторий, buildType и внешние параметры сборки. Если verifier принимает неизвестные параметры молча, атакующий или ошибочная конфигурация могут изменить результат при сохранении внешне правдоподобного statement.

\n

Секретная граница проходит через весь build

\n

Секрет — не только значение в переменной окружения. Это ещё аргументы процесса, файлы в рабочем каталоге, вывод команды, cache и слои образа. Docker прямо предупреждает, что build arguments и environment variables сохраняются в финальном образе, и предлагает secret mounts или SSH mounts для временного доступа.

\n

Безопаснее ограничить секрет одним шагом BuildKit. Например, Dockerfile может прочитать credential из стандартного пути mount и использовать его для получения зависимости:

\n
# syntax=docker/dockerfile:1\nFROM alpine:3.20\nRUN --mount=type=secret,id=private_token,target=/run/secrets/private_token     wget --header=\"Authorization: Bearer $(cat /run/secrets/private_token)\"       -O /tmp/dependency.tar.gz https://packages.example.invalid/dependency.tar.gz\nRUN tar -tf /tmp/dependency.tar.gz > /dev/null && rm /tmp/dependency.tar.gz
\n

Команда сборки передаёт секрет через клиент Docker, а не через ARG:

\n
DOCKER_BUILDKIT=1 docker build   --secret id=private_token,env=PRIVATE_TOKEN   -t registry.example/api:build-42 .
\n

Это пример границы доступа, а не сертификат отсутствия утечки. Если команда записала token в другой файл, shell включил трассировку или зависимость вывела заголовок в stdout, mount сам по себе не исправит проблему. После сборки нужно проверять именно выбранный digest и места, где он мог оставить данные.

\n

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

\n

Сначала полезно проверить отрицательный путь на локальной фикстуре. Фрагмент ниже не имитирует криптографическую подпись и не обращается к registry. Он проверяет только две связи: statement относится к тому же digest, который попал в release record, и builder разрешён политикой. Запустите его как есть командой node --input-type=module:

\n
node --input-type=module <<'NODE'\nconst release = {\n  deployInput: 'sha256:artifact-a',\n  expectedBuilder: 'https://ci.example/builders/trusted',\n};\n\nconst statement = {\n  subjectDigest: 'sha256:artifact-b',\n  builderId: 'https://ci.example/builders/trusted',\n};\n\nconst subjectMatches = statement.subjectDigest === release.deployInput;\nconst builderMatches = statement.builderId === release.expectedBuilder;\n\nif (!subjectMatches || !builderMatches) {\n  throw new Error('manual review: provenance does not match release input');\n}\n\nconsole.log('fixture passed');\nNODE
\n

Ожидаемый результат — ошибка о ручной проверке: subject намеренно указывает на artifact-b. Если заменить его на sha256:artifact-a, фикстура напечатает fixture passed. Это воспроизводит контракт сравнения, но не подменяет verifier: здесь нет проверки envelope, сертификата, transparency log, source revision и реального deploy.

\n

Как проверять реальный образ

\n

Для Cosign официальный сценарий начинается с образа, к которому attestation уже прикреплена. Команда ниже проверяет attestation с публичным ключом; значения URI и файла — проектные, поэтому их нужно заменить на доверенные для вашей системы:

\n
IMAGE=registry.example.com/api@sha256:REPLACE_WITH_RELEASE_DIGEST\ncosign verify-attestation   --key cosign.pub   \"$IMAGE\"
\n

Успешный exit code означает, что выбранный режим Cosign проверил подпись по указанному ключу и нашёл attestation для этого образа. Он не отвечает за вашу бизнес-политику автоматически. В policy нужно дополнительно зафиксировать допустимый builder.id, репозиторий, buildType, revision и способ получения deploy input. При keyless-проверке вместо файла ключа задают ожидаемые certificate identity и OIDC issuer; их нельзя оставлять широкими регулярными выражениями без причины.

\n

Практический evidence-пакет не должен содержать секреты. Достаточно сохранить digest, идентификатор statement, signer или certificate identity, результат verifier, параметры policy, время и ссылку на release record. Само наличие вывода в терминале не заменяет хранения результата там, где его сможет проверить следующий участник.

\n

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

\n
Диагностика разрыва между сборкой и выпуском
СимптомВероятная причинаТочная проверкаРешение
Attestation есть, deploy использует тег.Release не зафиксировал immutable input.Прочитать digest из фактической конфигурации deploy.Остановить автоматический выпуск и связать release с digest.
Subject не равен digest registry.Statement относится к другому output или перепутан.Сравнить subject, manifest digest и deploy input.Отклонить statement и запросить новое evidence.
Builder указан, signer не проверен.Identity приняли за доверие.Проверить signer, цепочку сертификатов и trust policy.Оставить статус not-verified.
Секрет был доступен build, слои не исследованы.Границу доступа описали, но не проверили output.Проверить Dockerfile, history, cache, logs и выбранные слои.Не публиковать значение; при сомнении ротировать секрет.
Известна revision, но нет записи invocation.Источник назван задним числом.Найти provenance с входами конкретной сборки.Не утверждать, что output собран из этой revision.
Нужен rollback, а digest неизвестен.Релиз хранит только тег.Сверить registry history, release record и runtime image ID.Сначала установить безопасный кандидат, затем откатывать.
\n

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

\n
  1. Зафиксируйте симптом. Запишите, что именно не сходится: тег, subject, builder, signer, revision или путь секрета.
  2. Назовите объект. Укажите repository, полный artifact digest, release ID и источник deploy input. Секреты и токены в evidence не копируйте.
  3. Проверьте subject. Сопоставьте digest statement с digest manifest и тем, что передаёт релизный механизм.
  4. Проверьте доверие. Убедитесь, что подпись валидна, signer разрешён, а builder.id и certificate identity соответствуют policy.
  5. Проверьте claims. Сравните канонический репозиторий, revision, buildType и внешние параметры с ожидаемыми значениями.
  6. Проверьте секретную границу. Назовите job и шаг, затем исследуйте stdout, аргументы, рабочие файлы, cache и слои именно этого output.
  7. Проверьте отказ. Для другого subject, неизвестного builder или отсутствующей записи deploy должен сработать останов или ручной review, а не «best effort».
  8. Сохраните минимальное evidence. Зафиксируйте digest, revision, identity, verifier, policy version, время и итог. Чувствительные значения оставьте в secret manager.
\n

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

\n

Provenance описывает происхождение output в пределах модели и доверия к builder. Она не доказывает отсутствие уязвимостей, доброкачественность исходного кода, безопасность зависимостей или отсутствие вредоносного инсайдера на самой build-платформе. SLSA отдельно указывает, что доверие к build platform остаётся частью модели verifier.

\n

Подпись защищает целостность statement относительно ключа или identity. Она не делает claims истинными без проверки контекста. Digest связывает конкретные байты, но не отвечает, разрешено ли запускать их в production. Secret mount уменьшает риск сохранения значения в слое, но не защищает от утечки через команду, зависимость, лог или неправильную очистку.

\n

Учебные команды используют фиктивные URI и digest. Первая команда запускается локально, вторая потребует установленного Cosign, доступного registry, реального attestation и доверенного ключа или keyless-политики. Они не проверяют конкретный production pipeline и не дают права объявлять его безопасным без чтения фактических записей.

\n

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

\n

Решение о deploy можно принимать, когда одна цепочка записей связывает revision, builder identity, statement subject, artifact digest и deploy input; verifier подтвердил подпись; policy одобрила builder, repository, buildType и claims; а secret boundary проверена для выбранного output. Любое расхождение переводит релиз в ручной review с понятным владельцем следующего действия.

\n

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

\n"} diff --git a/editorial/agent-rewrites/165.json b/editorial/agent-rewrites/165.json index d1358a3..20c8e25 100644 --- a/editorial/agent-rewrites/165.json +++ b/editorial/agent-rewrites/165.json @@ -1,7 +1,7 @@ { "index": 165, "slug": "editorial-2023-06-practice-secrets-supply-chain", - "title": "Секрет в CI и digest образа: как связать звенья цепочки поставки", - "excerpt": "Секрет не должен становиться частью образа, а deploy должен ссылаться на тот же digest, который прошёл проверки. Разбираем границы, evidence и отрицательный путь.", - "contentHtml": "

В релизе есть запись о deploy, но никто не может быстро ответить на четыре вопроса: из какой ревизии собрали образ, какой процесс его собрал, использовал ли build секрет и какой digest действительно запустили. Симптом часто выглядит безобидно: pipeline зелёный, сервис работает, а расследование останавливается на фразе «образ собрал CI». Цена ошибки появляется позже. Если токен попал в слой образа или deploy взял соседний тег, команда не может надёжно определить затронутый артефакт, отозвать доступ и объяснить происхождение выпуска.

\n

Тезис простой: цепочку поставки нужно проверять как связь фактов, а не как набор названий инструментов. Ревизия исходников, идентичность сборщика, граница секрета, digest образа, утверждение о происхождении и вход deploy должны иметь общий ключ и понятного владельца проверки. Декларация «образ подписан» не заменяет сопоставление subject с digest. Наличие переменной `TOKEN` в CI не доказывает, что её значение не попало в лог или слой образа.

\n

Механизм: секрет проходит этап, но не должен проходить в результат

\n

Секрет нужен build только на коротком шаге: например, чтобы скачать закрытую зависимость. Процесс должен получить ссылку на секрет, использовать её внутри команды и не записать значение в переменную окружения, слой, кэш или stdout. Образ после этого содержит приложение и публичные настройки, но не credential. В Docker BuildKit для такой границы используют secret mount. Build argument и обычная переменная окружения для этого не подходят: они могут сохраниться в истории сборки или финальном образе.

\n
# syntax=docker/dockerfile:1\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\n\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc \\\n    npm ci --ignore-scripts\n\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html
\n

Команда запуска должна передать секрет отдельно от контекста сборки. В учебном примере ниже имя файла условное. Не подставляйте настоящий токен в статью, shell history или CI log.

\n
DOCKER_BUILDKIT=1 docker build \\\n  --secret id=npmrc,src=/path/to/temporary/npmrc \\\n  --tag example/app:build-123 .\n\n# После сборки получить digest из registry и сохранить его\n# как значение, с которым сравниваются attestation и deploy.
\n

Этот код показывает границу передачи. Он не доказывает, что конкретный runner настроен безопасно, что registry доверенный и что deploy использовал правильный digest. Эти утверждения требуют наблюдаемых записей из вашей среды.

\n

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

\n

Представьте выпуск `release-123`. Исходная ревизия — `git:abc123`. Сборщик сообщает identity `ci/build-prod`. Registry возвращает digest `sha256:7f...`. Attestation имеет subject с тем же digest и ссылается на `git:abc123`. Deploy получает не тег `build-123`, а полный digest. Тогда расследование может пройти по одной цепочке. Если хотя бы одно звено хранит только свободный текст, связь становится гипотезой.

\n

Тег удобен для человека, но изменяем. Digest адресует конкретный результат. Поэтому тег можно показывать в интерфейсе, а проверяемым входом выкладки считать digest. Если attestation относится к `sha256:91...`, а deploy запускает `sha256:7f...`, выпуск нужно остановить. Нельзя исправить несовпадение новым комментарием в release.

\n
\"Границы
Карта границ доверия. Она помогает назвать проверяемые факты, но не является журналом реального CI и не подтверждает подпись образа.
\n

Симптомы и действия

\n
Как перейти от сигнала к проверке
СимптомПричинаПроверкаДействие
В логах виден фрагмент токенаСекрет попал в stdout, debug или командную строкуПроверить логи шага и маскирование, затем поискать значение в слоях и артефактахОтозвать credential, очистить путь вывода и повторить сборку без утечки
В Dockerfile есть `ARG TOKEN`Секрет передаётся как параметр и может остаться в историиПроверить history и metadata образаПерейти на secret mount и выпустить новый digest
Attestation и deploy ссылаются на разные digestВыкладка использует изменяемый тег или другой outputСравнить точные subject и deploy inputОстановить выпуск, выбрать проверенный digest, выяснить источник расхождения
Есть подпись, но нет записи о builderПроверяют целостность statement, но не происхождение сборкиПроверить identity подписанта и поля provenanceРазделить проверку подписи, builder и исходной ревизии
После deploy нельзя найти исходную ревизиюRelease хранит только номер задачи или короткий тегСверить metadata образа, CI run и commitСделать revision обязательным полем evidence
\n

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

\n
  1. Выберите один выпуск и зафиксируйте его точный deploy input. Если система принимает тег, получите digest, который фактически использовал runtime.
  2. Найдите исходную ревизию, из которой собрали этот digest. Не подменяйте её веткой: ветка меняется, commit остаётся идентификатором состояния.
  3. Определите identity builder и сохраните ссылку на конкретный запуск. Запись «собрано CI» недостаточна.
  4. Проверьте границу секрета: где его запросили, какой шаг получил доступ и какие файлы, слои, логи и кэши могли его сохранить.
  5. Сопоставьте subject attestation с digest образа. Затем отдельно проверьте подпись, доверенную identity и ожидаемую ревизию.
  6. Сравните этот же digest с входом deploy. При несовпадении не продолжайте выпуск и не заменяйте digest повторным тегированием.
  7. Зафиксируйте результат и владельца следующей проверки. Для каждого неизвестного факта оставьте статус «не подтверждено».
\n

Отрицательный путь: что делать при разрыве

\n

Наиболее опасная ветка начинается с частичного успеха. Образ собрался, тесты прошли, а subject attestation не совпал с digest deploy. В этот момент нельзя считать выпуск безопасным из-за зелёного pipeline. Остановите продвижение, сохраните безопасные метаданные, определите последний проверенный digest и выясните, где возникло расхождение: в registry, в выборе тега, в подготовке attestation или в конфигурации deploy.

\n

Если секрет уже попал в лог или образ, удаление строки не возвращает безопасность. Отзовите и замените credential по правилам вашей платформы. Удалите доступный артефакт, проверьте кэши и логи, а затем соберите новый образ с другим digest. Не утверждайте, что утечки не было, если проверка охватила только git и не охватила registry или CI.

\n

Ограничения

\n

Пример с Docker — учебный. В нём нет настоящего секрета, registry, CI run, подписи или deploy; плейсхолдер `/path/to/temporary/npmrc` нельзя использовать как production-рецепт. Secret mount снижает риск записи значения в финальный слой, но не защищает от команды, которая сама печатает секрет, сохраняет его в собранный файл или отправляет его в сеть. Secret scanning помогает обнаружить известные шаблоны, но не доказывает отсутствие всех credential.

\n

Provenance описывает заявленные входы и исполнителя. Оно не делает builder доверенным само по себе. Подпись подтверждает связь statement с ключом или доверенной identity, но не превращает любое утверждение в факт. Полная проверка зависит от политики организации, runner, registry, формата attestation и правил deploy. Поэтому статья не заявляет production-результатов и не заменяет проверку конкретной платформы.

\n

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

\n

Материал можно считать применённым к одному выпуску, когда команда без устных пояснений показывает: commit исходников, identity builder, границу доступа к секрету, digest образа, проверенное соответствие subject этому digest и тот же digest на входе deploy. Для отрицательного пути есть запись о том, что происходит при несовпадении. Если хотя бы одного поля нет или его нельзя проверить по первичному источнику, выпуск не помечают как подтверждённый.

\n

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

\n" + "title": "Секрет в CI и digest образа: как проверить цепочку поставки", + "excerpt": "Разбираем выпуск, в котором pipeline зелёный, но непонятно, какой commit и образ попали в deploy. Показываем границу секрета, проверку provenance и отрицательный путь.", + "contentHtml": "

Симптом выглядит так: зелёный pipeline не отвечает на главный вопрос расследования: что именно сейчас запущено. В записи о релизе может быть только тег build-123, хотя тег допускает переназначение. При этом команде нужно связать четыре факта: commit исходников, identity сборщика, digest образа и вход deploy. Если на шаге сборки использовали токен, добавляется пятый вопрос: где его значение могло сохраниться.

\n

Разберём типовой выпуск как цепочку evidence — проверяемых свидетельств, а не как список названий инструментов. Секрет должен быть доступен только нужной команде и не попасть в результат. Attestation должна относиться к тому же digest, который запускает deploy. В конце получится короткий контрольный маршрут, который можно повторить на CI без доступа к значениям секретов.

\n

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

\n

У выпуска должен быть один неизменяемый ключ — digest образа, а рядом с ним хранятся происхождение и решение о выкладке. Ветка и тег удобны для поиска, но не заменяют commit и digest: ветка движется, тег можно переиспользовать. Commit отвечает на вопрос «какие исходники взяли», digest — «какой результат собрали», а provenance — «какой builder заявил, как этот результат получил».

\n
{\n  "revision": "abc123...",\n  "builderRun": "https://ci.example.invalid/runs/8472",\n  "imageDigest": "registry.example.invalid/payments/api@sha256:7f...",\n  "attestationSubject": "registry.example.invalid/payments/api@sha256:7f...",\n  "deployInput": "registry.example.invalid/payments/api@sha256:7f...",\n  "secretUse": "mounted for npm ci; value is not evidence"\n}
\n

Идентификаторы в примере условные. В настоящем CI запись должна ссылаться на конкретный run, registry и commit, а не на текстовое поле, которое можно исправить вручную. Поле secretUse фиксирует способ доступа, но само по себе не доказывает отсутствие значения в логах, кэше или артефактах. Для этого нужны отдельные проверки.

\n

Секрет на сборке: ссылка, а не значение

\n

Закрытая зависимость иногда требует credential во время npm ci, pip install или скачивания приватного репозитория. BuildKit secret mount делает значение доступным конкретной инструкции и не записывает его в финальный слой автоматически. Это отличается от ARG TOKEN и ENV TOKEN: Docker предупреждает, что build arguments и environment variables не подходят для передачи секретов, а аргументы могут оказаться в history или provenance.

\n
# syntax=docker/dockerfile:1\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\n\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \\\n    npm ci --ignore-scripts\n\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html
\n

Вызов сборки передаёт путь к файлу секрета, а не само значение в аргументе командной строки:

\n
docker buildx build \\\n  --secret type=file,id=npmrc,src=/runner/secrets/npmrc \\\n  --tag registry.example.invalid/payments/web:abc123 \\\n  --push .
\n

Путь /runner/secrets/npmrc — контракт с вашим secret manager или runner, а не команда создания настоящего credential. В CI запрещаем печать файла, добавление его в build context и копирование в /app. Даже корректный mount не спасает команду, которая делает cat /run/secrets/npmrc, пишет ответ приватного сервера в артефакт или оставляет credential в debug-логе.

\n

Digest вместо изменяемого тега

\n

Digest — криптографический идентификатор содержимого образа. Тег можно переназначить, поэтому его оставляют человекочитаемым алиасом, а для передачи между registry, attestation и deploy используют ссылку вида image@sha256:.... Сразу после push сохраните digest из registry и передайте именно его следующему этапу.

\n
# Посмотреть digest опубликованного тега\ndocker buildx imagetools inspect registry.example.invalid/payments/web:abc123\n\n# Проверить, что registry отдаёт именно зафиксированный объект\ndocker pull registry.example.invalid/payments/web@sha256:7f00000000000000000000000000000000000000000000000000000000000000
\n

Команды требуют доступного registry и подставленного реального digest; значение sha256:7f... в статье — не существующий артефакт. Для multi-platform образа нужно заранее решить, что именно является входом deploy: digest manifest list или digest конкретного варианта для linux/amd64 либо linux/arm64. Сравнивать их как одну строку без этого решения нельзя.

\n
\"Схема
У каждого звена есть отдельный вопрос. Схема показывает учебный маршрут проверки и не является журналом конкретного CI-run.
\n

Attestation: заявление, которое нужно проверить

\n

Provenance описывает, где, когда и каким процессом получен артефакт. Это полезное заявление, но не автоматический сертификат безопасности. Проверяющий сначала удостоверяется в подписи по настроенному root of trust, затем сопоставляет subject с digest, проверяет ожидаемый predicateType и identity builder. После криптографической проверки остаётся ещё политический вопрос: разрешены ли этот репозиторий, workflow, commit и окружение.

\n

Для SLSA-подобной проверки порядок важен. Если subject относится к sha256:91..., а deploy запускает sha256:7f..., валидная подпись не исправляет расхождение. Если builder неизвестен политике, запись о provenance нельзя считать достаточным основанием для выпуска. Если проверка относится к тегу, зафиксируйте разрешённый digest рядом с результатом, иначе между проверкой и deploy возможна подмена тега.

\n
# Пример для GitHub Container Registry и GitHub CLI.\n# ORG, REPO и IMAGE заменяются значениями проекта.\ngh attestation verify \\\n  oci://ghcr.io/ORG/IMAGE:release-123 \\\n  -R ORG/REPO
\n

Команда проверяет доступную GitHub attestation для указанного образа, но не знает вашу политику автоматически. После неё отдельно сверяем repository, workflow, commit, builder identity и digest с deploy manifest. В другой CI-платформе остаётся тот же порядок, меняются формат attestation и инструмент проверки.

\n

Матрица симптомов и решений

\n
От наблюдаемого сигнала к проверяемому действию
СимптомГипотезаПроверкаРешение
Deploy содержит только тегТег переназначили после сборкиПолучить фактический digest из runtime и сравнить с registryПеревести manifest на digest и сохранить его в release evidence
Attestation есть, subject другойПроверяли один output, запускают другойСравнить полные строки subject и deploy inputОстановить выпуск, выбрать проверенный digest и найти место расхождения
В Dockerfile есть ARG TOKENCredential попал в history или metadataПроверить docker history, metadata и историю CIОтозвать токен, заменить передачу на secret mount, собрать новый образ
В логе виден фрагмент токенаКоманда или debug напечатали секретПроверить весь run, артефакты, кэш и системы логированияНемедленно отозвать credential и повторить выпуск с новым digest
Builder не входит в root of trustProvenance подписана неизвестным исполнителемСверить builder identity и ключ с политикой проектаНе принимать выпуск; сначала зарегистрировать доверенный путь или изменить builder
\n

Воспроизводимый контроль в CI

\n

Проверка должна завершаться сравнением значений, а не только просмотром зелёного статуса. Следующий фрагмент не публикует секрет и не меняет кластер: он моделирует последний decision gate перед deploy.

\n
set -eu\nATTESTED_DIGEST="registry.example.invalid/payments/api@sha256:7f..."\nDEPLOY_DIGEST="registry.example.invalid/payments/api@sha256:7f..."\n\ntest "$ATTESTED_DIGEST" = "$DEPLOY_DIGEST"\nprintf 'attestation and deploy refer to the same digest\\n'\n\n# Дальше запускается только заранее разрешённый deploy job.\n# В manifest сохраняем полный image@sha256:... без mutable tag.
\n

В реальном job переменные должны приходить из проверенных outputs, а не из ручного ввода. Добавьте отрицательный тест: намеренно подставьте другой digest и убедитесь, что test возвращает ненулевой код, job останавливается, а deploy не вызывается. Отдельно проверяйте commit и builder, потому что совпадение двух строк digest не доказывает происхождение образа.

\n

Порядок расследования

\n
  1. Зафиксируйте точный image reference, который runtime получил при deploy. Если запись содержит тег, разрешите его в digest и отметьте время проверки.
  2. Найдите commit, переданный в сборку, и ссылку на конкретный CI run. Не заменяйте commit названием ветки.
  3. Определите builder identity и проверьте, входит ли она в настроенный root of trust.
  4. Проверьте все места использования секрета: secret manager, шаг сборки, stdout, cache, слои и опубликованные артефакты.
  5. Проверьте подпись attestation, затем predicateType, subject digest и заявленные входы provenance.
  6. Сравните verified digest с тем же digest в deploy manifest. Для multi-platform публикации зафиксируйте уровень manifest, на котором сравниваете.
  7. Запишите результат каждого шага: подтверждено, не подтверждено или неприменимо. Не превращайте неизвестное поле в зелёный статус.
\n

Отрицательный путь: выпуск нужно остановить

\n

Представим, что тесты прошли, образ опубликован, но attestation относится к sha256:91..., а deploy manifest содержит sha256:7f.... Сохраняем логи и metadata, блокируем promotion и выясняем, где возник разрыв: push создал другой output, тег разрешился иначе, attestation выпустили для соседнего артефакта или manifest собрали из старого значения.

\n

Если credential попал в лог, слой или артефакт, удаление строки не возвращает его безопасность. Отзовите и замените credential по правилам вашей платформы, ограничьте доступ к копиям, проверьте retention и кэши, затем выпустите новый образ с новым digest. Результат расследования должен говорить, какие поверхности проверены; фраза «секрет не утёк» без охвата CI, registry и артефактов слишком сильна.

\n

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

\n

Примеры используют Docker BuildKit, registry, GitHub CLI и SLSA-термины. В Jenkins, GitLab, Yandex CI или закрытом registry будут другими команды, форматы attestation и политика доверия. Перед внедрением сверяйте версию Dockerfile frontend, возможности runner, режим кэширования, права registry и способ, которым runtime разрешает multi-platform image.

\n

Secret mount уменьшает вероятность записи значения в финальный слой, но не делает процесс невосприимчивым к вредоносной команде, debug-выводу или компрометации builder. Digest защищает от подмены содержимого по этому адресу, но не доказывает, что исходный код безопасен. Attestation связывает заявление с артефактом и builder; SLSA отдельно оговаривает доверие к самой build-платформе. Поэтому модель не заменяет threat model, ротацию credential, контроль прав и независимую проверку runner.

\n

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

\n

Выпуск можно принять, когда без устных пояснений доступны commit, ссылка на CI run и builder identity; секрет получен через разрешённую границу и не найден в логах, слоях или артефактах; подпись и provenance проверены; subject совпадает с digest; тот же digest записан в deploy input. Для несовпадения есть автоматический fail-closed тест и понятный владелец расследования. Если поле недоступно или проверка охватывает только одну поверхность, статус выпуска остаётся неподтверждённым.

\n

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

\n" } diff --git a/editorial/agent-rewrites/166.json b/editorial/agent-rewrites/166.json index 05495b0..eec2dba 100644 --- a/editorial/agent-rewrites/166.json +++ b/editorial/agent-rewrites/166.json @@ -2,6 +2,6 @@ "index": 166, "slug": "editorial-2023-05-field-static-analysis", "title": "Шумное правило статического анализа: как принять обратимое решение", - "excerpt": "Один результат статического анализа не объясняет, нужно ли менять правило или подавлять сигнал. Разбираем контекст, scope, срок, rollback и проверяемый критерий готовности.", - "contentHtml": "

В CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.

\n

Обратная ошибка тоже стоит дорого. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения. После нескольких таких итераций доверие к анализатору падает. Поэтому результат анализатора — это повод проверить контекст, а не готовый вердикт.

\n

Сначала отделите результат от вывода

\n

SARIF хранит сведения об инструменте, правиле, результате и позиции в файле. Эти поля отвечают на вопрос «где и по какой гипотезе сработал анализатор». Они не доказывают, что ветка исполняется, значение пришло из сети или команда действительно запускается. Для этого нужен контекст проекта: источник значения, путь до опасного вызова, граница доверия, владелец кода и область действия решения.

\n

Возьмём узкую учебную гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую правило может искать. Он не является результатом реального сканирования и не доказывает уязвимость.

\n
function runReport(request) {\n  const reportName = request.query.name;\n  return runShell(`report --name ${reportName}`);\n}\n\n// Учебный контекст: нужно отдельно проверить источник,\n// экранирование, достижимость ветки и фактический sink.
\n

У этого совпадения есть несколько независимых вопросов. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, допустимы только формулировки «результат требует проверки» и «контекст неполный».

\n

Три действия вместо глобального выключателя

\n

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

\n

tune меняет гипотезу правила. Такое действие нужно, если правило захватывает форму, которая не соответствует его назначению: например, оно не отличает безопасный массив аргументов от конкатенации строки. Tune требует новой версии правила, короткого описания diff и проверки того, какие совпадения перестанут появляться.

\n

suppress временно ограничивает один идентифицируемый результат. У него должны быть точный fingerprint, узкий scope, владелец, причина и дата окончания. Suppress не делает код безопасным. Он только задаёт политику отображения конкретного сигнала.

\n

Глобальное disable не заменяет ни одно из этих действий. Оно меняет поведение правила для текущих и будущих результатов. Если проекту действительно нужна такая смена policy, её надо рассматривать отдельно: назвать категорию, оценить потерю сигнала, назначить владельца и определить способ вернуть правило. Нельзя прятать решение уровня policy в комментарии к одному результату.

\n

Минимальный контракт решения

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один результат выглядит безопаснымНет источника значения и trust boundaryСопоставить ruleId, revision, URI, строку, fingerprint и путь данныхОставить keep до сбора контекста
Правило срабатывает на безопасном APIГипотеза не различает строку и массив аргументовПрочитать intent правила и проверить diff на минимальной паре примеровВыбрать tune с новой revision
Один результат блокирует выпускИсключение не имеет точного scope или срокаПроверить fingerprint, owner, reviewBy и expiresOnРазрешить только scoped suppress
Предлагают выключить всё правилоРезультат одного участка смешан с policy категорииОценить будущие результаты и отдельный rollback policyВынести global disable в отдельное решение
После изменения непонятно, что вернулосьRollback удаляет запись, но не повторяет анализСверить config diff и повторный отчёт на том же commitВернуть точное исключение или прежнюю revision и повторить проверку
\n

Как записать evidence

\n

Запись должна быть короткой, но достаточной для повторной проверки. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание изменения. Для suppress добавьте точный fingerprint, scope и expiresOn. Дата следующего review не должна быть позже даты окончания исключения.

\n

Причина «шум» ничего не объясняет. Хорошая причина связывает решение с проверяемым фактом: «вызов получает массив аргументов после нормализации; правило ожидает конкатенацию строки; diff проверен на двух учебных формах». В настоящем проекте сюда добавляют ссылку на задачу, commit или сохранённый контекст без секретов. Не добавляйте в публичную запись токены, пользовательские данные и полный фрагмент чувствительного кода.

\n

Учебная проверка отрицательных путей

\n

Небольшой synthetic-пример полезен, когда нужно проверить сам контракт решения. Он должен отклонять неполные варианты: глобальное отключение, suppress без fingerprint, suppress без срока, tune без новой revision и context без trust boundary. Следующий фрагмент запускает только детерминированную проверку объектов в памяти. Он не читает репозиторий, не загружает rule pack, не запускает Semgrep, не меняет CI и не сообщает о найденной уязвимости.

\n
const decision = {\n  action: 'suppress',\n  owner: 'security-review',\n  reason: 'Проверен один synthetic result',\n  fingerprint: 'synthetic-fingerprint-command-001',\n  scope: 'exact-result',\n  reviewBy: '2023-05-20',\n  expiresOn: '2023-05-27'\n};\n\nconst valid =\n  decision.action === 'suppress' &&\n  decision.fingerprint &&\n  decision.scope === 'exact-result' &&\n  decision.reviewBy <= decision.expiresOn;\n\nconsole.log(valid ? 'plan-valid' : 'plan-invalid');\n// Synthetic plan only: configuration is not applied.
\n

Проверка должна быть полезна прежде всего отрицательным исходом. Если убрать fingerprint, изменить scope на общий или поставить reviewBy после expiresOn, план обязан стать недействительным. Если тест проходит при action: 'disable-globally', контракт слишком слабый. Это проверка формы решения, а не доказательство качества правила и не оценка безопасности приложения.

\n
\"Гейт
Сначала собирается контекст результата, затем выбирается узкое действие. Схема показывает policy-контракт, а не запуск анализатора, реальные findings или эффект в production.
\n

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

\n
  1. Зафиксируйте симптом. Сохраните ruleId, revision, URI, строку, уровень, сообщение и fingerprint. Не начинайте с изменения конфигурации.
  2. Восстановите контекст. Найдите источник значения, путь до sink, trust boundary, владельца кода и артефакт, в который попадает модуль.
  3. Проверьте intent правила. Прочитайте описание, версию и diff. Отдельно отметьте, что результат показывает, а чего не показывает.
  4. Выберите действие. Используйте keep для неполного контекста, tune для неверной гипотезы, scoped suppress для одного проверенного результата. Global disable вынесите в отдельную policy.
  5. Заполните evidence. Добавьте owner, reason, scope, fingerprint и даты. Для tune укажите новую revision и ожидаемую границу.
  6. Проверьте отрицательный путь. Убедитесь, что неполный контекст, общий suppress и просроченные даты отклоняются.
  7. Подготовьте rollback. Для suppress удалите точное исключение и повторите анализ. Для tune верните прежнюю revision и сравните diff. Не считайте rollback выполненным по одному изменению файла.
\n

Ограничения

\n

Статический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.

\n

Формат исключений и fingerprint зависит от конкретного анализатора и версии CLI. Не переносите поля из учебного объекта в конфигурацию без проверки официальной документации. Не называйте synthetic result находкой, не заявляйте снижение числа ложных срабатываний без измерения и не утверждайте, что опасные случаи не потеряны без проверки на выбранном наборе кода.

\n

Rollback тоже имеет границу. Он возвращает видимость правила или убирает scoped exception. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным анализом.

\n

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

\n

Решение готово, когда другой инженер может по записи ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали keep, tune или suppress, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune должна существовать новая revision и проверенный diff. Для suppress должны совпадать fingerprint и scope, а expiry должна быть будущей. Для rollback должен быть выполнен повторный анализ на том же commit или явно зафиксировано, почему это невозможно.

\n

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

\n

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

\n" + "excerpt": "Один результат статического анализа не объясняет, нужно ли менять правило или ограничить только этот сигнал. Разбираем контекст, scope, срок, rollback и проверяемый критерий готовности.", + "contentHtml": "

В CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.

\n

Обратная ошибка стоит не меньше. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения, а затем перестают доверять анализатору. Результат анализатора — это повод проверить контекст, а не готовый вердикт. В этой статье keep, tune и scoped suppress — названия проектной policy, а не универсальные поля SARIF и не обещание, что любой анализатор понимает эти действия.

\n

Отделите результат от вывода

\n

SARIF (Static Analysis Results Interchange Format) описывает обмен результатами статического анализа. В записи можно найти инструмент, правило, результат, сообщение, расположение в артефакте и уровень сигнала. Эти поля отвечают на вопросы «какая гипотеза сработала» и «где её обнаружили». Они сами по себе не доказывают, что ветка исполняется, значение пришло от внешнего пользователя или опасный вызов достижим в выпущенном артефакте.

\n

У результата есть ещё одна важная граница: fingerprint нужен системе управления результатами для сопоставления логически одинаковых сигналов между запусками. Спецификация допускает, что fingerprint добавит именно result management system после загрузки отчёта; прямой производитель SARIF обычно не должен выдумывать его без устойчивого алгоритма. Поэтому строка, которую команда вручную назвала fingerprint, не становится стабильным идентификатором только из-за имени.

\n

Возьмём узкую гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую может искать правило. Это не результат реального сканирования и не доказательство уязвимости.

\n
function runReport(request) {\n  const reportName = request.query.name;\n  return runShell(\"report --name \" + reportName);\n}\n\n// Отдельно проверяем источник, экранирование,\n// достижимость ветки и фактический sink.
\n

Для этого совпадения нужны четыре независимых ответа. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, точная формулировка звучит так: «результат требует проверки, контекст неполный».

\n

Восстановите цепочку данных

\n

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

\n

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

\n
Симптом, проверка и допустимое решение
Что видноЧего не хватаетПроверкаРешение policy
Один результат выглядит безопаснымИсточник и trust boundaryПроследить значение до sink и проверить достижимостьkeep до завершения triage
Сигнал появляется на безопасном APIПравило различает формы слишком грубоСравнить intent правила с двумя минимальными примерамиtune с новой revision и diff
Один результат мешает выпускуТочный scope, владелец и срокСверить fingerprint, owner, reviewBy и expiresOnТолько scoped suppress
Предлагают выключить правило целикомОценка будущей потери сигналаРассмотреть изменение категории как отдельную policyОтдельное решение с rollback
После изменения непонятен возвратПовторный запуск на том же commitСравнить конфигурацию и новый отчётВернуть узкое исключение или revision
\n

Проверьте правило на минимальной паре

\n

Если подозрение относится к гипотезе правила, сначала подготовьте два маленьких примера: один должен соответствовать намерению правила, второй — быть безопасной формой, которую оно не должно захватывать. Запускайте одну и ту же версию CLI с одной и той же конфигурацией. Команда ниже показывает общий путь для локального Semgrep-скана; имя конфигурации и каталог замените своими. Она сохраняет SARIF-файл, но не отвечает за достижимость кода или эксплуатацию сигнала.

\n
semgrep --version\nsemgrep scan \\\n  --config rules/command-input.yml \\\n  --sarif \\\n  --output /tmp/command-input.sarif \\\n  src/
\n

После запуска сравните не только число строк. Проверьте ruleId, revision, URI и содержимое results. Если CLI обновился, формат дополнительных полей и поддерживаемые опции нужно сверить с документацией именно этой версии. Отдельный diff правила должен объяснять, какой класс безопасных совпадений исключается и какие опасные формы остаются в области проверки.

\n

Выберите узкое действие

\n

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

\n

tune меняет гипотезу правила. Такое действие оправданно, если правило захватывает форму, которая не соответствует его назначению. Укажите новую revision, покажите минимальный diff и проверьте положительный и отрицательный пример. Не называйте tune снижением false-positive rate без измерения на заранее выбранном наборе кода.

\n

scoped suppress ограничивает один идентифицируемый результат. В нашей policy у него должны быть точный fingerprint, узкий scope, владелец, причина, дата следующей проверки и дата окончания. Suppress не делает код безопасным: он меняет видимость конкретного сигнала. Формат исключения и его область зависят от инструмента, поэтому эти поля нельзя механически перенести в конфигурацию другого анализатора.

\n

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

\n
\"Гейт
Сначала собирается контекст результата, затем выбирается узкое действие. Схема показывает проектный policy-контракт, а не формат SARIF, запуск анализатора или эффект в production.
\n

Запишите evidence так, чтобы его повторили

\n

Evidence — это короткая запись, по которой другой инженер может повторить решение. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание diff. Для scoped suppress добавьте точный fingerprint, scope и expiresOn. Дата следующей проверки не должна быть позже срока окончания исключения.

\n

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

\n

Проверьте отрицательные пути

\n

До применения policy проверьте, что неполная запись не проходит. Нужна не проверка непустых строк, а минимальный контракт: разрешены только три действия; suppress требует точного scope и двух корректных дат; tune требует новой revision. Фрагмент ниже запускается в Node.js и работает только с объектом в памяти. Он не читает репозиторий, не запускает сканер и не применяет конфигурацию.

\n
node --input-type=module <<'NODE'\nconst decision = {\n  action: 'scoped-suppress',\n  owner: 'security-review',\n  reason: 'Проверены источник, граница доверия и sink',\n  fingerprint: 'result-command-001',\n  scope: 'exact-result',\n  reviewBy: '2023-05-20',\n  expiresOn: '2023-05-27',\n  ruleRevision: 'command-input/v3'\n};\n\nconst allowedActions = new Set(['keep', 'tune', 'scoped-suppress']);\nconst isDate = (value) => {\n  if (!/^\\\\d{4}-\\\\d{2}-\\\\d{2}$/.test(value)) return false;\n  return new Date(value + 'T00:00:00Z').toISOString().startsWith(value);\n};\n\nconst valid =\n  allowedActions.has(decision.action) &&\n  decision.owner && decision.reason &&\n  (decision.action !== 'tune' || decision.ruleRevision !== 'command-input/v2') &&\n  (decision.action !== 'scoped-suppress' ||\n    (decision.fingerprint && decision.scope === 'exact-result' &&\n      isDate(decision.reviewBy) && isDate(decision.expiresOn) &&\n      decision.reviewBy <= decision.expiresOn));\n\nconsole.log(valid ? 'plan-valid' : 'plan-invalid');\nNODE
\n

Положительный путь печатает plan-valid. Для отрицательной проверки удалите fingerprint, поставьте 2023-02-31, замените scope на общий или используйте действие disable-globally: во всех случаях должен получиться plan-invalid. Сравнение дат строкой безопасно только после строгой проверки формата и календаря, как в примере. Без этого значение вроде 31 февраля может пройти поверхностную проверку.

\n

Сделайте rollback проверяемым

\n

Rollback — не удаление строки из конфигурации. Для suppress удалите именно это исключение, повторите анализ на том же commit и проверьте, что ожидаемый результат снова виден. Для tune верните прежнюю revision, повторите минимальную пару и сравните отчёты. Если повторный анализ невозможен, зафиксируйте причину и риск, а не называйте возврат завершённым.

\n

У rollback есть ограничение: он возвращает видимость правила или убирает узкое исключение. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным запуском и сверкой baseline.

\n

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

\n

Статический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF фиксирует структуру обмена, но не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.

\n

Команда Semgrep в примере — ориентир для CLI, а не зафиксированный контракт всех будущих версий. Закрепите версию в CI, сохраните вывод semgrep --version и проверьте опции в документации перед миграцией. Не переносите названия keep, tune и scoped-suppress в инструмент без адаптера и теста его реального формата.

\n

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

\n

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

\n
  1. Зафиксируйте симптом. Сохраните ruleId, revision, URI, строку, уровень, сообщение и fingerprint, если его выдала система управления результатами.
  2. Восстановите контекст. Найдите источник значения, путь до sink, trust boundary, владельца и артефакт, в который попадает модуль.
  3. Проверьте intent правила. Прочитайте описание и версию, затем сравните безопасную и соответствующую гипотезе формы на минимальной паре.
  4. Выберите действие. Используйте keep для неполного контекста, tune для неверной гипотезы, scoped suppress для одного проверенного результата. Global disable вынесите в отдельное решение.
  5. Заполните evidence. Добавьте owner, reason, scope, fingerprint и даты. Для tune укажите новую revision и проверенный diff.
  6. Проверьте отказ. Убедитесь, что неполный контекст, общий suppress, неверная дата и просроченный review отклоняются.
  7. Подготовьте rollback. Верните прежнюю policy или revision и повторите анализ на том же commit.
\n

Решение готово, когда другой инженер может ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали действие, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune существует новая revision и проверенный diff. Для suppress совпадают fingerprint и scope, даты корректны, а expiry не прошёл. Для rollback есть повторный анализ или явно записана причина, почему он невозможен.

\n

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

\n" }