Files
progcode/web/scripts/upgrade-2020-02.mjs
T
huncode 839a9aeeb8
Build and deploy / deploy (push) Successful in 12s
revise February 2020 configuration articles
2026-07-31 11:14:56 +03:00

328 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + String(code).trim() + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two official sources are required');
}
return {
...meta,
contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources),
};
}
const gitIgnore = {
title: 'Git: gitignore documentation',
url: 'https://git-scm.com/docs/gitignore',
note: 'игнорируются только намеренно неотслеживаемые пути; уже tracked файл правило не убирает из индекса',
};
const dockerComposeEnvironment = {
title: 'Docker Docs: environment variables in Compose',
url: 'https://docs.docker.com/compose/how-tos/environment-variables/',
note: 'документация разделяет переменные контейнера, интерполяцию Compose и их приоритет',
};
const dockerDockerfile = {
title: 'Docker Docs: Dockerfile reference',
url: 'https://docs.docker.com/reference/dockerfile/',
note: 'ENV сохраняется в образе и доступен контейнеру; ARG не следует считать местом для credentials или токенов',
};
const nodeProcess = {
title: 'Node.js v12: process.env',
url: 'https://nodejs.org/dist/latest-v12.x/docs/api/process.html#process_process_env',
note: 'Node читает окружение процесса через process.env; это вход runtime, а не схема валидации сама по себе',
};
const githubLeakedSecret = {
title: 'GitHub Docs: remediating a leaked secret',
url: 'https://docs.github.com/en/code-security/tutorials/remediate-leaked-secrets/remediating-a-leaked-secret',
note: 'удаление строки не заменяет отзыв и выпуск новой учётной пары; затронутые потребители требуют отдельной проверки',
};
const configExample = [
'# config.example.env — учебный шаблон, его можно хранить рядом с кодом.',
'APP_ENV=development',
'PAYMENTS_API_URL=https://gateway.invalid',
'PAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET',
'LOG_LEVEL=info',
'',
'# В настоящем контуре значение токена приходит отдельным защищённым каналом.',
].join('\n');
const runtimeConfigExample = [
'const required = ["APP_ENV", "PAYMENTS_API_URL", "PAYMENTS_TOKEN"];',
'',
'function readRequired(name, env = process.env) {',
' const value = env[name];',
' if (!value) throw new Error("Missing required setting: " + name);',
' return value;',
'}',
'',
'export function loadConfig(env = process.env) {',
' for (const name of required) readRequired(name, env);',
' return {',
' appEnv: env.APP_ENV,',
' paymentsApiUrl: env.PAYMENTS_API_URL,',
' paymentsToken: env.PAYMENTS_TOKEN,',
' logLevel: env.LOG_LEVEL || "info",',
' };',
'}',
'',
'export function safeConfigReport(config) {',
' return { appEnv: config.appEnv, paymentsApiUrl: config.paymentsApiUrl,',
' paymentsToken: "[REDACTED]", logLevel: config.logLevel };',
'}',
].join('\n');
const dockerRuntimeExample = [
'FROM node:12-alpine',
'WORKDIR /app',
'COPY package*.json ./',
'RUN npm ci --only=production',
'COPY . .',
'ENV APP_ENV=production',
'CMD ["node", "server.js"]',
'',
'# Токен не объявлен через ARG или ENV в Dockerfile.',
'# Контур запуска передаёт его процессу отдельно от образа.',
].join('\n');
const redactionExample = [
'function redactHeaders(headers) {',
' const result = {};',
' for (const [name, value] of Object.entries(headers)) {',
' result[name] = /authorization|token|secret|password/i.test(name)',
' ? "[REDACTED]"',
' : value;',
' }',
' return result;',
'}',
'',
'const sample = {',
' authorization: "Bearer DEMO_NOT_A_REAL_TOKEN",',
' requestId: "sample-2020-02",',
'};',
'',
'redactHeaders(sample);',
'// { authorization: "[REDACTED]", requestId: "sample-2020-02" }',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2020-02-practice-configs-secrets',
title: 'Конфигурация без утечки: разделяем настройки, секреты и доставку',
categories: ['Безопасность', 'DevOps'],
cover: '/assets/editorial/2020/config-secret-boundary-2020.svg',
excerpt: 'Когда пароль лежит рядом с настройкой, он проходит через Git, образ и лог. Собираем короткий контракт конфигурации: что хранить в коде, что передавать при запуске и что проверять до выпуска.',
readingMinutes: 15,
},
[
paragraph('Симптом выглядит бытовым: локально сервис стартует с <code>.env</code>, а перед выпуском кто-то копирует тот же файл в репозиторий, Dockerfile или описание job. Через неделю токен оказывается в error-ответе, логе сборки либо в образе, который можно скачать из registry. Цена не сводится к неловкому commit: доступ нужно отзывать, потребителей переключать, а релиз в этот момент теряет предсказуемость.'),
paragraph('Причина обычно не в одном неосторожном человеке. В проекте нет явной границы между настройкой поведения и значением, которое даёт доступ. Исправление начинается не с лозунга «не коммитить пароли», а с небольшого контракта: имя переменной, класс значения, потребитель, канал доставки, правило логирования и владелец смены. Такой контракт связывает backend, delivery и поддержку, но не требует в феврале 2020 года строить отдельную платформу секретов.'),
heading('Сначала отделяем четыре класса значений'),
paragraph('У переменной может быть безопасное имя и опасное значение. <code>LOG_LEVEL</code> меняет поведение процесса и обычно подходит для шаблона. <code>PAYMENTS_API_URL</code> описывает адрес зависимости, но его всё равно не стоит бездумно отдавать в браузерный bundle. <code>PAYMENTS_TOKEN</code> даёт право выполнять действие; он не должен попадать в пример, клиентский код, лог или образ. Отдельно держим технический идентификатор: он полезен для поиска конфигурации, но не заменяет credential.'),
dataTable(
'Инвентарь минимальной конфигурации сервиса',
['Класс', 'Учебный пример', 'Где хранить имя и пример', 'Что можно писать в лог', 'Проверка перед выпуском'],
[
['Настройка поведения', '<code>LOG_LEVEL=info</code>', 'Шаблон и документация', 'Имя и выбранный уровень', 'Значение соответствует ожидаемому набору'],
['Адрес зависимости', '<code>PAYMENTS_API_URL=https://gateway.invalid</code>', 'Шаблон с фиктивным адресом', 'Имя; адрес — только если это не чувствительная внутренняя топология', 'Runtime получает адрес из нужного контура'],
['Секрет', '<code>PAYMENTS_TOKEN</code>', 'Только имя и описание назначения', 'Только имя и <code>[REDACTED]</code>', 'Есть отдельный канал доставки и владелец ротации'],
['Технический ID', '<code>CONFIG_REVISION=sample-42</code>', 'Шаблон или release record', 'ID допустим, если он не credential', 'ID позволяет сопоставить выпуск и набор настроек'],
],
),
paragraph('Таблица не объявляет адреса или идентификаторы безопасными по умолчанию. Внутренний hostname, имя клиента или путь к административному API тоже могут быть чувствительными в конкретном проекте. Смысл классификации в другом: до deploy команда знает, какое поле нельзя помещать в общий артефакт и что именно проверить, когда конфигурация меняется между средами.'),
figure(
'/assets/editorial/2020/config-secret-boundary-2020.svg',
'Вертикальная схема границы: репозиторий хранит имена и безопасный шаблон, delivery передаёт значение по отдельному каналу, runtime валидирует наличие, а лог получает только маску.',
'Один и тот же набор имён проходит четыре поверхности. Секрет не должен пересекать репозиторий, образ и диагностический вывод как обычная настройка.',
),
heading('Инвентарь важнее папки с env-файлами'),
paragraph('Начинаю с одного листа, а не с поиска универсального хранилища. Для каждого значения записываю: кто его создаёт, какой процесс читает, может ли оно жить в шаблоне, кто получит уведомление о смене и где значение можно случайно увидеть. Если в строке нет владельца или потребителя, это повод не переносить её в следующий deploy, пока назначение не станет понятным.'),
paragraph('Полезно отдельно отметить путь до процесса. Локальная машина может читать неотслеживаемый файл. Сборочная job может получить переменную из защищённой настройки самого CI. Боевой процесс может получить файл или переменную от привычного для команды механизма запуска. Эти способы не обязаны быть одинаковыми; опасно, когда копирование из одного способа в другой происходит молча и вместе с реальным токеном.'),
paragraph('Проверка здесь простая: в шаблоне остаются имена, объяснение и очевидно фиктивные значения; в проектной документации — способ получить доступ для разработчика; в журнале release — только факт, какой набор был применён. Никто не просит присылать значение в issue или чат «для проверки». Если без этого нельзя диагностировать ситуацию, сначала нужно добавить безопасный идентификатор конфигурации или отдельный тестовый credential.'),
heading('Делаем конфигурацию входом runtime, а не глобальной случайностью'),
paragraph('Node читает окружение через <code>process.env</code>, но сам объект не гарантирует типы, обязательность и отсутствие лишнего вывода. Если каждый модуль читает его напрямую, один обработчик начнёт падать на пустом значении, другой подставит тестовый URL, третий выведет весь объект в ошибку. Поэтому на старте процесса собираем небольшой объект конфигурации и дальше передаём его по зависимостям явно.'),
codeBlock(runtimeConfigExample),
paragraph('В примере нет настоящего ключа: строка с фиктивным значением находится только в шаблоне выше. Загрузка останавливает процесс до первого запроса, если имя не пришло. Ошибка содержит имя настройки, а не её значение. Отчёт для диагностики специально возвращает маску. Это не криптографическая защита и не замена прав доступа; это граница, которая не позволяет обычному <code>console.log</code> превратить конфигурацию в утечку.'),
paragraph('Конфигурацию стоит проверить в тесте отдельным объектом <code>env</code>: один сценарий без <code>PAYMENTS_TOKEN</code> должен дать понятную ошибку, другой — вернуть объект с ожидаемым URL, третий — показать <code>[REDACTED]</code> в safe report. Такой тест не обращается к платёжной системе и не знает реальный token. Он проверяет договорённость между кодом и delivery до того, как она станет аварией на окружении.'),
heading('Git-правило не лечит уже попавший файл'),
paragraph('Файл <code>.env.local</code> разумно добавить в <code>.gitignore</code>, чтобы новый локальный файл не попал в <code>git add</code>. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже был закоммичен, новое правило не удалит его из index и не закроет историю. Это частый ложный успех: в рабочем дереве всё выглядит тихо, а в diff или старом commit значение по-прежнему доступно.'),
codeBlock(configExample),
paragraph('Проверяю две вещи до merge: <code>git check-ignore -v .env.local</code> показывает, какое правило защищает новый локальный файл; <code>git ls-files --error-unmatch .env.local</code> не должен находить его среди tracked путей. Эти команды отвечают только о Git. Они не говорят, не попало ли значение в лог CI, Docker context или архив deployment. Для каждой из этих поверхностей нужен свой короткий check.'),
heading('Путь от шаблона к безопасному запуску'),
orderedList([
'Собрать список переменных у сервиса и отметить для каждой класс, потребителя и владельца. Не переносить неизвестное значение «на всякий случай».',
'Создать versioned шаблон с именами, безопасными defaults и адресами на доменах <code>.invalid</code>. Реальные значения в шаблон не подставлять.',
'Добавить один loader на границе runtime: он валидирует обязательные имена и выдаёт безопасный диагностический отчёт.',
'Выбрать существующий канал delivery для секрета: защищённая переменная CI, файл с ограниченным доступом или механизм хоста. Зафиксировать, кто меняет значение и как уведомляет потребителя.',
'Проверить Git отдельно от сборки: локальный файл ignored и не tracked; проверить Dockerfile и scripts на отсутствие токенов в аргументах, ENV и echo.',
'Выпустить change с идентификатором конфигурации без значения. После запуска проверить только факт чтения нужных имён, redacted log и работоспособность зависимого сценария.',
]),
heading('Где граница этого рецепта'),
paragraph('Этот порядок не выбирает за проект способ хранения или выдачи credentials. У маленькой команды это может быть защищённый файл на host и ручная передача по ограниченному каналу; у другой — переменные CI. До выбора сложной системы важнее получить базовую дисциплину: секрет не лежит в Git, не прошивается в образ, не выводится в лог и имеет человека, который может его заменить.'),
paragraph('Не стоит заявлять успех по одному зелёному deploy. Проверка закончена, когда известно: откуда процесс получил имя и значение, какой код остановит запуск при пустом поле, какой лог не раскроет token и кто проведёт ротацию при утечке. Если один из ответов неизвестен, это не повод расширить шаблон. Это точка для маленькой задачи на delivery или backend с явным владельцем.'),
],
[gitIgnore, nodeProcess, dockerComposeEnvironment, dockerDockerfile],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-02-mechanism-configs-secrets',
title: 'Под капотом: как настройка проходит до процесса и становится утечкой',
categories: ['Безопасность', 'DevOps'],
cover: '/assets/editorial/2020/config-secret-delivery-path-2020.svg',
excerpt: 'Настройка не исчезает после запуска: она проходит через репозиторий, сборку, образ, delivery и лог. Разбираем границы носителей, чтобы токен не оказался в удобном, но неверном месте.',
readingMinutes: 16,
},
[
paragraph('Симптом: приложение уверенно работает в development, но после выпуска либо получает не тот URL, либо оставляет credential в Docker image, console output или диагностическом объекте. Цена двойная. Сервис может стать недоступен из-за пустой переменной, а затем команда, пытаясь быстро увидеть конфигурацию, сама расширяет поверхность утечки. В обоих случаях проблема начинается раньше runtime — в пути, которым значение дошло до процесса.'),
paragraph('Причина — смешение носителей. Репозиторий, build context, image, job log, переменная процесса и система журналирования имеют разные сроки жизни и разные аудитории. Называть их одним словом «env» недостаточно. Для M3-практики 2020 года полезнее нарисовать маршрут значения, указать допустимую остановку на каждой границе и не делать вид, что любой токен уже обслуживает современная secrets-платформа.'),
heading('У каждого носителя свой срок жизни'),
dataTable(
'Граница конфигурации от кода до запущенного процесса',
['Носитель', 'Кто обычно видит', 'Как долго живёт', 'Допустимое содержимое', 'Типовая ошибка'],
[
['Репозиторий и шаблон', 'Разработчики и все клоны', 'История commit', 'Имена, документация, фиктивные defaults', 'Реальный token в <code>.env</code> или fixture'],
['Build context и job', 'Сборщик, логи CI, cache', 'До очистки job и cache policy', 'Исходники и безопасные параметры сборки', 'Печать окружения или передача credential в командной строке'],
['Docker image', 'Registry и тот, кто запускает image', 'Пока образ хранится', 'Код и несекретные runtime defaults', 'Секрет в <code>ENV</code> или <code>ARG</code> Dockerfile'],
['Runtime process', 'Процесс и ограниченный контур запуска', 'До restart или смены значения', 'Нужные приложению настройки и credential', 'Общий dump <code>process.env</code> в error'],
['Логи и issue', 'Поддержка, мониторинг, участники incident', 'По retention policy', 'Имена, request ID, redacted поля', 'Копирование значения «для расследования»'],
],
),
paragraph('Из таблицы следует важное ограничение: переменная окружения — способ передать значение процессу, а не доказательство, что оно скрыто. У процесса может быть много читателей: библиотека логирования, crash handler, shell wrapper, дочерняя команда. Секрет становится уязвимым не в момент чтения <code>process.env</code>, а в момент, когда его копируют в более широкий носитель без необходимости.'),
figure(
'/assets/editorial/2020/config-secret-delivery-path-2020.svg',
'Вертикальный путь конфигурации: версия в репозитории содержит только имена, delivery подаёт значение отдельно, loader проверяет контракт runtime, процесс получает объект, а лог видит redacted отчёт.',
'Схема подчёркивает точки остановки. Value не обязан проходить каждый этап и не должен попадать в образ или общий лог ради удобства диагностики.',
),
heading('Сборка и запуск отвечают на разные вопросы'),
paragraph('Сборка должна собрать воспроизводимый кодовый артефакт; запуск должен подать конфигурацию конкретного контура. Когда эти задачи склеены, production URL или token пытаются передать как build argument, записать в сгенерированный JavaScript либо положить в Dockerfile. В результате один и тот же credential начинает жить столько же, сколько image, хотя нужен только работающему процессу.'),
paragraph('Docker различает <code>ENV</code> и <code>ARG</code>, но это не повод использовать любой из них как тайник для credentials. <code>ENV</code> сохраняет значение для контейнеров, созданных из image. Документация Docker также предупреждает не передавать credentials и API tokens через build arguments: история и метаданные могут стать лишней поверхностью. В феврале 2020 года практическое правило проще технологии: секрет не появляется в Dockerfile, а delivery подаёт его после выбора image.'),
codeBlock(dockerRuntimeExample),
paragraph('Этот Dockerfile нарочно бедный: в нём есть безопасный runtime default <code>APP_ENV</code>, но нет URL платежей, пароля базы или токена. Конкретный контур запуска может передать нужные значения через свой ограниченный канал. Пример не утверждает, что контейнер уже защищён, и не показывает production-команду. Он фиксирует границу: image не обязан знать credential, чтобы сервис смог начать работу.'),
heading('Loader превращает набор строк в контракт'),
paragraph('После delivery процесс получает строки. Без loader значения начинают жить в произвольных модулях: handler подставляет fallback, worker читает другое имя, а диагностическая ветка сериализует весь объект окружения. Центральная загрузка не решает права доступа, зато делает два свойства явными: какие ключи обязательны и какие из них нельзя показать в отчёте.'),
codeBlock(runtimeConfigExample),
paragraph('Проверка loader не требует реального deployment. Для unit fixture передаём объект с <code>APP_ENV=staging</code>, <code>PAYMENTS_API_URL=https://gateway.invalid</code> и фиктивным <code>PAYMENTS_TOKEN</code>. Затем убираем один обязательный ключ и ожидаем ошибку с его именем; отдельным test проверяем, что safe report возвращает маску. Так backend получает контракт до сетевого вызова, а delivery знает, какие названия нельзя потерять при переносе job.'),
heading('Приоритет переменных — отдельный источник расхождений'),
paragraph('Compose и shell умеют брать значения из нескольких мест: файла, окружения вызывающего процесса, атрибутов configuration и параметров запуска. Поэтому сообщение «у нас есть .env» не отвечает на вопрос, какое значение получит контейнер. Сначала нужно зафиксировать источник для каждого класса переменных, затем посмотреть итоговую конфигурацию без раскрытия values и лишь после этого разбираться с кодом.'),
paragraph('Для непубличного URL или token я не предлагаю выводить итоговую строку в CI. Достаточно проверить наличие обязательного имени, identifier набора и факт, что runtime прошёл валидацию. Если команда действительно должна сравнить значение между средами, ей нужен отдельный безопасный способ сопоставления, а не <code>printenv</code> в общем логе. Такой запрет неудобен ровно до первого incident; затем он экономит время всем участникам.'),
heading('Короткая диагностика по границам'),
orderedList([
'Назвать наблюдаемый сбой: пустой ключ, неправильный URL, credential в image или value в логе. Не начинать с одновременного изменения кода и job.',
'Проверить repository boundary: шаблон содержит только имена и фиктивные defaults, а локальные файлы не tracked. Если credential уже в истории, переключиться на маршрут ротации, а не на обычный cleanup.',
'Проверить build boundary: Dockerfile, scripts и логи job не получают токен как аргумент, не печатают полный env и не записывают value в generated bundle.',
'Проверить delivery boundary: у каждого обязательного имени указан источник и владелец; лог выпуска содержит только безопасный revision или идентификатор.',
'Проверить runtime boundary: loader отвергает отсутствующее значение до первого запроса, а safe report маскирует секретные поля.',
'Проверить support boundary: error serializer, HTTP logger и issue template не копируют headers, env или конфигурационный объект целиком.',
'Только после этих проверок менять fallback или retry. Иначе технический симптом скроет неверную поставку конфигурации.',
]),
heading('Что проверяет образ, а что проверяет выпуск'),
paragraph('Образ проверяют на отсутствие секретов и на то, что он несёт код, зависимости и несекретные defaults. Выпуск проверяют на другой контракт: конкретный контур передал обязательные значения, приложение не раскрывает их при старте и зависимый сценарий прошёл с нужной конфигурацией. Эти проверки связаны, но не взаимозаменяемы. Чистый image не доказывает, что процесс получил верный URL; успешный запрос не доказывает, что token не остался в history.'),
paragraph('Иногда после такой проверки остаётся вопрос: где именно хранить credential, кто выдаёт доступ и как вести audit. Это правильный следующий вопрос, но он шире одного loader или Dockerfile. Автономный пакет не выбирает ответ вместо команды. Он оставляет минимальную техническую поверхность, на которой любой выбранный механизм можно проверить: секрет не в Git, не в build output, не в image по умолчанию и не в diagnostics.'),
heading('Ограничение: маска не отменяет доступ'),
paragraph('Редакция логов предотвращает случайное распространение, но не отменяет права того, кто уже может читать runtime environment или deployment host. Ignore-файл защищает новый локальный путь, но не отзывают старое значение. Loader делает недостающую настройку видимой, но не создаёт credential. Поэтому результат статьи — не обещание «секреты решены», а маршрут для узкой проверки: обнаружить носитель, назвать владельца, сократить копии и подготовить ротацию для случая утечки.'),
],
[dockerDockerfile, dockerComposeEnvironment, nodeProcess, gitIgnore],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-02-field-configs-secrets',
title: 'Разбор: токен попал в лог — как провести ротацию без ложного исправления',
categories: ['Безопасность', 'DevOps'],
cover: '/assets/editorial/2020/config-secret-rotation-2020.svg',
excerpt: 'Удалить строку из кода недостаточно, если credential уже увидели Git, лог или внешний сервис. Разбираем учебный incident: ограничить распространение, переключить потребителей, отозвать старое значение и оставить защиту.',
readingMinutes: 16,
},
[
paragraph('Симптом учебного incident: обработчик 500-го ответа сериализовал заголовки запроса, и в централизованном логе появилась строка <code>Authorization</code>. Через несколько минут её заметили в поиске по логам. Цена — не только удаление одного поля. Значение могло попасть в alert, экспорт поддержки или сохранённый debug-ответ; пока старый credential действует, у команды нет права считать проблему закрытой.'),
paragraph('Самая опасная реакция — быстро замаскировать свежий лог и написать «готово». Причина не устранена: нужно понять, где значение было создано, кто его потребляет, можно ли выпустить замену без простоя и как отозвать старое. В феврале 2020 года это можно разобрать без легенды о большой security-платформе: короткая карта фактов, смена credential у провайдера, обновление delivery и проверка всех зависимых процессов.'),
heading('Сначала фиксируем факты, не копируя секрет ещё раз'),
paragraph('В карточку incident не вставляю сам token, даже частично. Достаточно записать имя переменной, тип credential, момент обнаружения, носитель, предполагаемых потребителей и ссылку на закрытый безопасный канал владельца. Если значение уже видно в логе, дополнительная пересылка в issue расширяет круг читателей и создаёт ещё одну retention policy, которую потом придётся учитывать.'),
dataTable(
'Учебная карта ротации: что должно остаться после каждого шага',
['Этап', 'Ответственный за действие', 'Безопасный артефакт', 'Что блокирует переход'],
[
['Обнаружение и ограничение', 'Дежурный разработчик или владелец сервиса', 'ID incident, имя credential, время, носитель', 'Непонятно, активен ли старый credential и где он виден'],
['Новая пара и delivery', 'Владелец credential и deploy owner', 'ID новой версии, список потребителей без values', 'Не все consumers готовы читать новую пару'],
['Переключение', 'Владелец каждого процесса', 'Проверка зависимого сценария и redacted startup record', 'Есть consumer со старым значением или без owner'],
['Отзыв старого', 'Владелец у провайдера', 'Подтверждение revoke и время', 'Новая пара не проверена'],
['Очистка следов и профилактика', 'Владелец репозитория и logging path', 'Список носителей, тест redaction, follow-up task', 'Удаление строки выдано за отзыв credential'],
],
),
paragraph('Таблица разделяет роли сознательно. Тот, кто видит ошибку, может не иметь права отозвать ключ у внешнего поставщика. Тот, кто создаёт replacement, может не знать всех воркеров, которые читают старую переменную. Когда этапы смешаны, команда либо отзывает credential слишком рано и создаёт простой, либо ждёт бесконечно, потому что никто не ведёт переключение. Карта делает неопределённость наблюдаемой до необратимого шага.'),
figure(
'/assets/editorial/2020/config-secret-rotation-2020.svg',
'Вертикальная схема ротации: обнаружить и ограничить распространение, создать замену, обновить потребителей, отозвать старое значение, проверить следы и добавить защиту.',
'Ротация — это последовательность зависимых действий, а не commit с удалённой строкой. Старый credential отзывают только после проверки новой поставки или согласованного окна простоя.',
),
heading('Ограничиваем новую утечку до ротации'),
paragraph('Первое действие — остановить дальнейшее распространение. Для учебного случая это значит убрать сериализацию headers из error path, ограничить доступ к конкретному поисковому запросу и сообщить владельцу credential. Не нужно сносить все логи или удалять проект: расследованию пригодятся время, request ID и версия сервиса, но они должны жить в разрешённом контуре. Ценность события — в фактах, а не в копии секретной строки.'),
paragraph('Следующий быстрый check — найти все очевидные потребители <em>по имени</em>, а не по значению: backend service, worker, локальная инструкция, job delivery и тестовый контур. Поиск реального token в чатах или массовых логах может сам стать новой утечкой. Если нельзя связать имя с потребителем, эту неизвестность фиксируем как риск и не называем смену законченной.'),
heading('Правим diagnostic path отдельным маленьким change'),
paragraph('Логгеру не нужно угадывать, что каждый header безопасен. В обучающем примере имя заголовка проходит через allow/deny правило, а потенциально чувствительные поля получают одну и ту же маску. Реальный проект может иметь другой HTTP-клиент и другой формат логов; проверяемая идея одна: test должен доказывать, что в диагностическом объекте нет исходного значения.'),
codeBlock(redactionExample),
paragraph('Строка <code>DEMO_NOT_A_REAL_TOKEN</code> намеренно фиктивна. Она проверяет форму результата, а не доступ к внешней системе. У полезного test есть два исхода: <code>authorization</code> заменяется на <code>[REDACTED]</code>, а <code>requestId</code> остаётся, чтобы support мог связать запись с incident. Если test печатает sample целиком до вызова redaction, он не выполняет задачу — утечка уже случилась в самом тестовом выводе.'),
paragraph('В такой change не стоит одновременно «улучшать» всю observability. Нужен узкий diff: безопасная функция или сериализатор, fixture с явно ненастоящим значением и один отрицательный test. Затем отдельная проверка должна посмотреть, не обходит ли другой error path этот serializer. Иначе новая маска создаст уверенность, а второй обработчик продолжит писать credential без защиты.'),
heading('Ротация — это change с зависимостями'),
paragraph('Порядок зависит от провайдера credential. Если он допускает две активные пары, сначала создаём новую, доставляем её всем известным consumers, проверяем сценарий, а затем отзываем старую. Если пара не может существовать одновременно, заранее выбираем короткое окно: останавливаем потребителя, меняем значение, запускаем проверку и фиксируем простой. Нельзя обещать бесшовность там, где provider её не гарантирует.'),
paragraph('После обновления каждый consumer подтверждает только безопасный результат: название новой версии или время смены, успешный запрос в разрешённом тестовом сценарии, отсутствие старого имени в действующем config contract. Ни один из этих сигналов не требует передать token в issue. Если потребитель не может подтвердить смену, у него либо нет наблюдения, либо нет владельца; оба случая нужно закрыть до revoke.'),
heading('Удаление из Git и из лога не равно отзыву'),
paragraph('Git ignore помогает предотвратить добавление нового неотслеживаемого файла, но не удаляет уже tracked путь. Аналогично новый commit, который маскирует поле, не делает старое значение недействительным. GitHub в руководстве по утёкшим credentials отдельно ставит отзыв и выпуск замены раньше уборки repository: пока provider принимает старую пару, историческая или логовая копия остаётся рабочим риском.'),
paragraph('Очистка истории и retention логов может быть нужной, но это согласованная операция с владельцем репозитория, хостинга и backup policy. В учебном сценарии я не предлагаю переписывать основную ветку или удалять записи вслепую. Сначала создаём новую рабочую пару, переключаем процессы и фиксируем revoke. Затем команда оценивает, какие копии ещё доступны и какой именно процесс уборки поддерживает её хостинг.'),
heading('Маршрут incident от сигнала до критерия готовности'),
orderedList([
'Создать закрытую запись incident с именем credential, временем, носителем и владельцами; само значение не копировать.',
'Остановить новый поток утечки: исправить serializer или логгер, сузить доступ к найденной записи и сохранить безопасные диагностические ID.',
'Собрать список consumers по имени переменной и назначить владельца каждому. Не считать локальную инструкцию или worker неважным только потому, что он редко запускается.',
'Согласовать с провайдером способ замены: параллельная новая пара либо окно переключения. Создать replacement через разрешённый канал, не через commit.',
'Доставить новую конфигурацию каждому consumer и выполнить его узкую функциональную проверку. В записи оставить ID версии и результат, но не value.',
'Отозвать старое значение у провайдера после проверки всех известных consumers либо в согласованное окно простоя.',
'Проверить repository, image, CI log и logging path на следы прежней схемы; историю и retention чистить отдельной согласованной задачей.',
'Добавить regression test redaction, шаблон без values и follow-up на неизвестных consumers. Закрыть incident только с подтверждением revoke и результатом проверок.',
]),
heading('Что считается завершением, а что нет'),
paragraph('Результат достаточен, когда старая пара отозвана, новая конфигурация проверена каждым известным consumer, error path маскирует соответствующие поля и у incident есть список неразрешённых копий либо подтверждение их обработки. Нет оснований писать, что «утечки не было»: команда может знать только носители, которые успела проверить. Это честная граница вывода и причина оставить follow-up, если лог-архив или старый clone требует отдельного владельца.'),
paragraph('Этот разбор не выполняет ротацию реального сервиса, не открывает provider portal и не проверяет production. Он показывает форму безопасной работы: не размножать value в расследовании, не путать cleanup с revoke, не скрывать неизвестного consumer и добавлять контроль в точке, где diagnostic path раньше показал секрет. Для следующей команды это полезнее, чем один раз удалить строку и надеяться, что похожая ветка кода не вернётся.'),
],
[githubLeakedSecret, gitIgnore, dockerDockerfile, nodeProcess],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
}