# 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, ClamAV signatures, 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; - антивирусную проверку файла через ClamAV; - выбор 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/ClamAV, 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 | 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 | `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[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. 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 | ClamAV FOUND | Все 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. передаёт поток в `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; - `max_signature_age_hours` допускается в диапазоне `1..720` часов (не более 30 дней); seed policy использует `240` часов (10 дней); - limits согласованы с максимальным размером файла; - контейнер non-root, read-only root filesystem где возможно, отдельный writable volume только для signatures/runtime; - worker не передаёт в clamd object key, имя пользователя или иные PII. Недоступность AV переводит 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=clamav`. `signatures_version` формируется из ClamAV engine version и CVD/CLD metadata/hash после успешного `freshclam` activate/reload; значение входит в 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/clamd; внешнего 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; - правила и AV signatures обновляются только trusted deployment 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/AV outcome; - signatures age/version info; - cache hit/miss; - PostgreSQL/Redis/S3/ClamAV 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; - ClamAV PING и допустимый возраст signatures; - 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; ClamAV/S3 down выключает только `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: 1 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 clamav: 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= MESSAGE_SAFETY_CLAMAV_HOST=clamd MESSAGE_SAFETY_CLAMAV_PORT=3310 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/scan lanes и повторяет тест. Превышение 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; - 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 - 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/ClamAV/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 и ClamAV; - 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/ClamAV/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 описывает signature update, stale signatures, AV 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 ClamAV/file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. 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] clamav[clamd] 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 --> clamav sync --> managedPg sync --> bitrix collector --> signoz ``` На ВМ2 один root Compose включает собственный nginx с public webhook `443` и private `8443`, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и local OTEL Collector. 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; ВМ2→PG/S3/DNS/Bitrix/SigNoz/signature CDN по назначению | Схема **`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 по container identity: worker→PostgreSQL/S3/DNS; `freshclam`→signature CDN; `bitrix-sync`→Bitrix24; collector→SigNoz; - 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. **ClamAV** — clamd streaming, freshclam activate/rollback/signature age и EICAR tests. 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 §15–18 и 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.