Закрыли часть проблем с безопасностью + мелкие починки
This commit is contained in:
+48
-1
@@ -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
|
||||||
@@ -62,3 +71,41 @@ debounce на отправку СМС (сейчас есть Фиксирова
|
|||||||
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 логин/загрузки/интеграции сломаются.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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 работает на нестандартном порту или имя внешнего интерфейса известно
|
||||||
заранее, передайте параметры:
|
заранее, передайте параметры:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 по паролю отключены.
|
||||||
|
|||||||
@@ -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)\"}"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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` |
|
||||||
| `"\u00a07 дней"` | `"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 заглушки.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user