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

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.
7. Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране.
# В разработку:
3. Унифицировать сообщения гостевого режима о необходмости
На экране профиля в гостевом режиме добавить кнопку "Авторизоваться"
2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
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 включать только на время ревью.
6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
9. Веб-пуши для PWA
@@ -40,6 +47,8 @@
18. Разработка notification-service
20. Поднять второй контур для продакшн
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.).
25. Nginx metrics/tracing в signoz
@@ -61,4 +70,42 @@ debounce на отправку СМС (сейчас есть Фиксирова
4. Разработка notification-service
5. Подключить OTLP-провайдер
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_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.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_RATE_LIMIT_API=60r/m
NGINX_RATE_LIMIT_AUTH=60r/m
+4
View File
@@ -31,6 +31,10 @@ class JWKSValidator:
self._loaded_at = 0.0
self._lock = asyncio.Lock()
@property
def has_keys(self) -> bool:
return bool(self._keys)
async def refresh(self) -> None:
async with self._lock:
discovery_url = (
+21 -4
View File
@@ -113,6 +113,21 @@ async def refresh_settings_cache(app: FastAPI) -> None:
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
async def lifespan(app: FastAPI):
settings = get_settings()
@@ -142,12 +157,14 @@ async def lifespan(app: FastAPI):
await app.state.jwks.refresh()
except Exception:
structlog.get_logger().warning("jwks.warmup_failed")
jwks_task = asyncio.create_task(refresh_jwks_cache(app))
try:
yield
finally:
settings_task.cancel()
with suppress(asyncio.CancelledError):
await settings_task
for task in (settings_task, jwks_task):
task.cancel()
with suppress(asyncio.CancelledError):
await task
await app.state.http.aclose()
await app.state.redis.aclose()
await app.state.redis_rt.aclose()
@@ -462,7 +479,7 @@ async def ready(request: Request, db: Session):
components[name] = "ok"
except Exception:
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:
safety_response = await request.app.state.http.get(
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
@@ -1,14 +1,22 @@
import asyncio
import base64
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from alembic.config import Config
from alembic.script import ScriptDirectory
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
EXPECTED_PATHS = {
@@ -65,6 +73,39 @@ def test_readiness_expected_revision_matches_alembic_head() -> None:
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:
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;
- создаст `/opt/han-chat/backend`;
- создаст swap;
- включит автоматические обновления безопасности.
- включит автоматические обновления безопасности;
- отключит парольный SSH-вход и X11 forwarding;
- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей.
По умолчанию скрипт не отключает парольный SSH-вход. Не используйте
`HARDEN_SSH=true`, пока не проверили вход пользователем `deploy` по ключу в
отдельной сессии.
Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до
блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход
пользователем `root` и SSH TCP forwarding; включайте этот режим только после
проверки входа пользователем `deploy` по ключу в отдельной сессии.
Если SSH работает на нестандартном порту или имя внешнего интерфейса известно
заранее, передайте параметры:
+4 -2
View File
@@ -26,8 +26,10 @@ On a fresh Ubuntu 24.04 VM, run:
sudo deployment/scripts/setup-vm.sh
```
Before setting `HARDEN_SSH=true`, verify key-based access in a separate SSH
session. The script header documents its parameters and safe defaults.
The script disables password SSH and X11 forwarding by default, then locks the
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.
- [ ] 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
```
Перед включением `HARDEN_SSH=true` обязательно проверьте вход по ключу в отдельной
SSH-сессии. Параметры запуска и безопасные значения по умолчанию описаны в начале скрипта.
Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после
проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением
`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding,
обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии.
- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска.
- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены.
+67 -13
View File
@@ -20,11 +20,14 @@
# PUBLIC_DOCKER_PORTS=80,443
# COPY_SSH_KEYS=true
# HARDEN_SSH=false
# LOCK_ACCOUNT_PASSWORDS=true
# HSTS_MAX_AGE_SECONDS=31536000
# RESET_UFW=false
# SKIP_APT_UPGRADE=false
#
# HARDEN_SSH=true разрешено использовать только после проверки входа по ключу
# в отдельной SSH-сессии. По умолчанию парольный вход не отключается.
# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
# запрещает прямой root-вход и SSH TCP forwarding.
set -Eeuo pipefail
IFS=$'\n\t'
@@ -38,6 +41,8 @@ EXTERNAL_IF="${EXTERNAL_IF:-}"
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
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}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
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 >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
[[ "$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]+)*$ ]] \
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
}
@@ -138,6 +147,9 @@ create_deploy_user() {
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
if [[ -n "$source_user" && "$source_user" != "root" ]]; then
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
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
@@ -150,6 +162,21 @@ create_deploy_user() {
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() {
step "Каталоги HAN Chat"
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
@@ -361,29 +388,51 @@ EOF
}
configure_ssh() {
step "Проверка SSH hardening"
if [[ "$HARDEN_SSH" != "true" ]]; then
log "HARDEN_SSH=false: парольный вход не изменен"
return
fi
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя включить HARDEN_SSH: authorized_keys пользователя пуст"
step "Настройка SSH"
[[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys"
cat >/etc/ssh/sshd_config.d/99-han-chat.conf <<EOF
PermitRootLogin no
cat >/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowTcpForwarding no
MaxAuthTries 3
ClientAliveInterval 120
ClientAliveCountMax 2
Port ${SSH_PORT}
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 не пройдена"
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() {
@@ -443,6 +492,9 @@ summary() {
Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${LOG_FILE}
Следующие действия:
@@ -475,6 +527,7 @@ main() {
update_system
configure_time
create_deploy_user
configure_account_passwords
configure_layout
configure_swap
configure_sysctl
@@ -484,6 +537,7 @@ main() {
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
configure_application_security
install_ssl_timer_if_possible
verify
summary
@@ -6,8 +6,23 @@ lock=/tmp/han-chat-cert-renew.lock
exec 9>"$lock"
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
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
compose exec -T nginx nginx -t -c /tmp/nginx.conf
# Сигнал отправляется 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)\"}"
+1 -1
View File
@@ -9,7 +9,7 @@ services:
NGINX_TLS_ENABLED: ${NGINX_TLS_ENABLED:-true}
NGINX_TLS_CERTIFICATE: ${NGINX_TLS_CERTIFICATE}
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_RATE_LIMIT_API: ${NGINX_RATE_LIMIT_API:-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_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:
site = (ROOT / "nginx/templates/site-tls.conf.template").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, строго реализующей правила данного задания.
> Источники: [`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).
> Статус: целевая production-спецификация MVP.
> Канонические источники: [`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.
- Redis asyncio client, DB2.
- OpenTelemetry, JSON logging.
- pytest/anyio, HTTPX ASGI client, real Redis integration tests.
- Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope.
- строгую валидацию internal DTO;
- нормализацию и rule-based проверку текста;
- извлечение и проверку ссылок;
- валидацию file metadata и фактического формата;
- чтение файла из 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 обрабатываются до бизнес-правил;
2. нормализация;
3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`;
4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`;
5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`.
Этими операциями владеет `api-backend` или соответствующий архитектурный модуль.
Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. 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 |
| `" фраза"` | `"фраза"` | 403 |
| `"\u00a0 дней"` | `"7 дней"` | 203 + task |
| `"+7..."` | `"+7..."` | 200 |
| `"документ"` | `"документ"` | 200 |
| `"abc"` | `"abc"` | 200 |
| `""`/whitespace | `""` | 200 |
| Prompt/control injection | Версионированные Unicode-aware rules | `403 deny` |
| Попытка выдать текст за system/developer instruction | Нормализация + rule pack | `403 deny` |
| Script/active-content payload | Правила для script, event-handler и опасных embedding-конструкций | `403 deny` |
| Опасная URL-схема | Разрешены только `http` и `https` для распознанных web URL | `403 deny` |
| URL с userinfo/credentials | Запрет `user:password@host` | `403 deny` |
| SSRF-ссылка | DNS/IP classification, запрет private, loopback, link-local, multicast, unspecified и metadata endpoints | `403 deny` |
| Обход 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
X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN}
X-Request-ID: UUID/ULID (если нет — сервис создаёт)
X-Request-ID: UUID/ULID; при отсутствии генерируется сервисом
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`
Stub принимает минимальный versioned DTO, совместимый с потребностями api-backend:
### 8.1. POST `/internal/safety/v1/messages/check`
```json
{
"message_id": "uuid",
"content_kind": "text",
"text": "Фраза",
"text": "Текст сообщения",
"attachment": null
}
```
Для file:
```json
{
"message_id": "uuid",
@@ -104,162 +271,58 @@ Stub принимает минимальный versioned DTO, совместим
"quarantine_object_key": "opaque",
"mime_type": "application/pdf",
"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 allow`
`200`:
```json
{
"verdict": "allow",
"rule_id": "stub.default_allow",
"rule_id": "safety.all_checks_passed",
"rules_version": "2026-01-01"
}
```
### `403 deny` для `ф/Ф`
`403`:
```json
{
"verdict": "deny",
"rule_id": "stub.starts_with_cyrillic_ef",
"reason_code": "stub_blocked",
"rule_id": "file.malware_detected",
"reason_code": "message_blocked",
"rules_version": "2026-01-01"
}
```
### `203 pending` для цифры
`203`:
```json
{
"verdict": "pending",
"task_id": "uuid",
"poll_after_ms": 2000,
"expires_at": "2026-07-10T12:15:00Z",
"expires_at": "2026-07-29T15:00:00Z",
"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
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:
### 8.3. Error envelope
```json
{
@@ -272,187 +335,293 @@ Internal envelope:
}
```
| HTTP | Code | Retry/смысл |
| HTTP | Code/смысл | Retry |
|---|---|---|
| 400 | `validation_error` | malformed, не terminal verdict |
| 400 | `stub_final_error` + verdict deny | terminal task verdict, не malformed |
| 401 | `service_unauthorized` | не retry без исправления secret |
| 403 | domain `deny` POST | terminal safety verdict |
| 404 | `task_not_found` | expired/unknown, dependency contract failure |
| 409 | `safety_request_conflict` | message id с другим fingerprint |
| 429 | `rate_limit_exceeded` | retry по `Retry-After` |
| 500 | `internal_error` | retry/circuit |
| 503 | `redis_unavailable` | retry/circuit |
| `400` | malformed DTO/path | нет без исправления |
| `401` | `service_unauthorized` | нет без исправления secret |
| `403` | domain `deny` | нет |
| `404` | `task_not_found` | dependency reconciliation |
| `409` | `safety_request_conflict` | нет |
| `429` | `rate_limit_exceeded` | по `Retry-After` |
| `500` | `internal_error` | да |
| `503` | dependency/scanner/storage unavailable | да |
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 валидны;
- Redis DB2 auth, PING и короткий SET/GET/DEL с TTL;
- RNG provider доступен;
- OpenAPI schema загружена.
## 10. Хранение данных
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:
- requests/latency/errors по route/status;
- check outcomes allow/deny/pending;
- task GET outcomes pending/allow/final_error;
- observed distribution;
- task create/dedup/conflict/not-found/expired;
- Redis latency/error/pool;
- auth rejects, rate limit;
- RNG mode как low-cardinality info;
- readiness.
- request rate/latency/status по route;
- checks/verdicts по `content_kind`, `allow|deny|pending|error`;
- rule hits по low-cardinality `rule_id`;
- task queue/age/attempts/lease conflicts/timeouts;
- scan latency/bytes buckets/AV outcome;
- signatures age/version info;
- cache hit/miss;
- PostgreSQL/Redis/S3/ClamAV latency and errors;
- 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;
- 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.
`GET /health/live` проверяет только process/event loop.
## 18. Docker и env
`GET /health/ready` проверяет:
```text
message-safety/
app/
main.py
api/{routes,schemas,errors,auth}.py
application/{classifier,tasks}.py
infrastructure/{redis,rng,observability}.py
settings.py
tests/{unit,integration,contract}/
openapi.yaml
Dockerfile
docker-compose.yml
- settings, secret и rules bundle;
- PostgreSQL read/write в schema `message_safety`;
- Redis DB2 PING и prefixed SET/GET/DEL;
- worker heartbeat/lease processing;
- S3-quarantine Head/Get read permission на безопасный canary object;
- ClamAV PING и допустимый возраст signatures;
- OpenAPI schema availability.
Критическая dependency down → `503`:
```json
{
"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
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_SERVICE_TOKEN=<secret>
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
MESSAGE_SAFETY_TASK_TTL_SEC=900
MESSAGE_SAFETY_POLL_AFTER_MS=2000
SAFETY_STUB_WORKER_MODE=emulated_on_poll
SAFETY_STUB_RNG_SEED=
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
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
```
Новые 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`;
- check request union text/file;
- exact 200/203/403 responses POST;
- exact 200/203/400/404 responses GET;
- discriminator между malformed 400 и terminal stub 400;
`message-safety/openapi.yaml` OpenAPI 3.1 обязателен и содержит:
- `X-Service-Token` security scheme;
- common request/trace headers;
- examples, max lengths, UUID/checksum formats;
- health endpoints.
- strict discriminated union text/file;
- 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
- NFKC/whitespace/Unicode `Nd`;
- `ф`, `Ф`, fullwidth variants, punctuation/default;
- exact rule priority;
- DTO union/limits;
- injected sequence and seeded RNG;
- error discrimination and log redaction.
- NFKC, CRLF, Unicode whitespace, max length;
- positive/negative cases каждого text rule;
- границы токенов и false-positive corpus;
- ReDoS/time budget всех regex;
- URL parsing, IDNA, schemes, credentials и IP ranges IPv4/IPv6;
- DTO union, fingerprint и rule priority;
- MIME/magic/checksum decision table;
- sticky state transitions;
- log redaction.
### Integration
- Redis DB2 task/dedup/TTL/atomic concurrency;
- same message same/different fingerprint;
- task expiration;
- Redis outage/reconnect;
- ACL rejection outside prefix;
- poll count concurrency.
- PostgreSQL migration/constraints/idempotency/audit;
- Redis DB2 reserve/cache/TTL/lease concurrency/recovery;
- S3 read-only stream, object changed/missing/timeout;
- ClamAV clean, EICAR, FOUND, timeout, daemon down, stale signatures;
- full SHA-256 and size mismatch;
- duplicate concurrent request and worker retry;
- dependency recovery without changed final verdict.
### Contract
- POST `документ`/default 200, `ф/Ф` 403, digit 203;
- GET independent 203/200/400;
- terminal 400 schema versus malformed 400;
- text allow and each deny category;
- file cache hit, `203` then sticky `200`, `203` then sticky `403`;
- no random/non-sticky outcomes;
- malformed `400` never treated as deny;
- auth missing/wrong/correct;
- request id/trace propagation;
- api-backend mapping to public 422 and no Bitrix call;
- request-id and trace propagation;
- api-backend maps only domain `403` to public `422 message_blocked`;
- deny/timeout never calls Bitrix and never promotes quarantine;
- OpenAPI runtime parity.
### Statistical/failure
### Security/failure
- 30k+ GET draws approximately 1/3 each with non-flaky tolerance;
- prior outcome does not influence next seeded sequence;
- API sync wait terminates on first 200/400;
- repeated 203 reaches timeout behavior;
- Redis restart loses ephemeral task safely and API returns dependency error;
- no text/token/object key in logs.
- EICAR and safe corpus;
- malformed/polyglot/encrypted files;
- decompression and parser resource limits;
- URL private/link-local/metadata/IPv4-mapped-IPv6 cases;
- DNS rebinding simulation;
- 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 реализованы;
- правило normalized `ф/Ф → 403`, digit → `203 task`, others → `200` покрыто;
- каждый valid task GET независимо даёт 203/200/terminal 400 примерно 1/3;
- distinction terminal vs malformed 400 формально задано;
- api-backend contract mapping terminal 400 → public 422 проверен;
- Redis DB2 atomic task/dedup/TTL и degraded behavior готовы;
- RNG injected, deterministic tests не flaky, prod seed запрещён;
- service token/network/ACL/container hardening проверены;
- health, JSON logs, metrics/traces без PII/secrets;
- OpenAPI 3.1 committed и contract tests зелёные;
- контейнер запускается в root Compose без published port;
- intentional divergence с arch-02 либо принята как stub exception, либо arch-02 обновлён до production implementation.
- production `200/203/403` contract реализован без stub divergence;
- text rules и URL pipeline покрывают threat model и false-positive corpus;
- files проходят metadata, authoritative SHA-256, format detector и ClamAV;
- final task verdict sticky и durable;
- idempotency/concurrency/recovery доказаны тестами;
- PG schema `message_safety`, Redis DB2 и S3 read-only работают по least privilege;
- cache versioned rules/scanner/signatures и не сохраняет transient errors;
- api-backend mapping allow/deny/timeout проверен end-to-end;
- blocked/failed content не попадает в Bitrix и не promote-ится;
- health/readiness отражает PG/Redis/S3/workers/ClamAV/rules;
- OpenAPI 3.1 и runtime parity зелёные;
- 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 заглушки.