313 lines
41 KiB
JavaScript
313 lines
41 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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');
|