0044. Минимизация раскрытия в production и fail-fast на небоевых секретах
Копия ADR из репозитория:
backend/docs/adr/0044-production-disclosure-and-secret-fail-fast.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-08-05
- Связанные задачи: аудит безопасности 2026-08-05 (находка 2, hardening H3/H4/H5/H7/H8/H9)
Контекст и проблема
Аудит 2026-08-05 показал, что периметр сервиса раздаёт наружу больше, чем нужно для работы, и не защищён от очевидной ошибки эксплуатации:
- Портал документации раздавал исходный код.
.compodocrc.jsonне задавалdisableSourceCode, поэтому Compodoc клал в каждую страницу вкладку «Source» с полным текстом.ts-файла. Артефакт публикуется наdocs.optimawb.ruчерез nginx без авторизации: 43 из 43 injectable-файлов, включая реализацию шифрования WB-токенов (EncryptionService), были доступны анонимно (CWE-540). - Анонимный
/healthотдавал текст ошибки подключения к PostgreSQL/Redis (адреса, порты, коды драйвера) иuptimeпроцесса. - Заглушечные секреты работоспособны. Ключ AES и JWT-секреты из
.env.exampleиdocker-compose.yml— валидные значения: деплой, забывший выставить переменные в Coolify, поднимется на них молча, и любой читатель репозитория сможет подписать себе токен и расшифровать WB-ключи из дампа БД. X-Request-Idот клиента принимался как есть и попадал в каждую строку структурного лога и в заголовок ответа: инъекция перевода строки в JSON-лог, раздувание записей, 500 из-за недопустимых символов вres.setHeader.- Маскирование секретов работало только по имени ключа — строковые
message/stackне сканировались, и токен из текста ошибки HTTP-клиента попадал в лог целиком. - Контейнер бэкенда работал от
root; локальныйdocker-composeпубликовал PostgreSQL и Redis (без пароля) на все интерфейсы.
Общий знаменатель: сервис по умолчанию делится информацией, хотя ни один из перечисленных фрагментов не нужен внешнему потребителю.
Рассмотренные варианты
- Точечные заплатки без общего правила — закрыть каждый пункт отдельно. Плюсы: минимум изменений. Минусы: следующая фича повторит ту же ошибку, потому что «по умолчанию» осталось прежним.
- Закрывать периметр инфраструктурой (basic-auth на портал документации,
фильтрация
/healthна прокси). Плюсы: код не меняется. Минусы: защита живёт вне репозитория, не проверяется тестами и теряется при переезде; Coolify-конфигурация не версионируется. - Правило «в production наружу — только необходимое», зашитое в код, плюс fail-fast на небоевых секретах. Плюсы: поведение проверяется юнит-тестами и переезжает вместе с кодом; ошибка эксплуатации становится громкой. Минусы: расхождение поведения между окружениями; неверно настроенный прод не поднимется вовсе.
Решение
Выбран вариант 3.
Раскрытие. В production ответ GET /health сокращается до
{ status, timestamp }; детализация по компонентам и текст ошибки уходят
только в лог (HealthService.runCheck). uptime убран из /health и
/health/live во всех окружениях. Поля uptime и checks в
HealthCheckResultDto объявлены опциональными, а не удалены — контракт
OpenAPI остаётся обратно совместимым. Вне production ответ остаётся подробным:
там он нужен разработчику.
Compodoc собирается с disableSourceCode, disablePrivate, disableInternal:
портал документирует контракт кода (сигнатуры, TSDoc, зависимости), а не
его реализацию. Порог docs:coverage (80%) при этом сохраняется — фактическое
покрытие 86%.
Секреты. EncryptionService.onModuleInit в production падает
(InsecureSecretError), если активный ключ шифрования или любой из
JWT-секретов совпадает с заглушкой из репозитория, содержит маркер заглушки
(change-me, placeholder, …) или низкоэнтропиен (повтор короткого блока,
бедный алфавит); отдельно отвергается совпадение access- и refresh-секретов.
Проверка сознательно ограничена активным ключом: неактивные ключи ring —
это ровно те, с которых прод уходит, и запрет на них заблокировал бы ротацию.
Логи. Входящий X-Request-Id принимается только по
/^[A-Za-z0-9._-]{1,128}$/, иначе генерируется randomUUID() (запрос не
отклоняется — трассировка не повод возвращать 400). К маскированию по имени
ключа добавлен второй эшелон по значению: строки (message, stack, любые
строковые поля meta) сканируются на формат JWT (eyJ…) и Bearer <token>.
Периметр контейнеров. Образ бэкенда запускается от непривилегированного
node (COPY --chown + USER node); docker-compose.yml публикует порты
только на 127.0.0.1 и включает --requirepass для Redis.
Последствия
- Положительные:
- публичный портал больше не является бесплатной разведкой по коду;
- анонимный
/healthне раскрывает топологию и версии драйверов; - деплой с забытыми переменными окружения падает громко и сразу, а не работает с ключом, известным всем;
- лог перестаёт быть каналом утечки токенов и вектором инъекции.
- Отрицательные / компромиссы:
- поведение
/healthразличается между окружениями — при разборе инцидента в проде нужно смотреть лог, а не ответ пробы; - fail-fast может уронить прод при переходе на новый ключ, если значение
выбрано неудачно (например,
openssl randзаменили «красивой» строкой); сообщение об ошибке называет переменную и способ сгенерировать значение; - value-based маскирование теоретически может замаскировать безобидную
строку, начинающуюся с
eyJ(это всегда base64 от{", риск принят); Redisв локальном compose теперь требует пароль:.envразработчика должен содержатьREDIS_URL=redis://:optima-local@localhost:6379.
- поведение
- Что нужно сделать дальше:
- пересобрать и передеплоить
docs-site— старый образ продолжает раздавать исходники, пока не заменён; - привести
backend/.env.exampleв соответствие с новымREDIS_URL(значение с паролем) — файл вне зоны этого изменения; - ротировать JWT- и AES-ключи в prod/stage Coolify, если они когда-либо совпадали со значениями из репозитория.
- пересобрать и передеплоить