Files
han-app/VM2_services/documentation/module-05-message-safety.md
T

1113 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-05. Проектная спецификация `message-safety`
> Статус: нормативная постановка целевой production-реализации v2, готовая к разработке после прохождения Definition of Ready (§18). Текущий v1 stub остаётся test-only до отдельного cutover.
> Канонические источники: [`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), [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md), [`module-01-api-backend.md`](../../VM1_app/documentation/module-01-api-backend.md).
## 1. Назначение и приоритет
`message-safety` — внутренний сервис, который до отправки сообщения в Bitrix24 проверяет пользовательский текст, содержащиеся в нём ссылки и файлы из S3-quarantine.
Сервис закрывает угрозы, поступающие через пользовательское сообщение:
- semantic prompt-injection конструкции, которые в MVP только наблюдаются и не блокируют сообщение;
- опасные URL-схемы, URL с credentials и ссылки на private/link-local/metadata адреса;
- HTML/script-like payloads, способные стать активным содержимым при небезопасном отображении;
- подмену типа файла, несоответствие заявленного MIME фактическому формату и checksum;
- вредоносные файлы, обнаруживаемые антивирусными сигнатурами.
Спецификация детализирует обновлённую двух-VM архитектуру. При конфликте приоритет имеют `arch-00``arch-06`. Канонические domain outcomes v2:
- `200 allow`;
- `403 deny`;
- `202 Accepted` с `Location`/`Retry-After` и последующим sticky `200` или `403`.
Test-only правила по первому символу, случайные verdict и terminal `400 stub_final_error` в production-контракт не входят.
### 1.1. Владение решениями
| Роль | Ответственность |
|---|---|
| Product Owner | бизнес-приёмка generic deny UX и мнемоники `safety.chat.blocked` |
| Safety Service Owner | lifecycle v2, API/data contracts, service-owned config, capacity и cutover sign-off |
| Rule Pack Owner | версия rules bundle, corpus, monitor rollout и release notes |
| Security Owner | approval `monitor → deny` и config changes, ослабляющих policy; threat model, egress/secrets и risk acceptance |
| Operations Owner | VM2, host KESL/broker, антивирусные базы, alerts, rollback/reprovision и restore rehearsal |
Один человек может выполнять несколько ролей, но для каждого production release роли и approvals должны быть записаны в release checklist.
## 2. Границы ответственности
### 2.1. Сервис отвечает за
- строгую валидацию internal DTO;
- нормализацию и rule-based проверку текста;
- извлечение и local-only проверку ссылок с versioned link cache;
- валидацию file metadata и фактического формата;
- чтение файла из S3-quarantine по read-only credentials;
- вычисление authoritative SHA-256;
- антивирусную проверку файла через root-owned fail-closed broker по Unix socket `/run/han-kesl/scan.sock`; broker вызывает host KESL 12.4 standalone;
- выбор sync/async режима;
- создание и исполнение async safety tasks;
- sticky final verdict, verdict cache и audit в схеме `message_safety`;
- versioned text-rules cache по hash analysis form без хранения текста;
- PostgreSQL task queue/leases; Redis Safety только hot cache/rate/wakeup;
- internal API, health, метрики, трассировку и безопасные JSON-логи.
### 2.2. Сервис не отвечает за
- JWT пользователя, согласия и авторизацию доступа пользователя к диалогу;
- edge/API rate limits;
- загрузку файла и выдачу presigned URL;
- запись пользовательского сообщения и статусов в `han_app`;
- copy/promote файла из quarantine в S3-data и удаление объекта;
- доставку в Bitrix24, realtime и пользовательский текст ошибки;
- анализ входящих сообщений оператора;
- ML-модерацию смысла, токсичности или правдивости текста.
Этими операциями владеет `api-backend` или соответствующий архитектурный модуль.
### 2.3. Аварийный режим MOCK
Режим предназначен для ручного controlled bypass при поломке или неприемлемой деградации полного pipeline.
| Настройки | Text result | File result |
|---|---|---|
| `MOCK=false` | стандартный pipeline этого документа | стандартный pipeline этого документа |
| `MOCK=true`, `TEXT_FREE=true`, `FILE_FREE=true` | forced `200 allow` | forced `200 allow` |
| `MOCK=true`, `TEXT_FREE=true`, `FILE_FREE=false` | forced `200 allow` | forced `403 deny` |
| `MOCK=true`, `TEXT_FREE=false`, `FILE_FREE=true` | forced `403 deny` | forced `200 allow` |
| `MOCK=true`, `TEXT_FREE=false`, `FILE_FREE=false` | forced `403 deny` | forced `403 deny` |
При `MOCK=true` не выполняются normalization/rules, URL extraction/DNS, S3 read/checksum/format/KESL scan, verdict caches, task creation и async worker. Service authentication, body-size/JSON/strict DTO validation, idempotency conflict protection, PostgreSQL audit и rate limits остаются обязательными controls.
Forced allow использует `rule_id=safety.mock_forced_allow`; forced deny — `rule_id=safety.mock_forced_deny`, `reason_code=message_blocked`. Mock никогда не возвращает `202`. Internal response содержит `processing_mode=mock`; `api-backend` не раскрывает mode/rule клиенту. Mode фиксируется при первом принятии `message_id`: ранее созданный standard task/idempotency result не переклассифицируется и завершается в standard, а новый mock request не создаёт task. Это исключает смену verdict посередине обработки.
MOCK не включается HTTP endpoint-ом. Root-owned helper атомарно меняет защищённый config и перезапускает фиксированную Message Safety API operation внутри root Compose project; пользователь `deploy` может через sudo запускать только этот helper, без доступа к Docker/config:
```text
sudo /usr/local/sbin/han-message-safety-mode standard
sudo /usr/local/sbin/han-message-safety-mode mock --text-free true|false --file-free true|false
```
Helper принимает только указанные enum/boolean arguments, не принимает пути/commands/env expansion, пишет `/etc/han-chat/message-safety-mode.env` как `root:han-message-safety 0640` (dedicated GID `10001` совпадает с primary GID контейнера), проверяет config, выполняет restart и health verification. Ошибка включает rollback к предыдущему файлу. Ограничения по времени нет: MOCK действует до явного `standard`, но всё время формирует audit/метрики и active alert.
Команда `standard` атомарно записывает `MOCK=false`, `TEXT_FREE=false`, `FILE_FREE=false`; скрыто сохранять предыдущие free flags запрещено.
## 3. Threat model MVP
### 3.1. Текст и ссылки
| Угроза | Контроль | Результат |
|---|---|---|
| Prompt/control injection | Версионированные RU/EN semantic rules | `200 allow` + monitor audit/metric |
| Попытка выдать текст за system/developer instruction | Нормализация + monitor rule pack | `200 allow` + monitor audit/metric |
| 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` |
| Известный phishing/malware URL | Внешний reputation provider в MVP отсутствует; риск явно принят | Вне покрытия MVP |
| Обход Unicode/whitespace | NFKC, CRLF→LF, Unicode whitespace handling | Проверка нормализованного текста |
| ReDoS/DoS правилами | Линейные/ограниченные regex, лимиты текста, URL и времени | invalid limits → `400`; internal rule timeout → `500` |
| SSRF через fetch содержимого | **Запрет** HTTP GET/HEAD/render/redirect follow к пользовательским URL | Не выполняется |
Rules не заменяют безопасный rendering. Frontend и Bitrix integration обязаны экранировать текст; safety является дополнительным барьером, а не HTML sanitizer.
### 3.2. Файлы
| Угроза | Контроль | Результат |
|---|---|---|
| Недопустимый размер/MIME | Сверка DTO с allow-list и лимитами | `403 deny` |
| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` |
| Подмена содержимого после complete | Version-specific read + ETag + полный SHA-256 | `403 deny` |
| Malware | Host KESL 12.4 scan через fail-closed broker с актуальной базой | `403 deny` |
| Архивная бомба/ресурсное истощение | Лимиты размера, bounded staging и KESL/broker limits/timeouts | deny при policy hit; error при сбое |
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
`api-backend` применяет изменяемый бизнес allow-list `han_app.app_settings:chat.attachments.*`. `message-safety` не читает чужую схему `han_app`: он применяет active `message_safety.config_versions.file_policy` и immutable detector manifest §7.4. Эффективный allow — пересечение business allow-list, enabled MIME active safety config и форматов detector manifest; MIME/size в DTO должны пройти все слои. Config может только отключить MIME или ужесточить limits относительно manifest hard limits, но не добавить неподдерживаемый формат и не увеличить hard limit. Internal DTO не содержит имени файла, поэтому сервис не выводит расширение из object key.
### 3.3. Вне threat model MVP
- zero-day malware, отсутствующий в сигнатурах и эвристиках выбранного AV;
- OCR изображений и semantic analysis PDF;
- password-protected/encrypted containers: в MVP они запрещаются, если содержимое нельзя полностью проверить;
- DLP/поиск персональных данных, токсичности и запрещённой тематики;
- загрузка HTML/ресурсов страницы по пользовательской ссылке (browser-like fetch, redirect follow, screenshot, headless render);
- phishing/malware reputation URL без внешнего threat feed.
## 4. Общий pipeline
```mermaid
flowchart TD
postCheck[POST_check] --> auth[Auth_DTO_Idempotency]
auth --> mockMode{MOCK_enabled}
mockMode -->|true| mockKind{content_kind}
mockKind -->|text| mockText{TEXT_FREE}
mockKind -->|file| mockFile{FILE_FREE}
mockText -->|true| allow200[200_allow]
mockText -->|false| deny403[403_deny]
mockFile -->|true| allow200
mockFile -->|false| deny403
mockMode -->|false| kind{content_kind}
kind -->|text| normalizeText[Normalize_text]
normalizeText --> textCache{Text_rules_cache}
textCache -->|deny_hit| deny403
textCache -->|allow_or_monitor_hit| replayMonitor[Replay_monitor_audit]
textCache -->|miss| textRules[Text_rules]
textRules -->|deny| deny403
textRules -->|allow_or_monitor| persistTextCache[Persist_text_rules_result]
replayMonitor --> extractUrls[Extract_URLs]
persistTextCache --> extractUrls
extractUrls --> hasUrls{URLs_found}
hasUrls -->|no| allow200
hasUrls -->|yes| linkCache{Link_cache}
linkCache -->|allow_hit| refreshDns[Refresh_DNS_if_expired]
linkCache -->|deny_hit| deny403
linkCache -->|miss| linkChecks[Local_URL_checks]
refreshDns --> linkResult{Link_result}
linkChecks --> dnsClassify[DNS_IP_classification]
dnsClassify --> linkResult
linkResult -->|allow_or_monitor| allow200
linkResult -->|policy_deny| deny403
linkResult -->|dependency_error| error503[503_dependency]
kind -->|file| metadata[Metadata_validation]
metadata --> cache{SHA256_cache}
cache -->|hit| cached[Sticky_200_or_403]
cached --> cachedFileResult{Cached_file_verdict}
cachedFileResult -->|allow| allow200
cachedFileResult -->|deny| deny403
cache -->|miss| task[202_and_task]
task --> worker[File_worker]
worker --> objectRead[S3_stream_and_SHA256]
objectRead --> formatCheck[Format_validation]
formatCheck --> avScan[KESL_broker_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. mode snapshot: MOCK forced result либо standard pipeline;
4. нормализация;
5. обязательные проверки для соответствующего `content_kind`;
6. любой deny имеет приоритет над allow;
7. инфраструктурная ошибка не превращается ни в allow, ни в domain deny.
## 5. Текстовый pipeline
### 5.1. Нормализация
Pipeline детерминирован:
1. принять только JSON UTF-8;
2. заменить `CRLF`/`CR` на `LF`;
3. Unicode normalization `NFKC`;
4. построить analysis form: унифицировать Unicode whitespace, удалить/маркировать default-ignorable и zero-width controls, отдельно выявить bidi controls;
5. построить TR39 confusable skeleton и mixed-script signal только для detection; mixed-script сам по себе не является deny;
6. сохранить display form, исходный регистр и punctuation без изменения пользовательского текста;
7. применить IDNA2008/UTS-46 non-transitional к hostname;
8. применить абсолютный hard ceiling **10 000 Unicode code points**; значение не может быть увеличено runtime-настройкой, даже если бизнес-лимит станет больше;
9. вычислить SHA-256 analysis form для correlation/cache без хранения текста.
Превышение 10 000 code points после normalization → `400 validation_error`, `details.field=text`. Flags `default_ignorable`, `zero_width`, `bidi_control`, `mixed_script` записываются только как bounded `normalization_flags[]` в audit/metrics и не влияют на verdict MVP.
Strict union проверяется повторно независимо от upstream:
- `content_kind=text` требует непустой `text` и `attachment=null`;
- `content_kind=file` требует `text==""` и непустой `attachment`;
- mixed/unknown shape → `400 validation_error` до создания task или idempotency side effect.
### 5.2. Rule engine
Rules поставляются как статический read-only bundle `app/rules/{rules_version}/rules.yaml`, валидируемый committed JSON Schema. Динамический код, regex или rule definitions из запроса запрещены.
Каждое правило содержит:
- стабильный `rule_id`;
- `reason_code`;
- severity;
- scope (`text`, `url`, `file_metadata`);
- action (`deny` | `monitor`);
- `rules_version`;
- тестовые positive/negative cases.
Нормативный каталог MVP:
| `rule_id` | Scope | Action | Условие |
|---|---|---|---|
| `text.prompt_instruction_override` | text | monitor | RU/EN попытка переопределить инструкции |
| `text.prompt_role_impersonation` | text | monitor | имитация system/developer/tool instruction |
| `text.prompt_secret_extraction` | text | monitor | запрос внутренних инструкций/credentials |
| `text.active_script` | text | deny | script tag, inline handler или active embedding |
| `url.malformed` | url | deny | распознанная ссылка не проходит canonical parser |
| `url.forbidden_scheme` | url | deny | scheme кроме HTTP/HTTPS |
| `url.credentials_present` | url | deny | URL userinfo/credentials |
| `url.confusable_host` | url | deny | malformed/confusable IDNA hostname |
| `url.private_destination` | url | deny | literal/resolved private, loopback, link-local или metadata IP |
| `url.reserved_destination` | url | deny | multicast, unspecified или reserved IP |
| `url.nxdomain` | url | monitor | DNS NXDOMAIN; сообщение разрешается |
| `file.unsupported_mime` | file_metadata | deny | MIME отсутствует в технической matrix |
| `file.size_limit` | file_metadata | deny | размер превышает hard format limit |
| `file.object_changed` | file_content | deny | version/ETag/size/checksum mismatch |
| `file.format_mismatch` | file_content | deny | detector не совпал с declared MIME |
| `file.polyglot_or_ambiguous` | file_content | deny | detector ambiguity/polyglot |
| `file.encrypted_content` | file_content | deny | encrypted/password-protected container |
| `file.active_content` | file_content | deny | PDF JavaScript/OpenAction/Launch/XFA/embedded |
| `file.parser_limit` | file_content | deny | parser resource/decompression/object limit |
| `file.malware_detected` | file_content | deny | KESL сообщает infected |
Все domain deny используют `reason_code=message_blocked`; детализация остаётся во внутреннем `rule_id`. DTO/schema/authorization errors не получают `rule_id`.
Совпадение должно учитывать границы токенов и контекст, чтобы обычное обсуждение терминов не блокировалось простым substring match. Regex обязаны иметь ограниченную сложность и проходить ReDoS tests. Bundle содержит `schema_version`, `rules_version`, правила и embedded positive/negative vectors. Startup валидирует schema, уникальность `rule_id`, action/scope, компилирует patterns и запускает smoke vectors; любая ошибка завершает startup и даёт core not-ready.
`monitor` hit возвращает `200 allow`, пишет только `message_id`, hash, `rule_id`, version и метрику, не попадает в sticky deny cache. Retention такого audit — 180 дней. Перевод semantic rule в `deny` требует минимум 30 дней monitor rollout, corpus tests, precision ≥99.5%, false-positive rate ≤0.5% и approvals Rule Pack Owner + Security Owner. Неподдерживаемый язык не получает semantic deny.
Обычный текст без срабатывания enforce-rules получает `200 allow`. Неуверенное отсутствие совпадения не является deny. Ошибка rule engine является `500`, но не allow.
### 5.3. Кэш текстовых правил
После normalization и до link pipeline сервис проверяет durable PostgreSQL + hot Redis cache:
```text
text_rules:{sha256(analysis_form)}:{rules_version}
```
Cache хранит только результат **text rule engine**: `allow|deny`, deny `rule_id`, список monitor `rule_id`, normalization flags, `rules_version`, timestamps/expiry. Raw text не хранится.
- deny hit сразу возвращает тот же `403`;
- allow/monitor hit пропускает повторный rule evaluation, но для каждого нового `message_id` повторно пишет monitor audit/metric;
- после allow/monitor text-cache сервис выполняет URL extraction: при отсутствии ссылок сразу возвращает `200`, при наличии запускает link pipeline. Текстовый cache не кэширует DNS/link verdict и не позволяет обойти fresh DNS;
- transient/internal errors не кэшируются;
- смена `rules_version` автоматически инвалидирует key.
Начальный TTL active config — 48 часов (`cache.text_rule_ttl_sec=172800`).
## 6. Pipeline ссылок
После успешной text-rule phase сервис извлекает URL Unicode-aware parser-ом, а не одним regex. Если URL не обнаружены, text check сразу завершается `200 allow`; link pipeline не запускается. При наличии URL проверка выполняется **синхронно** и не создаёт отдельный async task.
### 6.1. Запрет перехода по ссылкам
Сервис **никогда** не выполняет переход по пользовательской ссылке для анализа. Запрещено:
- HTTP/HTTPS GET, HEAD, POST и любые browser-like запросы к URL из сообщения;
- следование redirects (301/302/meta refresh/JavaScript redirect chains);
- загрузка HTML, JS, CSS, изображений или иных ресурсов страницы;
- headless browser, screenshot, OCR или render страницы;
- использование пользовательского URL как webhook/callback target внутри safety.
Допустимые исходящие сетевые вызовы для link pipeline ограничены:
- DNS resolve через доверенный resolver (только A/AAAA, без произвольного TCP к порту URL);
- служебные вызовы к PostgreSQL, Redis, OTLP и internal dependencies сервиса.
Это жёсткое архитектурное ограничение. Внешний reputation/threat-feed provider в MVP отсутствует; известные phishing/malware URL вне покрытия. Policy проверяется только по URL/host/DNS/IP metadata.
### 6.2. Кэш ссылок
По аналогии с file verdict cache каждая **каноническая** ссылка проверяется через durable/hot cache до повторного полного pipeline.
Cache key:
```text
link_policy:{sha256(canonical_url)}:{rules_version}:{config_version}
```
Где `canonical_url` — результат нормализации (scheme/host/path/query без фрагмента `#...`, IDNA, lowercase host, согласованное кодирование). Полный URL в PostgreSQL/Redis/logs не хранится; для audit допустим hash и category.
`link_verdict_cache` (PostgreSQL) и hot cache Redis Safety хранят:
- canonical URL hash;
- sticky verdict (`allow` / `deny`);
- `rule_id`, `reason_code`;
- `rules_version`, `config_version`;
- first_seen_at, last_seen_at, hit_count;
- TTL/expiry.
Stable syntax/policy cache использует TTL active config (seed 48 часов). Он кэширует parsing, scheme, credentials, IDNA и literal-IP policy, но **не** разрешает обход свежей DNS classification. На stable allow hit при истёкшем DNS cache сервис повторяет A/AAAA lookup. DNS answers кэшируются отдельно не дольше фактического DNS TTL и active hard max (seed 900 секунд); NXDOMAIN negative cache — максимум active config (seed 60 секунд). Transient dependency errors в cache не попадают.
Инвалидация происходит при смене `rules_version` immutable bundle или active `config_version`.
### 6.3. Local URL checks
Для каждой ссылки без cache hit:
1. ограничить сообщение до 5 URL, каждый не длиннее 2048 code points; превышение → `400 validation_error`, `details.field=urls`;
2. выполнить canonical parsing без автоматического исправления malformed URL;
3. разрешить только `http` и `https`;
4. запретить userinfo/credentials;
5. нормализовать IDNA host и отклонить malformed/confusable host;
6. для literal IP применить IP policy; IPv4-mapped IPv6 `::ffff:0:0/96` сначала нормализовать к IPv4;
7. для hostname параллельно выполнить DNS resolve через configured trusted resolver и проверить все A/AAAA, включая mapped IPv6;
8. запретить loopback, RFC1918/ULA, link-local, multicast, unspecified, reserved ranges и cloud metadata endpoints.
Malformed URL или policy hit → sync `403 deny` с `rule_id` из local link rules. NXDOMAIN → `200 allow` с monitor event `url.nxdomain`; resolver timeout/SERVFAIL → dependency error `503`.
Metadata deny-list как минимум включает `169.254.169.254/32`, `fd00:ec2::254/128`, `100.100.100.200/32` и соответствующие provider-specific addresses из versioned policy bundle. Literal и resolved IP проходят одну policy.
### 6.4. Timeout budget local-only pipeline
Общий POST budget остаётся 5 секунд:
- normalization + text rules: до 1.5 с;
- URL extraction/local parsing: до 0.5 с;
- параллельные A/AAAA lookup: 1 с на lookup, до 2 с на весь link pipeline;
- cache/DB/margin: оставшийся budget.
Превышение internal rule budget → `500`; DNS dependency timeout → `503`. Сервис не превращает timeout в allow или domain deny.
## 7. Файловый pipeline
Precondition production file flow: Selectel S3 spike подтверждает signed `If-None-Match: *`, checksum headers, versioning, чтение конкретного `version_id` и conditional source match при promote. До успешного gate file capability не включается.
### 7.1. Fast path POST
До создания задачи сервис:
- валидирует attachment DTO;
- проверяет допустимый MIME и `size_bytes`;
- проверяет checksum `sha256:<64 lowercase hex>`;
- проверяет canonical key `quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid}`: lowercase RFC 4122 UUID, ASCII, длина ≤1024, без `%`, `..`, backslash и control characters;
- требует `quarantine_version_id` и `quarantine_etag`, зафиксированные `api-backend` при complete;
- ищет cache по `(sha256, config_version, rules_version, detector_version, scanner_engine, signatures_version)`.
Явно недопустимые metadata дают sync `403 deny`. Cache hit возвращает sticky `200` или `403`. Cache miss создаёт задачу и возвращает `202 Accepted`.
Если capability `files=unavailable`, file POST синхронно возвращает `503 dependency_unavailable`, `terminal=false`, `retryable=true` и не создаёт task. Для text с URL аналогично действует `links=unavailable`; text без URL продолжает text-only pipeline.
Сервис не доверяет 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 version с conditional ETag match и ограничением bytes/time;
3. вычисляет authoritative SHA-256;
4. сверяет фактический размер и checksum с DTO;
5. определяет реальный формат по содержимому;
6. сверяет detector result с declared MIME;
7. запрещает encrypted/password-protected и неподдерживаемые containers;
8. передаёт bounded file broker-у через Unix socket `/run/han-kesl/scan.sock`; root-owned broker вызывает `kesl-control --scan-file --action Inform`;
9. сохраняет sticky final verdict и audit;
10. записывает cache только для terminal результата;
11. завершает lease.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки и broker однозначно вернул `clean`. KESL `infected`, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным `rule_id`.
KESL/broker timeout, ошибка запуска или разбора результата `kesl-control`, stale database, недоступность S3/DB/Redis или потеря lease не являются ни allow, ни domain deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как `503`, а не `403`.
### 7.3. KESL runtime и broker
MVP использует установленный на VM2 host KESL 12.4 standalone и отдельный custom integration broker:
- KESL и broker не входят в Compose; `clamd`/`freshclam`, их volumes, healthchecks и egress из Compose удалены;
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`; ожидаемые права socket — `root:han-message-safety 0660`, socket монтируется в worker, TCP listener отсутствует;
- broker fail-closed: принимает только bounded scan request, не принимает произвольные command/arguments/path traversal и вызывает фиксированный `kesl-control --scan-file --action Inform`;
- `clean` разрешает продолжить allow-ветку, `infected` даёт domain deny; неизвестный формат/exit code, timeout, недоступность KESL и stale database дают retry, затем `503`;
- readiness проверяет broker, KESL version/database date и допустимый возраст базы; `max_signature_age_hours` допускается в диапазоне `1..720`, seed — `240`;
- KESL обновляет базы на host ежечасно по операторскому KESL runbook; egress к источникам обновления принадлежит host KESL, не Compose;
- worker не передаёт object key, имя пользователя или иные PII; временный файл и broker state очищаются по завершении.
Broker — custom integration: точный формат и exit semantics `kesl-control`, безопасная передача файла, очистка и throughput обязаны пройти gates на target VM2 с фактическим KESL 12.4. Недоступность scanner переводит capability `files` в `unavailable` и запрещает новые file allow, но оставляет core/text readiness доступной.
### 7.4. Исполнимая матрица форматов
| Declared MIME | Обязательная проверка | Limits | Deny |
|---|---|---|---|
| `image/jpeg` | JPEG magic + полный bounded decode | ≤5 MiB, ≤25 MP, dimension ≤10000 | truncation, decode error, polyglot |
| `image/png` | PNG signature/chunks + полный bounded decode | ≤5 MiB, ≤25 MP | invalid chunks, decompression budget |
| `image/webp` | RIFF/WEBP + bounded decode | ≤5 MiB, ≤25 MP, ≤100 frames | invalid/oversized animation |
| `image/heic`, `image/heif` | ISO BMFF `ftyp` brand + libheif bounded decode | ≤5 MiB, ≤25 MP, ≤100 items | unknown brand, ambiguity |
| `application/pdf` | `%PDF-`, bounded structural parser + EOF/xref validation | ≤5 MiB, ≤500 pages, ≤100000 objects, ≤100 MiB decoded budget | encryption/password, JavaScript, OpenAction, Launch, XFA, embedded files, malformed/polyglot |
Parser выполняется с CPU/memory/wall-time limits в отдельном sandboxed subprocess. Detector ambiguity, resource limit и disagreement detector↔declared MIME дают соответствующий `403 rule_id`. Immutable `detector-manifest.json` содержит bundle version, package/native library versions, supported MIME и hard limits; `detector_version` вычисляется как hash manifest, а не задаётся env. Active config ссылается на доступные `rules_version`/`detector_version` и может только сузить MIME/limits. Изменение manifest или active file policy инвалидирует file verdict cache через versioned cache key.
`scanner_engine=kesl`. `signatures_version` — стабильный hash канонической строки из KESL version и database date, полученных после успешной проверки broker/KESL; значение входит в verdict/cache/audit и readiness.
## 8. Internal API
Все `/internal/safety/v2/*` доступны только `api-backend` через private TLS gateway ВМ2.
Headers:
```text
X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN}
X-Request-ID: UUID/ULID; при отсутствии генерируется сервисом
traceparent: optional W3C
Content-Type: application/json; charset=utf-8
```
Token сравнивается constant-time. Missing/invalid token → `401 service_unauthorized` без подсказок.
### 8.1. POST `/internal/safety/v2/messages/check`
```json
{
"message_id": "uuid",
"content_kind": "text",
"text": "Текст сообщения",
"attachment": null
}
```
```json
{
"message_id": "uuid",
"content_kind": "file",
"text": "",
"attachment": {
"attachment_id": "uuid",
"quarantine_object_key": "quarantine/users/00000000-0000-4000-8000-000000000001/dialogs/00000000-0000-4000-8000-000000000002/00000000-0000-4000-8000-000000000003",
"quarantine_version_id": "opaque-version-id",
"quarantine_etag": "\"etag\"",
"mime_type": "application/pdf",
"size_bytes": 12345,
"checksum": "sha256:<64-lowercase-hex>"
}
}
```
`rule_id` и `reason_code` — internal diagnostic contract. `api-backend` не проксирует их клиенту и для любого domain deny использует public `422 message_blocked` и мнемонику `safety.chat.blocked`.
Неизвестные поля запрещены. `text` и `file` — строгий discriminated union.
`200`:
```json
{
"verdict": "allow",
"processing_mode": "standard",
"config_version": 1,
"rule_id": "safety.all_checks_passed",
"rules_version": "2026-01-01"
}
```
`expires_at = created_at + active_config.task_execution_deadline_sec`; task сохраняет `config_version`, поэтому последующая активация config не меняет его deadline. Это terminal execution deadline, а не inline public wait budget.
`403`:
```json
{
"verdict": "deny",
"processing_mode": "standard",
"config_version": 1,
"rule_id": "file.malware_detected",
"reason_code": "message_blocked",
"rules_version": "2026-01-01"
}
```
`202 Accepted`:
Headers:
```text
Location: /internal/safety/v2/messages/tasks/{task_id}
Retry-After: 2
Cache-Control: no-store
```
```json
{
"verdict": "pending",
"processing_mode": "standard",
"config_version": 1,
"task_id": "uuid",
"poll_after_ms": 2000,
"expires_at": "2026-07-29T15:00:00Z",
"rules_version": "2026-01-01"
}
```
### 8.2. GET `/internal/safety/v2/messages/tasks/{task_id}`
- running/retryable → `202 pending` с тем же body, `Location`, `Retry-After` и `expires_at`;
- sticky allow → `200 allow`;
- sticky deny → `403 deny`;
- terminal infrastructure failed → `503 task_failed` с `task_id`, `task_status=failed`, `terminal=true`, `retryable=false`;
- malformed UUID → `400 validation_error`;
- неизвестный task или task после retention expiry → `404 task_not_found`;
- transient dependency failure до terminal state → `503 dependency_unavailable`, `terminal=false`, `retryable=true`.
При достижении `expires_at` без verdict task атомарно становится `failed`, а GET возвращает terminal `503 task_failed`. После final verdict повторный GET возвращает тот же HTTP status, verdict, `rule_id` и `rules_version`. Task не удаляется до retention expiry.
### 8.3. Error envelope
```json
{
"error": {
"code": "validation_error",
"message": "Request is invalid",
"request_id": "uuid",
"details": {}
}
}
```
| HTTP | Code/смысл | Retry |
|---|---|---|
| `400` | malformed DTO/path | нет без исправления |
| `401` | `service_unauthorized` | нет без исправления secret |
| `403` | domain `deny` | нет |
| `404` | `task_not_found`/expired | только краткий reconciliation; затем terminal fail |
| `409` | `safety_request_conflict` | нет |
| `429` | `rate_limit_exceeded` | по `Retry-After` |
| `500` | `internal_error` | да |
| `503` | dependency unavailable или terminal `task_failed` | по `error.details.retryable` |
Domain `403` не считается circuit breaker failure.
Обязательные `error.details`:
| Code | Fields |
|---|---|
| `validation_error` | `field`, `constraint` без echo пользовательского значения |
| `safety_request_conflict` | `message_id`, `terminal=true`, `retryable=false` |
| `rate_limit_exceeded` | `retryable=true`, `retry_after_sec` |
| `dependency_unavailable` | `dependency_category`, `terminal=false`, `retryable=true` |
| `task_failed` | `task_id`, `task_status=failed`, `terminal=true`, `retryable=false` |
| `internal_error` | `terminal=false`, `retryable=true` |
### 8.4. MOCK responses
После auth/DTO/idempotency forced text/file allow возвращает sync `200`:
```json
{"verdict":"allow","processing_mode":"mock","config_version":1,"rule_id":"safety.mock_forced_allow","rules_version":"mock"}
```
Forced deny возвращает sync `403`:
```json
{"verdict":"deny","processing_mode":"mock","config_version":1,"rule_id":"safety.mock_forced_deny","reason_code":"message_blocked","rules_version":"mock"}
```
`processing_mode` и `config_version` обязательны во всех v2 verdict/pending responses и сохраняются в internal audit/checkpoint. Значение `mock` не доказывает прохождение safety controls.
## 9. Idempotency и state model
Fingerprint = SHA-256 от RFC 8785/JCS serialization нормализованного DTO без token/request-id/trace headers. Field order, `null`, nested attachment и Unicode test vectors входят в contract tests.
- одинаковый `(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.
Task states:
```text
pending -> processing -> allowed
-> denied
processing -> pending (retry with lease expiry)
processing -> failed (infrastructure retries exhausted)
```
`allowed`, `denied`, `failed` terminal и sticky. `failed` не является safety deny.
Inline poll api-backend ограничен caller-параметром `MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300` на ВМ1. Timeout/disconnect не отменяет task: `han_app.safety_tasks` recovery продолжает GET до terminal state или `expires_at`, что наступит раньше. Safety execution deadline берётся из snapshot active config (seed 1200 с); после него retry запрещён.
## 10. Хранение данных
### 10.1. PostgreSQL schema `message_safety`
Минимальный logical contract `safety_tasks`:
| Поля | Тип/constraint |
|---|---|
| `id`, `message_id`, `attachment_id` | UUID; PK `id`, UNIQUE `message_id`; attachment required для file task |
| `request_fingerprint`, `content_sha256` | `bytea(32)`, NOT NULL |
| `processing_mode` | `standard|mock`, NOT NULL; task creation разрешено только для `standard` |
| `config_version` | bigint NOT NULL, FK → immutable `config_versions.version` |
| `status` | `pending|processing|allowed|denied|failed`, NOT NULL |
| `attempt_count`, `lease_generation` | integer ≥0; generation увеличивается на каждый claim |
| `lease_owner`, `lease_until`, `next_attempt_at` | nullable text/timestamptz |
| `expires_at` | timestamptz NOT NULL |
| `quarantine_object_key`, `quarantine_version_id`, `quarantine_etag` | text NOT NULL для file task |
| `declared_mime`, `declared_size_bytes`, `declared_checksum` | bounded text/bigint, NOT NULL |
| `verdict`, `rule_id`, `reason_code` | nullable до terminal; CHECK согласован со status |
| `rules_version`, `detector_version`, `scanner_engine`, `signatures_version` | bounded text NOT NULL; snapshot фактически использованных artifacts |
| `created_at`, `updated_at`, `finished_at`, `purge_after` | timestamptz; finished/purge_after только terminal |
Обязательные индексы: UNIQUE `(message_id)`, queue `(status, next_attempt_at, created_at)`, lease recovery `(status, lease_until)`, retention `(finished_at)`. Final write использует `WHERE lease_generation=:generation AND lease_owner=:owner`.
`file_verdict_cache`: `(content_sha256, config_version, rules_version, detector_version, scanner_engine, signatures_version)` UNIQUE; `verdict allow|deny`, `rule_id`, `reason_code`, `created_at`, `expires_at`; transient/failed result запрещён constraint-ом.
`text_rules_cache`: `(analysis_sha256, rules_version)` UNIQUE; `result allow|deny`, nullable deny `rule_id`, bounded monitor rule ids/normalization flags, `created_at`, `expires_at`. Это cache только text rule phase, не полного сообщения и не link verdict.
`link_verdict_cache`: `(canonical_url_sha256, rules_version, config_version)` UNIQUE; только stable syntax/policy `allow|deny`, `rule_id`, `reason_code`, first/last seen, hit count, expiry. DNS answers и NXDOMAIN monitor не являются durable sticky verdict.
`safety_audit`: UUID PK, nullable request/message/task identifiers, event enum (`received`, `task_created`, `rule_hit`, `rule_hit_monitor`, `scan_completed`, `dependency_failed`, `mock_forced_allow`, `mock_forced_deny`, `config_activated`), `processing_mode`, `config_version`, verdict/rule/version, normalization flags, duration/error category, `created_at`, `purge_after`; индекс `(purge_after)` для retention и `(message_id, created_at)` для controlled review.
Service-owned runtime config хранится не как EAV, а версионированным документом:
`config_versions`: `id UUID PK`, `version bigint UNIQUE`, `schema_version integer`, `state draft|active|retired`, `config jsonb`, `config_sha256 bytea(32)`, `created_by/created_at`, `approved_by/approved_at`, `activated_at/retired_at`. Partial UNIQUE допускает не более одной `active`, а activation transaction и readiness требуют ровно одну; trigger запрещает UPDATE `config/config_sha256/schema_version/version`, active/retired rows не удаляются, пока на них ссылаются tasks/cache/audit.
Activation выполняет отдельный migration/config-admin role транзакционно: JSON Schema + cross-field validation, проверка referenced rules/detector artifacts, advisory lock, retire прежней версии, activate новой и `config_activated` audit. Runtime DB role имеет только `SELECT` config и не может активировать policy. HTTP admin endpoint для config отсутствует.
API загружает active config при startup и обновляет snapshot с max staleness 5 с (poll/optional `LISTEN/NOTIFY`); отсутствие/невалидность active version делает core readiness `503`. Каждый POST атомарно фиксирует одну `config_version`; worker читает immutable version задачи, поэтому activation влияет только на новые requests и не меняет in-flight verdict.
Raw message text, file bytes, filename, полный URL, service token и AV stream в PostgreSQL не сохраняются. Quarantine key/version/ETag хранятся только в active/final task row для исполнения/recovery и удаляются вместе с task retention; в audit/cache/logs они запрещены.
PostgreSQL — единственный task queue/lease source. Claim выполняется `FOR UPDATE SKIP LOCKED`/atomic UPDATE; lease/heartbeat/max attempts/deadline берутся из immutable config snapshot задачи (seed 90/30 с, 3, 1200 с), каждый claim увеличивает fencing generation. **5 параллельных file-worker slots** — deployment capacity из env/Compose, не policy в БД. Worker с потерянным lease не может записать final state.
### 10.2. Redis Safety
Redis используется для:
- hot text-rules cache;
- hot verdict cache;
- hot link verdict cache;
- optional worker wake-up;
- internal rate limits.
Redis находится на ВМ2 отдельным instance от DB0/DB1 ВМ1. Потеря Redis не меняет task/lease/sticky verdict и не делает core service not-ready: сервис читает PostgreSQL, временно теряет только cache/rate/wakeup acceleration.
Retention/TTL jobs используют active config для новых expiry и уже сохранённые `expires_at` для созданных rows: seed final task 30 дней, safety audit 180 дней, file verdict cache 30 дней, text-rules/stable link cache 48 часов. `han_app` checkpoint 7 дней и failed/orphan quarantine 48 часов принадлежат api-backend config. Deny-файл удаляет `api-backend` сразу best-effort. Raw text/URL/file в Safety не сохраняются.
## 11. Согласование с `api-backend`
```text
POST check
200 -> allow -> text accepted or file promote -> Bitrix delivery
403 -> blocked/rejected -> no Bitrix -> public 422 message_blocked
202 -> persist han_app.safety_tasks -> poll Location
GET 202 -> continue
GET 200 -> allow branch
GET 403 -> deny branch
terminal task_failed -> stop poll, public 503, no promote, no Bitrix
timeout/retryable 5xx -> public 503/504, no promote, no Bitrix
```
`message-safety` не перемещает и не удаляет S3 object. На deny это идемпотентно делает `api-backend`. Клиент не получает internal `202`: публичный POST продолжает ждать final result. Internal `409 safety_request_conflict` считается нарушением инварианта caller, маппится api-backend в `500 internal_error` + alert и не повторяет POST автоматически.
Public `422` содержит стандартный error envelope без internal `rule_id`/`reason_code`. `api-backend` создаёт локальную company-реплику с текстом из `text_resources` по единой мнемонике `safety.chat.blocked`; Message Safety не формирует пользовательский текст. Client timeout/disconnect не отменяет task: recovery продолжает poll до terminal state/`expires_at`.
При `processing_mode=mock` api-backend сохраняет mode в Message/audit. Forced file allow разрешает promote, но attachment получает `scan_status=bypassed`, а не `clean`; forced deny идёт по обычной deny-ветке. Mode не показывается пользователю в public DTO.
## 12. Security controls
- private HTTPS через private listener nginx ВМ2; public routes для Message Safety отсутствуют;
- unique service token только из env/secret mount;
- strict DTO, body/text/URL/file limits и запрет unknown fields;
- read-only S3-quarantine access, без list/write/delete;
- **запрет HTTP fetch/render/redirect follow** к пользовательским URL; link pipeline использует только доверенный DNS;
- egress по назначению: worker → DNS/S3/PostgreSQL, app → local Redis/OTLP; KESL update egress принадлежит host и ограничен операторским KESL runbook; внешнего reputation API нет;
- parameterized SQL и least-privilege DB role только на schema `message_safety`;
- Redis Safety ACL только 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;
- rules и KESL database обновляются только trusted deployment/operator process.
- MOCK не имеет runtime/public API; mode file root-owned, а `deploy` имеет sudo только на argument-validating helper и approved restart.
## 13. Observability и privacy
JSON logs:
- `timestamp`, `level`, `service.name=message-safety`, `module`, `event`;
- `request_id`, `trace_id`, `span_id`, route, status, duration;
- `processing_mode`, 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:
- request rate/latency/status по route;
- checks/verdicts по `content_kind`, `allow|deny|pending|error`;
- `message_safety_mock_enabled` gauge и forced outcomes по `text|file`/`allow|deny`;
- rule hits по low-cardinality `rule_id`;
- task queue/age/attempts/lease conflicts/timeouts;
- scan latency/bytes buckets/KESL outcome;
- signatures age/version info;
- cache hit/miss;
- PostgreSQL/Redis/S3/KESL broker latency and errors;
- active/used `config_version`, activation result и config refresh age;
- auth rejects/rate limit/readiness.
Telemetry collector unavailable не влияет на safety verdict и readiness.
Переход mode пишет security audit с actor `deploy`, old/new flags, release, host, timestamp и helper result. При `MOCK=true` постоянно активен high-severity alert; каждое forced allow отдельно считается, чтобы оценить объём непроверенного контента.
## 14. Health
`GET /health/live` проверяет только process/event loop.
`GET /health/ready` возвращает core readiness и capability map:
- ровно одна valid active config, доступность/verification referenced rules/detector artifacts и обязательных secrets;
- PostgreSQL read/write в schema `message_safety`;
- Redis Safety status как degraded accelerator, не core gate;
- worker heartbeat/lease processing;
- S3-quarantine Head/Get read permission на безопасный canary object;
- broker socket, KESL status и допустимый возраст database;
- trusted DNS resolver для `links`;
- OpenAPI/runtime parity проверяется на startup/CI, а не сетевым probe каждого ready request.
Пример partial degradation (`HTTP 200`, core готов):
```json
{
"status": "degraded",
"processing_mode": "standard",
"config_version": 1,
"components": {
"postgres": "ok",
"redis": "degraded",
"s3_quarantine": "ok",
"worker": "ok",
"antivirus": "down",
"dns": "ok",
"rules": "ok"
},
"capabilities": {
"text": "ready",
"links": "ready",
"files": "unavailable",
"worker": "ready"
}
}
```
Core `HTTP 503 status=not_ready` используется только для invalid config/rules/PostgreSQL. Потеря worker heartbeat делает `worker=unavailable` и `files=unavailable`, но сохраняет text/links; KESL broker/S3 down или stale KESL database выключает только `files`; DNS down — только `links`; Redis down прогревается из PostgreSQL и не выключает core. Каждый POST повторно проверяет требуемую capability и остаётся источником correctness; api-backend может кэшировать health snapshot не дольше 5 с только для fast-fail. Через private nginx ВМ2 endpoint доступен как exact `GET /internal/safety/status`, который проксируется в `/health/ready`; прямой `/health/ready` остаётся локальным container health. Private alias ограничен SG/source allow-list, не требует service token и не раскрывает credentials/hostnames.
В MOCK health всегда явно возвращает `processing_mode=mock`, `mock_policy.text=allow|deny`, `mock_policy.file=allow|deny` и `status=degraded`, даже если forced responses доступны. Normal pipeline dependencies показываются как `bypassed`, не `ok`. Active alert не закрывается до возврата в `standard`.
## 15. Configuration
Конфигурация разделена на четыре источника без дублирования:
1. `han_app.app_settings` — business allow-list/размеры, применяемые `api-backend`;
2. `message_safety.config_versions` — service-owned runtime policy;
3. env/secret files — bootstrap, topology, credentials и deployment capacity;
4. root-owned mode file — только emergency MOCK.
### 15.1. Seed active config в `message_safety`
```yaml
schema_version: 2
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
file_scan_timeout_sec: 60
lease_sec: 90
heartbeat_sec: 30
max_attempts: 3
execution_deadline_sec: 1200
max_pending: 100
rate:
text_rps: 10
file_rps: 2
retention:
task_days: 30
audit_days: 180
cache:
file_verdict_ttl_sec: 2592000
text_rule_ttl_sec: 172800
link_ttl_sec: 172800
dns_max_ttl_sec: 900
dns_negative_ttl_sec: 60
link:
max_per_message: 5
url_max_length: 2048
dns_lookup_timeout_sec: 1
pipeline_timeout_sec: 2
kesl:
scan_timeout_sec: 45
max_signature_age_hours: 240
file_policy:
enabled_mime_types:
- image/jpeg
- image/png
- image/webp
- image/heic
- image/heif
- application/pdf
max_size_bytes: 5242880
```
JSON Schema задаёт типы/ranges и cross-field constraints: `heartbeat_sec < lease_sec < execution_deadline_sec`, scan/pipeline timeout не больше execution deadline, TTL/retention положительны, `max_signature_age_hours` не превышает `720` часов (30 дней). `enabled_mime_types` — непустое уникальное подмножество detector manifest; `max_size_bytes` и последующие format overrides не превышают hard limits manifest. Referenced rules/detector artifacts обязаны быть доступны и пройти hash/signature verification до activation.
Seed/Schema/reference manifests являются immutable artifacts image. Любое их
изменение выпускает новый `MESSAGE_SAFETY_IMAGE` digest и новую монотонную
config version; config-admin сверяет artifact hashes с запущенным digest до
activation. Image-only rollback после активации несовместимой schema запрещён:
сначала создаётся новая совместимая config version (retired row повторно не
активируется), затем выбирается совместимый image. Rollback window хранит
только совместимые пары image digest + config schema/version.
### 15.2. Env и secrets Message Safety на ВМ2
```text
APP_ENV=production-like
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
```
Runtime secrets `MESSAGE_SAFETY_DATABASE_URL`, `MESSAGE_SAFETY_REDIS_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`, S3 read credentials и internal TLS key доставляются отдельными secret files. Bootstrap/topology env не переносятся в ту же БД, подключение к которой они обеспечивают.
`MESSAGE_SAFETY_POST_TIMEOUT_SEC=5`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2` и `MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300` — caller env `api-backend` на ВМ1, не настройки Message Safety. Nginx timeout выводится из caller poll budget.
### 15.3. Emergency mode
`MESSAGE_SAFETY_MOCK_ENABLED`, `MESSAGE_SAFETY_MOCK_TEXT_FREE`, `MESSAGE_SAFETY_MOCK_FILE_FREE` читаются только из read-only `/etc/han-chat/message-safety-mode.env` с host contract `root:han-message-safety 0640`, dedicated GID `10001`, совпадающим с primary GID контейнера; не из repository `.env` и не из БД. Bootstrap создаёт/проверяет группу до первого `up`. Отсутствие группы, mismatch GID, отрицательный read-test от container UID/GID либо доступ постороннего UID блокируют rollout. Startup отклоняет placeholders, insecure production defaults, отсутствующий/невалидный active config и комбинацию `MOCK=false` при любом `*_FREE=true`.
### 15.4. Performance acceptance MVP
Availability SLO для MVP не утверждается. До cutover обязателен воспроизводимый load test:
| Поток | Нагрузка | Gate |
|---|---|---|
| Text без URL/с URL | sustained 10 checks/s, URL в 30% запросов | p95 ≤2 с, p99 ≤5 с, error budget теста 0 для internal `500` |
| File | sustained 2 checks/s, 5 worker slots | среднее processing ≤2.5 с, p95 final ≤60 с, p99/public wait ≤300 с |
| Pending/backpressure | до 100 active tasks | queue age p95 ≤5 с; при 100 новый file POST получает retryable `503` без task |
File corpus: 70% JPEG/PNG/WebP до 1 MiB, 20% PDF/HEIC до 2 MiB, 10% boundary samples до 5 MiB; включает clean, EICAR, malformed, encrypted и parser-limit cases. Если 5 slots не подтверждают 2 file/s и среднее ≤2.5 с, Safety Service Owner до cutover корректирует slots/CPU/broker concurrency в пределах подтверждённой KESL capacity и повторяет target-VM test.
Превышение text/file token bucket → `429 rate_limit_exceeded` + `Retry-After`. Pending считается authoritative запросом PostgreSQL; Redis используется как быстрый счётчик. При Redis outage rate limiter использует conservative in-process limits, а pending gate остаётся в PostgreSQL.
## 16. OpenAPI
`message-safety/openapi.yaml` OpenAPI 3.1 обязателен и содержит:
- `X-Service-Token` security scheme;
- common request/trace headers;
- strict discriminated union text/file;
- обязательные internal `processing_mode` и `config_version` во всех verdict/pending responses;
- exact POST responses `200/202/400/401/403/409/429/500/503`;
- exact GET responses `200/202/400/401/403/404/429/500/503`;
- domain deny отдельно от error envelope;
- UUID/checksum/max length examples;
- health contracts.
Runtime schema сравнивается с committed artifact contract test. Terminal `400 stub_final_error` отсутствует.
## 17. Тестовая матрица
### Unit
- NFKC, CRLF, Unicode whitespace, max length;
- positive/negative cases каждого text rule;
- границы токенов и false-positive corpus;
- ReDoS/time budget всех regex;
- text-rules cache hit/miss/version invalidation и monitor replay без raw text;
- URL parsing, IDNA, schemes, credentials и IP ranges IPv4/IPv6;
- stable link policy cache и short DNS cache hit/miss/TTL/invalidation;
- NXDOMAIN monitor allow, SERVFAIL/timeout `503`, mapped IPv6/IP metadata deny;
- DTO union, fingerprint и rule priority;
- MIME/magic/checksum decision table;
- sticky state transitions;
- log redaction.
### Integration
- PostgreSQL migration/constraints/idempotency/audit;
- config draft/activate/retire, concurrent activation lock, invalid/cross-field reject и runtime read-only denial;
- in-flight task сохраняет прежнюю config version, новый request получает новую;
- PostgreSQL claim/lease/fencing concurrency/recovery; Redis cache loss;
- pending=100 backpressure, token buckets и 5 worker slots;
- S3 versioned read, ETag/version changed/missing/timeout;
- KESL broker clean, EICAR/infected, timeout, scanner down, stale database и неизвестный формат `kesl-control`;
- full SHA-256 and size mismatch;
- duplicate concurrent request and worker retry;
- dependency recovery without changed final verdict.
### Contract
- text allow and each deny category;
- text cache allow без URL сразу возвращает `200`; с URL запускает link pipeline; deny hit возвращает тот же rule;
- local link cache hit, DNS private-address deny и DNS dependency timeout;
- capability degradation: text without URL works while files/links unavailable;
- explicit absence of HTTP fetch to user URLs in integration tests;
- file cache hit, `202` then sticky `200`, `202` then sticky `403`, terminal failed `503`;
- no random/non-sticky outcomes;
- malformed `400` never treated as deny;
- auth missing/wrong/correct;
- request-id and trace propagation;
- `config_version` присутствует и совпадает в POST/GET/checkpoint/audit;
- api-backend maps only domain `403` to public `422 message_blocked`;
- deny/timeout never calls Bitrix and never promotes quarantine;
- OpenAPI runtime parity.
### Security/failure
- 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;
- performance corpus and gates §15.4.
## 18. Definition of Ready и Definition of Done
### 18.1. Definition of Ready
- роли §1.1 назначены в release/project checklist;
- target OpenAPI v2, migrations design, config/rules JSON Schema и detector manifest reviewed;
- Selectel S3 capability spike §7 пройден;
- corpus positive/negative/EICAR/boundary утверждён Rule Pack Owner и Security Owner;
- VM2 private DNS/TLS/SG/egress design reviewed Operations Owner;
- root-owned MOCK helper, five exact sudo commands и audit/alert route reviewed Security/Operations Owner;
- api-backend mapping, `safety.chat.blocked`, M8 redaction и company-replica contract согласованы;
- implementation backlog §20 оценён, зависимости/порядок cutover назначены;
- отсутствуют открытые TBD, меняющие DTO, verdict, retention, hard limits или security boundary.
### 18.2. Definition of Done
- production v2 `200/202/403` contract реализован без stub divergence;
- MOCK 2×2 text/file matrix даёт только sync `200/403`; тест доказывает отсутствие calls к rules/DNS/S3/KESL broker/cache/workers и сохранение auth/DTO/idempotency/audit/rate limits;
- text-rules cache, text rules, link cache и URL pipeline покрывают threat model и false-positive corpus;
- fetch/render/redirect follow к пользовательским URL отсутствует по design и тестам;
- files проходят metadata, authoritative SHA-256, format detector и KESL broker;
- final task verdict sticky и durable;
- idempotency/concurrency/recovery доказаны тестами;
- PG schema `message_safety`, Redis Safety и S3 version-specific read работают по least privilege;
- versioned config activation/rollback, one-active invariant, immutable task snapshot и invalid-config readiness покрыты migration/integration tests;
- cache versioned rules/scanner/signatures и не сохраняет transient errors;
- api-backend mapping allow/deny/timeout проверен end-to-end;
- mock mode сохраняется internal, не раскрывается public; forced file allow получает `scan_status=bypassed`, не `clean`;
- public deny использует `safety.chat.blocked` и не раскрывает internal rule;
- blocked/failed content не попадает в Bitrix и не promote-ится;
- health/readiness и capability-specific POST отражают PG/Redis/S3/workers/KESL broker/database age/DNS/rules;
- performance acceptance §15.4 пройден на target sizing;
- immutable/versioned S3 negative tests и conditional promote пройдены;
- OpenAPI 3.1 и runtime parity зелёные;
- logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
- hardened containers запускаются без public port;
- runbook описывает hourly KESL update, stale database, broker/KESL outage, retry и rollback.
- `deploy` может выполнить все пять exact mode commands, но не читать/писать mode config и не получить Docker/general sudo; helper rollback, persistent MOCK alert и возврат в standard испытаны.
## 19. Размещение проекта и вынос на отдельную ВМ
### 19.1. Расположение репозитория
Production-проект Message Safety размещается **вне** `codebase/backend/`:
```text
codebase/
message-safety/ # целевой production-сервис (отдельный deployable)
app/
tests/
openapi.yaml
Dockerfile
docker-compose.yml # service include root Compose ВМ2
backend/
message-safety/ # текущая test stub; подлежит замене/удалению после миграции
```
Stub в `codebase/backend/message-safety/` не является целевой реализацией. После готовности production-сервиса stub исключается из compose HAN_CHAT и удаляется или архивируется отдельной задачей.
### 19.2. Целевая топология
Message Safety и `bitrix-sync` выносятся **за пределы ВМ HAN_CHAT (ВМ1)** на самостоятельную **ВМ2 Processing** в той же private network/VPC. Цель — снять с ВМ1 file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. Host KESL 12.4 standalone и broker работают на VM2 вне Compose. Message Safety остаётся private API; Bitrix24 обращается напрямую к отдельному public host nginx ВМ2.
```mermaid
flowchart LR
subgraph vm1 [VM1_HAN_CHAT]
nginx[nginx]
api[api-backend]
redis1[Redis_DB0_DB1]
appDbRef[(han_app_checkpoint_reference)]
end
subgraph vm2 [VM2_Processing]
gateway[VM2_nginx_public_private]
safety[message-safety_api_worker]
sync[bitrix-sync]
kesl[KESL_12_4_and_broker]
redis2[Redis_Safety]
collector[otel-collector]
end
managedPg[(Managed_PostgreSQL)]
s3q[(S3_quarantine)]
bitrix[Bitrix24]
signoz[Private_SigNoz]
api -->|"HTTPS 8443 + service token"| gateway
bitrix -->|"CRM webhook HTTPS 443"| gateway
api --> appDbRef
gateway --> safety
gateway --> sync
safety --> redis2
safety --> managedPg
safety --> s3q
safety -->|"Unix socket"| kesl
sync --> managedPg
sync --> bitrix
collector --> signoz
```
На ВМ2 один root Compose включает собственный nginx с public webhook `443` и private `8443`, `message-safety` API/worker, `bitrix-sync`, Redis Safety и local OTEL Collector. `clamd`/`freshclam` в Compose отсутствуют; host KESL 12.4 standalone и root-owned broker управляются отдельно. Managed PostgreSQL и S3 остаются вне VM. Внешнего URL reputation provider нет.
### 19.3. Исключение из docker compose HAN_CHAT
Сервисы `message-safety` и `bitrix-sync` удаляются из root Compose ВМ1:
- нет их `build`/`image` на ВМ1;
- нет `depends_on: message-safety` health gate у `api-backend` на локальный контейнер;
- вместо этого `api-backend` обращается к remote URL через private network.
`api-backend` не делает remote Safety обязательным для общей read readiness. Send path читает capability status и fail-closed возвращает dependency error только для требуемой проверки.
### 19.4. Затронутые компоненты (impact analysis)
| Компонент | Текущее состояние | Изменение при выносе |
|---|---|---|
| `api-backend` | `MESSAGE_SAFETY_URL=http://message-safety:8080` | URL → `https://processing.internal:8443`, internal CA + token; сохраняет `processing_mode`, file MOCK allow → `scan_status=bypassed` |
| `han_app.safety_tasks` | checkpoint poll/recovery в App DB | таблица остаётся владельцем checkpoint; migration добавляет Location/version/ETag/rules/last-status fields, новой queue table нет |
| `message_safety` schema | в managed PG | остаётся; подключение с ВМ2 по TLS/private network |
| Redis DB2 | на ВМ1 compose | отдельный Redis Safety ВМ2; PG task queue остаётся authoritative |
| S3-quarantine read key | использовался локальным контейнером | credentials монтируются на ВМ2; firewall allow S3 endpoint |
| nginx | только edge ВМ1 | самостоятельный nginx ВМ2: public exact CRM webhook на 443 и private Message Safety на 8443 |
| Observability | collector ВМ1 | local collector ВМ2 → private SigNoz |
| Deployment/runbook | compose all-in-one | два root Compose/systemd stack, cutover/rollback и reprovision ВМ2 |
| `arch-03` | один Compose ВМ1 | один root Compose на каждой VM |
| `arch-04` | local Docker URLs | remote HTTPS URL ВМ1; service-specific env/secrets ВМ2 |
| CI/CD | build из backend tree | отдельный pipeline artifact `codebase/message-safety` |
| Security groups | intra-docker network | internet→nginx ВМ2:80/443 с exact route policy; ВМ1→ВМ2:8443; containers→PG/S3/DNS/Bitrix/SigNoz, host KESL→approved update sources по назначению |
Схема **`han_app` не получает новых таблиц** для remote safety: orchestration и recovery по-прежнему владеет `api-backend`. Меняется только сетевой адрес internal API и deployment boundary.
### 19.5. Сетевые и эксплуатационные требования ВМ2
- отдельный public IP/host nginx ВМ2 допускает только ACME/redirect policy на `80` и два exact CRM webhook на `443`; public Safety, generic `/internal/*`, admin и health запрещены;
- inbound: private TCP 8443 только с SG ВМ1/ops; server-auth TLS internal CA + service token;
- outbound по identity: worker→PostgreSQL/S3/DNS; `bitrix-sync`→Bitrix24; collector→SigNoz; host KESL→approved update sources по операторскому runbook;
- capability health Safety доступен api-backend и ops как exact `/internal/safety/status` на private `8443`; container `/health/live` и `/health/ready` наружу не публикуются;
- при недоступности ВМ2 `api-backend` возвращает `503 dependency_unavailable`, не отправляет сообщения в Bitrix.
ВМ2 — принятый SPOF MVP. Initial sizing: 4 vCPU, 8 GiB RAM, 80 GiB SSD. Меры: immutable images, PG/PITR durable truth, rebuildable Redis, resource/PID limits, queue backpressure, alerts, reprovision/restore rehearsal и RTO ≤4h. Scale-out/ВМ3 рассматриваются при sustained CPU/RAM >70%, queue age >30 с, провале performance gates §15.4, contention `bitrix-sync` или необходимости независимого release cadence.
## 20. Implementation backlog и переход с v1
Текущая реализация в `codebase/backend/message-safety/` остаётся test stub и **не соответствует** этой постановке. Реализация разбивается на reviewable tracks:
1. **API/schema foundation** — production project, OpenAPI v2, versioned config schema/activation, settings validation, migrations и error contracts.
2. **Tasks/queue** — PostgreSQL claim/lease/fencing/deadline, 5 slots, recovery и Redis wakeup/cache.
3. **Text/rules** — normalization, versioned bundle/schema, monitor corpus и hard active-content deny.
4. **URL policy** — parser/IDNA/IP/DNS, cache split, NXDOMAIN monitor и запрет fetch.
5. **File/S3** — immutable version precondition, authoritative checksum, detector manifest и hard matrix.
6. **KESL broker** — host KESL 12.4 standalone, root-owned fail-closed Unix-socket broker, hourly update/database age и EICAR tests; формат `kesl-control` и throughput проверяются на target VM.
7. **api-backend integration** — v2 adapter, Location polling, M8/company replica/mnemonic, conditional promote.
8. **VM2/observability/MOCK** — root Compose, internal nginx/TLS, egress, local collector, root-owned five-command mode helper, persistent alert и dashboards.
9. **Acceptance** — contract/security/failure/load tests §1518 и S3 negative gate.
10. **Cutover** — route v1→v2, reconcile active tasks, rollback rehearsal, затем удалить stub/references.
Каждый track имеет owner role, dependencies, tests и ссылку на §18. Merge track не означает production enable; cutover разрешён только после полного DoD.
До выполнения перехода контейнер stub на ВМ1 должен быть явно маркирован как non-production security control и не использоваться как целевой safety layer.