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 ожидал дату, по которой можно показать действие. Пользователь видит неполный сценарий, поддержка получает жалобу, а команда спорит, был ли релиз совместимым.
Цена ошибки растёт из-за ложного зелёного сигнала. Проверка JSON подтверждает форму, но не подтверждает, что consumer сможет закончить свой сценарий. Provider считает, что поле не менялось. Consumer видит изменение смысла. Владелец релиза видит успешный job и не получает основания остановить выкладку.
\nТезис статьи простой: контракт API состоит как минимум из трёх разных доказательств. Schema match проверяет структуру. Consumer expectation проверяет нужное поведение. Provider verification проверяет, что конкретная версия provider действительно отвечает опубликованному interaction. Один результат нельзя выдавать за другой.
\nСначала отделите форму от смысла. OpenAPI описывает интерфейс, который могут использовать люди и инструменты. Schema Object задаёт типы, обязательность, перечисления и допустимые варианты. Это хороший барьер против пропавшего ключа, числа вместо строки и неизвестного значения enum.
\nНо схема не знает, какое действие должен показать конкретный экран. Поле renewalAt может быть nullable для одного клиента и обязательным условием для другого сценария. Поэтому второй уровень должен принадлежать consumer: «для экрана продления активная подписка должна иметь применимую дату». Это уже не только свойство JSON. Это правило принятия решения.
Третий уровень связывает ожидание с 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 или ограничить решение областью проверки |
Рассмотрим искусственный сценарий GET /v1/subscriptions/sub-42. Имена synthetic-portal-web и synthetic-billing-api нужны только для объяснения механизма. Это не лог реального сервиса, не результат запуска и не утверждение о production.
Общая schema может разрешать такой ответ:
\n{\n \"id\": \"sub-42\",\n \"state\": \"active\",\n \"renewalAt\": null\n}\nНа уровне формы ответ выглядит допустимым. На уровне consumer он не подходит экрану продления: у экрана нет даты и он не должен выдумывать её. Правило можно записать рядом с interaction:
\nconst 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: она показывает, где именно возникло расхождение.
Начните с одного действия consumer, а не со всего API. Назовите метод, путь, вход, ожидаемый статус и минимальный ответ. Затем запишите provider state. Формулировка «подписка активна» слишком общая, если экрану нужна именно дата продления после текущего момента. State должен объяснять, почему provider обязан вернуть нужные данные.
\nОтдельно укажите отрицательный путь. Например: если state равен active, но renewalAt отсутствует или уже прошёл, consumer не показывает кнопку продления и сообщает, что действие недоступно. Это не означает, что поле надо сделать non-null для всех клиентов. Отрицательная ветка фиксирует решение одного сценария.
Храните рядом версии. Укажите версию schema, consumer contract, provider и provider state. Версия OpenAPI не заменяет версию API-сборки. Версия consumer не доказывает, какую сборку provider проверяли. Эти указатели нужны, чтобы зелёный результат можно было воспроизвести и связать с конкретным изменением.
\nSchema проверяет допустимость значения, а не его полезность для каждого клиента. Nullable-поле может быть корректным по общему договору и непригодным для конкретного действия. Enum может сохранить прежний набор строк, но поменять бизнес-смысл каждой строки. HTTP 200 может сообщать об успешной обработке запроса, но не о готовности пользовательского шага.
\nConsumer-driven contract помогает сузить проверку до реальной потребности клиента. Он не пытается описать все возможные ответы provider. Это достоинство для быстрого feedback, но и ограничение: неохваченный consumer остаётся неохваченным. Список interactions надо поддерживать вместе со списком клиентов, иначе команда легко перенесёт результат одного экрана на весь API.
\nProvider verification тоже не даёт универсальной гарантии. Она подтверждает конкретные interactions в подготовленных состояниях. Она не заменяет авторизацию, миграцию данных, нагрузочные проверки, совместимость старых мобильных версий и наблюдение после выкладки. Эти проверки отвечают на другие вопросы.
\nУчебный код выше не доказывает совместимость реальных версий. В нём нет сети, broker, Pact, авторизации, зависимостей provider и production-данных. Даже корректный локальный результат означает только то, что правило примера отделяет форму от смысла. Нельзя писать в release-описании «provider verified», если запуск provider verification не состоялся.
\nЕсли verification не прошла, сначала сохраните исходный contract и ответ. Затем решите, где находится граница изменения. Иногда provider должен вернуть прежний смысл. Иногда consumer должен перестать трактовать active слишком узко. Иногда безопаснее добавить новое поле и временно поддержать оба варианта. Автоматически делать nullable-поле обязательным нельзя: это может сломать другие сценарии.
Rollback также требует конкретики. Назовите версии, которые можно вернуть, данные, уже записанные новым кодом, и consumer, который ещё читает старый ответ. Snapshot JSON не откатывает endpoint, базу, флаг или опубликованный артефакт. Если эти условия неизвестны, готовность к rollback не доказана.
\nИзменение готово к выпуску, когда выполнены все четыре условия: schema проверена для нужной версии; consumer expectation содержит положительную и отрицательную ветки; provider state и версия provider названы; provider verification дала сохранённый результат для каждого consumer, которого затрагивает изменение. Если хотя бы одного пункта нет, вывод должен звучать точнее: «форма проверена», «ожидание записано» или «verification не запускалась». Слово «совместимо» оставляйте только для доказанной области.
\nСбой интеграции часто выглядит обманчиво: HTTP-ответ имеет статус 200, обязательные поля присутствуют, а валидатор схемы сообщает PASS. При этом кнопка продления не появляется. Ответ содержит state: \"active\" и renewalAt: null. Общая схема допускает null, но экрану нужна дата, по которой он может предложить действие. Пользователь получает неполный сценарий, а команда видит зелёный тест и поздно замечает расхождение.
Причина не в том, что schema validation бесполезна. Она отвечает на один вопрос: допустима ли форма сообщения по описанию? Совместимость интеграции требует ещё двух ответов: получил ли consumer данные, необходимые его сценарию, и проверил ли provider этот конкретный обмен на согласованном состоянии. Эти уровни нельзя сворачивать в один флаг compatible.
В этой статье schema — формальное описание структуры сообщения. В OpenAPI Schema Object задаёт типы, обязательность, перечисления, форматы и другие ограничения. OpenAPI 3.1 опирается на JSON Schema Draft 2020-12, но само описание не знает, какое поле нужно конкретной кнопке или какой порядок действий ожидает пользователь.
\nConsumer expectation — проверяемое ожидание клиента. Оно связывает ответ с действием: для активной подписки с датой в будущем экран показывает продление, а для отсутствующей или просроченной даты не показывает его и объясняет недоступность. Это правило принадлежит сценарию клиента, а не общей схеме ресурса.
\nProvider verification — запуск опубликованного набора взаимодействий против provider в заданном состоянии данных. Такой запуск показывает, что конкретная версия provider действительно отвечает ожидаемым запросам. Он не доказывает поведение клиентов, которых в наборе нет, и не заменяет проверки авторизации, нагрузки или миграции данных.
\n| Уровень | Вопрос | Положительный результат | Что остаётся неизвестным |
|---|---|---|---|
| Schema | Сообщение имеет допустимую форму? | Ключи, типы, enum и ограничения соответствуют схеме. | Подходит ли значение действию конкретного consumer. |
| Consumer expectation | Клиент сможет принять решение? | Положительная и отрицательная ветки сценария определены. | Отвечает ли им реальный provider. |
| Provider verification | Provider выполняет interaction? | Запрос и ответ прошли на указанном provider state. | Поведение неохваченных клиентов, нагрузку и весь production-контур. |
Предположим, endpoint возвращает сведения о подписке. Ниже — минимальная схема OpenAPI 3.1 в YAML. Запись type: [string, 'null'] намеренно допускает отсутствие даты: это может быть корректно для отменённой подписки или другого потребителя.
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.
Не менее опасен обратный случай. Provider возвращает будущую дату, а клиент сравнивает её с локальной строкой без часового пояса. Формально поле имеет строковый тип и формат даты, но решение клиента зависит от неверного разбора времени. Здесь schema PASS не отменяет тест на границе времени.
\nНачинайте не с полного API, а с одного пользовательского действия. Зафиксируйте метод, путь, статус, минимальный ответ, условие показа действия и отрицательную ветку. Например: «если state равен active, а renewalAt — дата в будущем относительно часов теста, экран показывает продление; иначе действие скрыто».
В правило нужно передать часы явно. Иначе тест, выполняющийся около полуночи или границы даты, станет случайным. Нельзя использовать произвольное «сейчас» внутри функции и затем считать результат воспроизводимым. В боевом коде формат даты, часовой пояс и источник времени должны быть частью соглашения команды.
\nfunction 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. Он доказывает только две ветки выбранного правила. Прежде чем переносить его в проект, добавьте тесты на отменённое состояние, прошедшую дату, некорректную дату и другой часовой пояс.
Consumer-driven contract описывает взаимодействие, которое действительно использует клиент. Для него важны сформированный запрос, заголовки, статус, нужные поля и обработка ответа. Такой тест полезен именно своей узкой областью: он быстро показывает, что provider перестал удовлетворять конкретному клиенту.
\nУзкая область одновременно создаёт риск. Если в наборе есть только веб-экран, результат нельзя автоматически распространить на мобильное приложение, партнёрский API или старую версию клиента. Список consumers и их interactions должен быть явным. Если неизвестно, кто ещё читает поле, вывод ограничивается проверенным набором.
\nНе помещайте в contract test всю бизнес-логику экрана. Проверка «кнопка имеет зелёный цвет» относится к UI или функциональному тесту. Контракт должен зафиксировать, какие данные и ответ нужны для связи между consumer и provider. Решение интерфейса можно проверять отдельным тестом, используя тот же набор граничных ответов.
\nProvider state — это не комментарий «в базе есть активная подписка», а воспроизводимая подготовка данных, при которой interaction имеет смысл. Для примера назовите его так: subscription sub-42 is active and renews after 2023-07-15T10:00:00Z. В описании состояния должны быть известны идентификатор фикстуры, версия provider и момент времени, относительно которого проверяется дата.
Проверка provider должна выполняться против локально запускаемого экземпляра или экземпляра в CI с контролируемыми зависимостями. Проверка уже развёрнутого общего окружения хуже отвечает задаче быстрого feedback: там труднее подготовить состояние, изолировать внешние сервисы и понять, какая версия обработала запрос. Это не запрет на smoke-тесты в окружении, а граница между ними и provider verification.
\nРезультат записывайте не только как PASS/FAIL. Нужны версия provider, идентификатор contract, provider state, список interactions, commit или сборка и время запуска. Если результат не найден, корректная формулировка — «consumer contract опубликован, verification не подтверждена», а не «API совместим».
\n| Поле | Пример | Зачем нужно |
|---|---|---|
| Consumer | web-renewal | Понимать, чьё ожидание проверялось. |
| Interaction | GET /v1/subscriptions/sub-42 | Связать ошибку с конкретным обменом. |
| Provider state | active, renewal after fixed time | Воспроизвести входные данные. |
| Provider version | build-2023-07-15.2 | Отличить код, который реально проверяли. |
| Verification result | PASS: 1 interaction | Не принять существование contract за его выполнение. |
При отказе не начинайте с изменения nullable-поля. Сначала определите уровень, на котором возникло расхождение. Один и тот же экранный симптом может быть следствием формы ответа, семантики данных, часов, provider state или отсутствия самого запуска.
\n| Симптом | Первая проверка | Ограниченное действие |
|---|---|---|
| Нет обязательного ключа или изменился тип | Сверить версию схемы, required, type и media type. | Исправить provider или согласовать версионное изменение. |
| Форма верна, но действие скрыто | Проверить decision rule, значение поля и фиксированные часы. | Уточнить смысл consumer или добавить явное поле. |
| Consumer зелёный, provider неизвестен | Найти запись provider verification для той же версии. | Не расширять область вывода за проверенный consumer. |
| Один consumer зелёный, другой сломан | Сверить список interactions и версий клиентов. | Добавить отдельный сценарий или ограничить изменение. |
| Тест нестабилен у границы даты | Проверить источник времени, формат и часовой пояс. | Передавать часы в правило и фиксировать момент в state. |
null, просроченную дату, неизвестный enum, ошибку авторизации и отсутствие записи там, где это входит в сценарий.Контрактные тесты не заменяют функциональные, end-to-end, нагрузочные и security-тесты. Они не доказывают корректность бизнес-расчёта для всех данных, доступность базы, задержку сети или работоспособность каждого UI-перехода. Они также не обнаружат consumer, о котором команда не знает и который не попал в набор interactions.
\nУчебный endpoint и фикстура в этой статье вымышлены. Команда node contract-rule.mjs проверяет локальный инвариант на двух значениях и не вызывает реальный API. Если проект использует OpenAPI 3.0, правило для nullable оформляется иначе, чем в OpenAPI 3.1; сверяйте версию спецификации и поведение конкретного валидатора. Формат date-time сам по себе не говорит, какую бизнес-зону времени выбрать.
Безопасный вывод должен быть узким: «ответ соответствует schema», «consumer rule проходит для этих fixtures» или «provider verification прошла для такого-то state». Формулировку «изменение совместимо» оставляйте только тогда, когда перечислены все затронутые consumers и для них есть соответствующие результаты. Если не хватает версии, state или verification result, это пробел в доказательстве, а не зелёный статус.
\nAPI возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.
Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.
\nТезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.
\nOpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.
Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:
{\n \"id\": \"sub-42\",\n \"state\": \"active\",\n \"renewalAt\": null\n}\nСхема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.
Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.
\n| Слой | Что проверяем | Что означает PASS | Чего PASS не означает |
|---|---|---|---|
| Schema match | Поля, типы, enum, обязательность и nullable-границы. | Ответ соответствует описанной форме. | Consumer может завершить свой пользовательский сценарий. |
| Semantic expectation | Минимальное значение, нужное конкретному consumer. | Ответ содержит предусловие выбранного действия. | Provider действительно обработал запрос. |
| Provider verification | Interaction исполняется на provider в названном состоянии. | Запущенный provider вернул ожидаемый ответ для этого contract. | Проверены все клиенты, методы и варианты данных. |
Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.
Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.
Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.
\nconst 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 отражал его запрос и его решение, а не отдельно созданный объект.
\nConsumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.
\nУ provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.
\nПроверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Схема зелёная, экран не показывает действие. | Смысловое предусловие не записано: допустимый 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 с явным сроком или запрет выпуска до готовности данных. |
active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.
\nProvider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.
\nПримеры в статье учебные. Они не запускались против production, не измеряют частоту отказов и не сообщают о совместимости конкретных сервисов. Для реального изменения укажите версии, подготовьте изолированное состояние и сохраните фактический результат verifier. Если запуск не выполнялся, напишите «не проверено», а не «совместимо».
\nИзменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.
API возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.
Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.
\nТезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.
\nOpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.
Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:
{\n \"id\": \"sub-42\",\n \"state\": \"active\",\n \"renewalAt\": null\n}\nСхема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.
Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.
\n| Слой | Что проверяем | Что означает PASS | Чего PASS не означает |
|---|---|---|---|
| Schema match | Поля, типы, enum, обязательность и nullable-границы. | Ответ соответствует описанной форме. | Consumer может завершить свой пользовательский сценарий. |
| Semantic expectation | Минимальное значение, нужное конкретному consumer. | Ответ содержит предусловие выбранного действия. | Provider действительно обработал запрос. |
| Provider verification | Interaction исполняется на provider в названном состоянии. | Запущенный provider вернул ожидаемый ответ для этого contract. | Проверены все клиенты, методы и варианты данных. |
Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.
Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.
Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.
\nconst 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 отражал его запрос и его решение, а не отдельно созданный объект.
\nConsumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.
\nУ provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.
\nПроверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Схема зелёная, экран не показывает действие. | Смысловое предусловие не записано: допустимый 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 с явным сроком или запрет выпуска до готовности данных. |
active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.Consumer-тест и provider verification отвечают на разные вопросы, поэтому их результаты нельзя свести к одному зелёному job. Сначала consumer публикует pact-файл с версией, которая однозначно указывает на сборку. Затем provider проверяет этот pact и публикует результат в Pact Broker. Только после этого release pipeline может спросить, существует ли успешная пара версий в нужном окружении.
\nexport 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 или другой уникальный номер сборки; запрос «последнюю версию» может дать другой ответ при повторном запуске.
Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.
\nProvider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.
\nПримеры в статье учебные. Они не запускались против production, не измеряют частоту отказов и не сообщают о совместимости конкретных сервисов. Для реального изменения укажите версии, подготовьте изолированное состояние и сохраните фактический результат verifier. Если запуск не выполнялся, напишите «не проверено», а не «совместимо».
\nИзменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.
В отчёте 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Цепочка поставки состоит из разных утверждений. Commit отвечает на вопрос о входном коде. Builder и его identity отвечают на вопрос о процессе сборки. Secret boundary показывает, какие данные могли попасть в процесс и куда им запрещено уходить. Digest связывает байты артефакта с конкретным output. Declaration описывает claims о сборке. Deploy input показывает, что именно пытались применить.
\nСоседнее утверждение не заменяет пропущенное. Имя job не доказывает, что job выполнила сборку. Digest не доказывает commit. Декларация не доказывает, что её subject попал в deploy. Подпись подтверждает целостность подписанного объекта при корректной проверке, но не превращает любой текст в наблюдение среды.
\nТакой разбор нужен и для секретов. Секрет не должен проходить через Dockerfile, командную строку, переменную, которую печатает shell, или общий кеш. Значение может быть скрыто в логе, но остаться в слое образа. Оно может исчезнуть из образа, но сохраниться в артефакте или history. Поэтому проверяют не только содержимое файла, но и границы процесса.
\nНачните с одной карточки выпуска. В ней достаточно шести полей: commit, builder, digest, declaration subject, deploy input и граница секрета. Для каждого поля запишите источник, время получения и допустимый способ просмотра. Не копируйте значение секрета. Нужен факт его отсутствия или контролируемого использования, а не само значение.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 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 |
Сборка должна получать секрет только там, где он нужен, и только на время операции. Не задавайте его через ARG, если значение может попасть в историю слоёв. Не выводите окружение командой вроде env в диагностический лог. Не сохраняйте рабочий каталог с credential в артефакт CI. Не передавайте секрет в шаг, который собирает публичный output.
Учебный фрагмент ниже показывает безопасную мысль, а не готовую конфигурацию конкретного 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 по процедуре владельца.
\nDigest полезен потому, что связывает имя output с конкретным содержимым. Поэтому release должен хранить полный digest, а не только tag вроде latest. Tag может указывать на другой объект после публикации. Но digest остаётся только якорем. Он не сообщает, кто собрал образ, с каким исходным кодом и какие входы получил builder.
Проверяйте цепочку в прямом порядке, даже если проблема обнаружилась на deploy. Найдите commit. Найдите запись builder. Получите digest output. Сверьте subject декларации. Сверьте manifest deploy. После этого отдельно проверьте, какие данные видел процесс сборки и где они могли сохраниться. Такой порядок не позволяет начать с красивой декларации и подогнать под неё остальные факты.
\nПроверка должна явно описывать отказ. Если declaration subject отличается от deploy digest, не выбирайте ближайший digest по времени. Если builder не имеет проверяемой identity, не принимайте название workflow за identity. Если secret попал в слой, не ограничивайтесь удалением тега: образ и связанные кеши уже требуют отдельной обработки.
\nНеудача проверки не всегда означает компрометацию. Она означает, что текущих данных недостаточно для заявленного вывода. Это важное различие. Статус not-verified честнее, чем pass, построенный на совпадении имён. Дальнейшее действие выбирают по риску: остановка выпуска, получение evidence, смена credential или rollback к известному digest.
Provenance не заменяет сканирование уязвимостей, контроль доступа, защиту registry и проверку содержания артефакта. Подпись не заменяет проверку subject и trusted identity. Digest не гарантирует безопасный исходный код. Secret store не защищает от вывода значения в лог, если build step печатает окружение.
\nУчебные примеры в статье не выполняют криптографическую проверку, не обращаются к CI, registry или production и не дают production-результатов. Формат declaration, issuer, policy и процедура отзыва зависят от ваших инструментов. Не объявляйте соответствие SLSA или SSDF по одному найденному полю. Сначала проверьте применимый профиль и границы заявленного уровня.
\nRollback тоже имеет границу. Он может вернуть известный deploy input, но не удаляет уже скачанный образ, не отзывает credential и не исправляет запись в чужом кеше. Эти действия требуют отдельной операционной процедуры и владельцев. Если известного кандидата нет, безопаснее остановить выпуск и сохранить минимальное evidence.
\nПроверка готова, если команда показывает одну карточку выпуска и отвечает на пять вопросов: какой commit вошёл в сборку, какой builder выполнил её, какой digest получен, какой declaration subject с ним совпадает и какой digest указан в deploy. Дополнительно команда показывает, где проверена секретная граница и какой отрицательный сценарий остановил выпуск.
\nКритерий не требует утверждать больше, чем доказано. Если любой ответ опирается на имя, tag, текст в ticket или декларацию без независимого сопоставления, статус остаётся not-verified. Если все связи проверены допустимыми evidence, отрицательный путь блокирует несоответствие, а план обработки секрета известен, следующий шаг можно принимать в рамках policy конкретной системы.
В исходном случае токен тестовой среды случайно сохранился в контейнерном образе. Такой дефект заметен не всегда: сборка проходит, registry принимает image, а deploy запускает именно тот digest, который ожидала команда. Проблема обнаруживается позже, когда нужно ответить на два разных вопроса: где оказался секрет и из какого commit получился запущенный образ.
\nЭти вопросы нельзя закрыть одной проверкой. Строка в Dockerfile может оставить секрет в слое, но отсутствие строки в Dockerfile не доказывает отсутствие значения в кеше, логе или артефакте CI. Тег образа показывает удобное имя, но не фиксирует его содержимое. Подписанная декларация подтверждает подписанный объект только после проверки доверия и subject, а не сам факт deploy.
\nПрактический критерий такой: выпуск можно считать проверенным только после сопоставления source revision, build platform, digest образа, subject attestation и deploy input. Если одно звено недоступно, статус должен остаться not-verified, а следующий шаг — ручной проверкой или остановкой выпуска.
Начните с точного образа, который был выбран для 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 object | Commit, builder и безопасность содержимого |
| Build log и run ID | Факт запуска конкретной job | Что все шаги выполнила доверенная платформа |
| Provenance attestation | Заявленные builder, параметры и subject | Истину claims без проверки доверия |
| Image history и логи | Возможные следы команд и вывода | Отсутствие секрета во всех кешах и артефактах |
Самый опасный путь — передать credential как ARG, записать его через ENV или подставить в команду RUN. Значение может оказаться в истории инструкций или в слое, который позже попадёт в registry. Даже если финальный файл удалить, предыдущий слой не исчезает автоматически.
Небезопасный фрагмент выглядит так:
\nARG 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Для локальной проверки используйте заведомо фиктивную строку. 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 синтаксис передачи секрета зависит от платформы; принцип остаётся тем же: минимум прав, короткое время доступа и отсутствие значения в выводе.
Тег вроде release или latest — это изменяемое имя. Для расследования нужен digest, то есть контентный идентификатор вида sha256:.... Он позволяет сравнить один и тот же image object в registry и deploy, но не рассказывает его историю.
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В модели SLSA attestation описывает, что build platform произвела subject через заданное определение сборки. Для практической проверки нужны как минимум четыре поля: digest subject, идентификатор builder, внешние параметры сборки и зафиксированные зависимости. Commit должен быть представлен в подходящем поле или зависимости именно той аттестации, которую вы проверяете.
Сначала проверьте подпись и корень доверия. Затем убедитесь, что 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Хорошая проверка полезна именно в момент отказа. Подставьте в тестовый manifest другой digest и убедитесь, что policy отклоняет его. Возьмите attestation от другого образа и проверьте, что subject не принимается. Запустите сборку без доступного secret store: она должна завершиться контролируемой ошибкой, а не тихо собрать публичный output без нужного шага.
\nДля утечки есть отдельный отрицательный сценарий. Если маркер или credential обнаружен в логе, слое, кеше или артефакте, остановите публикацию, ограничьте доступ к объектам и отзовите credential. Не полагайтесь на маскирование в логе: редактирование вывода не удаляет значение из файла, слоя или уже скачанной копии.
\nРасследование можно закрывать, когда одна запись выпуска отвечает на пять вопросов: какой commit вошёл в build, какая доверенная platform выполнила его, какой digest получен, какой subject attestation совпадает с этим digest и какой digest использовал deploy. Отдельно должна быть запись о границе секрета: где он был разрешён, какие места проверены и какой credential остался действующим.
\nЭтот критерий не означает, что image безопасен во всех смыслах. Он лишь делает происхождение и секретный риск проверяемыми. Сканирование уязвимостей, лицензий, зависимостей, прав registry и runtime policy остаётся отдельными контролями. Если не хватает одного поля, честный результат — manual review required, а не зелёная галочка по совпадению тега.
Пример рассчитан на Linux-контейнер и BuildKit с поддержкой secret mounts. Legacy builder, Windows-контейнеры, другой CI или multi-platform registry могут иметь иной синтаксис и другую семантику кеша. Команды с доменом example.invalid — шаблон: они не обращаются к реальному registry и не дают production-результата.
Image history — полезный источник следов, но не доказательство чистоты: значение могло попасть в кеш, артефакт, рабочий каталог runner или внешний сервис. Provenance — утверждение, которое нужно проверять с выбранными корнями доверия и policy. Подпись гарантирует целостность подписанного объекта в рамках модели доверия, но не безопасность исходного кода и не факт его deploy.
\nПосле выкладки в системе лежит образ и файл attestation. Команда открывает файл, видит source revision и builder, затем помечает релиз как проверенный. Позже выясняется, что statement ссылается на другой digest, identity сборщика никто не проверял, а deploy получил образ по тегу latest. Ошибка стоит дорого: нельзя уверенно определить затронутый артефакт, выбрать безопасный rollback и объяснить аудитору, какой факт подтверждён.
Проблема усиливается, когда в ту же декларацию добавляют сведения о секрете. Доступ CI к секрету не доказывает, что значение не попало в слой образа, cache, metadata или журнал. Provenance отвечает за происхождение output. Secret boundary отвечает за путь доступа к чувствительному значению. Это разные утверждения с разными проверками.
\nТезис статьи простой: attestation становится полезным evidence только после независимого сопоставления subject с artifact digest, проверки доверенной identity и связи digest с входом deploy. Наличие файла, подписи или знакомого названия инструмента не заменяет эти операции.
\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. |
| Attestation | Statement содержит subject. | Statement подписан и правдив. | Проверить подпись, signer и claims. |
| Secret boundary | Сборка получает секрет на названном этапе. | Значение не попало в output. | Проверить конкретный путь передачи и места хранения. |
Секрет должен жить внутри ограниченной границы. Например, job получает короткоживущий токен через secret manager, использует его для чтения зависимости и не записывает значение в environment, артефакт сборки или лог. Даже такая схема описывает только ожидаемый путь. Она не доказывает отсутствие утечки без проверки конкретного pipeline и его output.
\nОсобенно опасны аргументы командной строки, переменные, которые CI печатает при ошибке, кеши package manager и Docker layers. Секрет может исчезнуть из финального файла, но остаться в промежуточном слое. Поэтому вопрос «секрет есть в образе?» слишком широк. Сначала назовите образ, digest, слой или metadata и способ проверки. Если evidence нет, статус должен быть not-observed, а не «утечки нет».
Ниже учебный пример. Он работает только с заранее заданными строками, не читает CI, registry или secret manager и не выполняет deploy. Его задача — показать отрицательный путь: statement про другой subject нельзя принять.
\nconst 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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 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 выполнять только при известном безопасном кандидате. |
Provenance не доказывает отсутствие уязвимостей, добросовестность исходного кода или безопасность всех зависимостей. Она описывает происхождение и условия получения output в пределах выбранной модели. Если builder записывает неверные сведения, downstream-проверка должна учитывать доверие к builder и его identity.
\nAttestation не заменяет сканирование, review зависимостей, контроль доступа, ротацию секретов, тесты и наблюдение после выпуска. Подпись подтверждает целостность statement относительно ключа или identity. Она не делает claims истинными сама по себе. Digest связывает байты, но не объясняет, почему эти байты допустимы.
\nУчебный код и таблица не запускались против production и не сообщают результат конкретного pipeline. Источники ниже дают официальные модели и спецификации, но не доказывают соответствие вашего проекта. При отсутствии реального verifier корректная формулировка — «не проверено».
\nЦепочка готова к решению о выпуске, когда source revision, builder identity, artifact digest и deploy input связаны конкретными записями; subject attestation совпадает с digest; signer и claims проверены по названной trust policy; путь секрета ограничен и проверен для выбранного output; отрицательные случаи переводят решение в ручной review. Если есть только файл attestation, зелёный CI или тег образа, доказательство не завершено.
\nСимптом появляется после сборки: в registry лежат образ и attestation. В ней видны revision и builder, поэтому релиз помечают как проверенный. Но statement может ссылаться на другой digest, identity сборщика может не входить в доверенную политику, а deploy может получить образ по тегу latest. В этом случае команда не знает, какой именно набор байтов запущен, и не может уверенно выбрать rollback.
Секрет добавляет ещё один разрыв. Токен, доступный CI, не становится безопасным только потому, что его нет в финальном файле: он мог попасть в командную строку, лог, cache или промежуточный слой. Provenance отвечает на вопрос «как получен output», а secret boundary — «где чувствительное значение могло быть доступно». Это разные утверждения и разные проверки.
\nПрактический критерий такой: attestation — это evidence только после проверки подписи и доверенной identity, сопоставления subject с digest образа, проверки ожидаемых claims и связи того же digest с входом deploy. Если одного звена нет, корректный статус — «не проверено», а не «безопасно».
В цепочке поставки полезно хранить не один большой флаг, а несколько наблюдаемых полей. 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. |
В attestation subject идентифицирует artifact, к которому относится statement. Для контейнерного образа таким идентификатором обычно служит digest манифеста, а не подвижный тег. Тег удобен человеку, но может быть перепривязан к другой версии. Поэтому release record должен сохранять запись вроде registry.example/api@sha256:... и передавать в deploy именно её.
Расхождение subject и deploy input — достаточная причина остановить автоматический выпуск. Даже если revision и builder выглядят знакомо, statement про образ B ничего не доказывает для образа A. Сверка должна быть буквальной: алгоритм и полное значение digest должны совпадать после нормализации формата, принятой вашим registry.
\nСледующий уровень — ожидания к provenance. Помимо подписи и builder.id, проверяют канонический репозиторий, buildType и внешние параметры сборки. Если verifier принимает неизвестные параметры молча, атакующий или ошибочная конфигурация могут изменить результат при сохранении внешне правдоподобного statement.
Секрет — не только значение в переменной окружения. Это ещё аргументы процесса, файлы в рабочем каталоге, вывод команды, 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:
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Сначала полезно проверить отрицательный путь на локальной фикстуре. Фрагмент ниже не имитирует криптографическую подпись и не обращается к registry. Он проверяет только две связи: statement относится к тому же digest, который попал в release record, и builder разрешён политикой. Запустите его как есть командой node --input-type=module:
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.
Для Cosign официальный сценарий начинается с образа, к которому attestation уже прикреплена. Команда ниже проверяет attestation с публичным ключом; значения URI и файла — проектные, поэтому их нужно заменить на доверенные для вашей системы:
\nIMAGE=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; их нельзя оставлять широкими регулярными выражениями без причины.
Практический evidence-пакет не должен содержать секреты. Достаточно сохранить digest, идентификатор statement, signer или certificate identity, результат verifier, параметры policy, время и ссылку на release record. Само наличие вывода в терминале не заменяет хранения результата там, где его сможет проверить следующий участник.
\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. | Сначала установить безопасный кандидат, затем откатывать. |
builder.id и certificate identity соответствуют policy.buildType и внешние параметры с ожидаемыми значениями.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Решение о deploy можно принимать, когда одна цепочка записей связывает revision, builder identity, statement subject, artifact digest и deploy input; verifier подтвердил подпись; policy одобрила builder, repository, buildType и claims; а secret boundary проверена для выбранного output. Любое расхождение переводит релиз в ручной review с понятным владельцем следующего действия.
buildType и external parameters.cosign verify и cosign verify-attestation, включая проверку claims.В релизе есть запись о deploy, но никто не может быстро ответить на четыре вопроса: из какой ревизии собрали образ, какой процесс его собрал, использовал ли build секрет и какой digest действительно запустили. Симптом часто выглядит безобидно: pipeline зелёный, сервис работает, а расследование останавливается на фразе «образ собрал CI». Цена ошибки появляется позже. Если токен попал в слой образа или deploy взял соседний тег, команда не может надёжно определить затронутый артефакт, отозвать доступ и объяснить происхождение выпуска.
\nТезис простой: цепочку поставки нужно проверять как связь фактов, а не как набор названий инструментов. Ревизия исходников, идентичность сборщика, граница секрета, digest образа, утверждение о происхождении и вход deploy должны иметь общий ключ и понятного владельца проверки. Декларация «образ подписан» не заменяет сопоставление subject с digest. Наличие переменной `TOKEN` в CI не доказывает, что её значение не попало в лог или слой образа.
\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.
\nDOCKER_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Представьте выпуск `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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В логах виден фрагмент токена | Секрет попал в 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 |
Наиболее опасная ветка начинается с частичного успеха. Образ собрался, тесты прошли, а subject attestation не совпал с digest deploy. В этот момент нельзя считать выпуск безопасным из-за зелёного pipeline. Остановите продвижение, сохраните безопасные метаданные, определите последний проверенный digest и выясните, где возникло расхождение: в registry, в выборе тега, в подготовке attestation или в конфигурации deploy.
\nЕсли секрет уже попал в лог или образ, удаление строки не возвращает безопасность. Отзовите и замените credential по правилам вашей платформы. Удалите доступный артефакт, проверьте кэши и логи, а затем соберите новый образ с другим digest. Не утверждайте, что утечки не было, если проверка охватила только git и не охватила registry или CI.
\nПример с Docker — учебный. В нём нет настоящего секрета, registry, CI run, подписи или deploy; плейсхолдер `/path/to/temporary/npmrc` нельзя использовать как production-рецепт. Secret mount снижает риск записи значения в финальный слой, но не защищает от команды, которая сама печатает секрет, сохраняет его в собранный файл или отправляет его в сеть. Secret scanning помогает обнаружить известные шаблоны, но не доказывает отсутствие всех credential.
\nProvenance описывает заявленные входы и исполнителя. Оно не делает builder доверенным само по себе. Подпись подтверждает связь statement с ключом или доверенной identity, но не превращает любое утверждение в факт. Полная проверка зависит от политики организации, runner, registry, формата attestation и правил deploy. Поэтому статья не заявляет production-результатов и не заменяет проверку конкретной платформы.
\nМатериал можно считать применённым к одному выпуску, когда команда без устных пояснений показывает: commit исходников, identity builder, границу доступа к секрету, digest образа, проверенное соответствие subject этому digest и тот же digest на входе deploy. Для отрицательного пути есть запись о том, что происходит при несовпадении. Если хотя бы одного поля нет или его нельзя проверить по первичному источнику, выпуск не помечают как подтверждённый.
\nСимптом выглядит так: зелёный pipeline не отвечает на главный вопрос расследования: что именно сейчас запущено. В записи о релизе может быть только тег build-123, хотя тег допускает переназначение. При этом команде нужно связать четыре факта: commit исходников, identity сборщика, digest образа и вход deploy. Если на шаге сборки использовали токен, добавляется пятый вопрос: где его значение могло сохраниться.
Разберём типовой выпуск как цепочку evidence — проверяемых свидетельств, а не как список названий инструментов. Секрет должен быть доступен только нужной команде и не попасть в результат. Attestation должна относиться к тому же digest, который запускает deploy. В конце получится короткий контрольный маршрут, который можно повторить на CI без доступа к значениям секретов.
\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 фиксирует способ доступа, но само по себе не доказывает отсутствие значения в логах, кэше или артефактах. Для этого нужны отдельные проверки.
Закрытая зависимость иногда требует credential во время npm ci, pip install или скачивания приватного репозитория. BuildKit secret mount делает значение доступным конкретной инструкции и не записывает его в финальный слой автоматически. Это отличается от ARG TOKEN и ENV TOKEN: Docker предупреждает, что build arguments и environment variables не подходят для передачи секретов, а аргументы могут оказаться в history или provenance.
# 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Вызов сборки передаёт путь к файлу секрета, а не само значение в аргументе командной строки:
\ndocker 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-логе.
Digest — криптографический идентификатор содержимого образа. Тег можно переназначить, поэтому его оставляют человекочитаемым алиасом, а для передачи между registry, attestation и deploy используют ссылку вида image@sha256:.... Сразу после push сохраните digest из registry и передайте именно его следующему этапу.
# Посмотреть 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. Сравнивать их как одну строку без этого решения нельзя.
Provenance описывает, где, когда и каким процессом получен артефакт. Это полезное заявление, но не автоматический сертификат безопасности. Проверяющий сначала удостоверяется в подписи по настроенному root of trust, затем сопоставляет subject с digest, проверяет ожидаемый predicateType и identity builder. После криптографической проверки остаётся ещё политический вопрос: разрешены ли этот репозиторий, workflow, commit и окружение.
Для SLSA-подобной проверки порядок важен. Если subject относится к sha256:91..., а deploy запускает sha256:7f..., валидная подпись не исправляет расхождение. Если builder неизвестен политике, запись о provenance нельзя считать достаточным основанием для выпуска. Если проверка относится к тегу, зафиксируйте разрешённый digest рядом с результатом, иначе между проверкой и deploy возможна подмена тега.
# Пример для 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| Симптом | Гипотеза | Проверка | Решение |
|---|---|---|---|
| Deploy содержит только тег | Тег переназначили после сборки | Получить фактический digest из runtime и сравнить с registry | Перевести manifest на digest и сохранить его в release evidence |
| Attestation есть, subject другой | Проверяли один output, запускают другой | Сравнить полные строки subject и deploy input | Остановить выпуск, выбрать проверенный digest и найти место расхождения |
В Dockerfile есть ARG TOKEN | Credential попал в history или metadata | Проверить docker history, metadata и историю CI | Отозвать токен, заменить передачу на secret mount, собрать новый образ |
| В логе виден фрагмент токена | Команда или debug напечатали секрет | Проверить весь run, артефакты, кэш и системы логирования | Немедленно отозвать credential и повторить выпуск с новым digest |
| Builder не входит в root of trust | Provenance подписана неизвестным исполнителем | Сверить builder identity и ключ с политикой проекта | Не принимать выпуск; сначала зарегистрировать доверенный путь или изменить builder |
Проверка должна завершаться сравнением значений, а не только просмотром зелёного статуса. Следующий фрагмент не публикует секрет и не меняет кластер: он моделирует последний decision gate перед deploy.
\nset -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 не доказывает происхождение образа.
predicateType, subject digest и заявленные входы provenance.Представим, что тесты прошли, образ опубликован, но attestation относится к sha256:91..., а deploy manifest содержит sha256:7f.... Сохраняем логи и metadata, блокируем promotion и выясняем, где возник разрыв: push создал другой output, тег разрешился иначе, attestation выпустили для соседнего артефакта или manifest собрали из старого значения.
Если credential попал в лог, слой или артефакт, удаление строки не возвращает его безопасность. Отзовите и замените credential по правилам вашей платформы, ограничьте доступ к копиям, проверьте retention и кэши, затем выпустите новый образ с новым digest. Результат расследования должен говорить, какие поверхности проверены; фраза «секрет не утёк» без охвата CI, registry и артефактов слишком сильна.
\nПримеры используют Docker BuildKit, registry, GitHub CLI и SLSA-термины. В Jenkins, GitLab, Yandex CI или закрытом registry будут другими команды, форматы attestation и политика доверия. Перед внедрением сверяйте версию Dockerfile frontend, возможности runner, режим кэширования, права registry и способ, которым runtime разрешает multi-platform image.
\nSecret mount уменьшает вероятность записи значения в финальный слой, но не делает процесс невосприимчивым к вредоносной команде, debug-выводу или компрометации builder. Digest защищает от подмены содержимого по этому адресу, но не доказывает, что исходный код безопасен. Attestation связывает заявление с артефактом и builder; SLSA отдельно оговаривает доверие к самой build-платформе. Поэтому модель не заменяет threat model, ротацию credential, контроль прав и независимую проверку runner.
\nВыпуск можно принять, когда без устных пояснений доступны commit, ссылка на CI run и builder identity; секрет получен через разрешённую границу и не найден в логах, слоях или артефактах; подпись и provenance проверены; subject совпадает с digest; тот же digest записан в deploy input. Для несовпадения есть автоматический fail-closed тест и понятный владелец расследования. Если поле недоступно или проверка охватывает только одну поверхность, статус выпуска остаётся неподтверждённым.
\n--secret, типы file/env и RUN --mount=type=secret.В CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.
\nОбратная ошибка тоже стоит дорого. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения. После нескольких таких итераций доверие к анализатору падает. Поэтому результат анализатора — это повод проверить контекст, а не готовый вердикт.
\nSARIF хранит сведения об инструменте, правиле, результате и позиции в файле. Эти поля отвечают на вопрос «где и по какой гипотезе сработал анализатор». Они не доказывают, что ветка исполняется, значение пришло из сети или команда действительно запускается. Для этого нужен контекст проекта: источник значения, путь до опасного вызова, граница доверия, владелец кода и область действия решения.
\nВозьмём узкую учебную гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую правило может искать. Он не является результатом реального сканирования и не доказывает уязвимость.
\nfunction runReport(request) {\n const reportName = request.query.name;\n return runShell(`report --name ${reportName}`);\n}\n\n// Учебный контекст: нужно отдельно проверить источник,\n// экранирование, достижимость ветки и фактический sink.\nУ этого совпадения есть несколько независимых вопросов. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, допустимы только формулировки «результат требует проверки» и «контекст неполный».
keep оставляет результат видимым. Выбирайте его, когда сигнал понятен, но контекст ещё не собран. Это не признание уязвимости и не отказ от исправления. Это сохранение наблюдаемости до следующей проверки.
tune меняет гипотезу правила. Такое действие нужно, если правило захватывает форму, которая не соответствует его назначению: например, оно не отличает безопасный массив аргументов от конкатенации строки. Tune требует новой версии правила, короткого описания diff и проверки того, какие совпадения перестанут появляться.
suppress временно ограничивает один идентифицируемый результат. У него должны быть точный fingerprint, узкий scope, владелец, причина и дата окончания. Suppress не делает код безопасным. Он только задаёт политику отображения конкретного сигнала.
Глобальное disable не заменяет ни одно из этих действий. Оно меняет поведение правила для текущих и будущих результатов. Если проекту действительно нужна такая смена policy, её надо рассматривать отдельно: назвать категорию, оценить потерю сигнала, назначить владельца и определить способ вернуть правило. Нельзя прятать решение уровня policy в комментарии к одному результату.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один результат выглядит безопасным | Нет источника значения и 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 и повторить проверку |
Запись должна быть короткой, но достаточной для повторной проверки. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание изменения. Для suppress добавьте точный fingerprint, scope и expiresOn. Дата следующего review не должна быть позже даты окончания исключения.
Причина «шум» ничего не объясняет. Хорошая причина связывает решение с проверяемым фактом: «вызов получает массив аргументов после нормализации; правило ожидает конкатенацию строки; diff проверен на двух учебных формах». В настоящем проекте сюда добавляют ссылку на задачу, commit или сохранённый контекст без секретов. Не добавляйте в публичную запись токены, пользовательские данные и полный фрагмент чувствительного кода.
\nНебольшой synthetic-пример полезен, когда нужно проверить сам контракт решения. Он должен отклонять неполные варианты: глобальное отключение, suppress без fingerprint, suppress без срока, tune без новой revision и context без trust boundary. Следующий фрагмент запускает только детерминированную проверку объектов в памяти. Он не читает репозиторий, не загружает rule pack, не запускает Semgrep, не меняет CI и не сообщает о найденной уязвимости.
\nconst 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', контракт слишком слабый. Это проверка формы решения, а не доказательство качества правила и не оценка безопасности приложения.
Статический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.
\nФормат исключений и fingerprint зависит от конкретного анализатора и версии CLI. Не переносите поля из учебного объекта в конфигурацию без проверки официальной документации. Не называйте synthetic result находкой, не заявляйте снижение числа ложных срабатываний без измерения и не утверждайте, что опасные случаи не потеряны без проверки на выбранном наборе кода.
\nRollback тоже имеет границу. Он возвращает видимость правила или убирает scoped exception. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным анализом.
\nРешение готово, когда другой инженер может по записи ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали keep, tune или suppress, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune должна существовать новая revision и проверенный diff. Для suppress должны совпадать fingerprint и scope, а expiry должна быть будущей. Для rollback должен быть выполнен повторный анализ на том же commit или явно зафиксировано, почему это невозможно.
\nЕсли хотя бы один ответ отсутствует, глобальное выключение не является исправлением. Оставьте сигнал видимым, назначьте владельца и доберите контекст. Так статический анализ остаётся управляемым источником технических сигналов, а не безымянным переключателем громкости.
\nВ CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.
\nОбратная ошибка стоит не меньше. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения, а затем перестают доверять анализатору. Результат анализатора — это повод проверить контекст, а не готовый вердикт. В этой статье keep, tune и scoped suppress — названия проектной policy, а не универсальные поля SARIF и не обещание, что любой анализатор понимает эти действия.
SARIF (Static Analysis Results Interchange Format) описывает обмен результатами статического анализа. В записи можно найти инструмент, правило, результат, сообщение, расположение в артефакте и уровень сигнала. Эти поля отвечают на вопросы «какая гипотеза сработала» и «где её обнаружили». Они сами по себе не доказывают, что ветка исполняется, значение пришло от внешнего пользователя или опасный вызов достижим в выпущенном артефакте.
\nУ результата есть ещё одна важная граница: fingerprint нужен системе управления результатами для сопоставления логически одинаковых сигналов между запусками. Спецификация допускает, что fingerprint добавит именно result management system после загрузки отчёта; прямой производитель SARIF обычно не должен выдумывать его без устойчивого алгоритма. Поэтому строка, которую команда вручную назвала fingerprint, не становится стабильным идентификатором только из-за имени.
Возьмём узкую гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую может искать правило. Это не результат реального сканирования и не доказательство уязвимости.
\nfunction runReport(request) {\n const reportName = request.query.name;\n return runShell(\"report --name \" + reportName);\n}\n\n// Отдельно проверяем источник, экранирование,\n// достижимость ветки и фактический sink.\nДля этого совпадения нужны четыре независимых ответа. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, точная формулировка звучит так: «результат требует проверки, контекст неполный».
Начинайте с наблюдаемого результата, а не с предполагаемого исправления. Сохраните ruleId, revision правила, URI, строку, уровень, сообщение и идентификатор сопоставления, если его выдала система управления результатами. Затем пройдите значение от входа до операции, на которую указывает правило. На каждом переходе фиксируйте не впечатление, а проверяемый факт: какой тип данных получен, какая функция вызвана и в какой сборочный путь она попадает.
Граница доверия находится там, где данные переходят из внешнего или неуправляемого источника в код, принимающий решение. Для HTTP-запроса такой границей может быть контроллер; для очереди — consumer; для файла конфигурации — загрузчик и права на файл. Валидация меняет риск, но её наличие нужно подтвердить кодом и тестом. Название функции вроде sanitize не является доказательством корректного экранирования.
| Что видно | Чего не хватает | Проверка | Решение policy |
|---|---|---|---|
| Один результат выглядит безопасным | Источник и trust boundary | Проследить значение до sink и проверить достижимость | keep до завершения triage |
| Сигнал появляется на безопасном API | Правило различает формы слишком грубо | Сравнить intent правила с двумя минимальными примерами | tune с новой revision и diff |
| Один результат мешает выпуску | Точный scope, владелец и срок | Сверить fingerprint, owner, reviewBy и expiresOn | Только scoped suppress |
| Предлагают выключить правило целиком | Оценка будущей потери сигнала | Рассмотреть изменение категории как отдельную policy | Отдельное решение с rollback |
| После изменения непонятен возврат | Повторный запуск на том же commit | Сравнить конфигурацию и новый отчёт | Вернуть узкое исключение или revision |
Если подозрение относится к гипотезе правила, сначала подготовьте два маленьких примера: один должен соответствовать намерению правила, второй — быть безопасной формой, которую оно не должно захватывать. Запускайте одну и ту же версию CLI с одной и той же конфигурацией. Команда ниже показывает общий путь для локального Semgrep-скана; имя конфигурации и каталог замените своими. Она сохраняет SARIF-файл, но не отвечает за достижимость кода или эксплуатацию сигнала.
\nsemgrep --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 правила должен объяснять, какой класс безопасных совпадений исключается и какие опасные формы остаются в области проверки.
keep оставляет результат видимым. Выбирайте его, когда контекст ещё не собран или проверка не завершена. Это не признание уязвимости и не отказ от исправления: команда сохраняет наблюдаемость до следующего шага.
tune меняет гипотезу правила. Такое действие оправданно, если правило захватывает форму, которая не соответствует его назначению. Укажите новую revision, покажите минимальный diff и проверьте положительный и отрицательный пример. Не называйте tune снижением false-positive rate без измерения на заранее выбранном наборе кода.
scoped suppress ограничивает один идентифицируемый результат. В нашей policy у него должны быть точный fingerprint, узкий scope, владелец, причина, дата следующей проверки и дата окончания. Suppress не делает код безопасным: он меняет видимость конкретного сигнала. Формат исключения и его область зависят от инструмента, поэтому эти поля нельзя механически перенести в конфигурацию другого анализатора.
Глобальное disable не заменяет ни одно из трёх действий. Оно меняет поведение правила для текущих и будущих результатов. Если проекту действительно нужна такая смена, назовите категорию, оцените потерю сигнала, назначьте владельца, зафиксируйте срок и отдельно опишите способ возврата. Комментарий к одной строке не может быть policy для всей категории.
Evidence — это короткая запись, по которой другой инженер может повторить решение. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание diff. Для scoped suppress добавьте точный fingerprint, scope и expiresOn. Дата следующей проверки не должна быть позже срока окончания исключения.
Причина «шум» ничего не объясняет. Причина должна связывать решение с фактом: «вызов получает массив аргументов после нормализации; правило ожидает конкатенацию строки; оба минимальных примера проверены». В настоящем проекте добавьте ссылку на задачу или commit, но не добавляйте токены, пользовательские данные и полный чувствительный фрагмент кода.
\nДо применения policy проверьте, что неполная запись не проходит. Нужна не проверка непустых строк, а минимальный контракт: разрешены только три действия; suppress требует точного scope и двух корректных дат; tune требует новой revision. Фрагмент ниже запускается в Node.js и работает только с объектом в памяти. Он не читает репозиторий, не запускает сканер и не применяет конфигурацию.
\nnode --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 февраля может пройти поверхностную проверку.
Rollback — не удаление строки из конфигурации. Для suppress удалите именно это исключение, повторите анализ на том же commit и проверьте, что ожидаемый результат снова виден. Для tune верните прежнюю revision, повторите минимальную пару и сравните отчёты. Если повторный анализ невозможен, зафиксируйте причину и риск, а не называйте возврат завершённым.
\nУ rollback есть ограничение: он возвращает видимость правила или убирает узкое исключение. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным запуском и сверкой baseline.
\nСтатический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF фиксирует структуру обмена, но не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.
\nКоманда Semgrep в примере — ориентир для CLI, а не зафиксированный контракт всех будущих версий. Закрепите версию в CI, сохраните вывод semgrep --version и проверьте опции в документации перед миграцией. Не переносите названия keep, tune и scoped-suppress в инструмент без адаптера и теста его реального формата.
Не заявляйте покрытие, снижение ложных срабатываний или отсутствие пропущенных опасных случаев без измерения на конкретном наборе кода. Если неизвестны источник, граница доверия, владелец или артефакт, оставьте результат видимым. Это честнее, чем скрыть неопределённость глобальным выключателем.
\nРешение готово, когда другой инженер может ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали действие, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune существует новая revision и проверенный diff. Для suppress совпадают fingerprint и scope, даты корректны, а expiry не прошёл. Для rollback есть повторный анализ или явно записана причина, почему он невозможен.
\n