revise February 2020 configuration articles
Build and deploy / deploy (push) Successful in 12s

This commit is contained in:
2026-07-31 11:14:56 +03:00
parent 58941bea98
commit 839a9aeeb8
7 changed files with 557 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 73 из 358 созданных материалов. Остальные 285 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 76 из 358 созданных материалов. Остальные 282 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+115
View File
@@ -0,0 +1,115 @@
# Февраль 2020 — тройное ревью автономного пакета P24 «Настройки и секреты»
Статус: **принят в publication registry 31 июля 2026 года**. Ревизии
накладываются только по stable slug и сохраняют даты и авторов базового архива:
- <code>editorial-2020-02-practice-configs-secrets</code>;
- <code>editorial-2020-02-mechanism-configs-secrets</code>;
- <code>editorial-2020-02-field-configs-secrets</code>.
Модуль экспортирует ровно три revision. В объектах намеренно нет
<code>date</code> и <code>author</code>: дата и автор остаются у исходных
записей архива. При прямом вызове <code>--print-revisions</code> stdout
содержит только JSON.
## Проход 1. Факты, версии и технические ограничения — пройдено
| Утверждение или решение | Первичный источник | Проверенная граница |
| --- | --- | --- |
| `.gitignore` защищает только новый неотслеживаемый путь | [Git: gitignore](https://git-scm.com/docs/gitignore) | Тексты не называют его очисткой уже tracked файла или истории. |
| `process.env` — вход runtime для Node | [Node.js v12: process.env](https://nodejs.org/dist/latest-v12.x/docs/api/process.html#process_process_env) | Loader в примерах добавлен самим приложением; Node не обещает типизацию, redaction или delivery policy. |
| `ENV` сохраняется в образе, а credentials не стоит передавать через Docker build arguments | [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) | Статьи не объявляют ARG безопасным хранилищем и не строят обязательную cloud-native платформу. |
| Compose имеет несколько источников и приоритетов переменных | [Docker: environment variables in Compose](https://docs.docker.com/compose/how-tos/environment-variables/) | Материал не приписывает конкретную конфигурацию этому проекту; источник и итог нужно проверить в каждом контуре. |
| Удалить строку недостаточно для активного credential | [GitHub: leaked-secret remediation](https://docs.github.com/en/code-security/tutorials/remediate-leaked-secrets/remediating-a-leaked-secret) | Ротация показана как учебный маршрут. Пакет не отзывал реальный ключ и не делал production-проверку. |
Историческая граница зафиксирована сознательно: это М3 / февраль 2020 года.
Автор связывает backend loader, Docker image, CI/deploy delivery и logging,
но не выдаёт любой проект за Kubernetes-ландшафт и не рекомендует современные
secrets-платформы как исходную норму. Допустимый минимум — защищённая переменная
CI, файл с ограниченным доступом или привычный проекту канал запуска с явным
владельцем и маршрутом ротации.
Все значения в коде и конфигурации очевидно фиктивны: домен
<code>gateway.invalid</code>, <code>DEMO_ONLY_NOT_A_SECRET</code> и
<code>DEMO_NOT_A_REAL_TOKEN</code>. Реальных ключей, приватных endpoint и
рабочих имён окружений в пакете нет.
Вердикт прохода: **пройден**. Нормативные свойства отделены от учебных
примеров; современная документация использована для границ механизма, а не как
утверждение о конкретно выполненном production change 2020 года.
## Проход 2. Редактура и голос М3 / февраль 2020 — пройдено
| Ревизия | Проблема и цена в первых абзацах | Главный вопрос | Проверяемое действие и ограничение |
| --- | --- | --- | --- |
| Практика | Токен копируют из локального env в Git, image или лог; цена — ротация, остановка delivery и широкая поверхность читателей | Как описать классы конфигурации и дать сервису минимальный contract | Инвентарь, loader, безопасный шаблон и маршрут из шести шагов; выбор хранилища остаётся за проектом |
| Механизм | Runtime получает неверный URL или credential остаётся в image/log; цена — недоступность и расширение утечки во время диагностики | Где проходят границы repository, build, image, delivery, process и log | Таблица носителей, Dockerfile без секрета, loader и диагностический route; нет утверждения о фактическом deploy |
| Полевой разбор | `Authorization` попал в error-log; цена — действующий credential в нескольких носителях | Почему redaction и cleanup не равны revoke | Учебная карта incident, redaction fixture и восьмишаговая ротация; provider portal и production не запускались |
- Draft gate измерил основной текст без sources: практика — **8 457** знаков,
механизм — **8 628**, полевой разбор — **8 886**. Все три значения лежат в
коридоре 5 000–15 000; в тексте нет растягивающих вступлений и поздней
техлид-риторики.
- В трёх материалах выдержана цепочка «симптом → причина → проверка → действие».
Термины привязаны к наблюдаемой поверхности: tracked path, Docker image,
delivery, runtime loader, request ID, redact и revoke.
- У каждой revision не менее пяти смысловых разделов, таблица с
<code>caption</code>/<code>thead</code>, самостоятельный рисунок с alt и
caption, code/config example, упорядоченный маршрут и не менее двух
первичных или официальных источников.
- Тон М3 сохраняет практическую ширину автора: он уже видит стык backend и
delivery, но не приписывает команде SLO, supply-chain attestations,
универсальную secret platform или вымышленный production incident.
Вердикт прохода: **пройден**. Фактический gate подтвердил объём и структуру
всех трёх revision.
## Проход 3. Визуал и выпуск автономного пакета — пройдено в заданных границах
- <code>config-secret-boundary-2020.svg</code> показывает четыре поверхности
значения: repository, delivery, runtime и support/log. Его задача — отделить
secret от обычного config value, а не изобразить конкретный CI.
- <code>config-secret-delivery-path-2020.svg</code> ведёт от versioned шаблона
через delivery и loader к рабочему процессу и redacted report. Диаграмма
показывает, почему build и runtime не получают одинаковые данные.
- <code>config-secret-rotation-2020.svg</code> отделяет обнаружение, ограничение
распространения, replacement, revoke и follow-up. Она не изображает
несуществующий production incident.
- У SVG есть <code>title</code>, <code>desc</code>, <code>role="img"</code>,
вертикальные viewBox, контрастные карточки и короткие подписи. В файлах нет
JavaScript, <code>foreignObject</code>, внешних URL и растровых data URI.
- SVG локально отрендерены через Sharp при ширине **375 px**. Короткие подписи
читаемы, обрезания и горизонтального выхода за границы внутри SVG не найдено.
Это не browser e2e, не скриншот опубликованной страницы и не проверка
assistive technology; таких утверждений пакет не делает.
- Основной редактор независимо просмотрел все три финальные raster-версии на
375 px: вывод подтверждён. После подключения registry strict audit и
production build также пройдены; это не подменяет реальную проверку delivery
контура или ротации credential.
### Фактические команды и результаты
Команды запускаются после создания всех пяти файлов:
<pre><code>cd web
node --check scripts/upgrade-2020-02.mjs
npm run audit:draft -- scripts/upgrade-2020-02.mjs
xmllint --noout \
public/assets/editorial/2020/config-secret-boundary-2020.svg \
public/assets/editorial/2020/config-secret-delivery-path-2020.svg \
public/assets/editorial/2020/config-secret-rotation-2020.svg</code></pre>
| Проверка | Фактический результат |
| --- | --- |
| `node --check scripts/upgrade-2020-02.mjs` | PASS, код 0 |
| `npm run audit:draft -- scripts/upgrade-2020-02.mjs` | PASS: 8 457 / 8 628 / 8 886 знаков; три slug, tables, figures, code, routes, sources и локальные assets найдены |
| `xmllint --noout` для трёх SVG | PASS, код 0 |
| Локальный visual preflight | PASS: Sharp-render при 375 px, нет clipping или horizontal overflow внутри схем |
| Strict audit после подключения registry | PASS: 8 457 / 8 628 / 8 886 знаков; по одному figure и table, 2 / 2 / 1 code example |
| `npm run build` | PASS, code 0, 374 статические страницы |
| Scope/self-review | PASS: в revision нет date/author, а `articles.json` не перезаписан |
Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**.
Registry заменяет только редакционные поля по stable slug. Статические checks
не означают, что реальный delivery-контур, provider portal или ротация были
запущены; эти действия остаются отдельным сценарием проекта.
+2
View File
@@ -20,6 +20,7 @@ import { revisions as october2019Revisions } from '../scripts/upgrade-2019-10.mj
import { revisions as november2019Revisions } from '../scripts/upgrade-2019-11.mjs';
import { revisions as december2019Revisions } from '../scripts/upgrade-2019-12.mjs';
import { revisions as january2020Revisions } from '../scripts/upgrade-2020-01.mjs';
import { revisions as february2020Revisions } from '../scripts/upgrade-2020-02.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -45,4 +46,5 @@ export const editorialRevisions = [
...november2019Revisions,
...december2019Revisions,
...january2020Revisions,
...february2020Revisions,
];
@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1220" role="img" aria-labelledby="title desc">
<title id="title">Граница между настройкой и секретом</title>
<desc id="desc">Вертикальная схема показывает четыре поверхности: репозиторий хранит имена и шаблон, delivery передаёт значение отдельно, runtime валидирует его, а лог получает только маску.</desc>
<rect width="720" height="1220" rx="32" fill="#f8fafc"/>
<text x="360" y="58" text-anchor="middle" font-family="Arial, sans-serif" font-size="30" font-weight="700" fill="#172033">Граница настройки и секрета</text>
<text x="360" y="92" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#475569">У каждого значения — свой допустимый носитель</text>
<g font-family="Arial, sans-serif">
<rect x="58" y="140" width="604" height="178" rx="24" fill="#e0f2fe" stroke="#38bdf8" stroke-width="3"/>
<text x="94" y="188" font-size="28" font-weight="700" fill="#0c4a6e">1. Репозиторий</text>
<text x="94" y="232" font-size="22" fill="#0c4a6e">Имена, описание, шаблон</text>
<text x="94" y="266" font-size="22" fill="#0c4a6e">Только фиктивные defaults</text>
<text x="94" y="296" font-size="20" fill="#075985">Секрет сюда не попадает</text>
<path d="M360 318 L360 374" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="386" width="604" height="178" rx="24" fill="#dcfce7" stroke="#4ade80" stroke-width="3"/>
<text x="94" y="434" font-size="28" font-weight="700" fill="#14532d">2. Delivery</text>
<text x="94" y="478" font-size="22" fill="#14532d">Защищённая переменная или файл</text>
<text x="94" y="512" font-size="22" fill="#14532d">Есть владелец смены</text>
<text x="94" y="542" font-size="20" fill="#166534">Не печатаем value в job</text>
<path d="M360 564 L360 620" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="632" width="604" height="178" rx="24" fill="#fef3c7" stroke="#fbbf24" stroke-width="3"/>
<text x="94" y="680" font-size="28" font-weight="700" fill="#78350f">3. Runtime</text>
<text x="94" y="724" font-size="22" fill="#78350f">Loader проверяет обязательные имена</text>
<text x="94" y="758" font-size="22" fill="#78350f">Процесс получает нужное значение</text>
<text x="94" y="788" font-size="20" fill="#92400e">Пустой ключ останавливает запуск</text>
<path d="M360 810 L360 866" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="878" width="604" height="178" rx="24" fill="#fae8ff" stroke="#e879f9" stroke-width="3"/>
<text x="94" y="926" font-size="28" font-weight="700" fill="#701a75">4. Лог и поддержка</text>
<text x="94" y="970" font-size="22" fill="#701a75">Имя переменной и request ID</text>
<text x="94" y="1004" font-size="22" fill="#701a75">Значение заменено на [REDACTED]</text>
<text x="94" y="1034" font-size="20" fill="#86198f">Диагностика без копии секрета</text>
</g>
<rect x="58" y="1100" width="604" height="66" rx="16" fill="#e2e8f0"/>
<text x="360" y="1140" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="700" fill="#334155">Секрет нужен процессу, а не всем артефактам</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1260" role="img" aria-labelledby="title desc">
<title id="title">Путь конфигурации до работающего процесса</title>
<desc id="desc">Вертикальная схема проводит настройку от шаблона в репозитории через delivery и loader до runtime, оставляя в диагностике только безопасный отчёт.</desc>
<rect width="720" height="1260" rx="32" fill="#f8fafc"/>
<text x="360" y="58" text-anchor="middle" font-family="Arial, sans-serif" font-size="30" font-weight="700" fill="#172033">Путь настройки до процесса</text>
<text x="360" y="92" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#475569">Сборка и запуск не получают одинаковый набор данных</text>
<g font-family="Arial, sans-serif">
<rect x="58" y="140" width="604" height="164" rx="24" fill="#e0f2fe" stroke="#38bdf8" stroke-width="3"/>
<text x="94" y="188" font-size="28" font-weight="700" fill="#0c4a6e">Версия в Git</text>
<text x="94" y="232" font-size="22" fill="#0c4a6e">config.example.env</text>
<text x="94" y="266" font-size="22" fill="#0c4a6e">Имена и безопасные defaults</text>
<path d="M360 304 L360 352" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="364" width="604" height="164" rx="24" fill="#dcfce7" stroke="#4ade80" stroke-width="3"/>
<text x="94" y="412" font-size="28" font-weight="700" fill="#14532d">Delivery контур</text>
<text x="94" y="456" font-size="22" fill="#14532d">Подаёт требуемые значения</text>
<text x="94" y="490" font-size="22" fill="#14532d">Не выводит token в журнал</text>
<path d="M360 528 L360 576" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="588" width="604" height="164" rx="24" fill="#fef3c7" stroke="#fbbf24" stroke-width="3"/>
<text x="94" y="636" font-size="28" font-weight="700" fill="#78350f">Config loader</text>
<text x="94" y="680" font-size="22" fill="#78350f">Проверяет имена и формат</text>
<text x="94" y="714" font-size="22" fill="#78350f">Отказывает до первого запроса</text>
<path d="M360 752 L360 800" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="812" width="604" height="164" rx="24" fill="#fae8ff" stroke="#e879f9" stroke-width="3"/>
<text x="94" y="860" font-size="28" font-weight="700" fill="#701a75">Рабочий процесс</text>
<text x="94" y="904" font-size="22" fill="#701a75">Получает явный config object</text>
<text x="94" y="938" font-size="22" fill="#701a75">Не читает process.env повсюду</text>
<path d="M360 976 L360 1024" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="1036" width="604" height="142" rx="24" fill="#ede9fe" stroke="#a78bfa" stroke-width="3"/>
<text x="94" y="1084" font-size="28" font-weight="700" fill="#4c1d95">Безопасный отчёт</text>
<text x="94" y="1128" font-size="22" fill="#4c1d95">Имя, revision, [REDACTED]</text>
</g>
<rect x="58" y="1202" width="604" height="38" rx="12" fill="#e2e8f0"/>
<text x="360" y="1228" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#334155">Секрет не должен стать входом сборки или лога</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1300" role="img" aria-labelledby="title desc">
<title id="title">Маршрут ротации утёкшего секрета</title>
<desc id="desc">Вертикальная схема показывает, что после обнаружения утечки команда ограничивает распространение, создаёт замену, обновляет потребителей, отзывает старое значение и только затем закрывает follow-up.</desc>
<rect width="720" height="1300" rx="32" fill="#f8fafc"/>
<text x="360" y="58" text-anchor="middle" font-family="Arial, sans-serif" font-size="30" font-weight="700" fill="#172033">Ротация без ложного исправления</text>
<text x="360" y="92" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#475569">Удалить строку недостаточно, пока старая пара активна</text>
<g font-family="Arial, sans-serif">
<rect x="58" y="140" width="604" height="156" rx="24" fill="#fee2e2" stroke="#f87171" stroke-width="3"/>
<text x="94" y="188" font-size="28" font-weight="700" fill="#7f1d1d">1. Обнаружить</text>
<text x="94" y="232" font-size="22" fill="#7f1d1d">ID incident, имя, носитель</text>
<text x="94" y="264" font-size="20" fill="#991b1b">Не копировать value в issue</text>
<path d="M360 296 L360 340" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="352" width="604" height="156" rx="24" fill="#fef3c7" stroke="#fbbf24" stroke-width="3"/>
<text x="94" y="400" font-size="28" font-weight="700" fill="#78350f">2. Ограничить</text>
<text x="94" y="444" font-size="22" fill="#78350f">Остановить новый вывод в лог</text>
<text x="94" y="476" font-size="20" fill="#92400e">Собрать consumers по имени</text>
<path d="M360 508 L360 552" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="564" width="604" height="156" rx="24" fill="#dcfce7" stroke="#4ade80" stroke-width="3"/>
<text x="94" y="612" font-size="28" font-weight="700" fill="#14532d">3. Заменить</text>
<text x="94" y="656" font-size="22" fill="#14532d">Новая пара через delivery</text>
<text x="94" y="688" font-size="20" fill="#166534">Проверить каждого consumer</text>
<path d="M360 720 L360 764" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="776" width="604" height="156" rx="24" fill="#e0f2fe" stroke="#38bdf8" stroke-width="3"/>
<text x="94" y="824" font-size="28" font-weight="700" fill="#0c4a6e">4. Отозвать старое</text>
<text x="94" y="868" font-size="22" fill="#0c4a6e">После проверки или окна простоя</text>
<text x="94" y="900" font-size="20" fill="#075985">Фиксировать время revoke</text>
<path d="M360 932 L360 976" stroke="#64748b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="58" y="988" width="604" height="156" rx="24" fill="#fae8ff" stroke="#e879f9" stroke-width="3"/>
<text x="94" y="1036" font-size="28" font-weight="700" fill="#701a75">5. Проверить следы</text>
<text x="94" y="1080" font-size="22" fill="#701a75">Git, image, job, diagnostic path</text>
<text x="94" y="1112" font-size="20" fill="#86198f">Добавить redaction test</text>
</g>
<rect x="58" y="1190" width="604" height="62" rx="16" fill="#e2e8f0"/>
<text x="360" y="1228" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="700" fill="#334155">Cleanup не заменяет revoke</text>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+327
View File
@@ -0,0 +1,327 @@
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));
}