Закрыли часть проблем с безопасностью + мелкие починки

This commit is contained in:
mi
2026-07-29 18:21:08 +03:00
parent bda3ff39d7
commit 049c45db5c
13 changed files with 716 additions and 339 deletions
+49 -2
View File
@@ -22,10 +22,17 @@
22. Ограничить кол-во символов в сообщении на фронте. Показывать в моменте счетчик: n/max, где n сколько символов уже напечатано, max сколько может быть отправлено. Максимальное кол-во символов - положить в app_settings. 22. Ограничить кол-во символов в сообщении на фронте. Показывать в моменте счетчик: n/max, где n сколько символов уже напечатано, max сколько может быть отправлено. Максимальное кол-во символов - положить в app_settings.
7. Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране. 7. Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране.
# В разработку: # В разработку:
3. Унифицировать сообщения гостевого режима о необходмости
На экране профиля в гостевом режиме добавить кнопку "Авторизоваться"
2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. 2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
3. аудит безопасности вм 3. аудит безопасности вм
3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться"
5. Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью. 5. Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью.
6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) 6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
9. Веб-пуши для PWA 9. Веб-пуши для PWA
@@ -40,6 +47,8 @@
18. Разработка notification-service 18. Разработка notification-service
20. Поднять второй контур для продакшн 20. Поднять второй контур для продакшн
21. Спрятать сеть за балансировщиком нагрузки 21. Спрятать сеть за балансировщиком нагрузки
22. Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
23. WireGuard-only SSH.
24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.). 24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.).
25. Nginx metrics/tracing в signoz 25. Nginx metrics/tracing в signoz
@@ -61,4 +70,42 @@ debounce на отправку СМС (сейчас есть Фиксирова
4. Разработка notification-service 4. Разработка notification-service
5. Подключить OTLP-провайдер 5. Подключить OTLP-провайдер
6. Починить баги 6. Починить баги
7. Второй контур для продакшн 7. Второй контур для продакшн
# Переезд на тестовый домен
**Нет — одного `.env` и новых сертификатов недостаточно.**
Нужно пройти цепочку:
### 1. DNS
`A`-запись нового домена → IP ВМ (до выпуска сертификата).
### 2. `.env` — не одно поле, а все публичные URL
- `PUBLIC_HOST`, `PUBLIC_WEB_URL`, `PUBLIC_API_URL`, `PUBLIC_AUTH_URL`
- `KEYCLOAK_PUBLIC_URL`
- `NGINX_TLS_CERTIFICATE` / `NGINX_TLS_CERTIFICATE_KEY` (путь `/etc/letsencrypt/live/<новый-домен>/...`)
- `BITRIX_PUBLIC_BASE_URL`
- `IDGTL_SMS_CALLBACK_PUBLIC_URL` (если SMS уже подключён)
### 3. Сертификат
Certbot на новый `-d` / `--cert-name`, затем nginx с TLS.
### 4. Пересборка / перезапуск сервисов
- **frontend-static** — URL зашиты на build (`EXPO_PUBLIC_*` из `PUBLIC_WEB_URL` / `PUBLIC_AUTH_URL`)
- **keycloak** — `KC_HOSTNAME` из `KEYCLOAK_PUBLIC_URL`
- **nginx**, **api-backend** и связанные сервисы — подхватить новый env
### 5. Настройки в БД (seed / app-settings)
В `app-settings.production-like.yaml`:
- `security.cors.allowed_origins``https://новый-домен`
- `notification.instruction.allowed_hosts` → новый хост
После правки — снова `deployment/scripts/seed.sh` (или ручное обновление в БД).
### 6. Внешние системы
- **S3 CORS** (Selectel) — `Allowed origin: https://новый-домен`
- **Bitrix24** — URL установки/обработчика (`/bitrix/install`, `/bitrix/handler`)
- **Keycloak client** — redirect URIs / web origins (в realm сейчас зашиты конкретные домены вроде `chat.han0107.ru`)
- **i-Digital** — callback URL, если провайдер его фиксирует
Итого: `.env` + сертификат — ядро, но без DNS, CORS (API + S3), rebuild frontend, Keycloak hostname/redirects, Bitrix URL и seed CORS логин/загрузки/интеграции сломаются.
+1 -1
View File
@@ -39,7 +39,7 @@ NGINX_HTTPS_PORT=443
NGINX_TLS_ENABLED=true NGINX_TLS_ENABLED=true
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
NGINX_HSTS_MAX_AGE=0 NGINX_HSTS_MAX_AGE=31536000
NGINX_CLIENT_MAX_BODY_SIZE=8m NGINX_CLIENT_MAX_BODY_SIZE=8m
NGINX_RATE_LIMIT_API=60r/m NGINX_RATE_LIMIT_API=60r/m
NGINX_RATE_LIMIT_AUTH=60r/m NGINX_RATE_LIMIT_AUTH=60r/m
+4
View File
@@ -31,6 +31,10 @@ class JWKSValidator:
self._loaded_at = 0.0 self._loaded_at = 0.0
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@property
def has_keys(self) -> bool:
return bool(self._keys)
async def refresh(self) -> None: async def refresh(self) -> None:
async with self._lock: async with self._lock:
discovery_url = ( discovery_url = (
+21 -4
View File
@@ -113,6 +113,21 @@ async def refresh_settings_cache(app: FastAPI) -> None:
await asyncio.sleep(30) await asyncio.sleep(30)
async def refresh_jwks_cache(app: FastAPI) -> None:
retry_delay = 5
refresh_delay = max(30, app.state.settings.jwks_cache_ttl_seconds)
delay = refresh_delay if app.state.jwks.has_keys else retry_delay
while True:
await asyncio.sleep(delay)
try:
await app.state.jwks.refresh()
except Exception:
log.warning("jwks.refresh_failed")
delay = retry_delay
else:
delay = refresh_delay
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
@@ -142,12 +157,14 @@ async def lifespan(app: FastAPI):
await app.state.jwks.refresh() await app.state.jwks.refresh()
except Exception: except Exception:
structlog.get_logger().warning("jwks.warmup_failed") structlog.get_logger().warning("jwks.warmup_failed")
jwks_task = asyncio.create_task(refresh_jwks_cache(app))
try: try:
yield yield
finally: finally:
settings_task.cancel() for task in (settings_task, jwks_task):
with suppress(asyncio.CancelledError): task.cancel()
await settings_task with suppress(asyncio.CancelledError):
await task
await app.state.http.aclose() await app.state.http.aclose()
await app.state.redis.aclose() await app.state.redis.aclose()
await app.state.redis_rt.aclose() await app.state.redis_rt.aclose()
@@ -462,7 +479,7 @@ async def ready(request: Request, db: Session):
components[name] = "ok" components[name] = "ok"
except Exception: except Exception:
components[name] = "failed" components[name] = "failed"
components["jwks"] = "ok" if request.app.state.jwks._keys else "failed" components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
try: try:
safety_response = await request.app.state.http.get( safety_response = await request.app.state.http.get(
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready", f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
@@ -1,14 +1,22 @@
import asyncio
import base64 import base64
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest
import yaml import yaml
from alembic.config import Config from alembic.config import Config
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from pydantic import SecretStr from pydantic import SecretStr
from app.main import EXPECTED_API_DB_REVISION, app, otp_settings, websocket_token from app.main import (
EXPECTED_API_DB_REVISION,
app,
otp_settings,
refresh_jwks_cache,
websocket_token,
)
from app.services import SettingsSnapshot from app.services import SettingsSnapshot
EXPECTED_PATHS = { EXPECTED_PATHS = {
@@ -65,6 +73,39 @@ def test_readiness_expected_revision_matches_alembic_head() -> None:
assert EXPECTED_API_DB_REVISION == scripts.get_current_head() assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
@pytest.mark.asyncio
async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None:
class FakeJWKS:
has_keys = False
refresh_calls = 0
async def refresh(self) -> None:
self.refresh_calls += 1
self.has_keys = True
jwks = FakeJWKS()
test_app = SimpleNamespace(
state=SimpleNamespace(
jwks=jwks,
settings=SimpleNamespace(jwks_cache_ttl_seconds=300),
)
)
delays: list[int] = []
async def fake_sleep(delay: int) -> None:
delays.append(delay)
if len(delays) > 1:
raise asyncio.CancelledError
monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await refresh_jwks_cache(test_app)
assert jwks.refresh_calls == 1
assert delays == [5, 300]
def test_websocket_route_is_registered() -> None: def test_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes) assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
@@ -66,11 +66,14 @@ sudo /tmp/setup-vm.sh
- откроет только SSH, HTTP и HTTPS; - откроет только SSH, HTTP и HTTPS;
- создаст `/opt/han-chat/backend`; - создаст `/opt/han-chat/backend`;
- создаст swap; - создаст swap;
- включит автоматические обновления безопасности. - включит автоматические обновления безопасности;
- отключит парольный SSH-вход и X11 forwarding;
- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей.
По умолчанию скрипт не отключает парольный SSH-вход. Не используйте Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до
`HARDEN_SSH=true`, пока не проверили вход пользователем `deploy` по ключу в блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход
отдельной сессии. пользователем `root` и SSH TCP forwarding; включайте этот режим только после
проверки входа пользователем `deploy` по ключу в отдельной сессии.
Если SSH работает на нестандартном порту или имя внешнего интерфейса известно Если SSH работает на нестандартном порту или имя внешнего интерфейса известно
заранее, передайте параметры: заранее, передайте параметры:
+4 -2
View File
@@ -26,8 +26,10 @@ On a fresh Ubuntu 24.04 VM, run:
sudo deployment/scripts/setup-vm.sh sudo deployment/scripts/setup-vm.sh
``` ```
Before setting `HARDEN_SSH=true`, verify key-based access in a separate SSH The script disables password SSH and X11 forwarding by default, then locks the
session. The script header documents its parameters and safe defaults. local `root` and `deploy` passwords after checking authorized keys. Before
setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding,
verify key-based deploy access in a separate SSH session.
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active. - [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
- [ ] Key-only deploy account works in a second session; root/password SSH is off. - [ ] Key-only deploy account works in a second session; root/password SSH is off.
+4 -2
View File
@@ -29,8 +29,10 @@
sudo deployment/scripts/setup-vm.sh sudo deployment/scripts/setup-vm.sh
``` ```
Перед включением `HARDEN_SSH=true` обязательно проверьте вход по ключу в отдельной Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после
SSH-сессии. Параметры запуска и безопасные значения по умолчанию описаны в начале скрипта. проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением
`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding,
обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии.
- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска. - [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска.
- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены. - [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены.
+67 -13
View File
@@ -20,11 +20,14 @@
# PUBLIC_DOCKER_PORTS=80,443 # PUBLIC_DOCKER_PORTS=80,443
# COPY_SSH_KEYS=true # COPY_SSH_KEYS=true
# HARDEN_SSH=false # HARDEN_SSH=false
# LOCK_ACCOUNT_PASSWORDS=true
# HSTS_MAX_AGE_SECONDS=31536000
# RESET_UFW=false # RESET_UFW=false
# SKIP_APT_UPGRADE=false # SKIP_APT_UPGRADE=false
# #
# HARDEN_SSH=true разрешено использовать только после проверки входа по ключу # Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
# в отдельной SSH-сессии. По умолчанию парольный вход не отключается. # по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
# запрещает прямой root-вход и SSH TCP forwarding.
set -Eeuo pipefail set -Eeuo pipefail
IFS=$'\n\t' IFS=$'\n\t'
@@ -38,6 +41,8 @@ EXTERNAL_IF="${EXTERNAL_IF:-}"
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}" PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}" COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
HARDEN_SSH="${HARDEN_SSH:-false}" HARDEN_SSH="${HARDEN_SSH:-false}"
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}"
RESET_UFW="${RESET_UFW:-false}" RESET_UFW="${RESET_UFW:-false}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}" SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}" LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
@@ -73,6 +78,10 @@ validate_parameters() {
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом" [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом"
((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона" ((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
[[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом" [[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом"
[[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \
|| die "HSTS_MAX_AGE_SECONDS должен быть целым числом"
((HSTS_MAX_AGE_SECONDS >= 31536000)) \
|| die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000"
[[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \ [[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443" || die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
} }
@@ -138,6 +147,9 @@ create_deploy_user() {
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys" local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
if [[ -n "$source_user" && "$source_user" != "root" ]]; then if [[ -n "$source_user" && "$source_user" != "root" ]]; then
source_keys="/home/${source_user}/.ssh/authorized_keys" source_keys="/home/${source_user}/.ssh/authorized_keys"
elif [[ -s /root/.ssh/authorized_keys ]]; then
source_user="root"
source_keys="/root/.ssh/authorized_keys"
fi fi
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
@@ -150,6 +162,21 @@ create_deploy_user() {
fi fi
} }
configure_account_passwords() {
step "Блокировка локальных паролей привилегированных учетных записей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
return
fi
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст"
passwd --lock root
passwd --lock "$DEPLOY_USER"
log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен"
}
configure_layout() { configure_layout() {
step "Каталоги HAN Chat" step "Каталоги HAN Chat"
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR" install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
@@ -361,29 +388,51 @@ EOF
} }
configure_ssh() { configure_ssh() {
step "Проверка SSH hardening" step "Настройка SSH"
if [[ "$HARDEN_SSH" != "true" ]]; then [[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
log "HARDEN_SSH=false: парольный вход не изменен" || die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys"
return
fi
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя включить HARDEN_SSH: authorized_keys пользователя пуст"
cat >/etc/ssh/sshd_config.d/99-han-chat.conf <<EOF cat >/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PermitRootLogin no
PasswordAuthentication no PasswordAuthentication no
KbdInteractiveAuthentication no KbdInteractiveAuthentication no
PubkeyAuthentication yes PubkeyAuthentication yes
X11Forwarding no X11Forwarding no
AllowTcpForwarding no
MaxAuthTries 3 MaxAuthTries 3
ClientAliveInterval 120 ClientAliveInterval 120
ClientAliveCountMax 2 ClientAliveCountMax 2
Port ${SSH_PORT} Port ${SSH_PORT}
EOF EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
PermitRootLogin no
AllowTcpForwarding no
EOF
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
else
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
sshd -t || die "Проверка конфигурации sshd не пройдена" sshd -t || die "Проверка конфигурации sshd не пройдена"
systemctl reload ssh systemctl reload ssh
log "SSH hardening включен" }
configure_application_security() {
step "Безопасные HTTP-заголовки приложения"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
else
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file"
fi
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
chmod 600 "$env_file"
log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}"
else
log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию"
fi
} }
install_ssl_timer_if_possible() { install_ssl_timer_if_possible() {
@@ -443,6 +492,9 @@ summary() {
Пользователь развертывания: ${DEPLOY_USER} Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR} Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443 Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${LOG_FILE} Лог настройки: ${LOG_FILE}
Следующие действия: Следующие действия:
@@ -475,6 +527,7 @@ main() {
update_system update_system
configure_time configure_time
create_deploy_user create_deploy_user
configure_account_passwords
configure_layout configure_layout
configure_swap configure_swap
configure_sysctl configure_sysctl
@@ -484,6 +537,7 @@ main() {
configure_unattended_upgrades configure_unattended_upgrades
configure_docker_firewall configure_docker_firewall
configure_ssh configure_ssh
configure_application_security
install_ssl_timer_if_possible install_ssl_timer_if_possible
verify verify
summary summary
@@ -6,8 +6,23 @@ lock=/tmp/han-chat-cert-renew.lock
exec 9>"$lock" exec 9>"$lock"
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; } flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
docker compose --profile certbot run --rm certbot renew \ compose() {
docker compose --env-file .env "$@"
}
nginx_container="$(compose ps --status running --quiet nginx)"
if [ -z "$nginx_container" ]; then
echo '{"event":"tls.renew.failed","reason":"nginx_not_running"}' >&2
exit 1
fi
compose --profile certbot run --rm certbot renew \
--webroot -w /var/www/certbot --quiet --webroot -w /var/www/certbot --quiet
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
# Сигнал отправляется PID 1 контейнера. Нельзя использовать `nginx -s reload`:
# он ищет дефолтный /var/run/nginx.pid, тогда как рабочий PID — /tmp/nginx.pid.
compose kill --signal HUP nginx
compose ps --status running --quiet nginx | awk 'NF {found=1} END {exit !found}'
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}" echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
+1 -1
View File
@@ -9,7 +9,7 @@ services:
NGINX_TLS_ENABLED: ${NGINX_TLS_ENABLED:-true} NGINX_TLS_ENABLED: ${NGINX_TLS_ENABLED:-true}
NGINX_TLS_CERTIFICATE: ${NGINX_TLS_CERTIFICATE} NGINX_TLS_CERTIFICATE: ${NGINX_TLS_CERTIFICATE}
NGINX_TLS_CERTIFICATE_KEY: ${NGINX_TLS_CERTIFICATE_KEY} NGINX_TLS_CERTIFICATE_KEY: ${NGINX_TLS_CERTIFICATE_KEY}
NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-0} NGINX_HSTS_MAX_AGE: ${NGINX_HSTS_MAX_AGE:-31536000}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m} NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-8m}
NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m} NGINX_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-60r/m}
NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-60r/m} NGINX_RATE_LIMIT_AUTH: ${NGINX_RATE_LIMIT_AUTH:-60r/m}
+23
View File
@@ -75,6 +75,29 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx) self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx)
self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx) self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx)
def test_vm_and_nginx_security_defaults(self) -> None:
setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8")
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
ssl_renew = (
ROOT / "deployment/scripts/ssl-renew.sh"
).read_text(encoding="utf-8")
self.assertIn("LOCK_ACCOUNT_PASSWORDS=true", setup)
self.assertIn('passwd --lock root', setup)
self.assertIn('passwd --lock "$DEPLOY_USER"', setup)
self.assertIn("X11Forwarding no", setup)
self.assertIn("PasswordAuthentication no", setup)
self.assertIn("NGINX_HSTS_MAX_AGE=31536000", env_example)
self.assertIn("NGINX_HSTS_MAX_AGE:-31536000", compose)
self.assertIn("compose kill --signal HUP nginx", ssl_renew)
executable_ssl_renew = "\n".join(
line
for line in ssl_renew.splitlines()
if not line.lstrip().startswith("#")
)
self.assertNotIn("nginx -s reload", executable_ssl_renew)
def test_nginx_internal_denies_precede_spa(self) -> None: def test_nginx_internal_denies_precede_spa(self) -> None:
site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8") site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8")
compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
+475 -306
View File
@@ -1,99 +1,266 @@
# module-05. Проектная спецификация заглушки `message-safety` # module-05. Проектная спецификация `message-safety`
> Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания. > Статус: целевая production-спецификация MVP.
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-04-redis.md`](module-04-redis.md). > Канонические источники: [`README.md`](../architectory/README.md), [`arch-00-glossary.md`](../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../architectory/arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md).
## 1. Назначение и ограничение ## 1. Назначение и приоритет
Сервис — internal stub для проверки orchestration `api-backend`, а не реальный moderation/antivirus engine. Он доступен только в Docker network и реализует канонические пути arch-02: `message-safety` — внутренний сервис, который до отправки сообщения в Bitrix24 проверяет пользовательский текст, содержащиеся в нём ссылки и файлы из S3-quarantine.
- `POST /internal/safety/v1/messages/check`; Сервис закрывает угрозы, поступающие через пользовательское сообщение:
- `GET /internal/safety/v1/messages/tasks/{task_id}`;
- `GET /health/live`;
- `GET /health/ready`.
Сервис не публикуется через nginx, не получает JWT пользователя, не перемещает S3 objects, не отправляет сообщения в Bitrix и не хранит бизнес-историю. - управляющие и prompt-injection конструкции, направленные на оператора или последующую автоматическую обработку;
- опасные URL-схемы, URL с credentials и ссылки на private/link-local/metadata адреса;
- HTML/script-like payloads, способные стать активным содержимым при небезопасном отображении;
- подмену типа файла, несоответствие заявленного MIME фактическому формату и checksum;
- вредоносные файлы, обнаруживаемые антивирусными сигнатурами.
## 2. Главное отличие тестовой заглушки Спецификация детализирует архитектуру, но не меняет её. При конфликте приоритет имеют `arch-00``arch-05`. Канонические domain outcomes:
По базовой архитектуре final deny у Message Safety обычно `403`. Для этой заглушки пользователь явно задал особый task-контракт: `GET task` независимо возвращает примерно с равной вероятностью `203`, `200` или **`400`**. - `200 allow`;
- `403 deny`;
- `203 pending` с последующим sticky `200` или `403`.
Здесь `400` на валидном `GET task`**финальный отрицательный verdict/error заглушки**, а не malformed HTTP request. `api-backend` обязан трактовать его как terminal safety rejection и отображать публично как `422 message_blocked`, выставляя `safety_status=blocked`, `delivery_status=rejected`, без вызова Bitrix. Клиенту raw internal `400` не проксируется. Test-only правила по первому символу, случайные verdict и terminal `400 stub_final_error` в production-контракт не входят.
Это намеренное test-only расширение текущей таблицы arch-02 (`200/203/403`). Перед использованием не как заглушки arch-02 и contract tests должны быть обновлены либо `400` должен быть заменён на канонический `403`. Существующие arch-файлы в рамках этой задачи не изменяются. ## 2. Границы ответственности
## 3. Технологический профиль ### 2.1. Сервис отвечает за
- Python 3.12+, FastAPI, Pydantic v2, Uvicorn. - строгую валидацию internal DTO;
- Redis asyncio client, DB2. - нормализацию и rule-based проверку текста;
- OpenTelemetry, JSON logging. - извлечение и проверку ссылок;
- pytest/anyio, HTTPX ASGI client, real Redis integration tests. - валидацию file metadata и фактического формата;
- Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope. - чтение файла из S3-quarantine по read-only credentials;
- вычисление authoritative SHA-256;
- антивирусную проверку файла через ClamAV;
- выбор sync/async режима;
- создание и исполнение async safety tasks;
- sticky final verdict, verdict cache и audit в схеме `message_safety`;
- task coordination/cache/rate limits в Redis DB2;
- internal API, health, метрики, трассировку и безопасные JSON-логи.
## 4. Приоритет правил ### 2.2. Сервис не отвечает за
Перед классификацией текст нормализуется. Правила применяются строго в порядке: - JWT пользователя, согласия и авторизацию доступа пользователя к диалогу;
- edge/API rate limits;
- загрузку файла и выдачу presigned URL;
- запись пользовательского сообщения и статусов в `han_app`;
- copy/promote файла из quarantine в S3-data и удаление объекта;
- доставку в Bitrix24, realtime и пользовательский текст ошибки;
- анализ входящих сообщений оператора;
- ML-модерацию смысла, токсичности или правдивости текста.
1. validation/auth: invalid DTO или service token обрабатываются до бизнес-правил; Этими операциями владеет `api-backend` или соответствующий архитектурный модуль.
2. нормализация;
3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`;
4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`;
5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`.
Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. Rule `ф/Ф` записан раньше для явности. Для file-only request без текста default — `200 allow`; заглушка не сканирует файл. ## 3. Threat model MVP
## 5. Нормализация ### 3.1. Текст и ссылки
Детерминированный pipeline: | Угроза | Контроль | Результат |
1. требовать JSON UTF-8;
2. заменить `CRLF/CR` на `LF`;
3. Unicode normalization `NFKC`;
4. удалить leading Unicode whitespace (`lstrip`);
5. не менять регистр всей строки и не удалять punctuation;
6. ограничить текст max length до значения internal DTO (ориентир 10 000 code points).
Примеры:
| Вход | После нормализации | Результат |
|---|---|---| |---|---|---|
| `"Файл"` | `"Файл"` | 403 | | Prompt/control injection | Версионированные Unicode-aware rules | `403 deny` |
| `" фраза"` | `"фраза"` | 403 | | Попытка выдать текст за system/developer instruction | Нормализация + rule pack | `403 deny` |
| `"\u00a0 дней"` | `"7 дней"` | 203 + task | | Script/active-content payload | Правила для script, event-handler и опасных embedding-конструкций | `403 deny` |
| `"+7..."` | `"+7..."` | 200 | | Опасная URL-схема | Разрешены только `http` и `https` для распознанных web URL | `403 deny` |
| `"документ"` | `"документ"` | 200 | | URL с userinfo/credentials | Запрет `user:password@host` | `403 deny` |
| `"abc"` | `"abc"` | 200 | | SSRF-ссылка | DNS/IP classification, запрет private, loopback, link-local, multicast, unspecified и metadata endpoints | `403 deny` |
| `""`/whitespace | `""` | 200 | | Обход Unicode/whitespace | NFKC, CRLF→LF, Unicode whitespace handling | Проверка нормализованного текста |
| ReDoS/DoS правилами | Линейные/ограниченные regex, лимиты текста, URL и времени | `400` или dependency error |
«Цифра» означает Unicode category `Nd` после NFKC, не только ASCII `[0-9]`. Rules не заменяют безопасный rendering. Frontend и Bitrix integration обязаны экранировать текст; safety является дополнительным барьером, а не HTML sanitizer.
## 6. Authentication и common headers ### 3.2. Файлы
Каждый `/internal/safety/v1/*` требует: | Угроза | Контроль | Результат |
|---|---|---|
| Недопустимый размер/MIME | Сверка DTO с allow-list и лимитами | `403 deny` |
| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` |
| Подмена содержимого после complete | Полный SHA-256 против DTO checksum | `403 deny` |
| Malware | ClamAV scan актуальными сигнатурами | `403 deny` |
| Архивная бомба/ресурсное истощение | Лимиты размера, stream scan, ClamAV limits/timeouts | deny при policy hit; error при сбое |
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
MVP принимает только типы из `chat.attachments.allowed_extensions` и `chat.attachments.allowed_mime_types`, при `chat.attachments.max_size_mb`. Расширение проверяет `api-backend` до вызова safety; `message-safety` независимо проверяет MIME и фактический формат байтов. Internal DTO не содержит имени файла, поэтому сервис не выводит расширение из object key.
### 3.3. Вне threat model MVP
- zero-day malware, отсутствующий в сигнатурах и эвристиках выбранного AV;
- OCR изображений и semantic analysis PDF;
- password-protected/encrypted containers: в MVP они запрещаются, если содержимое нельзя полностью проверить;
- DLP/поиск персональных данных, токсичности и запрещённой тематики;
- переход по пользовательской ссылке и анализ удалённой страницы.
## 4. Общий pipeline
```mermaid
flowchart TD
postCheck[POST_check] --> auth[Auth_and_DTO]
auth --> kind{content_kind}
kind -->|text| normalizeText[Normalize_text]
normalizeText --> textRules[Text_rules]
textRules --> linkRules[Link_pipeline]
linkRules --> syncVerdict[200_or_403]
kind -->|file| metadata[Metadata_validation]
metadata --> cache{SHA256_cache}
cache -->|hit| cached[Sticky_200_or_403]
cache -->|miss| task[203_and_task]
task --> worker[File_worker]
worker --> objectRead[S3_stream_and_SHA256]
objectRead --> formatCheck[Format_validation]
formatCheck --> avScan[ClamAV_scan]
avScan --> finalVerdict[Persist_sticky_verdict]
finalVerdict --> taskGet[GET_task_200_or_403]
```
Приоритет:
1. service authentication, body/content-type/size и DTO validation;
2. idempotency/fingerprint conflict;
3. нормализация;
4. обязательные проверки для соответствующего `content_kind`;
5. любой deny имеет приоритет над allow;
6. инфраструктурная ошибка не превращается ни в allow, ни в domain deny.
## 5. Текстовый pipeline
### 5.1. Нормализация
Pipeline детерминирован:
1. принять только JSON UTF-8;
2. заменить `CRLF`/`CR` на `LF`;
3. Unicode normalization `NFKC`;
4. удалить leading Unicode whitespace;
5. сохранить исходный регистр и punctuation для rules;
6. ограничить текст internal DTO до 10 000 Unicode code points;
7. вычислить SHA-256 нормализованного текста для correlation/cache без хранения текста.
`content_kind=text` требует поле `text`; пустой текст отклоняется upstream `api-backend`. `content_kind=file` допускает пустой `text`; текстовые rules тогда не запускаются.
### 5.2. Rule engine
Rules поставляются как статический read-only bundle приложения. Динамический код, regex или rule definitions из запроса запрещены.
Каждое правило содержит:
- стабильный `rule_id`;
- `reason_code`;
- severity;
- scope (`text`, `url`, `file_metadata`);
- action (`deny`);
- `rules_version`;
- тестовые positive/negative cases.
Начальный rule pack MVP:
- `text.prompt_instruction_override` — конструкции вида «игнорируй предыдущие инструкции» и эквиваленты на поддерживаемых языках;
- `text.prompt_role_impersonation` — попытка обозначить пользовательский фрагмент как system/developer/tool instruction;
- `text.prompt_secret_extraction` — запрос раскрыть system prompt, credentials, tokens или внутренние инструкции;
- `text.active_script``<script>`, inline event handlers и эквивалентные active-content шаблоны;
- `url.forbidden_scheme`;
- `url.credentials_present`;
- `url.private_destination`.
Совпадение должно учитывать границы токенов и контекст, чтобы обычное обсуждение терминов не блокировалось простым substring match. Regex обязаны иметь ограниченную сложность и проходить ReDoS tests. Rule pack загружается и компилируется на startup; ошибка делает service not-ready.
Обычный текст без срабатывания обязательных rules получает `200 allow`. Неуверенное отсутствие совпадения не является deny. Ошибка rule engine является `500`, но не allow.
## 6. Pipeline ссылок
Сервис извлекает URL Unicode-aware parser-ом, а не одним regex.
Для каждой ссылки:
1. ограничить количество ссылок в сообщении и длину URL;
2. выполнить canonical parsing без автоматического исправления malformed URL;
3. разрешить только `http` и `https`;
4. запретить userinfo/credentials;
5. нормализовать IDNA host и отклонить malformed/confusable host;
6. для literal IP применить IP policy;
7. для hostname выполнить DNS resolve через доверенный resolver и проверить все A/AAAA;
8. запретить loopback, RFC1918/ULA, link-local, multicast, unspecified, reserved ranges и cloud metadata endpoints.
Сервис не загружает содержимое URL и не следует redirects. Поэтому URL scan не создаёт исходящий HTTP SSRF. DNS failure или malformed URL, явно распознанный как ссылка, возвращает deny по policy; недоступность resolver для всех ссылок является dependency error.
Лимиты URL должны быть техническими env/settings, документированными в `arch-04` до реализации.
## 7. Файловый pipeline
### 7.1. Fast path POST
До создания задачи сервис:
- валидирует attachment DTO;
- проверяет допустимый MIME и `size_bytes`;
- проверяет checksum `sha256:<64 lowercase hex>`;
- проверяет, что `quarantine_object_key` соответствует разрешённому opaque key contract и не содержит traversal/control characters;
- ищет cache по `(sha256, scanner_engine, signatures_version, rules_version)`.
Явно недопустимые metadata дают sync `403 deny`. Cache hit возвращает sticky `200` или `403`. Cache miss создаёт задачу и возвращает `203 pending`.
Сервис не доверяет key как path и не строит произвольный URL. S3 client обращается только к configured quarantine bucket с virtual-hosted addressing и read-only credentials.
### 7.2. Worker
Worker:
1. атомарно claim-ит pending task с lease;
2. открывает S3 object stream с ограничением bytes/time;
3. вычисляет authoritative SHA-256;
4. сверяет фактический размер и checksum с DTO;
5. определяет реальный формат по содержимому;
6. сверяет detector result с declared MIME;
7. запрещает encrypted/password-protected и неподдерживаемые containers;
8. передаёт поток в `clamd` через internal network;
9. сохраняет sticky final verdict и audit;
10. записывает cache только для terminal результата;
11. завершает lease.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки. `ClamAV FOUND`, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным `rule_id`.
ClamAV timeout, protocol error, недоступность S3/DB/Redis или потеря lease не являются deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как `503`, а не `403`.
### 7.3. AV runtime
MVP использует отдельный `clamd` sidecar/service в private Docker network:
- порт не публикуется наружу;
- сигнатуры обновляет `freshclam`;
- readiness требует daemon PING и допустимый возраст signatures;
- limits согласованы с максимальным размером файла;
- контейнер non-root, read-only root filesystem где возможно, отдельный writable volume только для signatures/runtime;
- worker не передаёт в clamd object key, имя пользователя или иные PII.
Недоступность AV переводит `/health/ready` в `503` и запрещает новые file allow.
## 8. Internal API
Все `/internal/safety/v1/*` доступны только `api-backend` в private network.
Headers:
```text ```text
X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN} X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN}
X-Request-ID: UUID/ULID (если нет — сервис создаёт) X-Request-ID: UUID/ULID; при отсутствии генерируется сервисом
traceparent: optional W3C traceparent: optional W3C
Content-Type: application/json; charset=utf-8
``` ```
Token сравнивается constant-time. Missing/invalid token → `401` или `403` internal auth error; выбран единый `401 service_unauthorized`, без подсказки о значении. Health не требует token внутри network либо использует отдельную ops policy. Token сравнивается constant-time. Missing/invalid token → `401 service_unauthorized` без подсказок.
## 7. DTO `POST .../check` ### 8.1. POST `/internal/safety/v1/messages/check`
Stub принимает минимальный versioned DTO, совместимый с потребностями api-backend:
```json ```json
{ {
"message_id": "uuid", "message_id": "uuid",
"content_kind": "text", "content_kind": "text",
"text": "Фраза", "text": "Текст сообщения",
"attachment": null "attachment": null
} }
``` ```
Для file:
```json ```json
{ {
"message_id": "uuid", "message_id": "uuid",
@@ -104,162 +271,58 @@ Stub принимает минимальный versioned DTO, совместим
"quarantine_object_key": "opaque", "quarantine_object_key": "opaque",
"mime_type": "application/pdf", "mime_type": "application/pdf",
"size_bytes": 12345, "size_bytes": 12345,
"checksum": "sha256:..." "checksum": "sha256:<64-lowercase-hex>"
} }
} }
``` ```
Неизвестные поля запрещены. `content_kind=text` требует text field (пустой разрешён именно stub default); `file` допускает attachment metadata, но не читает S3. `message_id` нужен для correlation/idempotency, не для выбора verdict. Неизвестные поля запрещены. `text` и `file` — строгий discriminated union.
## 8. Ответы `POST .../check` `200`:
### `200 allow`
```json ```json
{ {
"verdict": "allow", "verdict": "allow",
"rule_id": "stub.default_allow", "rule_id": "safety.all_checks_passed",
"rules_version": "2026-01-01" "rules_version": "2026-01-01"
} }
``` ```
### `403 deny` для `ф/Ф` `403`:
```json ```json
{ {
"verdict": "deny", "verdict": "deny",
"rule_id": "stub.starts_with_cyrillic_ef", "rule_id": "file.malware_detected",
"reason_code": "stub_blocked", "reason_code": "message_blocked",
"rules_version": "2026-01-01" "rules_version": "2026-01-01"
} }
``` ```
### `203 pending` для цифры `203`:
```json ```json
{ {
"verdict": "pending", "verdict": "pending",
"task_id": "uuid", "task_id": "uuid",
"poll_after_ms": 2000, "poll_after_ms": 2000,
"expires_at": "2026-07-10T12:15:00Z", "expires_at": "2026-07-29T15:00:00Z",
"rules_version": "2026-01-01" "rules_version": "2026-01-01"
} }
``` ```
Все три — нормальные domain outcomes. `403` не участвует в circuit breaker failure count. ### 8.2. GET `/internal/safety/v1/messages/tasks/{task_id}`
## 9. Task storage Redis DB2 - running/retryable → `203 pending`;
- sticky allow → `200 allow`;
- sticky deny → `403 deny`;
- malformed UUID → `400 validation_error`;
- unknown/expired → `404 task_not_found`;
- dependency failure → `503`.
Ключ: После final verdict повторный GET возвращает тот же HTTP status, verdict, `rule_id` и `rules_version`. Task не удаляется до retention expiry.
```text ### 8.3. Error envelope
han:safety:task:{task_id}
```
HASH/JSON v1:
```json
{
"schema_version": 1,
"message_id": "uuid",
"created_at_ms": 0,
"poll_count": 0,
"rng_context": "optional-test-only",
"rules_version": "2026-01-01"
}
```
TTL `MESSAGE_SAFETY_TASK_TTL_SEC`, default 900 seconds, должен быть больше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC` (300) плюс network/recovery margin. Текст, attachment key и checksum в Redis не нужны. Создание task и TTL атомарны. Коллизия UUID повторяется bounded.
`message_id → task_id` dedup key допустим для идемпотентного повторного POST:
```text
han:safety:task-by-message:{message_id} -> task_id
```
с тем же TTL; reserve обоих keys выполняется Lua. Повтор одинакового check возвращает тот же active task. Если fingerprint изменился для того же message id — `409 safety_request_conflict`.
## 10. `GET .../tasks/{task_id}`
Сначала проверяются token, UUID и существование task. Затем **на каждый GET независимо** выбирается один из трёх outcomes с вероятностью примерно 1/3:
- `203 pending`;
- `200 allow`;
- `400 stub_final_error` (terminal deny/error).
Предыдущий `200` или `400` не фиксируется как sticky verdict в Redis по буквальному требованию «дальнейший GET случайно и независимо». Следовательно, повторный GET того же task после terminal ответа теоретически может вернуть другой outcome. `api-backend` обязан прекратить polling на первом terminal `200/400`, поэтому противоречие снаружи не возникает.
Это поведение специально тестовое и не годится для production moderation. Для безопасной recovery production service должен сохранять sticky final verdict; переход потребует изменения режима/контракта.
### Ответы
`203`:
```json
{"verdict":"pending","task_id":"uuid","poll_after_ms":2000}
```
`200`:
```json
{"verdict":"allow","task_id":"uuid","rule_id":"stub.random_allow"}
```
`400` terminal:
```json
{
"verdict":"deny",
"task_id":"uuid",
"error":{
"code":"stub_final_error",
"message":"Stub task returned a final negative verdict",
"request_id":"uuid",
"details":{"terminal":true}
}
}
```
Для malformed `task_id` используется `400 validation_error`, но его envelope имеет `verdict` отсутствующий и `details.terminal` отсутствует/false. Для неизвестного/expired task — `404 task_not_found`. Api-backend различает terminal stub `400` строго по schema/code, а не по одному HTTP status.
## 11. Worker/poll model
Реальный worker не требуется. Task создаётся сразу, а GET эмулирует состояние worker случайным outcome. Контракт остаётся таким же, как для async orchestration: check создаёт `task_id`, api-backend poll-ит GET внутри исходного user POST.
Опциональный `SAFETY_STUB_WORKER_MODE=emulated_on_poll` — единственный режим MVP. Будущий worker mode не должен менять endpoint/DTO, но final verdict тогда становится sticky.
Api-backend:
```text
POST check
200 -> allow
403 -> deny -> public 422 message_blocked
203 -> poll GET
GET 203 -> continue
GET 200 -> allow
GET 400 + code=stub_final_error + terminal=true
-> deny -> public 422 message_blocked
other 400 -> dependency contract error, not message verdict
timeout/5xx/redis unavailable -> public 503/504
```
## 12. Randomness и deterministic testing
Production-like stub default использует криптографически достаточный process RNG либо `random.Random` с entropy seed; распределение не является security decision.
RNG внедряется через интерфейс `VerdictRng.choice()`. Test implementations:
- sequence RNG: `pending, allow, final_error`;
- seeded RNG через `SAFETY_STUB_RNG_SEED` только при `APP_ENV=test`;
- forced outcome через dependency override, не public header.
В production-like env seed/forced mode вызывает startup failure, чтобы внешний caller не управлял verdict. Статистический test на большой выборке проверяет каждую долю в допустимом диапазоне (например, 0.30–0.36), но основные tests используют sequence RNG и не flaky.
«Независимо» означает новый RNG draw на каждый валидный GET; poll count/предыдущий outcome не влияют на draw.
## 13. Error semantics
Internal envelope:
```json ```json
{ {
@@ -272,187 +335,293 @@ Internal envelope:
} }
``` ```
| HTTP | Code | Retry/смысл | | HTTP | Code/смысл | Retry |
|---|---|---| |---|---|---|
| 400 | `validation_error` | malformed, не terminal verdict | | `400` | malformed DTO/path | нет без исправления |
| 400 | `stub_final_error` + verdict deny | terminal task verdict, не malformed | | `401` | `service_unauthorized` | нет без исправления secret |
| 401 | `service_unauthorized` | не retry без исправления secret | | `403` | domain `deny` | нет |
| 403 | domain `deny` POST | terminal safety verdict | | `404` | `task_not_found` | dependency reconciliation |
| 404 | `task_not_found` | expired/unknown, dependency contract failure | | `409` | `safety_request_conflict` | нет |
| 409 | `safety_request_conflict` | message id с другим fingerprint | | `429` | `rate_limit_exceeded` | по `Retry-After` |
| 429 | `rate_limit_exceeded` | retry по `Retry-After` | | `500` | `internal_error` | да |
| 500 | `internal_error` | retry/circuit | | `503` | dependency/scanner/storage unavailable | да |
| 503 | `redis_unavailable` | retry/circuit |
Domain `403` и terminal stub `400` не считаются infrastructure failure circuit breaker. Domain `403` не считается circuit breaker failure.
## 14. Idempotency и concurrency ## 9. Idempotency и state model
POST fingerprint = SHA-256 canonical normalized DTO без request-id/token. Lua reserve обеспечивает один task на `(message_id,fingerprint)` в TTL. Concurrent duplicate получает тот же task id. Fingerprint = SHA-256 canonical normalized DTO без token/request-id/trace headers.
GET атомарно проверяет существование и увеличивает `poll_count`; RNG draw выполняется независимо. Удалять task после terminal нельзя, иначе повтор получил бы 404 и нарушил независимый test behavior. TTL выполняет cleanup. - одинаковый `(message_id, fingerprint)` возвращает тот же активный task или sticky final verdict;
- тот же `message_id` с другим fingerprint → `409 safety_request_conflict`;
- concurrent duplicate создаёт одну task;
- final verdict изменять запрещено;
- transient error не кэшируется как allow/deny;
- повторный worker delivery безопасен через task state compare-and-set.
## 15. Health Task states:
`GET /health/live`: только process/event loop, всегда без Redis call. ```text
pending -> processing -> allowed
-> denied
processing -> pending (retry with lease expiry)
processing -> failed (infrastructure retries exhausted)
```
`GET /health/ready` проверяет: `allowed`, `denied`, `failed` terminal и sticky. `failed` не является safety deny.
- env/token/rules version валидны; ## 10. Хранение данных
- Redis DB2 auth, PING и короткий SET/GET/DEL с TTL;
- RNG provider доступен;
- OpenAPI schema загружена.
Redis down → `503 {"status":"not_ready","components":{"redis":"down"}}`. Текстовые sync rules технически вычислимы, но service целиком not-ready, а digit check возвращает 503, чтобы не выдавать task без storage. ### 10.1. PostgreSQL schema `message_safety`
## 16. Observability `safety_tasks`:
JSON fields: timestamp, level, `service.name=message-safety`, module, event, request_id, trace_id/span_id, route, status, duration, rule_id, verdict, task_age_bucket, poll_count bucket, error_code. - `id`, `message_id`, `attachment_id`;
- request fingerprint и content hash;
- task status, attempts, lease owner/until, next attempt/deadline;
- declared MIME/size/checksum;
- verdict, `rule_id`, `reason_code`;
- rules/scanner/signatures versions;
- timestamps и common audit fields.
Не логируются service token, message text, attachment key/name, checksum, DTO body или PII. Разрешены message/task UUID при принятой retention либо их hash. `verdict_cache`:
- content SHA-256;
- rules/scanner/signatures versions;
- sticky verdict и rule;
- expiry/created timestamps;
- unique versioned cache key.
`safety_audit`:
- request/task identifiers;
- event (`received`, `task_created`, `rule_hit`, `scan_completed`, `dependency_failed`);
- verdict/rule/version;
- duration and technical error category;
- timestamp.
Raw message text, file bytes, object key, filename, service token и AV stream в PostgreSQL не сохраняются. Для correlation используются UUID и cryptographic hashes.
### 10.2. Redis DB2
Redis используется для:
- short-lived task lookup;
- dedup/reservation coordination;
- hot verdict cache;
- leases/locks;
- internal rate limits.
PostgreSQL остаётся durable source of truth. Потеря Redis не должна менять sticky final verdict; сервис восстанавливает state из PostgreSQL. Redis unavailable делает service not-ready и file task creation недоступным.
TTL task должен превышать `MESSAGE_SAFETY_TASK_POLL_MAX_SEC` плюс recovery/network margin. Verdict cache TTL задаётся отдельно и инвалидируется версиями rules/scanner/signatures.
## 11. Согласование с `api-backend`
```text
POST check
200 -> allow -> text accepted or file promote -> Bitrix delivery
403 -> blocked/rejected -> no Bitrix -> public 422 message_blocked
203 -> persist han_app.safety_tasks -> poll GET
GET 203 -> continue
GET 200 -> allow branch
GET 403 -> deny branch
timeout/5xx -> public 503/504, no promote, no Bitrix
```
`message-safety` не перемещает и не удаляет S3 object. На deny это идемпотентно делает `api-backend`. Клиент не получает internal `203`.
## 12. Security controls
- internal network only; endpoint не публикуется через nginx и host port;
- unique service token только из env/secret mount;
- strict DTO, body/text/URL/file limits и запрет unknown fields;
- read-only S3-quarantine access, без list/write/delete;
- no arbitrary URL fetch, no redirect following;
- egress allow-list только DNS, S3, PostgreSQL, Redis, OTLP и clamd по назначению;
- parameterized SQL и least-privilege DB role только на schema `message_safety`;
- Redis ACL только DB2 и prefixes `han:safety:*`;
- dependency pinning, SBOM/image scanning;
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities, no-new-privileges;
- OpenAPI UI выключен в production;
- безопасные generic errors без stack/internal addresses;
- правила и AV signatures обновляются только trusted deployment process.
## 13. Observability и privacy
JSON logs:
- `timestamp`, `level`, `service.name=message-safety`, `module`, `event`;
- `request_id`, `trace_id`, `span_id`, route, status, duration;
- verdict, `rule_id`, rules/scanner/signatures version;
- task state, attempt, age/size bucket;
- dependency/error code.
Запрещено логировать message text, extracted URL целиком, file bytes, object key/name, checksum целиком, DTO body и service token. URL host и hash допустимы только при утверждённой retention policy; по умолчанию логируется category/hash.
Metrics: Metrics:
- requests/latency/errors по route/status; - request rate/latency/status по route;
- check outcomes allow/deny/pending; - checks/verdicts по `content_kind`, `allow|deny|pending|error`;
- task GET outcomes pending/allow/final_error; - rule hits по low-cardinality `rule_id`;
- observed distribution; - task queue/age/attempts/lease conflicts/timeouts;
- task create/dedup/conflict/not-found/expired; - scan latency/bytes buckets/AV outcome;
- Redis latency/error/pool; - signatures age/version info;
- auth rejects, rate limit; - cache hit/miss;
- RNG mode как low-cardinality info; - PostgreSQL/Redis/S3/ClamAV latency and errors;
- readiness. - auth rejects/rate limit/readiness.
Trace связывается с api-backend через `traceparent`, `X-Request-ID` возвращается. Telemetry collector unavailable не влияет на safety verdict и readiness.
## 17. Security ## 14. Health
- только Docker backend network, без nginx/public route и host port; `GET /health/live` проверяет только process/event loop.
- constant-time token compare, secret только env/secret mount;
- strict JSON schema/max body/max text;
- no dynamic code/rules from request;
- Redis ACL только DB2 prefixes;
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities;
- OpenAPI docs UI production отключён, committed YAML остаётся;
- CORS не нужен internal service;
- rate limit по service identity/network защищает от accidental loops;
- error response не раскрывает internal host/stack/secret.
## 18. Docker и env `GET /health/ready` проверяет:
```text - settings, secret и rules bundle;
message-safety/ - PostgreSQL read/write в schema `message_safety`;
app/ - Redis DB2 PING и prefixed SET/GET/DEL;
main.py - worker heartbeat/lease processing;
api/{routes,schemas,errors,auth}.py - S3-quarantine Head/Get read permission на безопасный canary object;
application/{classifier,tasks}.py - ClamAV PING и допустимый возраст signatures;
infrastructure/{redis,rng,observability}.py - OpenAPI schema availability.
settings.py
tests/{unit,integration,contract}/ Критическая dependency down → `503`:
openapi.yaml
Dockerfile ```json
docker-compose.yml {
"status": "not_ready",
"components": {
"postgres": "ok",
"redis": "ok",
"s3_quarantine": "ok",
"worker": "ok",
"antivirus": "down",
"rules": "ok"
}
}
``` ```
Compose: `expose: 8080`, networks `backend`,`observability`, без `ports`, depends_on Redis health, собственный retry startup. Health не требует service token внутри private ops network и не раскрывает credentials/hostnames.
Env: ## 15. Configuration
Канонические существующие env:
```text ```text
APP_ENV=production-like APP_ENV=production-like
MESSAGE_SAFETY_PORT=8080 MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:<secret>@<host>:5433/han_chat
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/2 MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/2
MESSAGE_SAFETY_SERVICE_TOKEN=<secret> MESSAGE_SAFETY_SERVICE_TOKEN=<secret>
MESSAGE_SAFETY_RULES_VERSION=2026-01-01 MESSAGE_SAFETY_RULES_VERSION=2026-01-01
MESSAGE_SAFETY_TASK_TTL_SEC=900 MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
MESSAGE_SAFETY_POLL_AFTER_MS=2000 MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
SAFETY_STUB_WORKER_MODE=emulated_on_poll MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
SAFETY_STUB_RNG_SEED= MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=<secret>
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=<secret>
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
``` ```
Новые env (`TASK_TTL`, `POLL_AFTER`, stub mode/seed) требуют внесения в arch-04 перед реализацией production config; здесь они зафиксированы как предложение. ClamAV endpoint, signature max age, worker concurrency, retry/lease, task/cache TTL, text/URL limits и detector version требуют добавления в `arch-04` до реализации. Бизнес allow-list типов/размера остаётся в `app_settings`; `api-backend` передаёт согласованные metadata, а safety использует versioned runtime snapshot.
## 19. OpenAPI Startup отклоняет placeholders, insecure production defaults, несовместимые timeout/TTL и отсутствующие обязательные параметры.
`message-safety/openapi.yaml` OpenAPI 3.1 обязателен и включает: ## 16. OpenAPI
- security scheme `X-Service-Token`; `message-safety/openapi.yaml` OpenAPI 3.1 обязателен и содержит:
- check request union text/file;
- exact 200/203/403 responses POST; - `X-Service-Token` security scheme;
- exact 200/203/400/404 responses GET;
- discriminator между malformed 400 и terminal stub 400;
- common request/trace headers; - common request/trace headers;
- examples, max lengths, UUID/checksum formats; - strict discriminated union text/file;
- health endpoints. - exact POST responses `200/203/400/401/403/409/429/500/503`;
- exact GET responses `200/203/400/401/403/404/429/500/503`;
- domain deny отдельно от error envelope;
- UUID/checksum/max length examples;
- health contracts.
Generated/runtime schema сравнивается с committed artifact. Contract test api-backend отдельно закрепляет mapping terminal `400 stub_final_error → 422 message_blocked`. Runtime schema сравнивается с committed artifact contract test. Terminal `400 stub_final_error` отсутствует.
## 20. Тестовая матрица ## 17. Тестовая матрица
### Unit ### Unit
- NFKC/whitespace/Unicode `Nd`; - NFKC, CRLF, Unicode whitespace, max length;
- `ф`, `Ф`, fullwidth variants, punctuation/default; - positive/negative cases каждого text rule;
- exact rule priority; - границы токенов и false-positive corpus;
- DTO union/limits; - ReDoS/time budget всех regex;
- injected sequence and seeded RNG; - URL parsing, IDNA, schemes, credentials и IP ranges IPv4/IPv6;
- error discrimination and log redaction. - DTO union, fingerprint и rule priority;
- MIME/magic/checksum decision table;
- sticky state transitions;
- log redaction.
### Integration ### Integration
- Redis DB2 task/dedup/TTL/atomic concurrency; - PostgreSQL migration/constraints/idempotency/audit;
- same message same/different fingerprint; - Redis DB2 reserve/cache/TTL/lease concurrency/recovery;
- task expiration; - S3 read-only stream, object changed/missing/timeout;
- Redis outage/reconnect; - ClamAV clean, EICAR, FOUND, timeout, daemon down, stale signatures;
- ACL rejection outside prefix; - full SHA-256 and size mismatch;
- poll count concurrency. - duplicate concurrent request and worker retry;
- dependency recovery without changed final verdict.
### Contract ### Contract
- POST `документ`/default 200, `ф/Ф` 403, digit 203; - text allow and each deny category;
- GET independent 203/200/400; - file cache hit, `203` then sticky `200`, `203` then sticky `403`;
- terminal 400 schema versus malformed 400; - no random/non-sticky outcomes;
- malformed `400` never treated as deny;
- auth missing/wrong/correct; - auth missing/wrong/correct;
- request id/trace propagation; - request-id and trace propagation;
- api-backend mapping to public 422 and no Bitrix call; - api-backend maps only domain `403` to public `422 message_blocked`;
- deny/timeout never calls Bitrix and never promotes quarantine;
- OpenAPI runtime parity. - OpenAPI runtime parity.
### Statistical/failure ### Security/failure
- 30k+ GET draws approximately 1/3 each with non-flaky tolerance; - EICAR and safe corpus;
- prior outcome does not influence next seeded sequence; - malformed/polyglot/encrypted files;
- API sync wait terminates on first 200/400; - decompression and parser resource limits;
- repeated 203 reaches timeout behavior; - URL private/link-local/metadata/IPv4-mapped-IPv6 cases;
- Redis restart loses ephemeral task safely and API returns dependency error; - DNS rebinding simulation;
- no text/token/object key in logs. - no raw text/token/URL/object key/checksum in logs/traces/errors;
- S3 credentials cannot list/write/delete;
- Redis ACL and PostgreSQL schema isolation;
- telemetry outage does not affect verdict.
## 21. Definition of Done ## 18. Definition of Done
- канонические endpoint paths arch-02 реализованы; - production `200/203/403` contract реализован без stub divergence;
- правило normalized `ф/Ф → 403`, digit → `203 task`, others → `200` покрыто; - text rules и URL pipeline покрывают threat model и false-positive corpus;
- каждый valid task GET независимо даёт 203/200/terminal 400 примерно 1/3; - files проходят metadata, authoritative SHA-256, format detector и ClamAV;
- distinction terminal vs malformed 400 формально задано; - final task verdict sticky и durable;
- api-backend contract mapping terminal 400 → public 422 проверен; - idempotency/concurrency/recovery доказаны тестами;
- Redis DB2 atomic task/dedup/TTL и degraded behavior готовы; - PG schema `message_safety`, Redis DB2 и S3 read-only работают по least privilege;
- RNG injected, deterministic tests не flaky, prod seed запрещён; - cache versioned rules/scanner/signatures и не сохраняет transient errors;
- service token/network/ACL/container hardening проверены; - api-backend mapping allow/deny/timeout проверен end-to-end;
- health, JSON logs, metrics/traces без PII/secrets; - blocked/failed content не попадает в Bitrix и не promote-ится;
- OpenAPI 3.1 committed и contract tests зелёные; - health/readiness отражает PG/Redis/S3/workers/ClamAV/rules;
- контейнер запускается в root Compose без published port; - OpenAPI 3.1 и runtime parity зелёные;
- intentional divergence с arch-02 либо принята как stub exception, либо arch-02 обновлён до production implementation. - logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
- hardened containers запускаются без public port;
- runbook описывает signature update, stale signatures, AV outage, retry и rollback.
## 22. Решения, допущения и TBD ## 19. Переход с текущей заглушки и follow-up
**Решения:** normalizer NFKC+lstrip; Unicode `Nd`; default allow; emulation on GET без worker; independent non-sticky outcomes; `400 stub_final_error` terminal и преобразуется API в 422. Текущая реализация в `codebase/backend/message-safety/` остаётся test stub и **не соответствует** этой production-спецификации. Для перехода отдельной задачей необходимо:
**Допущения:** пустой/file-only text попадает в default 200; `message_id` передаётся internal DTO; Redis task TTL 900 секунд достаточен для MVP tests. 1. удалить правила `ф/Ф`, digit task и random RNG;
2. удалить terminal `400 stub_final_error`;
3. реализовать sticky canonical `403` для async deny;
4. подключить PostgreSQL schema `message_safety`, Redis DB2, S3 read-only и ClamAV workers;
5. добавить migrations, полный OpenAPI и тестовую матрицу;
6. синхронизировать `module-01-api-backend.md` и код api-backend, удалив test-only mapping `stub_final_error`;
7. синхронизировать `module-09-observability.md` и dashboards, удалив stub distribution panels/маркировку;
8. добавить новые technical env и ClamAV deployment contract в `arch-04`/`arch-03` до production реализации.
**TBD:** До выполнения перехода контейнер должен быть явно маркирован как stub и не считаться production security control.
- S1 формально обновить arch-02 для test-only terminal 400 или вернуть production 403;
- S2 окончательный internal DTO/fingerprint в OpenAPI;
- S3 добавить новые env в arch-04;
- S4 точный Redis task TTL относительно extended recovery module-01;
- S5 sticky final verdict при переходе от stub к реальному Safety;
- S6 реальные file/link checks, PostgreSQL schema и S3 read-only — вне scope заглушки.