1115 lines
75 KiB
Markdown
1115 lines
75 KiB
Markdown
# 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), [`module-01-api-backend.md`](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. Health доступен только через private SG ВМ1/ops, не требует 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=<VPC-resolver-IP>
|
||
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
|
||
|
||
- только private IP; публичный ingress запрещён;
|
||
- 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;
|
||
- health `/health/live` и `/health/ready` доступны api-backend и ops из private network;
|
||
- при недоступности ВМ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.
|
||
|