Внедрение KESL на ВМ2 + замена CLAMAV на KESL

This commit is contained in:
mi
2026-09-08 01:39:37 +03:00
parent 85df788f2d
commit fdfdeaffb4
43 changed files with 2210 additions and 329 deletions
@@ -31,7 +31,7 @@ Test-only правила по первому символу, случайные
| 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 |
| Operations Owner | VM2, host KESL/broker, антивирусные базы, alerts, rollback/reprovision и restore rehearsal |
Один человек может выполнять несколько ролей, но для каждого production release роли и approvals должны быть записаны в release checklist.
@@ -45,7 +45,7 @@ Test-only правила по первому символу, случайные
- валидацию file metadata и фактического формата;
- чтение файла из S3-quarantine по read-only credentials;
- вычисление authoritative SHA-256;
- антивирусную проверку файла через ClamAV;
- антивирусную проверку файла через root-owned fail-closed broker по Unix socket `/run/han-kesl/scan.sock`; broker вызывает host KESL 12.4 standalone;
- выбор sync/async режима;
- создание и исполнение async safety tasks;
- sticky final verdict, verdict cache и audit в схеме `message_safety`;
@@ -78,7 +78,7 @@ Test-only правила по первому символу, случайные
| `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.
При `MOCK=true` не выполняются normalization/rules, URL extraction/DNS, S3 read/checksum/format/KESL scan, verdict caches, task creation и async worker. Service authentication, body-size/JSON/strict DTO validation, idempotency conflict protection, PostgreSQL audit и rate limits остаются обязательными controls.
Forced allow использует `rule_id=safety.mock_forced_allow`; forced deny — `rule_id=safety.mock_forced_deny`, `reason_code=message_blocked`. Mock никогда не возвращает `202`. Internal response содержит `processing_mode=mock`; `api-backend` не раскрывает mode/rule клиенту. Mode фиксируется при первом принятии `message_id`: ранее созданный standard task/idempotency result не переклассифицируется и завершается в standard, а новый mock request не создаёт task. Это исключает смену verdict посередине обработки.
@@ -118,8 +118,8 @@ Rules не заменяют безопасный rendering. Frontend и Bitrix i
| Недопустимый размер/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 при сбое |
| Malware | Host KESL 12.4 scan через fail-closed broker с актуальной базой | `403 deny` |
| Архивная бомба/ресурсное истощение | Лимиты размера, bounded staging и KESL/broker limits/timeouts | deny при policy hit; error при сбое |
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
@@ -179,7 +179,7 @@ flowchart TD
task --> worker[File_worker]
worker --> objectRead[S3_stream_and_SHA256]
objectRead --> formatCheck[Format_validation]
formatCheck --> avScan[ClamAV_scan]
formatCheck --> avScan[KESL_broker_scan]
avScan --> finalVerdict[Persist_sticky_verdict]
finalVerdict --> taskGet[GET_task_200_or_403]
```
@@ -255,7 +255,7 @@ Rules поставляются как статический read-only bundle `a
| `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 |
| `file.malware_detected` | file_content | deny | KESL сообщает infected |
Все domain deny используют `reason_code=message_blocked`; детализация остаётся во внутреннем `rule_id`. DTO/schema/authorization errors не получают `rule_id`.
@@ -389,29 +389,28 @@ Worker:
5. определяет реальный формат по содержимому;
6. сверяет detector result с declared MIME;
7. запрещает encrypted/password-protected и неподдерживаемые containers;
8. передаёт поток в `clamd` через internal network;
8. передаёт bounded file broker-у через Unix socket `/run/han-kesl/scan.sock`; root-owned broker вызывает `kesl-control --scan-file --action Inform`;
9. сохраняет sticky final verdict и audit;
10. записывает cache только для terminal результата;
11. завершает lease.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки. `ClamAV FOUND`, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным `rule_id`.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки и broker однозначно вернул `clean`. KESL `infected`, 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`.
KESL/broker timeout, ошибка запуска или разбора результата `kesl-control`, stale database, недоступность S3/DB/Redis или потеря lease не являются ни allow, ни domain deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как `503`, а не `403`.
### 7.3. AV runtime
### 7.3. KESL runtime и broker
MVP использует отдельный `clamd` sidecar/service в private Docker network:
MVP использует установленный на VM2 host KESL 12.4 standalone и отдельный custom integration broker:
- порт не публикуется наружу;
- сигнатуры обновляет `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.
- KESL и broker не входят в Compose; `clamd`/`freshclam`, их volumes, healthchecks и egress из Compose удалены;
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`; ожидаемые права socket — `root:han-message-safety 0660`, socket монтируется в worker, TCP listener отсутствует;
- broker fail-closed: принимает только bounded scan request, не принимает произвольные command/arguments/path traversal и вызывает фиксированный `kesl-control --scan-file --action Inform`;
- `clean` разрешает продолжить allow-ветку, `infected` даёт domain deny; неизвестный формат/exit code, timeout, недоступность KESL и stale database дают retry, затем `503`;
- readiness проверяет broker, KESL version/database date и допустимый возраст базы; `max_signature_age_hours` допускается в диапазоне `1..720`, seed — `240`;
- KESL обновляет базы на host ежечасно по операторскому KESL runbook; egress к источникам обновления принадлежит host KESL, не Compose;
- worker не передаёт object key, имя пользователя или иные PII; временный файл и broker state очищаются по завершении.
Недоступность AV переводит capability `files` в `unavailable` и запрещает новые file allow, но оставляет core/text readiness доступной.
Broker — custom integration: точный формат и exit semantics `kesl-control`, безопасная передача файла, очистка и throughput обязаны пройти gates на target VM2 с фактическим KESL 12.4. Недоступность scanner переводит capability `files` в `unavailable` и запрещает новые file allow, но оставляет core/text readiness доступной.
### 7.4. Исполнимая матрица форматов
@@ -425,7 +424,7 @@ MVP использует отдельный `clamd` sidecar/service в private D
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.
`scanner_engine=kesl`. `signatures_version` — стабильный hash канонической строки из KESL version и database date, полученных после успешной проверки broker/KESL; значение входит в verdict/cache/audit и readiness.
## 8. Internal API
@@ -697,14 +696,14 @@ Public `422` содержит стандартный error envelope без inter
- 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 нет;
- egress по назначению: worker → DNS/S3/PostgreSQL, app → local Redis/OTLP; KESL update egress принадлежит host и ограничен операторским KESL runbook; внешнего reputation API нет;
- parameterized SQL и least-privilege DB role только на schema `message_safety`;
- Redis Safety ACL только prefixes `han:safety:*`;
- dependency pinning, SBOM/image scanning;
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities, no-new-privileges;
- OpenAPI UI выключен в production;
- безопасные generic errors без stack/internal addresses;
- правила и AV signatures обновляются только trusted deployment process.
- rules и KESL database обновляются только trusted deployment/operator process.
- MOCK не имеет runtime/public API; mode file root-owned, а `deploy` имеет sudo только на argument-validating helper и approved restart.
## 13. Observability и privacy
@@ -726,10 +725,10 @@ Metrics:
- `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;
- scan latency/bytes buckets/KESL outcome;
- signatures age/version info;
- cache hit/miss;
- PostgreSQL/Redis/S3/ClamAV latency and errors;
- PostgreSQL/Redis/S3/KESL broker latency and errors;
- active/used `config_version`, activation result и config refresh age;
- auth rejects/rate limit/readiness.
@@ -748,7 +747,7 @@ Telemetry collector unavailable не влияет на safety verdict и readine
- Redis Safety status как degraded accelerator, не core gate;
- worker heartbeat/lease processing;
- S3-quarantine Head/Get read permission на безопасный canary object;
- ClamAV PING и допустимый возраст signatures;
- broker socket, KESL status и допустимый возраст database;
- trusted DNS resolver для `links`;
- OpenAPI/runtime parity проверяется на startup/CI, а не сетевым probe каждого ready request.
@@ -777,7 +776,7 @@ Telemetry collector unavailable не влияет на safety verdict и readine
}
```
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.
Core `HTTP 503 status=not_ready` используется только для invalid config/rules/PostgreSQL. Потеря worker heartbeat делает `worker=unavailable` и `files=unavailable`, но сохраняет text/links; KESL broker/S3 down или stale KESL database выключает только `files`; DNS down — только `links`; Redis down прогревается из PostgreSQL и не выключает core. Каждый POST повторно проверяет требуемую capability и остаётся источником correctness; api-backend может кэшировать health snapshot не дольше 5 с только для fast-fail. Через private nginx ВМ2 endpoint доступен как exact `GET /internal/safety/status`, который проксируется в `/health/ready`; прямой `/health/ready` остаётся локальным container health. Private alias ограничен SG/source allow-list, не требует service token и не раскрывает credentials/hostnames.
В MOCK health всегда явно возвращает `processing_mode=mock`, `mock_policy.text=allow|deny`, `mock_policy.file=allow|deny` и `status=degraded`, даже если forced responses доступны. Normal pipeline dependencies показываются как `bypassed`, не `ok`. Active alert не закрывается до возврата в `standard`.
@@ -793,7 +792,7 @@ Core `HTTP 503 status=not_ready` используется только для in
### 15.1. Seed active config в `message_safety`
```yaml
schema_version: 1
schema_version: 2
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
@@ -820,7 +819,7 @@ link:
url_max_length: 2048
dns_lookup_timeout_sec: 1
pipeline_timeout_sec: 2
clamav:
kesl:
scan_timeout_sec: 45
max_signature_age_hours: 240
file_policy:
@@ -850,8 +849,7 @@ activation. Image-only rollback после активации несовмест
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
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
@@ -875,7 +873,7 @@ Availability SLO для MVP не утверждается. До cutover обяз
| 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 и повторяет тест.
File corpus: 70% JPEG/PNG/WebP до 1 MiB, 20% PDF/HEIC до 2 MiB, 10% boundary samples до 5 MiB; включает clean, EICAR, malformed, encrypted и parser-limit cases. Если 5 slots не подтверждают 2 file/s и среднее ≤2.5 с, Safety Service Owner до cutover корректирует slots/CPU/broker concurrency в пределах подтверждённой KESL capacity и повторяет target-VM test.
Превышение text/file token bucket → `429 rate_limit_exceeded` + `Retry-After`. Pending считается authoritative запросом PostgreSQL; Redis используется как быстрый счётчик. При Redis outage rate limiter использует conservative in-process limits, а pending gate остаётся в PostgreSQL.
@@ -920,7 +918,7 @@ Runtime schema сравнивается с committed artifact contract test. Ter
- 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;
- KESL broker clean, EICAR/infected, timeout, scanner down, stale database и неизвестный формат `kesl-control`;
- full SHA-256 and size mismatch;
- duplicate concurrent request and worker retry;
- dependency recovery without changed final verdict.
@@ -972,10 +970,10 @@ Runtime schema сравнивается с committed artifact contract test. Ter
### 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;
- MOCK 2×2 text/file matrix даёт только sync `200/403`; тест доказывает отсутствие calls к rules/DNS/S3/KESL broker/cache/workers и сохранение auth/DTO/idempotency/audit/rate limits;
- text-rules cache, text rules, link cache и URL pipeline покрывают threat model и false-positive corpus;
- fetch/render/redirect follow к пользовательским URL отсутствует по design и тестам;
- files проходят metadata, authoritative SHA-256, format detector и ClamAV;
- files проходят metadata, authoritative SHA-256, format detector и KESL broker;
- final task verdict sticky и durable;
- idempotency/concurrency/recovery доказаны тестами;
- PG schema `message_safety`, Redis Safety и S3 version-specific read работают по least privilege;
@@ -985,13 +983,13 @@ Runtime schema сравнивается с committed artifact contract test. Ter
- 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;
- health/readiness и capability-specific POST отражают PG/Redis/S3/workers/KESL broker/database age/DNS/rules;
- performance acceptance §15.4 пройден на target sizing;
- immutable/versioned S3 negative tests и conditional promote пройдены;
- OpenAPI 3.1 и runtime parity зелёные;
- logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
- hardened containers запускаются без public port;
- runbook описывает signature update, stale signatures, AV outage, retry и rollback.
- runbook описывает hourly KESL update, stale database, broker/KESL outage, retry и rollback.
- `deploy` может выполнить все пять exact mode commands, но не читать/писать mode config и не получить Docker/general sudo; helper rollback, persistent MOCK alert и возврат в standard испытаны.
## 19. Размещение проекта и вынос на отдельную ВМ
@@ -1016,7 +1014,7 @@ Stub в `codebase/backend/message-safety/` не является целевой
### 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.
Message Safety и `bitrix-sync` выносятся **за пределы ВМ HAN_CHAT (ВМ1)** на самостоятельную **ВМ2 Processing** в той же private network/VPC. Цель — снять с ВМ1 file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. Host KESL 12.4 standalone и broker работают на VM2 вне Compose. Message Safety остаётся private API; Bitrix24 обращается напрямую к отдельному public host nginx ВМ2.
```mermaid
flowchart LR
@@ -1030,7 +1028,7 @@ flowchart LR
gateway[VM2_nginx_public_private]
safety[message-safety_api_worker]
sync[bitrix-sync]
clamav[clamd]
kesl[KESL_12_4_and_broker]
redis2[Redis_Safety]
collector[otel-collector]
end
@@ -1046,13 +1044,13 @@ flowchart LR
safety --> redis2
safety --> managedPg
safety --> s3q
safety --> clamav
safety -->|"Unix socket"| kesl
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 нет.
На ВМ2 один root Compose включает собственный nginx с public webhook `443` и private `8443`, `message-safety` API/worker, `bitrix-sync`, Redis Safety и local OTEL Collector. `clamd`/`freshclam` в Compose отсутствуют; host KESL 12.4 standalone и root-owned broker управляются отдельно. Managed PostgreSQL и S3 остаются вне VM. Внешнего URL reputation provider нет.
### 19.3. Исключение из docker compose HAN_CHAT
@@ -1079,7 +1077,7 @@ flowchart LR
| `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 по назначению |
| Security groups | intra-docker network | internet→nginx ВМ2:80/443 с exact route policy; ВМ1→ВМ2:8443; containers→PG/S3/DNS/Bitrix/SigNoz, host KESL→approved update sources по назначению |
Схема **`han_app` не получает новых таблиц** для remote safety: orchestration и recovery по-прежнему владеет `api-backend`. Меняется только сетевой адрес internal API и deployment boundary.
@@ -1087,7 +1085,7 @@ flowchart LR
- отдельный 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;
- outbound по identity: worker→PostgreSQL/S3/DNS; `bitrix-sync`→Bitrix24; collector→SigNoz; host KESL→approved update sources по операторскому runbook;
- capability health Safety доступен api-backend и ops как exact `/internal/safety/status` на private `8443`; container `/health/live` и `/health/ready` наружу не публикуются;
- при недоступности ВМ2 `api-backend` возвращает `503 dependency_unavailable`, не отправляет сообщения в Bitrix.
@@ -1102,7 +1100,7 @@ flowchart LR
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.
6. **KESL broker** — host KESL 12.4 standalone, root-owned fail-closed Unix-socket broker, hourly update/database age и EICAR tests; формат `kesl-control` и throughput проверяются на target VM.
7. **api-backend integration** — v2 adapter, Location polling, M8/company replica/mnemonic, conditional promote.
8. **VM2/observability/MOCK** — root Compose, internal nginx/TLS, egress, local collector, root-owned five-command mode helper, persistent alert и dashboards.
9. **Acceptance** — contract/security/failure/load tests §1518 и S3 negative gate.