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

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
+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 заглушки.