Files
progcode/web/scripts/upgrade-2027-05.mjs
2026-07-31 22:26:56 +03:00

313 lines
41 KiB
JavaScript
Raw Permalink 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 escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const REFERENCES = deepFreeze({
http: { title: 'RFC 9110 — HTTP Semantics', version: 'IETF Standards Track, June 2022', url: 'https://www.rfc-editor.org/rfc/rfc9110.html' },
tls: { title: 'RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3', version: 'IETF Standards Track, August 2018', url: 'https://www.rfc-editor.org/rfc/rfc8446.html' },
});
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_HTTP_TLS_CASES = deepFreeze({
status404: { status: 404, headers: { 'content-type': 'text/plain' }, body: 'missing' },
status503: { status: 503, headers: { 'retry-after': '2' }, body: 'busy' },
tlsNameMismatch: { error: 'ERR_TLS_CERT_ALTNAME_INVALID', stage: 'certificate' },
invalid: { status: 0, headers: {}, body: '' },
});
export function createFixedHttpTlsCase(id = 'status404') {
return FIXED_HTTP_TLS_CASES[id] ? deepFreeze(cloneFixed(FIXED_HTTP_TLS_CASES[id])) : undefined;
}
export function assessFixedHttpTlsPlan(input) {
if (!input || typeof input !== 'object') return { status: 'stop', class: 'invalid-input', action: 'проверить форму наблюдения' };
if (input.error === 'ERR_TLS_CERT_ALTNAME_INVALID') return { status: 'check-certificate-name', class: 'TLS', action: 'сверить имя узла с SAN сертификата' };
if (input.status === 404) return { status: 'check-route', class: 'HTTP', action: 'проверить URI и маршрут' };
if (input.status === 503) return { status: 'check-overload', class: 'HTTP', action: 'прочитать Retry-After и проверить зависимость' };
if (input.status >= 200 && input.status < 400) return { status: 'success', class: 'HTTP', action: 'проверить тело и контракт ответа' };
return { status: 'stop', class: 'unknown', action: 'собрать расширенный вывод отдельно' };
}
export function runFixedHttpTlsFixture() {
const checks = [
['status404', 'check-route'],
['status503', 'check-overload'],
['tlsNameMismatch', 'check-certificate-name'],
['invalid', 'stop'],
].map(([id, expected]) => ({ id, expected, actual: assessFixedHttpTlsPlan(createFixedHttpTlsCase(id)).status }));
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks });
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const refs = [
{ key: 'http', use: 'Нужен для различения методов, статусов, заголовков, маршрутизации и ответа посредника.', boundary: 'Не объясняет конкретную конфигурацию прокси, DNS или причину ошибки в вашем сервисе.' },
{ key: 'tls', use: 'Нужен для разбора рукопожатия TLS 1.3, проверки имени узла и сообщения об ошибке сертификата.', boundary: 'Не подтверждает доверие к конкретному центру сертификации и не заменяет проверку ключевого материала.' },
];
const practice = revision({
slug: 'editorial-2027-05-practice-http-tls-guide',
title: 'HTTP и TLS без гадания: как разобрать 404, 503 и ошибку сертификата',
categories: ['Сети', 'Диагностика'],
cover: '/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg',
excerpt: 'Практический маршрут от текста ошибки к уровню, на котором действительно нужно искать причину.',
readingMinutes: 14,
}, [
p('Запрос к сервису может не дойти до приложения, хотя пользователь видит обычную страницу ошибки. <code>404</code> говорит о выбранном ресурсе, <code>503</code> — о доступности обработчика, а <code>ERR_TLS_CERT_ALTNAME_INVALID</code> возникает ещё до HTTP. Цена смешения этих уровней — часы на исправление маршрута в коде, когда проблема находится в имени узла или на обратном прокси.'),
p('Разберём три наблюдаемых случая на одном маршруте: сначала получим ответ локального HTTP-сервера, затем отделим HTTP-статус от TLS-этапа и зафиксируем следующий запрос для проверки. Пример учебный: он не обращается к внешней сети и не выдаёт локальный результат за состояние чужой инфраструктуры.'),
h2('Сначала фиксируем точку отказа'),
p('У любого запроса есть последовательность: разрешение имени, установка TCP-соединения, TLS-рукопожатие для <code>https</code>, отправка HTTP-запроса и чтение ответа. Важен первый наблюдаемый факт. Если клиент не смог проверить сертификат, у него нет HTTP-статуса. Если получен <code>404</code>, TLS и HTTP-соединение уже состоялись, а искать нужно URI, метод или маршрутизацию.'),
table('Какой уровень проверять первым', ['Наблюдение', 'Уровень', 'Первое действие', 'Чего не делать'], [
['Ошибка имени сертификата', 'TLS', 'сверить host и SAN', 'не менять HTTP-заголовки'],
['404 Not Found', 'HTTP-маршрут', 'проверить путь и метод', 'не увеличивать timeout'],
['503 Service Unavailable', 'обработчик или зависимость', 'прочитать заголовки и логи', 'не повторять POST вслепую'],
['Нет ответа и timeout', 'сеть или сервер', 'разделить connect/read timeout', 'не считать это 500'],
]),
figure('/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg', 'Схема уровней запроса: DNS, TCP, TLS, HTTP и ответ с точкой остановки диагностики.', 'Диаграмма показывает порядок уровней. Стрелка останавливается на первом слое, о котором есть наблюдение.'),
h2('Учебный HTTP-ответ на локальном сервере'),
p('Чтобы не спорить о сообщении браузера, поднимем два endpoint в одном Node-процессе. Вход — путь запроса. Ожидаемый результат — числовой статус и тело. Такой запуск показывает семантику HTTP-ответа, но не тестирует TLS: для TLS нужен отдельный сервер с сертификатом и проверкой имени.'),
code([
"import { createServer } from 'node:http';",
'',
"const server = createServer((request, response) => {",
" if (request.url === '/health') {",
" response.writeHead(200, { 'content-type': 'text/plain' });",
" response.end('ok');",
' return;',
' }',
" response.writeHead(404, { 'content-type': 'text/plain' });",
" response.end('missing');",
'});',
'',
"server.listen({ port: 0, host: '127.0.0.1' }, async () => {",
' const port = server.address().port;',
" const response = await fetch('http://127.0.0.1:' + port + '/missing');",
" console.log(response.status, await response.text());",
' server.close();',
'});',
].join('\n')),
p('Запустите файл командой <code>node check-http.mjs</code>. В консоли будет <code>404 missing</code>. Входом является только локальный путь; ожидаемый результат проверяем двумя значениями. Если изменить путь на <code>/health</code>, получится <code>200 ok</code>. Это полезнее, чем проверять только цвет страницы: статус и тело принадлежат разным частям HTTP-контракта.'),
h2('Почему 503 нельзя лечить повтором по умолчанию'),
p('<code>503</code> означает, что сервер временно не может обработать запрос. Заголовок <code>Retry-After</code> может дать клиенту ориентир, но он не делает повтор безопасным. Для <code>GET</code> повтор обычно не меняет ресурс, а для <code>POST</code> повтор способен создать вторую запись или списать деньги повторно. Перед автоматикой нужно знать семантику метода и наличие ключа идемпотентности.'),
p('В диагностической записи сохраняйте метод, путь без секретов, статус, важные заголовки, время ожидания и первый байт ответа. Не прикладывайте токен авторизации и полные cookie. Для <code>503</code> сначала проверяется зависимость, ограничение соединений или обслуживание сервера; затем выбирается контролируемый повтор с лимитом и задержкой.'),
h2('Ошибка сертификата находится до HTTP'),
p('TLS 1.3 устанавливает защищённый канал и проверяет имя узла в сертификате. Имя берётся из URL и должно совпасть с одним из значений Subject Alternative Name. Если клиент подключается к IP вместо доменного имени, использует устаревший alias или получает сертификат другого виртуального хоста, HTTP-заголовок <code>Host</code> проблему не исправит: запрос ещё не отправлен.'),
p('Практическая граница видна в подробном выводе клиента: после строки об успешной проверке сертификата и до ответа <code>404</code> проблема уже относится к HTTP-маршруту. Если TLS завершается исключением, статус и тело приложения искать бессмысленно. Это простое разделение экономит одну итерацию проверки.'),
p('Проверка должна разделять имя, цепочку доверия и срок действия. В учебной диагностике достаточно зафиксировать hostname, адрес назначения и текст ошибки библиотеки. Флаг вроде <code>--insecure</code> может подтвердить, что сервер отвечает, но он отключает проверку и не является исправлением. После такого эксперимента соединение нужно закрыть и повторить проверку с обычной валидацией.'),
h2('Порядок диагностики'),
ol([
'Записать URL, метод, момент запроса и безопасный идентификатор запроса; убрать Authorization, Cookie и персональные параметры.',
'Проверить разрешение имени и адрес назначения отдельно от приложения.',
'Для HTTPS проверить hostname, SAN, срок действия и цепочку сертификата обычным клиентом.',
'Только после успешного TLS посмотреть HTTP-статус, заголовок Allow, Location, Retry-After и тело.',
'Сопоставить метод и действие: повтор разрешать только для операции с понятной идемпотентностью.',
'Зафиксировать один следующий тест и ожидаемый результат, например «/health возвращает 200, /missing — 404».',
]),
h2('Ограничения и следующий шаг'),
p('Локальный сервер не показывает работу CDN, DNS-балансировщика, корпоративного proxy или реального центра сертификации. Статус 404 не доказывает, что маршрут одинаково настроен во всех регионах, а 503 не называет виновную зависимость. Для этого нужны согласованные логи и сетевые наблюдения с разрешённым доступом.'),
p('Следующим шагом соберите две безопасные записи: успешный запрос к health-endpoint и один ошибочный запрос с тем же hostname. Сравните этап, статус, заголовки и время. Если различие появляется до HTTP, оставайтесь на TLS или сети; если оба запроса дошли до сервера, переходите к маршруту и контракту приложения.'),
], refs);
const mechanism = revision({
slug: 'editorial-2027-05-mechanism-http-tls-guide',
title: 'Где рождается ошибка HTTP: разбираем цепочку DNS, TLS и заголовков',
categories: ['Сети', 'HTTP'],
cover: '/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg',
excerpt: 'Механика запроса по слоям: почему одинаковое слово «ошибка» требует разных проверок.',
readingMinutes: 15,
}, [
p('Когда браузер показывает «не удалось подключиться», виден только итог. Цена ошибки — изменить код приложения, не проверив, что запрос вообще не прошёл TLS, или принять ответ кэша за ответ origin-сервера. Для точной диагностики нужно восстановить цепочку по наблюдаемым границам, а не угадывать виновника по одному коду.'),
p('В этой статье разложим запрос на переходы и посмотрим, какие поля подтверждают каждый переход. Практический результат — короткая таблица: какой лог или команда отвечает на конкретный вопрос. Пример запускается локально на Node и показывает обмен HTTP-заголовками без обращения к внешнему узлу.'),
h2('Пять границ одного запроса'),
p('DNS превращает имя в адрес, TCP устанавливает поток байтов, TLS защищает его и связывает с именем, HTTP передаёт метод и путь, а приложение формирует ответ. Посредник может добавить свой статус или заголовок на каждом шаге. Поэтому поле <code>server</code> в ответе не доказывает, что ответ сформирован именно вашим приложением.'),
table('Поле и вопрос диагностики', ['Граница', 'Что можно утверждать', 'Что проверить'], [
['DNS', 'имя разрешилось в адрес', 'ответ A/AAAA и выбранный адрес'],
['TCP', 'порт принял соединение', 'connect error и время установки'],
['TLS', 'сертификат подходит имени', 'SAN, chain, protocol version'],
['HTTP', 'получен статус и заголовки', 'method, path, status, headers'],
['Приложение', 'обработан контракт endpoint', 'лог маршрута и request id'],
]),
figure('/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg', 'Матрица симптомов HTTP и TLS с границей, которую подтверждает каждый вид наблюдения.', 'У каждой строки есть отдельный вопрос. Нельзя переносить ответ из соседней строки: HTTP-статус не подтверждает TLS, а DNS-ответ не подтверждает маршрут.'),
h2('Заголовок не равен доказательству источника'),
p('Заголовок <code>Via</code>, <code>Server</code> или пользовательский <code>X-Request-Id</code> помогает построить гипотезу, но это данные сообщения, а не криптографическая аттестация сервера. Посредник может удалить или переписать поля. Для сопоставления запроса с серверным логом нужен идентификатор, который генерируется на входе доверенного компонента и сохраняется без смены формата.'),
p('У HTTP/2 и HTTP/3 целевой authority может передаваться не так, как привычная строка <code>Host</code>. Поэтому в диагностической записи сохраняйте логическое имя назначения и не делайте вывод о виртуальном хосте по одному полю. Сопоставляйте его с тем, что использовал TLS-клиент.'),
p('Удобная минимальная запись выглядит так: метод, нормализованный путь, статус, request id, длительность, размер ответа и класс ошибки. Секреты и произвольные query-параметры не входят в журнал по умолчанию. Нормализация важна: если один компонент пишет полный URL, а другой — только путь, поиск по записи будет давать ложные пропуски.'),
h2('Учебный обмен с заголовками'),
p('Локальный сервер ниже возвращает два заголовка и JSON-тело. Вход — HTTP-запрос с <code>Accept</code>. Ожидаемый результат — <code>200</code>, тип содержимого и один идентификатор. Это реальный обмен между клиентом и сервером, но он не проверяет TLS или поведение прокси.'),
code([
"import { createServer } from 'node:http';",
'',
"const server = createServer((request, response) => {",
" response.writeHead(200, {",
" 'content-type': 'application/json; charset=utf-8',",
" 'x-request-id': 'local-001',",
' });',
" response.end(JSON.stringify({ method: request.method, path: request.url }));",
'});',
'',
"server.listen(0, 'localhost', async () => {",
' const port = server.address().port;',
" const endpoint = 'http://localhost:' + port + '/orders';",
' const response = await fetch(endpoint);',
" console.log(response.status, response.headers.get('x-request-id'));",
" console.log(await response.json());",
' server.close();',
'});',
].join('\n')),
p('Результат содержит <code>200 local-001</code> и объект с методом <code>GET</code> и путём <code>/orders</code>. Если убрать заголовок из ответа, клиент всё равно получит 200: это показывает, что request id — средство сопоставления, а не условие корректности HTTP. В рабочей системе нужно договориться, какой компонент отвечает за его создание и где он попадает в лог.'),
h2('TLS меняет порядок проверки'),
p('При HTTPS нельзя начинать с ответа приложения. Клиент сначала отправляет ClientHello, сервер выбирает параметры и предъявляет сертификат, затем стороны завершают рукопожатие. Проверка имени происходит относительно hostname, который клиент считает целевым. Если соединение идёт через proxy, отдельно фиксируйте имя proxy и имя origin: это две разные проверки.'),
p('Сертификат с правильной цепочкой, но неправильным SAN — ошибка имени. Сертификат с правильным SAN, который не доверен локальному хранилищу, — ошибка доверия. Сертификат с истёкшим сроком — ошибка времени. Эти причины нельзя объединять в «проблему SSL»: для каждой нужен собственный ожидаемый результат и свой владелец исправления.'),
h2('Кэш и промежуточный ответ'),
p('HTTP допускает intermediaries (посредников), а RFC 9110 отдельно описывает кэширование и маршрутизацию. Ответ может быть свежим объектом кэша, перенаправлением или сообщением origin. Проверяйте <code>Age</code>, <code>Cache-Control</code>, <code>ETag</code>, <code>Via</code> и время изменения тела, но не делайте вывод о кэше по одному заголовку: политика конкретного посредника может быть сложнее.'),
table('Набор безопасных полей для записи', ['Поле', 'Пример', 'Риск при сборе'], [
['method', 'GET', 'малый, если путь обезличен'],
['path', '/orders', 'query может содержать секрет'],
['status', '200', 'не показывает причину сам по себе'],
['durationMs', '42', 'нужно знать часы и границы замера'],
['requestId', 'local-001', 'нельзя принимать внешний id без правила'],
['responseSize', '31', 'не заменяет проверку тела'],
]),
h2('Порядок проверки по границам'),
ol([
'Зафиксировать имя, адрес и режим proxy отдельно; query-параметры очистить.',
'Проверить DNS и TCP независимым клиентом, сохранив только код ошибки и длительность.',
'Для HTTPS сверить hostname, SAN, цепочку и время действия сертификата.',
'Снять HTTP-статус и выбранные заголовки, затем сопоставить request id с логом доверенного входа.',
'Сравнить ответ с origin и кэшем, если между клиентом и приложением есть intermediary.',
'Записать причину только на том уровне, который подтверждён наблюдением.',
]),
h2('Ограничения и следующий шаг'),
p('Локальный обмен не показывает потери пакетов, балансировку, корпоративный proxy и особенности браузерного хранилища. Поле <code>x-request-id</code> в учебном сервере задано вручную; в настоящем сервисе его происхождение и доверенная зона должны быть описаны отдельно. Не смешивайте диагностическую метку с секретом или пользовательским идентификатором.'),
p('Следующий шаг — добавить к одной безопасной проверке два вывода: сетевой этап и серверный лог. Сначала докажите, что запрос достиг доверенного входа, потом связывайте его с handler. Такой порядок уменьшает область поиска и не заставляет приложение отвечать за сбой, произошедший раньше.'),
], refs);
const field = revision({
slug: 'editorial-2027-05-field-http-tls-guide',
title: 'Как передавать сетевую ошибку: короткая запись без утечки токенов',
categories: ['Сети', 'Инженерные практики'],
cover: '/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg',
excerpt: 'Формат сетевой заметки, который помогает следующему инженеру продолжить проверку и не раскрывает секреты.',
readingMinutes: 14,
}, [
p('Сетевая ошибка часто передаётся одной строкой: «HTTPS не работает». Цена такой записи — повторить тот же эксперимент, потерять hostname за маской или отправить в чат токен из вывода <code>curl -v</code>. Получателю нужны не все байты, а минимальный набор фактов, по которому можно выбрать следующий безопасный запрос.'),
p('Ниже соберём полевую карточку для одного обращения: отделим входные данные, наблюдение и гипотезу, затем применим простую очистку вывода. Учебный код принимает текст, удаляет секретные заголовки и сохраняет строки, необходимые для различения TLS и HTTP. Он не отправляет данные и не пишет файл.'),
h2('Карточка должна разделять факт и гипотезу'),
p('Факт — это то, что клиент действительно увидел: код, текст библиотеки, время соединения, имя узла и безопасная часть ответа. Гипотеза — «вероятно, просрочен сертификат» или «путь переписал proxy». Если смешать их в одном поле, следующий инженер примет предположение за результат и начнёт проверку с неверного уровня.'),
table('Поля сетевой карточки', ['Поле', 'Пример', 'Роль'], [
['Цель', 'api.example.test', 'какое имя проверяли'],
['Метод и путь', 'GET /health', 'какой HTTP-контракт вызван'],
['Этап', 'TLS или HTTP', 'где появился первый факт'],
['Наблюдение', '404, тело missing', 'что вернул клиент'],
['Гипотеза', 'маршрут не смонтирован', 'что проверить дальше'],
['Следующий запрос', 'GET /health', 'ожидаемый результат'],
]),
figure('/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg', 'Цикл сетевой диагностики: безопасный сбор, классификация этапа, проверка гипотезы и повторяемая запись результата.', 'Схема показывает круг работы с одной ошибкой. На каждом переходе остаётся только нужный факт, а секретные поля отбрасываются до передачи.'),
h2('Почему вывод curl требует очистки'),
p('Подробный вывод полезен для порядка рукопожатия, редиректов и заголовков, но в нём могут оказаться <code>Authorization</code>, cookie, query-параметры и внутренние адреса. Маскирование должно работать до копирования в задачу или чат. Не полагайтесь на память: человек легко пропустит строку, если вывод длинный.'),
p('Отдельно проверьте заголовки proxy-аутентификации и значения после редиректа: очистка только строки <code>Authorization</code> не покрывает все каналы секрета. В карточке оставьте имя поля и замените значение целиком, чтобы получатель понимал, какой тип аутентификации был задействован.'),
p('Очистка не должна удалять метод, статус и имя заголовка. Иначе получатель увидит «что-то с HTTP», но не сможет различить 401 и 403. Значение <code>Authorization</code> заменяем целиком, cookie очищаем по имени, а query оставляем только после удаления секретных ключей. Для персональных данных нужен отдельный список полей.'),
h2('Учебный санитайзер текстового вывода'),
p('Входом функции является обычная многострочная строка. Ожидаемый результат — тот же порядок строк, но значение Authorization и Cookie заменены. Пример запускается без сети; он показывает обработку диагностического artefact, а не проверяет сертификат.'),
code([
'function redactNetworkOutput(text) {',
' return text',
" .replace(/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1[masked]')",
" .replace(/(Cookie:\\s*)[^\\n]+/gi, '$1[masked]')",
" .replace(/([?&](?:token|secret|signature)=)[^&\\s]+/gi, '$1[masked]');",
'}',
'',
"const raw = 'GET /health?token=abc HTTP/1.1\\nAuthorization: Bearer abc\\nCookie: sid=xyz';",
'console.log(redactNetworkOutput(raw));',
'// GET /health?token=[masked] HTTP/1.1',
'// Authorization: Bearer [masked]',
'// Cookie: [masked]',
].join('\n')),
p('Проверка результата здесь буквальная: в трёх строках не осталось исходных значений, а имена полей сохранились. Регулярное выражение учебное и намеренно ограниченное. Оно не понимает бинарные данные, нестандартное форматирование и секреты в произвольном JSON, поэтому перед передачей нужен отдельный просмотр очищенного вывода.'),
h2('Сохраняем причинную цепочку'),
p('Хорошая карточка отвечает на четыре вопроса. Что вызвали? Где остановился клиент? Что именно получено? Какой следующий запрос отличит две гипотезы? Например: «GET /health, TLS завершён, HTTP 503 и Retry-After: 2, следующий шаг — повторить GET через две секунды и сравнить зависимость». Это уже проверяемый маршрут, а не комментарий «сервер тормозит».'),
p('Не нужно прикладывать полный дамп, если достаточно нескольких строк. При этом нельзя вырезать контекст, который меняет смысл: hostname, порт, метод и статус должны остаться. Время указывайте вместе с часовым поясом, а длительность — с единицей измерения. Если был proxy, напишите это явно, иначе получатель будет считать соединение прямым.'),
h2('Когда 401, 403 и 404 похожи'),
table('Статус и следующий вопрос', ['Статус', 'Наблюдаемая семантика', 'Проверка'], [
['401', 'нужна аутентификация или она не принята', 'какой challenge вернул сервер'],
['403', 'сервер понял запрос, но отказывает', 'правило доступа и origin запроса'],
['404', 'ресурс не найден по выбранному маршруту', 'путь, метод и версия API'],
['405', 'метод не разрешён для ресурса', 'Allow и контракт метода'],
]),
p('Текст страницы может быть одинаковым у разных кодов, особенно на proxy. Поэтому в карточке статус важнее заголовка «access denied». RFC 9110 описывает классы статусов и методы, но не знает правила конкретного приложения. Источник задаёт язык сообщения; фактическая причина появляется только из вашего наблюдения и сопоставленного лога.'),
h2('Порядок безопасной передачи'),
ol([
'Скопировать исходный вывод во временную локальную область, не отправляя его в общий канал.',
'Удалить Authorization, Cookie, токены в query и внутренние персональные значения.',
'Оставить hostname, порт, метод, путь без секретных параметров, этап, статус и длительность.',
'Отделить наблюдение от гипотезы и не называть гипотезу причиной.',
'Сформулировать один следующий запрос и его ожидаемый ответ.',
'После повторной проверки заменить гипотезу новым фактом или явно сохранить её как неподтверждённую.',
]),
h2('Ограничения и следующий шаг'),
p('Санитайзер не является системой управления секретами и не гарантирует, что неизвестный формат не содержит чувствительных данных. Не вставляйте очищенный текст в публичный issue без проверки. Для постоянной диагностики лучше использовать структурированные поля с allowlist, чем регулярно маскировать свободный текст.'),
p('Следующим шагом заведите шаблон карточки в репозитории: цель, метод, этап, статус, длительность, гипотеза и ожидаемый результат. Добавьте к нему локальный тест на маскирование трёх известных секретов и один отрицательный пример. Тогда следующая ошибка будет начинаться с проверяемого входа, а не с повторного поиска контекста.'),
], refs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const fixture = runFixedHttpTlsFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml);
});
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');