diff --git a/VM1_app/documentation/module-01-api-backend.md b/VM1_app/documentation/module-01-api-backend.md index fbe1588..5e61eed 100644 --- a/VM1_app/documentation/module-01-api-backend.md +++ b/VM1_app/documentation/module-01-api-backend.md @@ -960,7 +960,7 @@ Inbound operator file: - validate count/size/MIME and URL scheme/host policy; - protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming; - download with timeout to temporary stream, never local persistent disk; -- Message Safety/ClamAV не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP; +- Message Safety/KESL не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP; - upload directly to S3-data attachments; - only then atomically save attachment/message and ack inbox. diff --git a/VM1_app/documentation/module-09-observability-vm1.md b/VM1_app/documentation/module-09-observability-vm1.md index ad6a19d..7e15a56 100644 --- a/VM1_app/documentation/module-09-observability-vm1.md +++ b/VM1_app/documentation/module-09-observability-vm1.md @@ -8,7 +8,7 @@ Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**. -ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync`, ClamAV и Redis Safety живут на ВМ2; ВМ1 только вызывает Safety по private HTTPS и продолжает trace. +ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync` и Redis Safety живут на ВМ2; KESL 12.4 и его локальный scan-broker работают на host ВМ2. ВМ1 только вызывает Safety по private HTTPS и продолжает trace. Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2. diff --git a/VM2_services/codebase/services/.env.example b/VM2_services/codebase/services/.env.example index b7b1444..6af4743 100644 --- a/VM2_services/codebase/services/.env.example +++ b/VM2_services/codebase/services/.env.example @@ -7,7 +7,6 @@ MESSAGE_SAFETY_IMAGE=/han-message-safety@sha256: BITRIX_SYNC_IMAGE=/han-bitrix-sync@sha256: NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256: REDIS_IMAGE=redis@sha256: -CLAMAV_IMAGE=clamav/clamav@sha256: OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256: REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256: NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256: @@ -20,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0 MESSAGE_SAFETY_PORT=8080 MESSAGE_SAFETY_WORKER_CONCURRENCY=5 MESSAGE_SAFETY_DNS_RESOLVERS= -MESSAGE_SAFETY_CLAMAV_HOST=clamd -MESSAGE_SAFETY_CLAMAV_PORT=3310 +MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env diff --git a/VM2_services/codebase/services/deployment/RUNBOOK.md b/VM2_services/codebase/services/deployment/RUNBOOK.md index 9b7837d..9053cc2 100644 --- a/VM2_services/codebase/services/deployment/RUNBOOK.md +++ b/VM2_services/codebase/services/deployment/RUNBOOK.md @@ -46,7 +46,9 @@ The full step-by-step procedure with gates and copy-paste commands is in role. Migration credentials are mounted only into the `ops` profile jobs. 9. The setup script leaves UFW egress open for bootstrap. Before production, constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL, - S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations. + S3, Secrets Manager, Bitrix24, DNS/NTP and SigNoz destinations. Host KESL + receives only update-source egress approved by + [`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package access exists only during controlled maintenance windows. ## Who runs what @@ -78,8 +80,9 @@ Compose first: `bitrix_sync`, separate migration/runtime DSNs; see [`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6. 3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record - immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav, - otel-collector, Redis exporter and nginx exporter). + immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, + otel-collector, Redis exporter and nginx exporter). `clamd`/`freshclam` are + absent from Compose; KESL 12.4 standalone and its broker run on the host. 4. **Selectel Secrets Manager** — populate all remote names from `deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys, `REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`). @@ -157,6 +160,13 @@ Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable `han-processing.service` until Gate 5 completes successfully. +Before Message Safety starts in Gate 5, complete the operator KESL runbook, +then enable/start `han-kesl-scan-broker.socket` and verify KESL/socket status +plus `root:han-message-safety 0660` on `/run/han-kesl/scan.sock`. Only then +start Message Safety. The broker is a custom integration: exact output/exit +semantics of `kesl-control --scan-file --action Inform`, cleanup and throughput +must pass on the target VM2. + | Gate | Purpose | | --- | --- | | 1 | Secrets materialized via `han-secrets-vm2.service` | @@ -195,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement. briefly. 3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot timer, unit journals — without secret values. -4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV - signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API). +4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL + version/database date, hourly update, broker/socket status, OTEL queue, + disk/RAM, MOCK mode, private Safety API). 5. Proceed to controlled Message Safety cutover on VM1 — see [`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13. 6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix @@ -209,8 +220,8 @@ Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7. ## Failure policy - Safety dependency failure is fail-closed: VM1 must not send/promote content. -- Stale/unavailable ClamAV signatures disable file capability only; they never - convert a scan error to allow. +- A stale/unavailable KESL database or broker error disables file capability + only; scan errors remain retryable and eventually return `503`, never allow. - Redis loss may remove acceleration but PostgreSQL remains authoritative. - OTEL outage queues within the bounded volume and must not change verdicts. - Rollback does not downgrade schemas, delete durable tasks/mappings, or run @@ -234,10 +245,12 @@ health, and restores the previous mode on failure. MOCK has no timeout: keep a high-severity alert active until explicit `standard`, then verify normal text/link/file capabilities and an EICAR canary. -## Known image exceptions +## Host KESL and broker -ClamAV images may require UID/path adjustments after validating the exact -digest. Do not weaken `read_only`, capabilities or mounts globally: document -the smallest writable signature/runtime paths and compensate with network and -resource limits. `freshclam` alone receives signature-CDN egress; `clamd` -receives none. +KESL 12.4 standalone and the root-owned fail-closed broker are not Compose +images. The Message Safety worker receives only +`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root +access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash +of KESL version plus database date. Installation, hourly database updates, +socket permissions, clean/EICAR/error/stale gates and rollback follow +[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). diff --git a/VM2_services/codebase/services/deployment/RUNBOOK.ru.md b/VM2_services/codebase/services/deployment/RUNBOOK.ru.md index db3ec89..164fbf7 100644 --- a/VM2_services/codebase/services/deployment/RUNBOOK.ru.md +++ b/VM2_services/codebase/services/deployment/RUNBOOK.ru.md @@ -51,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн `ops`. 9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До production ограничьте egress правилами Selectel SG/NAT/proxy до - утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и - источников ClamAV. Registry/package repositories оставляйте только на + утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz. + Для host KESL разрешите только источники обновления из + [`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на controlled maintenance window. ## Кто что выполняет @@ -87,7 +88,8 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн [`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6. 3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`; получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis, - clamav, otel-collector, Redis exporter, nginx exporter). + otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в + Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host. 4. **Selectel Secrets Manager** — заполнить все remote names из `deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys, `REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`). @@ -526,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM ; новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно активировать старую версию нельзя. Alembic downgrade запрещён. -При обновлении ClamAV policy образ Message Safety должен содержать согласованные +При обновлении KESL policy образ Message Safety должен содержать согласованные seed и schema: seed `max_signature_age_hours=240`, schema maximum `720` (30 дней). После обновления immutable image digest создайте новую config version; существующую active version не редактируйте и не активируйте повторно: @@ -548,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>' /usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \ message-safety-api message-safety-worker /usr/local/sbin/han-vm2-compose ps \ - message-safety-api message-safety-worker clamd freshclam + message-safety-api message-safety-worker unset NEXT_VERSION ``` @@ -588,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ Под `root` на VM2: ```sh +cd /opt/han-chat/services +# Сначала полностью выполните operator runbook: +# deployment/kesl/RUNBOOK.KESL.ru.md +systemctl enable --now han-kesl-scan-broker.socket +systemctl --no-pager status kesl han-kesl-scan-broker.socket +test -S /run/han-kesl/scan.sock +stat -c '%U:%G %a %n' /run/han-kesl/scan.sock + /usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector -/usr/local/sbin/han-vm2-compose up -d freshclam clamd /usr/local/sbin/han-vm2-compose up -d \ message-safety-api message-safety-worker /usr/local/sbin/han-vm2-compose up -d \ @@ -598,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ /usr/local/sbin/han-vm2-compose ps ``` +До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4 +standalone, успешное ежечасное обновление, допустимый database date и canary. +Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним +UID. Broker является custom +integration: неизвестный output/exit code `kesl-control --scan-file --action Inform` +считается scanner error, а не clean. Формат и throughput проверяются на target VM2. + `otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`; сам Collector стартует только после этого. @@ -892,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service - container unhealthy/restart/OOM; - срок TLS; -- возраст ClamAV signatures; +- KESL version/database date, результат ежечасного update и broker/socket status; - OTEL queue/export errors; - disk/RAM; - активный MOCK mode; @@ -1107,8 +1123,9 @@ plan. - Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать контент. -- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую - capability; ошибка сканирования никогда не превращается в allow. +- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую + capability; ошибка сканирования никогда не превращается в allow и ведёт к + retry/`503`. - Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником истины. - Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен @@ -1134,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false таймаута: держите high-severity alert активным до явного `standard`, затем проверьте нормальные text/link/file capabilities и EICAR-canary. -## Известные исключения по образам +## Host KESL и broker -Образы ClamAV могут потребовать корректировок UID/path после валидации -точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально: -задокументируйте минимальные writable пути для сигнатур/runtime и -компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN -получает только `freshclam`; `clamd` — нет. +KESL 12.4 standalone и root-owned fail-closed broker не являются Compose +образами. Message Safety worker получает только Unix socket +`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root +ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как +hash KESL version + database date. Установка, ежечасное обновление, права +socket, clean/EICAR/error/stale gates и rollback выполняются строго по +[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). diff --git a/VM2_services/codebase/services/deployment/han-processing.service b/VM2_services/codebase/services/deployment/han-processing.service index a073275..b092f72 100644 --- a/VM2_services/codebase/services/deployment/han-processing.service +++ b/VM2_services/codebase/services/deployment/han-processing.service @@ -1,7 +1,7 @@ [Unit] Description=HAN Processing VM2 root Compose stack -Requires=docker.service han-secrets-vm2.service -After=docker.service han-secrets-vm2.service network-online.target +Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket +After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target [Service] Type=oneshot diff --git a/VM2_services/codebase/services/deployment/kesl/EVIDENCE.AVZ.ru.md b/VM2_services/codebase/services/deployment/kesl/EVIDENCE.AVZ.ru.md new file mode 100644 index 0000000..d40e429 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/EVIDENCE.AVZ.ru.md @@ -0,0 +1,95 @@ +# Карта доказательств KESL на ВМ2 + +Заполняется оператором после `RUNBOOK.KESL.ru.md`. Не включать activation code, +secrets, ПД, S3 object key, file bytes, полный checksum или EICAR. + +## 1. Изменение + +- Change ID / окно: +- Оператор / approvers Security, Service, Operations: +- Hostname, Ubuntu, kernel, architecture: +- KESL package/version, SHA-256, источник: +- HAN release SHA и image digests: +- Коммерческая сборка не заявлена сертифицированной ФСТЭК: да / нет +- KSN decision и правовое основание: + +## 2. Baseline и stop conditions + +- VM2 containers healthy/running: +- Public/private smoke: +- CPU, available RAM, swap activity, disk/IO wait: +- Safety p95/p99, error rate, queue age: +- Redis latency/blocked clients: +- Restart/OOM, OTEL queue: +- Утверждённые пороги и rollback approver: + +## 3. АВЗ.1 + +Норма: Приказ ФСТЭК №21, приложение АВЗ.1; п. 8.6 — обнаружение +вредоносных программ/информации и реагирование. + +- [ ] `kesl.service` active, лицензия действительна. +- [ ] File Threat Protection task 1 = `Started`. +- [ ] `ActionOnThreat=DisinfectDeleteIfNotPossible`, `ScanArchived=No`. +- [ ] fanotify on-access обнаружил и обработал разрешённый EICAR. +- [ ] Событие detection/action присутствует в KESL events. +- [ ] Исключения ограничены фактическими hot-data mountpoint. +- [ ] Broker staging отсутствует в `ExcludedFromScanScope`. +- [ ] Broker подтверждает `scanned >= 1`, `skipped = 0`, `errors = 0`. +- [ ] Firewall, smoke и health после Block успешны. +- [ ] 24 часа без неприемлемой деградации/OOM/restart/5xx. + +Артефакты/время/результат: + +## 4. АВЗ.2 + +Норма: Приказ ФСТЭК №21, приложение АВЗ.2 — обновление базы признаков +вредоносных компьютерных программ. + +- [ ] Update task 6 вручную завершилась успешно. +- [ ] Базы загружены, дата актуальна. +- [ ] Schedule = Hourly. +- [ ] Наблюдён последующий автоматический successful update. +- [ ] Alert на update failure / unloaded / age >240h / license failure. +- [ ] Назначен ежедневный контроль и owner. + +Артефакты/время последнего automatic update: + +## 5. Message Safety scan-broker + +- [ ] Socket `root:han-message-safety:0660`, TCP listener отсутствует. +- [ ] Clean corpus → `clean`; Message Safety final `200 allow`. +- [ ] EICAR → `infected`; Message Safety sticky `403 deny`. +- [ ] KESL stopped/timeout/unknown output → retry/terminal `503`, не allow/deny. +- [ ] `scanner_engine=kesl`. +- [ ] `signatures_version` меняется при обновлении KESL databases. +- [ ] Старый cache не используется после смены signatures version. +- [ ] Logs/traces не содержат bytes, object key, checksum, filename или secrets. +- [ ] 5 slots / 2 files per second gate пройден. + +Результаты corpus/load и ссылки на безопасные метрики: + +## 6. РСБ и АНЗ.2 + +- [ ] Определены и защищены KESL detection/remediation/update/license events. +- [ ] Определены место/срок хранения и экспорт/регламент просмотра. +- [ ] Версия KESL и upgrade lifecycle контролируются. +- [ ] Kernel/Docker/KESL upgrade требует compatibility pilot + evidence delta. + +## 7. Cutover и rollback + +- [ ] До cutover KESL+ClamAV coexistence не нарушило resource gates. +- [ ] После cutover `clamd`/`freshclam` отсутствуют, старый egress закрыт. +- [ ] Предыдущая совместимая release/config пара зафиксирована. +- [ ] Desk check: application rollback возвращает ClamAV release. +- [ ] Desk check: минимальный host rollback — `kesl-control --stop-task 1`. +- [ ] Rollback не удаляет Docker volumes и не использует `down -v`. + +## 8. Итог + +- АВЗ.1: принято / не принято; ограничения: +- АВЗ.2: принято / не принято; ограничения: +- Message Safety KESL cutover: принято / не принято: +- Residual risk custom broker / parser / throughput: +- Operations / Security / Service owner, ФИО, подпись, дата: +- Следующий review: diff --git a/VM2_services/codebase/services/deployment/kesl/RUNBOOK.KESL.ru.md b/VM2_services/codebase/services/deployment/kesl/RUNBOOK.KESL.ru.md new file mode 100644 index 0000000..3a5ecc3 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/RUNBOOK.KESL.ru.md @@ -0,0 +1,288 @@ +# KESL 12.4 standalone на ВМ2 Processing + +Операторский runbook для Ubuntu 24.04 ВМ2. Команды выполняет персональная роль +`admin` через `sudo` в отдельное maintenance window. Репозиторий не устанавливает, +не активирует и не запускает KESL автоматически. + +Цели: + +1. АВЗ.1: обнаружение и реагирование через host File Threat Protection. +2. АВЗ.2: автоматическое обновление баз. +3. Замена ClamAV в Message Safety локальным fail-closed scan-broker. + +KESL 12.4 commercial не заявляется как сертифицированная ФСТЭК сборка. + +## 0. Approvals и stop conditions + +До окна зафиксировать change ID, оператора, Security/Service/Operations approvers, +hostname, ОС/ядро, package version/hash/source, лицензию, release SHA и rollback. +EULA, Privacy Policy и KSN согласовать отдельно. Activation code, ключи и secrets +не помещать в репозиторий, историю shell, chat или evidence. + +Остановиться, если: + +- ОС/архитектура/ядро отсутствуют в матрице KESL 12.4; +- установлен другой AV или неизвестный KESL; +- свободно менее 10 ГБ, нет 4 ГБ swap либо baseline ВМ2 уже деградирован; +- KESL+ClamAV coexistence оставляет менее 2 ГБ available memory, вызывает + устойчивый swap IO, OOM/restart, IO wait >10% за 5 минут; +- p95 Safety/Redis вырос более чем на 20%, queue age >30 секунд; +- package hash/source не подтверждены, лицензия недействительна или базы не загружены. + +Не совмещать с deploy, миграциями, backup, TLS renewal, Docker restart или reboot. + +## 1. Read-only baseline + +```sh +date -Is +hostnamectl +uname -a +dpkg --print-architecture +free -h +swapon --show +df -hT / /var/lib/docker /tmp +df -ih / /var/lib/docker /tmp +systemctl is-active docker fail2ban ufw han-processing.service +dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' \ + kesl kesl-gui kav4fs 2>/dev/null || true +/usr/local/sbin/han-vm2-compose ps +docker stats --no-stream +iptables -S HAN-CHAT-VM2 +ufw status verbose +``` + +Сохранить baseline CPU/RAM/swap/IO, Safety latency/error/queue, Redis latency, +container restart/OOM, OTEL queue и внешний/private smoke. Получить фактические +mountpoint только для `redis-safety-data` и `otel-queue` через +`docker volume inspect`; prefix Compose не угадывать. + +## 2. Дистрибутив и установка + +Использовать amd64 DEB KESL 12.4 из официального канала. Хеши пилота ВМ1 не +считать универсальными: записать фактические SHA-256 DEB/ISO и, если поставщик +публикует ФИКС/ГОСТ, проверить соответствующим алгоритмом. + +Проверка файла: +``` +apt install rhash +ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso' +command -v rhash +rhash --gost "$ISO" +``` + +Должно быть 6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3 + +Если совпадёт, извлеките DEB: +``` +install -d -m 0700 -o root -g root \ + /root/kesl-install /mnt/kesl-iso + +mount -o ro,loop "$ISO" /mnt/kesl-iso + +find /mnt/kesl-iso -type f \ + \( -iname 'kesl_*_amd64.deb' -o -iname '*.deb' \) | + sort +``` + +Затем: +``` +DEB='/mnt/kesl-iso/kesl/kesl_12.4.0-1225_amd64.deb' + +dpkg-deb -f "$DEB" Package Version Architecture +sha256sum "$DEB" + +install -m 0600 -o root -g root \ + "$DEB" /root/kesl-install/kesl.deb + +umount /mnt/kesl-iso +rmdir /mnt/kesl-iso +``` + +Ожидаемые свойства: kesl, версия 12.4.x, архитектура amd64. После этого можно выполнять: +``` +apt-get install /root/kesl-install/kesl.deb +``` + +Создать `/root/kesl-install/autoinstall.ini` mode `0600`: + +```ini +KSVLA_MODE=No +ENDPOINT_AGENT_MODE=No +EULA_AGREED= +PRIVACY_POLICY_AGREED= +USE_KSN= +GROUP_CLEAN=Yes +LOCALE=ru_RU.UTF-8 +INSTALL_LICENSE=None +UPDATER_SOURCE=KLServers +UPDATE_EXECUTE=No +KERNEL_SRCS_INSTALL=No +USE_GUI=No +CONFIGURE_SELINUX=No +DISABLE_PROTECTION=Yes +INTERCEPTOR_MODE=UseFanotify +ENABLE_TRACES_ON_FIRST_STARTUP=No +``` + +```sh +chmod 0600 /root/kesl-install/autoinstall.ini +/opt/kaspersky/kesl/bin/kesl-setup.pl \ + --autoinstall=/root/kesl-install/autoinstall.ini +systemctl --no-pager status kesl +kesl-control --app-info --json +kesl-control --get-task-list +``` + +При `INSTALL_LICENSE=None` setup может вернуть 71; не запускать повторно, если +`kesl.service` active и компоненты защиты остановлены. Активацию выполнить без +history, затем удалить переменную: + +```sh +set +o history +unset HISTFILE +read -rsp 'KESL activation code: ' KESL_CODE; echo +kesl-control --add-active-key "$KESL_CODE" +unset KESL_CODE +set -o history +kesl-control -L --query +``` + +## 3. Resource budget до первой проверки + +Defaults `ScanMemoryLimit=8192` и `MaxMemory=auto` не принимать на ВМ2 8 ГБ. +Стартовый кандидат — `ScanMemoryLimit=1024`, `MaxMemory=2048MB`; окончательные +значения определяются baseline и coexistence с ещё работающим ClamAV. + +```sh +kesl-control --get-app-settings \ + --file /root/kesl-install/app-settings.before.ini +kesl-control --set-app-settings \ + UseOnDemandCPULimit=Yes OnDemandCPULimit= +``` + +`ScanMemoryLimit`/`MaxMemory` менять только по официальной процедуре в секции +`[General]` `/var/opt/kaspersky/kesl/common/kesl.ini` при остановленном KESL. + +## 4. АВЗ.2 — базы + +ID подтвердить через `--get-task-list`. + +```sh +kesl-control --get-settings 6 +kesl-control --start-task 6 -W +kesl-control --get-task-state 6 +kesl-control --app-info --json +START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')" +kesl-control --set-schedule 6 RuleType=Hourly \ + "StartTime=${START}" RunMissedStartRules=No RandomInterval=0 +kesl-control --get-schedule 6 +``` + +До приёмки наблюдать хотя бы один автоматический hourly cycle. Alert: ошибка +Update, базы старше 240 часов, базы не загружены, лицензия недействительна. + +## 5. Host protection — АВЗ.1 + +Добавить только фактические hot-data mountpoint Redis Safety и OTEL queue. +Не исключать `/var/lib/docker`, release, incoming, secrets, `/tmp` или `/`. +Не исключать staging broker `/var/lib/han-kesl-scan/staging`: исключение task 1 +распространяется и на явный `Scan_File`, поэтому файл будет пропущен. Если +исключение уже добавлено, удалить его до проверки broker: + +```sh +kesl-control --set-settings 1 \ + --del-exclusion /var/lib/han-kesl-scan/staging +``` + +```sh +kesl-control --get-settings 1 \ + --file /root/kesl-install/file-threat.before.ini +kesl-control --set-settings 1 \ + --add-exclusion +kesl-control --set-settings 1 \ + --add-exclusion +kesl-control --start-task 1 +kesl-control --set-settings 1 \ + ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No +kesl-control --get-task-state 1 +kesl-control --get-settings 1 +``` + +Проверять события, health, firewall и ресурсы каждые 15 минут. При деградации: +`kesl-control --stop-task 1`. + +## 6. Scan-broker + +После активации release повторить host setup для установки root-owned unit, +затем проверить файлы и включить socket: + +```sh +systemd-analyze verify \ + /etc/systemd/system/han-kesl-scan-broker.service \ + /etc/systemd/system/han-kesl-scan-broker.socket +systemctl enable --now han-kesl-scan-broker.socket +systemctl is-active kesl han-kesl-scan-broker.socket +stat -c '%U:%G:%a %n' /run/han-kesl/scan.sock +/usr/local/libexec/han-kesl-scan-broker --probe +journalctl --since '-10 min' --no-pager -u han-kesl-scan-broker.service +``` + +Ожидается `root:han-message-safety:660`. Broker не имеет TCP port, принимает +не более 5 MiB, не получает S3 key/имя/ПД, запускает фиксированный Scan_File +`Inform` и возвращает только verdict/version. Неизвестный вывод KESL — error. +Для `Scan_File` код возврата `72` означает обнаружение угрозы и принимается +только вместе с итогом `detected > 0`; любое противоречие обрабатывается как +`scanner_unavailable`. +Broker service намеренно не содержит `Requires=kesl.service`: иначе обращение +к socket автоматически запустит остановленный KESL и сделает fail-closed +проверку недостоверной. Порядок запуска обеспечивается `After=kesl.service`. + +## 7. Приёмка и cutover + +EICAR разрешён только письменно Security и не хранится в repository/evidence. +Отдельно выполнить: + +1. host on-access: обнаружение и реагирование task 1; +2. broker: clean → clean, EICAR → infected, остановленный KESL → error; +3. 5 concurrent broker slots и corpus 2 file/s; p95 final ≤60 s, public wait + ≤300 s, queue age p95 ≤5 s; +4. Message Safety: clean `200`, malware `403`, scanner error `503`, verdict sticky; +5. smoke ВМ2/ВМ1, firewall unchanged, 24 часа без OOM/restart/5xx/degradation. + +Только после gates активировать новую config version, release без +`clamd`/`freshclam`, затем подтвердить отсутствие их контейнеров/volumes и +закрыть старый signature-CDN egress. + +## 8. Evidence + +Заполнить `EVIDENCE.AVZ.ru.md` без activation code, secrets, file bytes, S3 key, +полного checksum и EICAR. Сохранить безопасные excerpts: + +```sh +systemctl is-active kesl han-kesl-scan-broker.socket +kesl-control --app-info --json +kesl-control --get-task-state 1 +kesl-control --get-settings 1 +kesl-control --get-schedule 6 +kesl-control -E --query -n 100 --reverse --json +/usr/local/sbin/han-vm2-compose ps +``` + +## 9. Rollback + +Application rollback: вернуть предыдущие совместимые image/config/release с +`clamd`, не создавать новый task id для уже pending request. Host KESL оставить, +если он не причина инцидента. + +Если проблема в host protection: + +```sh +kesl-control --stop-task 1 +# если KESL не отвечает: +systemctl stop kesl +``` + +`apt-get purge kesl` — только по решению approver. Не выполнять compose +`down -v`, не удалять Redis/OTEL volumes и не менять firewall ради обхода. + +После rollback повторить smoke, resource checks и зафиксировать incident. diff --git a/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.service b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.service new file mode 100644 index 0000000..1c30061 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.service @@ -0,0 +1,43 @@ +[Unit] +Description=HAN local KESL scan broker +Requires=han-kesl-scan-broker.socket +After=kesl.service + +[Service] +Type=simple +User=root +Group=han-message-safety +ExecStart=/usr/local/libexec/han-kesl-scan-broker +Environment=HAN_KESL_SCAN_TIMEOUT_SEC=60 +Environment=HAN_KESL_SCAN_CONCURRENCY=5 +StateDirectory=han-kesl-scan +StateDirectoryMode=0700 +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +ReadWritePaths=/var/lib/han-kesl-scan +CapabilityBoundingSet= +AmbientCapabilities= +LimitCORE=0 +LimitNOFILE=128 +TasksMax=64 +MemoryMax=256M + +[Install] +WantedBy=multi-user.target diff --git a/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.socket b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.socket new file mode 100644 index 0000000..a98ba41 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.socket @@ -0,0 +1,15 @@ +[Unit] +Description=HAN KESL scan broker socket +Before=han-processing.service + +[Socket] +ListenStream=/run/han-kesl/scan.sock +SocketUser=root +SocketGroup=han-message-safety +SocketMode=0660 +DirectoryMode=0750 +RemoveOnStop=true +Service=han-kesl-scan-broker.service + +[Install] +WantedBy=sockets.target diff --git a/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf new file mode 100644 index 0000000..b6f5889 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf @@ -0,0 +1 @@ +d /run/han-kesl 0750 root han-message-safety - diff --git a/VM2_services/codebase/services/deployment/kesl/kesl_scan_broker.py b/VM2_services/codebase/services/deployment/kesl/kesl_scan_broker.py new file mode 100644 index 0000000..dfab24e --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/kesl_scan_broker.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Local, fail-closed bridge between Message Safety and host KESL.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import re +import socket +import struct +import sys +import tempfile +from pathlib import Path +from typing import Any + +MAX_HEADER_BYTES = 4096 +MAX_FILE_BYTES = 5 * 1024 * 1024 +MAX_RESPONSE_BYTES = 16 * 1024 +DEFAULT_TIMEOUT_SECONDS = 60.0 +KESL_CONTROL = Path("/opt/kaspersky/kesl/bin/kesl-control") +STAGING_DIR = Path("/var/lib/han-kesl-scan/staging") + +_SUMMARY_PATTERNS = { + "scanned": re.compile( + r"(?im)^\s*(?:Scanned objects|Objects scanned|Проверенные объекты)" + r"\s*:\s*(\d+)\s*$" + ), + "detected": re.compile( + r"(?im)^\s*(?:Total detected objects|Всего обнаружено объектов)" + r"\s*:\s*(\d+)\s*$" + ), + "errors": re.compile( + r"(?im)^\s*(?:Scan errors|Ошибки проверки)\s*:\s*(\d+)\s*$" + ), + "skipped": re.compile( + r"(?im)^\s*(?:Skipped objects|Objects skipped|Пропущено объектов)" + r"\s*:\s*(\d+)\s*$" + ), +} +_THREAT_RE = re.compile(r"(?im)^\s*(?:Threat|Detect name)\s*:\s*(.{1,256})\s*$") + + +class ProtocolError(ValueError): + pass + + +class KeslError(RuntimeError): + pass + + +def _json_bytes(payload: dict[str, Any]) -> bytes: + body = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode() + if len(body) > MAX_RESPONSE_BYTES: + raise KeslError("response exceeds protocol limit") + return struct.pack(">I", len(body)) + body + + +async def _read_frame(reader: asyncio.StreamReader) -> tuple[dict[str, Any], bytes]: + (header_size,) = struct.unpack(">I", await reader.readexactly(4)) + if header_size < 2 or header_size > MAX_HEADER_BYTES: + raise ProtocolError("invalid header size") + try: + header = json.loads((await reader.readexactly(header_size)).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProtocolError("invalid header") from exc + if not isinstance(header, dict) or set(header) - {"op", "size"}: + raise ProtocolError("invalid header fields") + operation = header.get("op") + if operation == "status": + if "size" in header: + raise ProtocolError("status request cannot contain a body") + return header, b"" + if operation != "scan" or not isinstance(header.get("size"), int): + raise ProtocolError("unsupported operation") + size = header["size"] + if isinstance(size, bool) or size < 1 or size > MAX_FILE_BYTES: + raise ProtocolError("invalid file size") + return header, await reader.readexactly(size) + + +def parse_scan_output(output: str, *, exit_code: int = 0) -> tuple[str, str | None]: + """Parse the documented KESL Scan_File summary, rejecting format drift.""" + if exit_code not in (0, 72): + raise KeslError(f"unexpected KESL scan exit code: {exit_code}") + summary: dict[str, int] = {} + for name, pattern in _SUMMARY_PATTERNS.items(): + match = pattern.search(output) + if not match: + raise KeslError(f"KESL scan summary field is missing: {name}") + summary[name] = int(match.group(1)) + if summary["scanned"] < 1: + raise KeslError("KESL did not scan the submitted object") + if summary["errors"] or summary["skipped"]: + raise KeslError("KESL scan completed with errors or skipped objects") + detected = summary["detected"] + if (exit_code == 72) != (detected > 0): + raise KeslError("KESL exit code contradicts scan summary") + if detected == 0: + return "clean", None + threat = _THREAT_RE.search(output) + return "infected", threat.group(1).strip() if threat else "detected" + + +def _flatten_json(value: Any, prefix: str = "") -> dict[str, Any]: + result: dict[str, Any] = {} + if isinstance(value, dict): + for key, item in value.items(): + normalized = re.sub(r"[^\w]+", "_", str(key).casefold()).strip("_") + result.update(_flatten_json(item, f"{prefix}_{normalized}".strip("_"))) + else: + result[prefix] = value + return result + + +def parse_app_info(output: str) -> dict[str, Any]: + try: + flattened = _flatten_json(json.loads(output)) + except json.JSONDecodeError as exc: + raise KeslError("invalid KESL app-info JSON") from exc + + def find(*suffixes: str) -> Any: + for suffix in suffixes: + for key, value in flattened.items(): + if key == suffix or key.endswith(f"_{suffix}"): + return value + raise KeslError(f"KESL app-info field is missing: {suffixes[0]}") + + version = str(find("version", "application_version", "версия")).strip() + databases_loaded = find( + "databases_loaded", + "application_databases_loaded", + "базы_приложения_загружены", + ) + databases_date = str( + find( + "databases_date", + "last_release_date_of_databases", + "database_date", + "дата_последнего_выпуска_баз_приложения", + ) + ).strip() + license_info = str( + find( + "license_info", + "license_status", + "key_status", + "license_information", + "информация_о_лицензии_приложения", + ) + ).strip() + if databases_loaded not in (True, "Yes", "yes", "true", "Да", "да", 1): + raise KeslError("KESL databases are not loaded") + if not version or not databases_date or databases_date.upper() == "N/A": + raise KeslError("KESL version or database date is unavailable") + if "valid" not in license_info.lower() and "действ" not in license_info.lower(): + raise KeslError("KESL license is not valid") + signature_source = f"{version}\0{databases_date}".encode() + return { + "status": "ready", + "engine_version": version, + "databases_date": databases_date, + "signatures_version": "sha256:" + hashlib.sha256(signature_source).hexdigest(), + } + + +async def _run_kesl( + *arguments: str, + timeout: float, + allowed_returncodes: frozenset[int] = frozenset({0}), +) -> tuple[str, int]: + if not KESL_CONTROL.is_file(): + raise KeslError("KESL control utility is unavailable") + process = await asyncio.create_subprocess_exec( + str(KESL_CONTROL), + *arguments, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"}, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout) + except TimeoutError: + process.kill() + await process.wait() + raise KeslError("KESL command timed out") from None + if process.returncode not in allowed_returncodes: + detail = stderr.decode("utf-8", "replace").strip()[:256] + raise KeslError(f"KESL command failed: {detail or process.returncode}") + return stdout.decode("utf-8", "replace"), process.returncode + + +class Broker: + def __init__(self, *, timeout: float, concurrency: int) -> None: + self.timeout = timeout + self.slots = asyncio.Semaphore(concurrency) + + async def status(self) -> dict[str, Any]: + output, _ = await _run_kesl( + "--app-info", "--json", timeout=min(self.timeout, 10.0) + ) + return parse_app_info(output) + + async def scan(self, body: bytes) -> dict[str, Any]: + async with self.slots: + status_before = await self.status() + STAGING_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix="scan-", dir=STAGING_DIR) + path = Path(name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as target: + target.write(body) + target.flush() + os.fsync(target.fileno()) + output, exit_code = await _run_kesl( + "--scan-file", + str(path), + "--action", + "Inform", + timeout=self.timeout, + allowed_returncodes=frozenset({0, 72}), + ) + verdict, threat = parse_scan_output(output, exit_code=exit_code) + status_after = await self.status() + if ( + status_before["signatures_version"] + != status_after["signatures_version"] + ): + raise KeslError("KESL databases changed during scan") + return { + "status": "completed", + "verdict": verdict, + "threat": threat, + "engine_version": status_after["engine_version"], + "signatures_version": status_after["signatures_version"], + } + finally: + path.unlink(missing_ok=True) + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + header, body = await asyncio.wait_for(_read_frame(reader), 10.0) + result = await (self.status() if header["op"] == "status" else self.scan(body)) + except (ProtocolError, asyncio.IncompleteReadError, TimeoutError): + result = {"status": "error", "error": "invalid_request"} + except KeslError as exc: + print(f"KESL scanner unavailable: {exc}", file=sys.stderr, flush=True) + result = {"status": "error", "error": "scanner_unavailable"} + except Exception: + result = {"status": "error", "error": "internal_error"} + writer.write(_json_bytes(result)) + await writer.drain() + writer.close() + await writer.wait_closed() + + +def _activation_socket() -> socket.socket: + if int(os.environ.get("LISTEN_FDS", "0")) != 1 or os.getpid() != int( + os.environ.get("LISTEN_PID", "0") + ): + raise SystemExit("exactly one systemd activation socket is required") + descriptor = socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM) + descriptor.setblocking(False) + return descriptor + + +async def serve() -> None: + timeout = float(os.environ.get("HAN_KESL_SCAN_TIMEOUT_SEC", DEFAULT_TIMEOUT_SECONDS)) + concurrency = int(os.environ.get("HAN_KESL_SCAN_CONCURRENCY", "5")) + if not 1 <= concurrency <= 16 or not 1 <= timeout <= 300: + raise SystemExit("invalid broker limits") + server = await asyncio.start_unix_server( + Broker(timeout=timeout, concurrency=concurrency).handle, + sock=_activation_socket(), + limit=MAX_FILE_BYTES + MAX_HEADER_BYTES + 4, + ) + async with server: + await server.serve_forever() + + +async def probe() -> None: + reader, writer = await asyncio.wait_for( + asyncio.open_unix_connection("/run/han-kesl/scan.sock"), 5.0 + ) + try: + header = json.dumps({"op": "status"}, separators=(",", ":")).encode() + writer.write(struct.pack(">I", len(header)) + header) + await writer.drain() + (size,) = struct.unpack(">I", await asyncio.wait_for(reader.readexactly(4), 10.0)) + if size < 2 or size > MAX_RESPONSE_BYTES: + raise SystemExit("invalid broker response") + response = json.loads((await asyncio.wait_for(reader.readexactly(size), 10.0)).decode()) + if response.get("status") != "ready": + print(json.dumps(response, ensure_ascii=True, sort_keys=True), file=sys.stderr) + raise SystemExit("KESL broker is not ready") + print(json.dumps(response, ensure_ascii=True, sort_keys=True)) + finally: + writer.close() + await writer.wait_closed() + + +if __name__ == "__main__": + if not sys.argv[1:]: + asyncio.run(serve()) + elif sys.argv[1:] == ["--probe"]: + asyncio.run(probe()) + else: + raise SystemExit("usage: han-kesl-scan-broker [--probe]") diff --git a/VM2_services/codebase/services/deployment/kesl/policy-standalone.conf.example b/VM2_services/codebase/services/deployment/kesl/policy-standalone.conf.example new file mode 100644 index 0000000..c2985d0 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/policy-standalone.conf.example @@ -0,0 +1,62 @@ +# Reference decisions for KESL 12.4 on HAN Processing VM2. +# Do not import this partial file into KESL. Export the installed-build +# settings, review them, and apply individual values from RUNBOOK.KESL.ru.md. + +[deployment] +product_major_minor=12.4 +mode=standard_standalone +gui=disabled +update_source=KLServers +interceptor=fanotify +network_features=disabled +ksn= + +[resource_budget] +# VM2 starts at 8 GiB RAM. Values require measured baseline and coexistence gate. +scan_memory_limit_mb=<1024_OR_APPROVED_VALUE> +max_memory=<2048MB_OR_APPROVED_VALUE> +use_on_demand_cpu_limit=Yes +on_demand_cpu_limit_percent= + +[update_task_6] +rule_type=Hourly +required_result=completed_successfully +required_bases_loaded=Yes +stale_bases_alert_hours=240 + +[file_threat_protection_task_1] +steady_state=Started +action_on_threat=DisinfectDeleteIfNotPossible +scan_archived=No + +[message_safety_broker] +socket=/run/han-kesl/scan.sock +max_file_bytes=5242880 +scan_action=Inform +fail_closed=Yes +staging=/var/lib/han-kesl-scan/staging +# Do not add staging to ExcludedFromScanScope: KESL Scan_File can skip it. +staging_exclusion=forbidden +explicit_scan_before_allow=required + +[allowed_hot_data_exclusions] +item_0000= +item_0001= + +[forbidden_broad_exclusions] +item_0000=/var/lib/docker +item_0001=/var/lib/docker/overlay2 +item_0002=/opt/han-chat +item_0003=/var/lib/han-deploy/incoming +item_0004=/etc/han +item_0005=/run/han-chat +item_0006=/tmp +item_0007=/ + +[acceptance] +host_on_access_eicar=required +broker_clean_eicar_error=required +automatic_hourly_update_observed=required +message_safety_200_403_503=required +performance_two_files_per_second=required +resource_comparison_24h=required diff --git a/VM2_services/codebase/services/deployment/kesl/test_kesl_scan_broker.py b/VM2_services/codebase/services/deployment/kesl/test_kesl_scan_broker.py new file mode 100644 index 0000000..3298dd1 --- /dev/null +++ b/VM2_services/codebase/services/deployment/kesl/test_kesl_scan_broker.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import struct +import sys +from pathlib import Path + +import pytest + +MODULE_PATH = Path(__file__).with_name("kesl_scan_broker.py") +SPEC = importlib.util.spec_from_file_location("kesl_scan_broker", MODULE_PATH) +assert SPEC and SPEC.loader +broker = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = broker +SPEC.loader.exec_module(broker) + + +def test_scan_output_is_strict_and_fail_closed() -> None: + clean = ( + "Scanned objects: 1\n" + "Total detected objects: 0\n" + "Scan errors: 0\n" + "Skipped objects: 0\n" + ) + assert broker.parse_scan_output(clean) == ("clean", None) + assert broker.parse_scan_output( + "Threat: EICAR-Test-File\n" + "Scanned objects: 1\n" + "Total detected objects: 1\n" + "Scan errors: 0\n" + "Skipped objects: 0\n", + exit_code=72, + ) == ("infected", "EICAR-Test-File") + with pytest.raises(broker.KeslError): + broker.parse_scan_output("scan allegedly completed") + + +def test_scan_output_accepts_russian_summary_and_rejects_skipped_file() -> None: + clean = ( + "Проверенные объекты: 1\n" + "Всего обнаружено объектов: 0\n" + "Ошибки проверки: 0\n" + "Пропущено объектов: 0\n" + ) + assert broker.parse_scan_output(clean) == ("clean", None) + + skipped = clean.replace("Проверенные объекты: 1", "Проверенные объекты: 0").replace( + "Пропущено объектов: 0", "Пропущено объектов: 1" + ) + with pytest.raises(broker.KeslError): + broker.parse_scan_output(skipped) + + +def test_scan_output_rejects_exit_code_summary_mismatch() -> None: + infected = ( + "Scanned objects: 1\n" + "Total detected objects: 1\n" + "Scan errors: 0\n" + "Skipped objects: 0\n" + ) + clean = infected.replace("Total detected objects: 1", "Total detected objects: 0") + with pytest.raises(broker.KeslError): + broker.parse_scan_output(infected, exit_code=0) + with pytest.raises(broker.KeslError): + broker.parse_scan_output(clean, exit_code=72) + + +def test_app_info_builds_versioned_signature() -> None: + raw = json.dumps( + { + "product info": { + "version": "12.4.0.1225", + "databases_loaded": True, + "databases_date": "2026-09-07T11:25:00+00:00", + "license_info": "The key is valid", + } + } + ) + status = broker.parse_app_info(raw) + assert status["status"] == "ready" + assert status["engine_version"] == "12.4.0.1225" + assert str(status["signatures_version"]).startswith("sha256:") + + +def test_app_info_accepts_kesl_12_4_control_json() -> None: + raw = json.dumps( + { + "Application databases loaded": "Yes", + "Application license information": "The key is valid", + "Last release date of databases": "2026-09-07 15:41:00", + "Version": "12.4.0.1225", + } + ) + status = broker.parse_app_info(raw) + assert status["status"] == "ready" + assert status["engine_version"] == "12.4.0.1225" + assert status["databases_date"] == "2026-09-07 15:41:00" + + +def test_app_info_accepts_russian_kesl_12_4_control_json() -> None: + raw = json.dumps( + { + "Базы приложения загружены": "Да", + "Версия": "12.4.0.1225", + "Дата последнего выпуска баз приложения": "2026-09-07 15:41:00", + "Информация о лицензии приложения": "Ключ действителен", + } + ) + status = broker.parse_app_info(raw) + assert status["status"] == "ready" + assert status["engine_version"] == "12.4.0.1225" + assert status["databases_date"] == "2026-09-07 15:41:00" + + +def test_app_info_rejects_license_and_database_failures() -> None: + base = { + "version": "12.4", + "databases_loaded": True, + "databases_date": "2026-09-07T11:25:00+00:00", + "license_info": "The key is valid", + } + for key, value in (("databases_loaded", False), ("license_info", "Expired")): + candidate = dict(base) + candidate[key] = value + with pytest.raises(broker.KeslError): + broker.parse_app_info(json.dumps(candidate)) + + +def test_protocol_rejects_oversized_file() -> None: + async def exercise() -> None: + header = json.dumps({"op": "scan", "size": broker.MAX_FILE_BYTES + 1}).encode() + reader = asyncio.StreamReader() + reader.feed_data(struct.pack(">I", len(header)) + header) + reader.feed_eof() + with pytest.raises(broker.ProtocolError): + await broker._read_frame(reader) + + asyncio.run(exercise()) diff --git a/VM2_services/codebase/services/deployment/preflight.sh b/VM2_services/codebase/services/deployment/preflight.sh index ac0b86c..6a8e623 100644 --- a/VM2_services/codebase/services/deployment/preflight.sh +++ b/VM2_services/codebase/services/deployment/preflight.sh @@ -21,6 +21,24 @@ fail() { fail "root-owned Message Safety mode file is missing; initialize standard mode" /usr/bin/getent group han-message-safety | /usr/bin/awk -F: '$3 == 10001 {found=1} END {exit !found}' || fail "han-message-safety group with GID 10001 is missing" +[ -x /opt/kaspersky/kesl/bin/kesl-control ] || + fail "KESL 12.4 control utility is missing" +/usr/bin/systemctl is-active --quiet kesl.service || + fail "kesl.service is not active" +/usr/bin/systemctl is-active --quiet han-kesl-scan-broker.socket || + fail "KESL scan broker socket is not active" +[ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl 2>/dev/null || true)" = \ + "root:han-message-safety:750" ] || + fail "KESL broker runtime directory must be root:han-message-safety 0750" +[ -S /run/han-kesl/scan.sock ] || fail "KESL scan broker Unix socket is missing" +if [ -S /run/han-kesl/scan.sock ]; then + [ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl/scan.sock)" = \ + "root:han-message-safety:660" ] || + fail "KESL broker socket must be root:han-message-safety 0660" +fi +[ -x /usr/local/libexec/han-kesl-scan-broker ] && + /usr/local/libexec/han-kesl-scan-broker --probe >/dev/null || + fail "KESL scan broker readiness probe failed" public_tls_dir=/var/lib/han-chat/public-tls /usr/bin/getent group han-nginx-tls | /usr/bin/awk -F: '$3 == 11001 {found=1} END {exit !found}' || @@ -59,7 +77,7 @@ if [ -f "$ENV_FILE" ]; then true|false) ;; *) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;; esac - for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do + for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE") echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' || fail "$image_key must be pinned by sha256 digest" @@ -224,7 +242,11 @@ fi for protected in \ "$ROOT/docker-compose.yml" \ "$ROOT/deployment/han-message-safety-mode" \ - "$ROOT/deployment/han-processing.service" + "$ROOT/deployment/han-processing.service" \ + "$ROOT/deployment/kesl/kesl_scan_broker.py" \ + "$ROOT/deployment/kesl/han-kesl-scan-broker.service" \ + "$ROOT/deployment/kesl/han-kesl-scan-broker.socket" \ + "$ROOT/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf" do [ -f "$protected" ] || continue owner=$(/usr/bin/stat -c '%U:%G' "$protected") diff --git a/VM2_services/codebase/services/deployment/scripts/setup-vm.sh b/VM2_services/codebase/services/deployment/scripts/setup-vm.sh index c54d6bd..9966dfa 100644 --- a/VM2_services/codebase/services/deployment/scripts/setup-vm.sh +++ b/VM2_services/codebase/services/deployment/scripts/setup-vm.sh @@ -603,6 +603,10 @@ install_release_helpers_if_possible() { install -m 0755 -o root -g root \ "${deployment}/han-message-safety-mode" \ /usr/local/sbin/han-message-safety-mode + install -d -m 0755 -o root -g root /usr/local/libexec + install -m 0750 -o root -g root \ + "${deployment}/kesl/kesl_scan_broker.py" \ + /usr/local/libexec/han-kesl-scan-broker install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy install -m 0755 -o root -g root \ "${deployment}/scripts/ssl-renew-deploy-hook.sh" \ @@ -613,6 +617,16 @@ install_release_helpers_if_possible() { install -m 0644 -o root -g root \ "${deployment}/han-processing.service" \ /etc/systemd/system/han-processing.service + install -m 0644 -o root -g root \ + "${deployment}/kesl/han-kesl-scan-broker.service" \ + /etc/systemd/system/han-kesl-scan-broker.service + install -m 0644 -o root -g root \ + "${deployment}/kesl/han-kesl-scan-broker.socket" \ + /etc/systemd/system/han-kesl-scan-broker.socket + install -m 0644 -o root -g root \ + "${deployment}/kesl/han-kesl-scan-broker.tmpfiles.conf" \ + /etc/tmpfiles.d/han-kesl-scan-broker.conf + systemd-tmpfiles --create /etc/tmpfiles.d/han-kesl-scan-broker.conf safety_sudoers="$(mktemp)" sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers" chmod 0440 "$safety_sudoers" diff --git a/VM2_services/codebase/services/docker-compose.yml b/VM2_services/codebase/services/docker-compose.yml index fb723c3..b719868 100644 --- a/VM2_services/codebase/services/docker-compose.yml +++ b/VM2_services/codebase/services/docker-compose.yml @@ -22,8 +22,7 @@ x-message-safety-environment: &message-safety-environment MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080} MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5} MESSAGE_SAFETY_DNS_RESOLVERS: ${MESSAGE_SAFETY_DNS_RESOLVERS:?set trusted DNS resolvers} - MESSAGE_SAFETY_CLAMAV_HOST: ${MESSAGE_SAFETY_CLAMAV_HOST:-clamd} - MESSAGE_SAFETY_CLAMAV_PORT: ${MESSAGE_SAFETY_CLAMAV_PORT:-3310} + MESSAGE_SAFETY_ANTIVIRUS_SOCKET: /run/han-kesl/scan.sock MESSAGE_SAFETY_ARTIFACTS_DIR: ${MESSAGE_SAFETY_ARTIFACTS_DIR:-/app/app/artifacts} MESSAGE_SAFETY_MODE_FILE: /etc/han-chat/message-safety-mode.env PG_CA_FILE: /run/config/postgresql-ca.pem @@ -169,49 +168,6 @@ services: mem_limit: 64m cpus: 0.25 - clamd: - <<: *hardening - image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest} - entrypoint: ["/init-unprivileged"] - user: "100:101" - command: ["clamd", "--foreground=true"] - volumes: - - clamav-signatures:/var/lib/clamav:ro - - clamav-runtime:/run/clamav - tmpfs: - - /tmp:rw,noexec,nosuid,nodev,size=64m - - /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750 - networks: [backend] - expose: ["3310"] - healthcheck: - test: ["CMD-SHELL", "clamdscan --ping 1 >/dev/null 2>&1"] - interval: 30s - timeout: 5s - retries: 5 - start_period: 60s - pids_limit: 300 - mem_limit: 2g - cpus: 2.0 - - freshclam: - <<: *hardening - image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest} - entrypoint: ["/init-unprivileged"] - user: "100:101" - command: ["freshclam", "--daemon", "--foreground", "--checks=12"] - volumes: - - clamav-signatures:/var/lib/clamav - - clamav-runtime:/run/clamav - tmpfs: - - /tmp:rw,noexec,nosuid,nodev,size=64m - - /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750 - healthcheck: - disable: true - networks: [signature-egress] - pids_limit: 100 - mem_limit: 256m - cpus: 0.5 - otel-queue-init: <<: *hardening image: ${REDIS_IMAGE:?set immutable Redis image digest} @@ -270,6 +226,7 @@ services: MESSAGE_SAFETY_PROCESS_ROLE: api volumes: - /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro + - /run/han-kesl:/run/han-kesl:ro - *postgres-ca-volume tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=64m @@ -306,6 +263,7 @@ services: MESSAGE_SAFETY_PROCESS_ROLE: worker volumes: - /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro + - /run/han-kesl:/run/han-kesl:ro - *postgres-ca-volume tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=256m @@ -322,8 +280,6 @@ services: depends_on: redis-safety: condition: service_healthy - clamd: - condition: service_healthy pids_limit: 400 mem_limit: 2g cpus: 2.0 @@ -465,13 +421,10 @@ networks: internal: true safety-egress: bitrix-egress: - signature-egress: telemetry-egress: volumes: redis-safety-data: - clamav-signatures: - clamav-runtime: otel-queue: secrets: diff --git a/VM2_services/codebase/services/message-safety/README.md b/VM2_services/codebase/services/message-safety/README.md index c3319a8..5ca0193 100644 --- a/VM2_services/codebase/services/message-safety/README.md +++ b/VM2_services/codebase/services/message-safety/README.md @@ -30,8 +30,16 @@ message-safety-config activate --version 1 --approved-by security-owner `docker-compose.fragment.yml` is an include fragment for the root VM2 Compose. It publishes no host port, runs API and worker as UID 10001 with a read-only filesystem, drops all capabilities, -and mounts only service-specific secret files. The root project owns networks/secrets and the -root-owned emergency mode file. +and mounts only service-specific secret files plus the broker Unix socket +`/run/han-kesl/scan.sock` into the worker. The root project owns networks/secrets and the +root-owned emergency mode file. KESL 12.4 standalone and its root-owned fail-closed broker are +host services, not Compose services; `clamd` and `freshclam` are absent from Compose. + +The broker invokes the fixed host command `kesl-control --scan-file --action Inform`. A clean +result may continue to allow, an infected result denies, and scanner errors, unknown output, +timeouts or a stale KESL database remain retryable and eventually return `503` rather than +allowing content. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash of KESL +version plus database date. KESL database updates run hourly under the operator KESL runbook. ## External release gates @@ -41,8 +49,10 @@ target environment verifies them: - Selectel S3 supports version-specific `GetObject`, signed conditional ETag behavior, bucket versioning, checksum metadata, virtual-host addressing and a read-only IAM policy without list/write/delete. -- ClamAV engine/signature metadata is supplied to readiness and task cache keys; freshclam - activate/reload, signature-age alarms and clean/EICAR/malformed corpora pass on VM2. +- KESL version/database date is supplied through the broker to readiness and task cache keys; + hourly update, stale-database alarms and clean/EICAR/malformed corpora pass on VM2. +- The broker is a custom integration: exact `kesl-control` output/exit semantics, socket + permissions, cleanup and throughput must pass gates on the target VM2 with KESL 12.4. - HEIF native decoding and PDF parser sandbox resource limits pass the approved corpus. The in-process detector is bounded by 5 MiB and validates active/encrypted PDF markers, but OS-level CPU/memory/wall-time isolation must be enforced by the worker container and target runtime. diff --git a/VM2_services/codebase/services/message-safety/app/api.py b/VM2_services/codebase/services/message-safety/app/api.py index d80b4cb..7794297 100644 --- a/VM2_services/codebase/services/message-safety/app/api.py +++ b/VM2_services/codebase/services/message-safety/app/api.py @@ -96,6 +96,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI: @app.get("/health/ready") async def ready() -> JSONResponse: + await service.refresh_antivirus() mode = "mock" if service.mode.mock else "standard" components = { "postgres": "ok", diff --git a/VM2_services/codebase/services/message-safety/app/artifacts/config.schema.json b/VM2_services/codebase/services/message-safety/app/artifacts/config.schema.json index 10cedb2..d162806 100644 --- a/VM2_services/codebase/services/message-safety/app/artifacts/config.schema.json +++ b/VM2_services/codebase/services/message-safety/app/artifacts/config.schema.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "clamav", "file_policy"], + "required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "antivirus", "file_policy"], "properties": { - "schema_version": {"const": 1}, + "schema_version": {"const": 2}, "rules_bundle_ref": {"type": "string", "pattern": "^rules-[0-9]{4}-[0-9]{2}-[0-9]{2}$"}, "detector_manifest_ref": {"const": "detector-2026-08-03"}, "task": { @@ -48,9 +48,13 @@ "pipeline_timeout_sec": {"type": "number", "exclusiveMinimum": 0, "maximum": 5} } }, - "clamav": { - "type": "object", "additionalProperties": false, "required": ["scan_timeout_sec", "max_signature_age_hours"], - "properties": {"scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 120}, "max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720}} + "antivirus": { + "type": "object", "additionalProperties": false, "required": ["engine", "scan_timeout_sec", "max_signature_age_hours"], + "properties": { + "engine": {"const": "kesl"}, + "scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 300}, + "max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720} + } }, "file_policy": { "type": "object", "additionalProperties": false, "required": ["enabled_mime_types", "max_size_bytes"], diff --git a/VM2_services/codebase/services/message-safety/app/artifacts/seed-config.yaml b/VM2_services/codebase/services/message-safety/app/artifacts/seed-config.yaml index 670d4a5..3ebb60e 100644 --- a/VM2_services/codebase/services/message-safety/app/artifacts/seed-config.yaml +++ b/VM2_services/codebase/services/message-safety/app/artifacts/seed-config.yaml @@ -1,4 +1,4 @@ -schema_version: 1 +schema_version: 2 rules_bundle_ref: rules-2026-01-01 detector_manifest_ref: detector-2026-08-03 task: @@ -21,8 +21,9 @@ link: url_max_length: 2048 dns_lookup_timeout_sec: 1 pipeline_timeout_sec: 2 -clamav: - scan_timeout_sec: 45 +antivirus: + engine: kesl + scan_timeout_sec: 60 max_signature_age_hours: 240 file_policy: enabled_mime_types: diff --git a/VM2_services/codebase/services/message-safety/app/file_pipeline.py b/VM2_services/codebase/services/message-safety/app/file_pipeline.py index fbfd656..4593c37 100644 --- a/VM2_services/codebase/services/message-safety/app/file_pipeline.py +++ b/VM2_services/codebase/services/message-safety/app/file_pipeline.py @@ -7,6 +7,8 @@ import json import struct from collections.abc import AsyncIterator from dataclasses import dataclass +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from pathlib import Path from typing import Protocol @@ -33,7 +35,24 @@ class ObjectReader(Protocol): class Antivirus(Protocol): - async def scan(self, chunks: AsyncIterator[bytes]) -> str | None: ... + async def scan( + self, chunks: AsyncIterator[bytes], *, scan_timeout: float + ) -> AntivirusScanResult: ... + + async def status(self) -> AntivirusStatus: ... + + +@dataclass(frozen=True) +class AntivirusScanResult: + threat: str | None + signatures_version: str + + +@dataclass(frozen=True) +class AntivirusStatus: + engine_version: str + signatures_version: str + databases_date: datetime @dataclass(frozen=True) @@ -119,65 +138,101 @@ async def collect_and_hash( return bytes(body), digest.digest() -class ClamAvInstream: - def __init__(self, host: str, port: int, timeout: float = 45.0) -> None: - self.host, self.port, self.timeout = host, port, timeout +class KeslSocketScanner: + MAX_HEADER = 4096 + MAX_RESPONSE = 16 * 1024 - async def scan(self, chunks: AsyncIterator[bytes]) -> str | None: + def __init__(self, socket_path: Path, timeout: float = 60.0) -> None: + self.socket_path = socket_path + self.timeout = timeout + + async def _request( + self, + header: dict[str, object], + body: bytes = b"", + *, + request_timeout: float | None = None, + ) -> dict[str, object]: + encoded = json.dumps(header, separators=(",", ":")).encode() + if len(encoded) > self.MAX_HEADER: + raise DependencyFailure("KESL request header is too large") + + async def operation() -> dict[str, object]: + reader, writer = await asyncio.open_unix_connection(str(self.socket_path)) + try: + writer.write(struct.pack(">I", len(encoded)) + encoded + body) + await writer.drain() + (size,) = struct.unpack(">I", await reader.readexactly(4)) + if size < 2 or size > self.MAX_RESPONSE: + raise DependencyFailure("invalid KESL broker response size") + value = json.loads((await reader.readexactly(size)).decode()) + if not isinstance(value, dict): + raise DependencyFailure("invalid KESL broker response") + return value + finally: + writer.close() + await writer.wait_closed() + + try: + return await asyncio.wait_for(operation(), request_timeout or self.timeout) + except (OSError, TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError) as exc: + raise DependencyFailure("KESL unavailable") from exc + + async def scan( + self, chunks: AsyncIterator[bytes], *, scan_timeout: float + ) -> AntivirusScanResult: + body = bytearray() + async for chunk in chunks: + body.extend(chunk) with tracer.start_as_current_span( - "message_safety.clamav.scan", - attributes={"server.address.type": "clamav"}, + "message_safety.antivirus.scan", + attributes={"message_safety.antivirus.engine": "kesl"}, ): - return await self._scan(chunks) - - async def _scan(self, chunks: AsyncIterator[bytes]) -> str | None: - async def operation() -> str | None: - reader, writer = await asyncio.open_connection(self.host, self.port) - try: - writer.write(b"zINSTREAM\0") - async for chunk in chunks: - writer.write(struct.pack(">I", len(chunk)) + chunk) - await writer.drain() - writer.write(struct.pack(">I", 0)) - await writer.drain() - result = await reader.readuntil(b"\0") - text = result.rstrip(b"\0").decode("utf-8", "replace") - if text.endswith(" OK"): - return None - if text.endswith(" FOUND"): - return text.rsplit(": ", 1)[-1].removesuffix(" FOUND") - raise DependencyFailure("invalid ClamAV response") - finally: - writer.close() - await writer.wait_closed() - - try: - return await asyncio.wait_for(operation(), self.timeout) - except (OSError, TimeoutError) as exc: - raise DependencyFailure("ClamAV unavailable") from exc - - async def signatures_version(self) -> str: - with tracer.start_as_current_span("message_safety.clamav.version"): - return await self._signatures_version() - - async def _signatures_version(self) -> str: - try: - reader, writer = await asyncio.wait_for( - asyncio.open_connection(self.host, self.port), 2.0 + value = await self._request( + {"op": "scan", "size": len(body)}, + bytes(body), + request_timeout=scan_timeout, ) + if value.get("status") != "completed" or value.get("verdict") not in { + "clean", + "infected", + }: + raise DependencyFailure("KESL scan failed") + signatures = value.get("signatures_version") + if not isinstance(signatures, str) or not signatures.startswith("sha256:"): + raise DependencyFailure("KESL signatures version is missing") + threat = value.get("threat") + if value["verdict"] == "infected" and not isinstance(threat, str): + raise DependencyFailure("KESL infected verdict has no threat") + return AntivirusScanResult( + threat=threat if isinstance(threat, str) else None, + signatures_version=signatures, + ) + + async def status(self) -> AntivirusStatus: + with tracer.start_as_current_span( + "message_safety.antivirus.status", + attributes={"message_safety.antivirus.engine": "kesl"}, + ): + value = await self._request({"op": "status"}) + if value.get("status") != "ready": + raise DependencyFailure("KESL is not ready") + try: + database_date = datetime.fromisoformat( + str(value["databases_date"]).replace("Z", "+00:00") + ) + except ValueError: try: - writer.write(b"zVERSION\0") - await writer.drain() - raw = await asyncio.wait_for(reader.readuntil(b"\0"), 2.0) - finally: - writer.close() - await writer.wait_closed() - except (OSError, TimeoutError) as exc: - raise DependencyFailure("ClamAV unavailable") from exc - value = raw.rstrip(b"\0") - if not value.startswith(b"ClamAV ") or len(value) > 512: - raise DependencyFailure("invalid ClamAV version response") - return "sha256:" + hashlib.sha256(value).hexdigest() + database_date = parsedate_to_datetime(str(value["databases_date"])) + except (TypeError, ValueError) as exc: + raise DependencyFailure("invalid KESL database date") from exc + if database_date.tzinfo is None: + database_date = database_date.replace(tzinfo=UTC) + engine = value.get("engine_version") + signatures = value.get("signatures_version") + if not isinstance(engine, str) or not isinstance(signatures, str): + raise DependencyFailure("incomplete KESL status") + return AntivirusStatus(engine, signatures, database_date.astimezone(UTC)) async def one_chunk(data: bytes) -> AsyncIterator[bytes]: diff --git a/VM2_services/codebase/services/message-safety/app/main.py b/VM2_services/codebase/services/message-safety/app/main.py index 6a68a5d..747735f 100644 --- a/VM2_services/codebase/services/message-safety/app/main.py +++ b/VM2_services/codebase/services/message-safety/app/main.py @@ -8,7 +8,7 @@ from app.adapters import TrustedDnsResolver from app.api import create_app from app.config import ActiveConfig, validate_config from app.db import engine_and_sessions -from app.file_pipeline import ClamAvInstream, DependencyFailure +from app.file_pipeline import DependencyFailure, KeslSocketScanner from app.repository import Repository from app.service import SafetyService from app.settings import BootstrapSettings, EmergencyMode @@ -29,13 +29,14 @@ async def build_runtime() -> tuple[object, object]: resolver = TrustedDnsResolver( [item.strip() for item in settings.dns_resolvers.split(",") if item.strip()] ) - clamav = ClamAvInstream(settings.clamav_host, settings.clamav_port) + antivirus = KeslSocketScanner(settings.antivirus_socket) if mode.mock: signatures_version = "unavailable" files_ready = False else: try: - signatures_version = await clamav.signatures_version() + status = await antivirus.status() + signatures_version = status.signatures_version files_ready = True except DependencyFailure: signatures_version = "unavailable" @@ -47,6 +48,7 @@ async def build_runtime() -> tuple[object, object]: resolver, files_ready=files_ready, signatures_version=signatures_version, + antivirus=antivirus, ) app = create_app(service, settings.service_token.get_secret_value()) instrument_fastapi(app) diff --git a/VM2_services/codebase/services/message-safety/app/repository.py b/VM2_services/codebase/services/message-safety/app/repository.py index 0173d39..dacbc2c 100644 --- a/VM2_services/codebase/services/message-safety/app/repository.py +++ b/VM2_services/codebase/services/message-safety/app/repository.py @@ -90,7 +90,7 @@ class Repository: FileVerdictCache.config_version == config.version, FileVerdictCache.rules_version == config.rules_version, FileVerdictCache.detector_version == config.detector.version, - FileVerdictCache.scanner_engine == "clamav", + FileVerdictCache.scanner_engine == "kesl", FileVerdictCache.signatures_version == signatures_version, FileVerdictCache.expires_at > func.now(), ) @@ -262,9 +262,29 @@ class Repository: return result.rowcount == 1 async def finish( - self, task_id: uuid.UUID, owner: str, generation: int, *, allow: bool, rule_id: str + self, + task_id: uuid.UUID, + owner: str, + generation: int, + *, + allow: bool, + rule_id: str, + signatures_version: str | None = None, ) -> bool: now = datetime.now(UTC) + values = { + "status": TaskStatus.allowed if allow else TaskStatus.denied, + "verdict": "allow" if allow else "deny", + "rule_id": rule_id, + "reason_code": None if allow else "message_blocked", + "finished_at": now, + "purge_after": now + timedelta(days=30), + "updated_at": now, + "lease_owner": None, + "lease_until": None, + } + if signatures_version is not None: + values["signatures_version"] = signatures_version async with self.sessions.begin() as session: result = await session.execute( update(SafetyTask) @@ -275,17 +295,7 @@ class Repository: SafetyTask.lease_generation == generation, SafetyTask.lease_until > func.now(), ) - .values( - status=TaskStatus.allowed if allow else TaskStatus.denied, - verdict="allow" if allow else "deny", - rule_id=rule_id, - reason_code=None if allow else "message_blocked", - finished_at=now, - purge_after=now + timedelta(days=30), - updated_at=now, - lease_owner=None, - lease_until=None, - ) + .values(**values) ) if result.rowcount == 1: await session.execute( diff --git a/VM2_services/codebase/services/message-safety/app/service.py b/VM2_services/codebase/services/message-safety/app/service.py index 2d4445b..ee3f833 100644 --- a/VM2_services/codebase/services/message-safety/app/service.py +++ b/VM2_services/codebase/services/message-safety/app/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import uuid from datetime import UTC, datetime, timedelta from time import perf_counter @@ -14,7 +15,7 @@ from app.db import ( TaskStatus, TextRulesCache, ) -from app.file_pipeline import validate_metadata +from app.file_pipeline import Antivirus, DependencyFailure, validate_metadata from app.fingerprint import fingerprint from app.normalization import normalize_text from app.rate_limit import ConservativeRateLimiter @@ -52,6 +53,7 @@ class SafetyService: links_ready: bool = True, files_ready: bool = True, signatures_version: str = "unverified", + antivirus: Antivirus | None = None, ) -> None: self.repository = repository self.config = config @@ -60,11 +62,40 @@ class SafetyService: self.links_ready = links_ready self.files_ready = files_ready self.signatures_version = signatures_version + self.antivirus = antivirus + self._antivirus_checked_at = 0.0 + self._antivirus_check_lock = asyncio.Lock() self.rate_limiter = ConservativeRateLimiter( config.document["rate"]["text_rps"], config.document["rate"]["file_rps"] ) record_runtime_state("mock" if mode.mock else "standard", config.version) + async def refresh_antivirus(self) -> bool: + if self.mode.mock: + self.files_ready = False + return False + if self.antivirus is None: + return self.files_ready + if perf_counter() - self._antivirus_checked_at < 5: + return self.files_ready + async with self._antivirus_check_lock: + if perf_counter() - self._antivirus_checked_at < 5: + return self.files_ready + try: + status = await self.antivirus.status() + maximum_age = timedelta( + hours=self.config.document["antivirus"]["max_signature_age_hours"] + ) + if datetime.now(UTC) - status.databases_date > maximum_age: + raise DependencyFailure("KESL databases are stale") + self.signatures_version = status.signatures_version + self.files_ready = True + except DependencyFailure: + self.signatures_version = "unavailable" + self.files_ready = False + self._antivirus_checked_at = perf_counter() + return self.files_ready + def _verdict( self, allow: bool, @@ -222,7 +253,7 @@ class SafetyService: ) async def _check_file(self, request: FileCheck, digest: bytes) -> Verdict | Pending: - if not self.files_ready: + if not await self.refresh_antivirus(): raise CapabilityUnavailable("files") rule = validate_metadata( request.attachment, @@ -274,7 +305,7 @@ class SafetyService: declared_checksum=request.attachment.checksum, rules_version=self.config.rules_version, detector_version=self.config.detector.version, - scanner_engine="clamav", + scanner_engine="kesl", signatures_version=self.signatures_version, origin_trace_id=trace_id, origin_span_id=span_id, diff --git a/VM2_services/codebase/services/message-safety/app/settings.py b/VM2_services/codebase/services/message-safety/app/settings.py index 61026fa..00bc0fd 100644 --- a/VM2_services/codebase/services/message-safety/app/settings.py +++ b/VM2_services/codebase/services/message-safety/app/settings.py @@ -32,8 +32,10 @@ class BootstrapSettings(BaseSettings): default=5, ge=1, le=32, alias="MESSAGE_SAFETY_WORKER_CONCURRENCY" ) dns_resolvers: str = Field(default="", alias="MESSAGE_SAFETY_DNS_RESOLVERS") - clamav_host: str = Field(default="clamd", alias="MESSAGE_SAFETY_CLAMAV_HOST") - clamav_port: int = Field(default=3310, ge=1, le=65535, alias="MESSAGE_SAFETY_CLAMAV_PORT") + antivirus_socket: Path = Field( + default=Path("/run/han-kesl/scan.sock"), + alias="MESSAGE_SAFETY_ANTIVIRUS_SOCKET", + ) s3_endpoint_url: str = Field(alias="SELECTEL_S3_ENDPOINT_URL") s3_bucket: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE") artifacts_dir: Path = Field( diff --git a/VM2_services/codebase/services/message-safety/app/worker.py b/VM2_services/codebase/services/message-safety/app/worker.py index 00d043c..40b765d 100644 --- a/VM2_services/codebase/services/message-safety/app/worker.py +++ b/VM2_services/codebase/services/message-safety/app/worker.py @@ -12,8 +12,9 @@ from app.config import validate_config from app.contracts import Attachment from app.db import FileVerdictCache, SafetyAudit, engine_and_sessions from app.file_pipeline import ( - ClamAvInstream, + Antivirus, DependencyFailure, + KeslSocketScanner, ObjectChanged, collect_and_hash, detect_format, @@ -34,7 +35,7 @@ tracer = trace.get_tracer("message-safety.worker") class Worker: def __init__( - self, repository: Repository, reader: S3VersionReader, antivirus: ClamAvInstream, artifacts + self, repository: Repository, reader: S3VersionReader, antivirus: Antivirus, artifacts ) -> None: self.repository, self.reader, self.antivirus, self.artifacts = ( repository, @@ -94,10 +95,15 @@ class Worker: ) record_dependency("s3", "get_object", "success") rule = detect_format(body, attachment.mime_type) + signatures_version = task.signatures_version if not rule: - malware = await self.antivirus.scan(one_chunk(body)) - record_dependency("clamav", "scan", "success") - rule = "file.malware_detected" if malware else None + scan = await self.antivirus.scan( + one_chunk(body), + scan_timeout=row.config["antivirus"]["scan_timeout_sec"], + ) + signatures_version = scan.signatures_version + record_dependency("antivirus", "scan", "success") + rule = "file.malware_detected" if scan.threat else None with tracer.start_as_current_span("message_safety.worker.finalize"): finished = await self.repository.finish( task.id, @@ -105,6 +111,7 @@ class Worker: task.lease_generation, allow=rule is None, rule_id=rule or "safety.all_checks_passed", + signatures_version=signatures_version, ) if finished: record_worker("allow" if rule is None else "deny", task_age) @@ -116,7 +123,7 @@ class Worker: rules_version=task.rules_version, detector_version=task.detector_version, scanner_engine=task.scanner_engine, - signatures_version=task.signatures_version, + signatures_version=signatures_version, verdict="allow" if rule is None else "deny", rule_id=rule or "safety.all_checks_passed", reason_code=None if rule is None else "message_blocked", @@ -192,7 +199,7 @@ async def serve() -> None: settings.s3_access_key.get_secret_value(), settings.s3_secret_key.get_secret_value(), ), - ClamAvInstream(settings.clamav_host, settings.clamav_port), + KeslSocketScanner(settings.antivirus_socket), settings.artifacts_dir, ) async with asyncio.TaskGroup() as group: diff --git a/VM2_services/codebase/services/message-safety/tests/test_api_contract.py b/VM2_services/codebase/services/message-safety/tests/test_api_contract.py index 691a0a1..f57b01c 100644 --- a/VM2_services/codebase/services/message-safety/tests/test_api_contract.py +++ b/VM2_services/codebase/services/message-safety/tests/test_api_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from uuid import uuid4 import httpx @@ -7,6 +8,7 @@ import pytest from app.api import create_app from app.db import TaskStatus +from app.file_pipeline import AntivirusStatus from app.repository import ConflictError from app.service import SafetyService from app.settings import EmergencyMode @@ -54,6 +56,15 @@ class ForbiddenResolver: raise AssertionError("MOCK must not call DNS") +class StaleAntivirus: + async def status(self): + return AntivirusStatus( + "12.4", + "sha256:" + "a" * 64, + datetime.now(UTC) - timedelta(hours=241), + ) + + def body(kind: str, message_id=None) -> dict: value = { "message_id": str(message_id or uuid4()), @@ -188,3 +199,27 @@ async def test_final_task_response_keeps_task_config_snapshot(active_config) -> assert final.status_code == 200 assert final.json()["config_version"] == 99 + + +async def test_stale_kesl_databases_disable_only_files(active_config) -> None: + repo = FakeRepository() + service = SafetyService( + repo, + active_config, + EmergencyMode(), + ForbiddenResolver(), + antivirus=StaleAntivirus(), + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(service, "secret")), + base_url="http://test", + headers={"X-Service-Token": "secret"}, + ) as client: + file_response = await client.post( + "/internal/safety/v2/messages/check", json=body("file") + ) + text_response = await client.post( + "/internal/safety/v2/messages/check", json=body("text") + ) + assert file_response.status_code == 503 + assert text_response.status_code == 200 diff --git a/VM2_services/codebase/services/message-safety/tests/test_config_and_schema.py b/VM2_services/codebase/services/message-safety/tests/test_config_and_schema.py index 37ae83f..91850ff 100644 --- a/VM2_services/codebase/services/message-safety/tests/test_config_and_schema.py +++ b/VM2_services/codebase/services/message-safety/tests/test_config_and_schema.py @@ -35,12 +35,13 @@ def test_config_cross_field_and_manifest_subset(artifacts: Path) -> None: validate_config(bad, artifacts) -def test_clamav_signature_age_policy_bounds(artifacts: Path) -> None: +def test_kesl_signature_age_policy_bounds(artifacts: Path) -> None: document = seed(artifacts) - assert document["clamav"]["max_signature_age_hours"] == 240 - document["clamav"]["max_signature_age_hours"] = 720 + assert document["antivirus"]["engine"] == "kesl" + assert document["antivirus"]["max_signature_age_hours"] == 240 + document["antivirus"]["max_signature_age_hours"] = 720 validate_config(document, artifacts) - document["clamav"]["max_signature_age_hours"] = 721 + document["antivirus"]["max_signature_age_hours"] = 721 with pytest.raises(ValidationError): validate_config(document, artifacts) diff --git a/VM2_services/codebase/services/message-safety/tests/test_files.py b/VM2_services/codebase/services/message-safety/tests/test_files.py index f70809e..00e5a21 100644 --- a/VM2_services/codebase/services/message-safety/tests/test_files.py +++ b/VM2_services/codebase/services/message-safety/tests/test_files.py @@ -1,14 +1,24 @@ from __future__ import annotations +import asyncio import hashlib import io +import json +import struct +from pathlib import Path from uuid import UUID import pytest from PIL import Image from app.contracts import Attachment -from app.file_pipeline import ObjectChanged, collect_and_hash, detect_format +from app.file_pipeline import ( + KeslSocketScanner, + ObjectChanged, + collect_and_hash, + detect_format, + one_chunk, +) def image_bytes(format_name: str) -> bytes: @@ -71,3 +81,65 @@ async def test_authoritative_stream_hash_and_size() -> None: assert body == data and digest == hashlib.sha256(data).digest() with pytest.raises(ObjectChanged): await collect_and_hash(Reader(data), attachment(data, size=len(data) + 1), max_size=100) + + +class FakeWriter: + def __init__(self) -> None: + self.request = bytearray() + + def write(self, value: bytes) -> None: + self.request.extend(value) + + async def drain(self) -> None: + return None + + def close(self) -> None: + return None + + async def wait_closed(self) -> None: + return None + + +def framed(value: dict[str, object]): + body = json.dumps(value).encode() + reader = __import__("asyncio").StreamReader() + reader.feed_data(struct.pack(">I", len(body)) + body) + reader.feed_eof() + return reader + + +async def test_kesl_socket_clean_infected_and_status(monkeypatch, tmp_path: Path) -> None: + responses = [ + { + "status": "completed", + "verdict": "clean", + "threat": None, + "engine_version": "12.4", + "signatures_version": "sha256:" + "a" * 64, + }, + { + "status": "completed", + "verdict": "infected", + "threat": "EICAR-Test-File", + "engine_version": "12.4", + "signatures_version": "sha256:" + "b" * 64, + }, + { + "status": "ready", + "engine_version": "12.4", + "databases_date": "2026-09-07T11:25:00+00:00", + "signatures_version": "sha256:" + "c" * 64, + }, + ] + + async def connect(_): + return framed(responses.pop(0)), FakeWriter() + + monkeypatch.setattr(asyncio, "open_unix_connection", connect, raising=False) + scanner = KeslSocketScanner(tmp_path / "scan.sock") + clean = await scanner.scan(one_chunk(b"clean"), scan_timeout=1) + infected = await scanner.scan(one_chunk(b"eicar"), scan_timeout=1) + status = await scanner.status() + assert clean.threat is None + assert infected.threat == "EICAR-Test-File" + assert status.engine_version == "12.4" diff --git a/VM2_services/documentation/module-05-message-safety.md b/VM2_services/documentation/module-05-message-safety.md index dbfa419..d1aeee8 100644 --- a/VM2_services/documentation/module-05-message-safety.md +++ b/VM2_services/documentation/module-05-message-safety.md @@ -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= -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 §15–18 и S3 negative gate. diff --git a/VM2_services/documentation/module-09-observability-vm2.md b/VM2_services/documentation/module-09-observability-vm2.md index 4736086..52bfc90 100644 --- a/VM2_services/documentation/module-09-observability-vm2.md +++ b/VM2_services/documentation/module-09-observability-vm2.md @@ -8,7 +8,7 @@ Документ задаёт, **что агент ВМ2 реализует в Compose, коде, тестах и алертах этой машины**. -ВМ2 владеет nginx (public CRM webhook + private `8443`), `message-safety`, `bitrix-sync`, `clamd`/`freshclam`, Redis Safety и локальным Collector. Guest bootstrap, Keycloak, `api-backend` и `bitrix-local-app` живут на ВМ1. ВМ2 не использует Docker hostname collector ВМ1. +ВМ2 владеет nginx (public CRM webhook + private `8443`), `message-safety`, `bitrix-sync`, Redis Safety и локальным Collector в Compose, а также host KESL 12.4 standalone и root-owned fail-closed broker. Guest bootstrap, Keycloak, `api-backend` и `bitrix-local-app` живут на ВМ1. ВМ2 не использует Docker hostname collector ВМ1. Агент ВМ2 не добавляет scrape, дашборды и алерты сервисов ВМ1. @@ -22,7 +22,7 @@ | Redis Safety | `redis` | | local Collector | `otel-collector` | -`clamd` / `freshclam` покрываются host/container metrics и сигналами Safety (signature age, scan lanes), отдельное `service.name` в реестр arch-07 не добавляется без явного решения. +Host KESL и broker покрываются host/systemd metrics и сигналами Safety (database age, broker availability/latency, scan concurrency); отдельное `service.name` в реестр arch-07 не добавляется без явного решения. Различать экземпляр от ВМ1 через `host.name` / `service.instance.id`. @@ -44,7 +44,7 @@ Scrape targets ВМ2 (кроме самого Collector): Redis Safety exporter, - server spans API с route template; poll `202` не маскирует финальный verdict; - worker spans claim/process/finalize и span links на origin `request_id` / `trace_id` caller; -- child spans: PostgreSQL Safety schema, Redis Safety, S3 quarantine, ClamAV/DNS classification без file content и raw URL; +- child spans: PostgreSQL Safety schema, Redis Safety, S3 quarantine, KESL broker/DNS classification без file content, socket payload/path и raw URL; - `/health/live` исключить из traces; capability/readiness — metrics и sampled logs; - stub mode (`400`, non-sticky) маркируется как `stub`; production SLO Safety на stub недостоверен. @@ -67,7 +67,7 @@ Exporter и ACL — arch-07 §8.2–8.3. Клиентские pool/queue metrics ### 4.5. Host/Docker ВМ2 -CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5. Отдельно контролировать `clamd` restarts, signature age и scan lane saturation. +CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5. Отдельно контролировать host KESL/broker unit state, права `/run/han-kesl/scan.sock`, database age, hourly update result и broker/KESL saturation. ## 5. Метрики бизнес-потоков ВМ2 @@ -105,7 +105,7 @@ UUID/user/session/dialog/task/message id не labels. В SigNoz, с filter `host.name` / environment ВМ2: 1. **nginx ingress ВМ2**: RPS, 4xx/5xx, upstream latency/status, TLS, cache, **CRM webhook** (accept/reject by source IP, method, rate limit). Без guest API/WS — это ВМ1. -2. **message-safety**: capabilities, verdicts, `202` poll, PG queue age/leases/fencing, ClamAV/signature age, file/link cache и DNS dependency. Stub явно маркируется. +2. **message-safety**: capabilities, verdicts, `202` poll, PG queue age/leases/fencing, KESL broker/database age, file/link cache и DNS dependency. `scanner_engine=kesl`; `signatures_version` представлен как hash KESL version + database date. Stub явно маркируется. 3. **bitrix-sync**: mode/readiness, queue depth/oldest age, workflow/command transitions, CRM batch latency/subcommand outcome, limiter/throttle, retry/DLQ, webhook/reconciliation lag, mapping invariants и business-alert SLA. До cutover — `sync_disabled`. 4. **Redis Safety**: memory/evictions/AOF/latency/clients/keyspace. @@ -127,7 +127,7 @@ UUID/user/session/dialog/task/message id не labels. - Redis Safety unavailable, AOF error или sustained evictions; - Collector ВМ2 exporter queue >80%, dropped/refused telemetry >0 sustained; - TLS expiry public webhook host и private `8443` <14 дней warning, <7 дней page; -- disk/OOM/restart loop ВМ2; `clamd` restart loop / stale signatures по runbook module-05. +- disk/OOM/restart loop ВМ2; KESL/broker unit down, hourly update failure или stale database по runbook module-05. ### Ticket/warning @@ -151,7 +151,7 @@ Optional profile `observability-local` на ВМ2 по умолчанию вык ### Высокая latency сообщения (hop ВМ2) 1. Найти task/span по `request_id` caller. -2. Разделить Safety API, worker lease, ClamAV/DNS, S3 quarantine, sync queue. +2. Разделить Safety API, worker lease, KESL broker/DNS, S3 quarantine, sync queue. 3. Проверить circuit, queue age, PG leases/fencing и Redis Safety. 4. Не повторять ambiguous scan/send без исходного idempotency key. 5. Следовать runbook [`module-05-message-safety.md`](module-05-message-safety.md) / [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md). @@ -171,7 +171,7 @@ Optional profile `observability-local` на ВМ2 по умолчанию вык - Collector ВМ2 validate + up в root Compose; hostname collector ВМ1 не используется; - инструментированы `message-safety` API/worker и `bitrix-sync`; - nginx JSON parsing, webhook reject metric и private `8443` correlation проверены; -- Redis Safety, host/Collector, ClamAV signature age metrics доступны; +- Redis Safety, host/Collector, KESL broker/database age/hourly update metrics доступны; - дашборды и alerts §6–§7 provisioned либо явно TBD до SigNoz packaging; - stub/MOCK маркируются; production Safety SLO не объявляется на stub; - до sync cutover dashboard `sync_disabled`; diff --git a/VM2_services/documentation/module-10-deployment-vm2.md b/VM2_services/documentation/module-10-deployment-vm2.md index 1b7ad45..40e88af 100644 --- a/VM2_services/documentation/module-10-deployment-vm2.md +++ b/VM2_services/documentation/module-10-deployment-vm2.md @@ -8,7 +8,7 @@ ## 1. Границы -ВМ2 владеет nginx public `80/443` (только exact CRM webhook) и private `8443` (Message Safety), `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety, Collector. Guest API, Keycloak, SPA и SMS callback здесь не разворачиваются. +ВМ2 владеет nginx public `80/443` (только exact CRM webhook) и private `8443` (Message Safety), `message-safety` API/worker, `bitrix-sync`, Redis Safety и Collector в Compose. KESL 12.4 standalone и root-owned fail-closed broker работают на host VM2 вне Compose; broker слушает `/run/han-kesl/scan.sock`. Guest API, Keycloak, SPA и SMS callback здесь не разворачиваются. `` / `` — репозиторий ВМ2. Public ACME host — ``. Private DNS `processing.internal` не публикуется. @@ -23,15 +23,15 @@ - 100 pending принимаются; 101-й file POST — retryable `503` без новой task; - RPS overflow — `429 + Retry-After`; - long Safety poll не блокирует WS/read API ВМ1; -- если gate не пройден — увеличить slots/CPU/clamd scan lanes; production traffic не открывать. +- если gate не пройден — корректировать slots/CPU/broker concurrency в пределах подтверждённой KESL capacity; production traffic не открывать. -Monthly availability SLO Safety в MVP не задаётся. Scale-out/ВМ3: sustained CPU/RAM >70%, queue age >30 с, провал performance gates, contention `bitrix-sync` или независимый release cadence. Workers масштабируются первыми по queue depth, `clamd` — scan lanes. +Monthly availability SLO Safety в MVP не задаётся. Scale-out/ВМ3: sustained CPU/RAM >70%, queue age >30 с, провал performance gates, contention `bitrix-sync` или независимый release cadence. Workers масштабируются первыми по queue depth; предел задаёт проверенная throughput host KESL/broker. ## 3. Hardening и egress Arch-10 §5 / arch-06. Public Docker ports — `80,443`. Private `8443` не internet SG. -Default-deny egress. Bootstrap window для registry/OS, затем закрыть. Оставить: S3, approved Bitrix portal (`bitrix-sync`), signature CDN (`freshclam`), DNS/NTP, SigNoz `4317`, PG. Постоянный open egress запрещён. +Default-deny egress. Bootstrap window для registry/OS, затем закрыть. Оставить: S3, approved Bitrix portal (`bitrix-sync`), DNS/NTP, SigNoz `4317`, PG; для host KESL — только approved update sources по операторскому KESL runbook. Постоянный open egress запрещён. Отдельный IAM principal Selectel: только VM2 secret names. @@ -57,11 +57,11 @@ Checkout exact SHA. Структура: root Compose, `nginx`, Safety, `bitrix-s ## 6. Root Compose ВМ2 -Сервисы: public/private nginx, `message-safety-api`, `message-safety-worker`, `clamd`, `freshclam`, `bitrix-sync`, Redis Safety, local `otel-collector`. +Сервисы Compose: public/private nginx, `message-safety-api`, `message-safety-worker`, `bitrix-sync`, Redis Safety, local `otel-collector`. `clamd`/`freshclam` удалены. -Networks: `public` (только nginx webhook/ACME), `backend`, `egress` (freshclam, bitrix-sync, Safety worker → S3/PG/DNS, collector → SigNoz), `observability`. Safety API/clamd/Redis без общего internet egress. +Networks: `public` (только nginx webhook/ACME), `backend`, `egress` (`bitrix-sync`, Safety worker → S3/PG/DNS, collector → SigNoz), `observability`. Safety API/Redis без общего internet egress. Worker получает только read/write bind Unix socket `/run/han-kesl/scan.sock`, а не доступ к host command execution. -Volumes: Redis Safety data (rebuildable), ClamAV signatures, ACME public, `otel-queue` + init. Frontend-static нет. Published: nginx 80/443; `8443` только SG ВМ1/ops. +Volumes: Redis Safety data (rebuildable), ACME public, `otel-queue` + init. KESL database/runtime не являются Compose volumes. Frontend-static нет. Published: nginx 80/443; `8443` только SG ВМ1/ops. Compose gate — arch-10 применительно к этому Compose. @@ -108,9 +108,11 @@ Rollback: закрыть webhook или `503`; `BITRIX_SYNC_ENABLED=false`; ВМ ## 10. Ordered startup ВМ2 -1. Redis Safety, `otel-queue-init`, Collector; -2. `clamd`/`freshclam`, Safety API/worker, `bitrix-sync`; -3. nginx последним: оба TLS, exact webhook, capability health, signature age, negative ingress/egress. +1. Выполнить operator KESL runbook: проверить KESL 12.4 standalone, database date и ежечасное обновление. +2. `enable/start` broker socket/service; проверить status и owner/group/mode `/run/han-kesl/scan.sock`. +3. Redis Safety, `otel-queue-init`, Collector. +4. Safety API/worker и `bitrix-sync`; files остаётся fail-closed до успешной проверки broker. +5. nginx последним: оба TLS, exact webhook, capability health, KESL database age, negative ingress/egress. До enablement `bitrix-sync` — `sync_disabled`. Safety v2 capability `text|links|files|worker`. Redis Safety не core gate readiness. @@ -139,7 +141,7 @@ Cutover gates: private TLS chain/SAN; Safety v2 PG migration и lease/fencing sm Rollback ВМ2 не требует изменения nginx ВМ1. Caller rollback — runbook ВМ1. -## 14. Emergency MOCK и Freshclam +## 14. Emergency MOCK и KESL `deploy` без root login: @@ -153,11 +155,11 @@ sudo /usr/local/sbin/han-message-safety-mode standard Helper `root:root 0755`; sudoers только этот executable. Config `root:han-message-safety 0640`, GID `10001`. Нет auto-expiry; incident не закрывать без `standard` и canary. File в MOCK — `scan_status=bypassed`, не `clean`. `deploy` не пишет Compose/Docker. -`freshclam`: controlled egress только к signature CDN. Seed `max_signature_age_hours=240`, schema max `720`. Stale/failed update выключает только `files` и alert. Новая база — integrity/EICAR, atomic activate; regression — предыдущая валидная база. +KESL обновляет базы на host ежечасно по отдельному operator KESL runbook. Seed `max_signature_age_hours=240`, schema max `720`. Stale/failed update выключает только `files` и создаёт alert; scanner error не становится allow. Broker является custom integration: формат/exit semantics `kesl-control --scan-file --action Inform` и throughput подтверждаются target-VM gates. ## 15. Rollback, ops, incidents ВМ2 -Redis Safety restore — empty/clean, lazy cache ([`module-04-redis-vm2.md`](module-04-redis-vm2.md)). Routine: signature age, Safety queue, sync DLQ/webhook lag, Collector, disk, egress still deny. +Redis Safety restore — empty/clean, lazy cache ([`module-04-redis-vm2.md`](module-04-redis-vm2.md)). Routine: KESL version/database date и hourly update, broker/socket status, Safety queue, sync DLQ/webhook lag, Collector, disk, egress still deny. Incident: Safety timeout — checkpoint, не новый task id; Bitrix down — circuit/DLQ, webhook `503`; MOCK page — вернуть standard; cert processing host и private CA. @@ -171,7 +173,7 @@ Incident: Safety timeout — checkpoint, не новый task id; Bitrix down - private `8443` fail-closed до cutover, затем только источники ВМ1; - webhook allow-list и preflight `BITRIX_SYNC_ENABLED` согласованы; - capability/load gates module-05; -- MOCK helper и Freshclam rehearsal; +- MOCK helper и KESL/broker rehearsal; - observability ВМ2 + redaction (нет query/body/file content); - cutover §13 не объявлен выполненным только документацией. diff --git a/architectory/README.md b/architectory/README.md index db18499..bf5b21c 100644 --- a/architectory/README.md +++ b/architectory/README.md @@ -82,7 +82,7 @@ ## Каноническое размещение production-контуров - **ВМ1 HAN Chat** — самостоятельная публичная точка входа приложения: nginx, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контур, Redis DB0/DB1 и локальный OTEL Collector. -- **ВМ2 Processing** — самостоятельная service VM с отдельным public webhook host, private Message Safety ingress и постоянным ограниченным egress: `message-safety`, `bitrix-sync`, `clamd`/`freshclam`, отдельный Redis Safety, nginx и локальный OTEL Collector. +- **ВМ2 Processing** — самостоятельная service VM с отдельным public webhook host и private Message Safety ingress: в root Compose работают `message-safety`, `bitrix-sync`, отдельный Redis Safety, nginx и локальный OTEL Collector; на host работают KESL 12.4 standalone и root-owned fail-closed broker с Unix socket `/run/han-kesl/scan.sock`. `clamd`/`freshclam` в Compose отсутствуют. - На каждой VM действует один root Compose project и отдельный root-owned systemd deployment unit. «Единый Compose» означает один проект **на VM**, а не один общий project через несколько хостов. - Bitrix24 вызывает CRM webhook напрямую на nginx ВМ2; ВМ1 в route не участвует. ВМ1 вызывает только Message Safety по private HTTPS. - При росте нагрузки `bitrix-sync` может быть перенесён на ВМ3 без изменения API и границ схем PostgreSQL. diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index 3a6feae..8a1b8d7 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -63,7 +63,7 @@ HAN Chat - приложение для мигрантов, где стартов Production-like backend разделён на два контура в одной private network/VPC: - **ВМ1 HAN Chat**: edge `nginx`, `api-backend`, `keycloak`, `sms-service`/worker, `bitrix-local-app`, Redis DB0/DB1 и локальный `otel-collector`; -- **ВМ2 Processing**: собственный public/private `nginx`, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, отдельный Redis Safety и локальный `otel-collector`; +- **ВМ2 Processing**: в root Compose — собственный public/private `nginx`, `message-safety` API/worker, `bitrix-sync`, отдельный Redis Safety и локальный `otel-collector`; на host — KESL 12.4 standalone и root-owned fail-closed broker с `/run/han-kesl/scan.sock`; - каждая VM имеет один root Compose project и отдельный root-owned systemd deployment unit; - ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress на своих nginx; ВМ2 публикует только exact CRM webhook; - ВМ1 вызывает ВМ2 по private HTTPS с проверкой internal CA, service token, cloud SG и host firewall; @@ -97,7 +97,8 @@ flowchart LR api -->|"HTTPS 8443 + service token"| privateGateway[VM2_PrivateListener] privateGateway --> safety[MessageSafetyApi] safety --> worker[SafetyWorker] - worker --> clamd[Clamd] + worker -->|"Unix socket /run/han-kesl/scan.sock"| keslBroker[KESLBroker] + keslBroker --> hostKesl[HostKESL12_4] worker --> s3q[S3Quarantine] worker --> pg[ManagedPostgreSQL] sync --> pg @@ -590,7 +591,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn Минимальный целевой real-SMS контур разделён на два stack: - ВМ1: `nginx`, `api-backend`, `keycloak`, `sms-service`/worker, `bitrix-local-app`, Redis DB0/DB1, `otel-collector`; -- ВМ2: nginx с public webhook/private internal server blocks, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety, `otel-collector`. +- ВМ2: nginx с public webhook/private internal server blocks, `message-safety` API/worker, `bitrix-sync`, Redis Safety, `otel-collector` в Compose; KESL 12.4 standalone и root-owned broker на host. `clamd`/`freshclam` удалены из Compose. До SMS rollout сервисы SMS могут отсутствовать, но Keycloak обязан оставаться в mock mode. diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index cd2ac3a..c26f2cd 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -64,7 +64,6 @@ processing/ nginx-internal/docker-compose.yml message-safety/docker-compose.yml bitrix-sync/docker-compose.yml - clamav/docker-compose.yml redis/docker-compose.yml observability/docker-compose.yml ``` @@ -93,7 +92,7 @@ volumes: redis-data: ``` -Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие. +Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `bitrix-sync`, Redis Safety и локальный OTEL Collector. `clamd`/`freshclam` удалены из Compose; KESL 12.4 standalone и root-owned fail-closed broker работают на host VM2. Секреты, сети и volumes двух projects не общие. ### Правила для сервисных compose-файлов @@ -254,6 +253,7 @@ Python FastAPI backend. - runtime role читает immutable active `message_safety.config_versions`; создавать/активировать config может только отдельный migration/config-admin job; - использует локальный Redis Safety только для hot cache/rate/wakeup; PostgreSQL владеет task queue/leases; - запускает async workers для file scan из S3-quarantine; +- worker монтирует только Unix socket `/run/han-kesl/scan.sock` root-owned broker; KESL и `kesl-control` остаются на host, TCP scanner port отсутствует; - API container получает read-only `/etc/han-chat/message-safety-mode.env` с host contract `root:han-message-safety 0640` и GID `10001`; менять его и перезапускать @@ -262,29 +262,26 @@ Python FastAPI backend. - экспортирует traces/logs в `otel-collector`; - таймауты: POST check 5 s, GET task 2 s, file scan 60 s (см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), переменные `MESSAGE_SAFETY_*`). -### ClamAV на ВМ2 +### Host KESL и broker на ВМ2 -`clamd` и `freshclam` используют один immutable image digest, но разные -security-профили: +KESL 12.4 работает standalone на host и не является Compose service. Отдельный +root-owned custom broker слушает только `/run/han-kesl/scan.sock` и вызывает +фиксированный `kesl-control --scan-file --action Inform`. -- оба запускаются через vendor `init-unprivileged`, а не root entrypoint; -- `clamd` читает volume signatures read-only, не подключён к signature CDN и - имеет healthcheck daemon socket; -- `freshclam` один пишет в signatures и имеет только разрешённый egress к CDN; -- `/run/clamav` — отдельный runtime volume, `/var/log/clamav` и `/tmp` — - ограниченные tmpfs с UID/GID ClamAV; -- `freshclam` работает как foreground daemon с заданным interval; inherited - healthcheck `clamd` отключён, потому что updater не поднимает daemon socket; -- работоспособность updater подтверждается состоянием `Up`, отсутствием - restart loop и отдельным контролем возраста/signature version, а не - искусственным container healthcheck. -- Архитектурная верхняя граница допустимого возраста signatures — `720` часов - (30 дней); активный seed-порог `max_signature_age_hours` — `240` часов - (10 дней), то есть строже предельного значения. +- socket монтируется только в Message Safety worker и доступен выделенной + группе; worker не получает host binary, shell, Docker socket или host root; +- broker принимает только bounded scan request и fail-closed сопоставляет + `clean → allow`, `infected → deny`, а timeout, stale database, неизвестный + output/exit code и недоступность KESL → retry/`503`; +- `scanner_engine=kesl`; `signatures_version` — hash KESL version + database + date; +- KESL обновляет database ежечасно на host; update egress не подключает + контейнеры к internet; +- schema допускает `max_signature_age_hours` до `720`, seed — `240`. -Смена digest ClamAV требует повторной проверки entrypoint, UID/GID, writable -paths, `clamd` health и фактического обновления signatures. Нельзя менять -только tag/digest, считая security contract image неизменным. +Формат результата `kesl-control`, socket permissions, cleanup и throughput +этой custom integration подтверждаются gates на target VM2 с фактическим KESL +12.4; repository-only/Compose health не считается достаточным evidence. ### bitrix-sync @@ -400,9 +397,9 @@ Identity provider. **Обязателен** в compose-контуре с пер - ВМ1 `public`: edge nginx, Keycloak proxy и frontend entrypoint. - ВМ1 `backend`: `api-backend`, `bitrix-local-app`, Keycloak, SMS API и Redis DB0/DB1. -- ВМ2 `backend`: nginx, Safety API/worker, `bitrix-sync`, `clamd` и Redis Safety. +- ВМ2 `backend`: nginx, Safety API/worker, `bitrix-sync` и Redis Safety; broker доступен worker только через host Unix socket. - ВМ1 `egress` подключается только к процессам с назначением: `sms-worker` → i-Digital Direct; Keycloak → SmartCaptcha только при включённом feature flag; `api-backend` → S3 и private PG, а вызов Safety идёт к `processing.internal:8443`; local collector → private SigNoz. `sms-service` без совмещённого worker, `bitrix-local-app` и Redis не получают общий internet egress. -- ВМ2 `egress` подключается только к процессам с назначением: `freshclam` → signature CDN; `bitrix-sync` → утверждённый Bitrix portal; Safety worker → S3/PG/DNS; collector → private SigNoz. Общего internet egress у Safety API/clamd/Redis нет. +- ВМ2 `egress` подключается только к процессам с назначением: `bitrix-sync` → утверждённый Bitrix portal; Safety worker → S3/PG/DNS; collector → private SigNoz. Общего internet egress у Safety API/Redis нет; KESL update egress задаётся отдельно на host. - `observability` существует отдельно на каждой VM и ведёт в её local collector. Базы данных, Redis, OTLP receivers и internal service ports не публикуются. Cross-host calls идут через private network, точные SG и TLS. @@ -412,8 +409,8 @@ Identity provider. **Обязателен** в compose-контуре с пер Минимальные persistent volumes: - ВМ1: Redis DB0/DB1 data, local OTEL queue; -- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime, - internal TLS secrets, local OTEL queue. +- ВМ2: Redis Safety data (rebuildable), internal TLS secrets, local OTEL queue. + KESL database/runtime и `/run/han-kesl` принадлежат host, не Compose volumes. Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные @@ -561,7 +558,7 @@ WAF не заменяет обязательные лимиты, валидац - `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response; - `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis DB0/DB1, JWKS/discovery Keycloak и S3 permissions. Недоступность remote Message Safety отражается как degraded dependency и блокирует только send path, но не readiness read API; -- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; ClamAV/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate; +- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; KESL broker/stale database/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate; - `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет validated config/secrets, PostgreSQL/grants, worker/limiter state и CRM webhook config; invalid credential/config даёт not-ready, краткая CRM outage — degraded по stale policy; при `BITRIX_SYNC_ENABLED=false` ready возвращает not-ready `sync_disabled`; - `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`; - `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL; @@ -591,8 +588,8 @@ host-side secrets/TLS materialized, controlled migrations и seed заверше ВМ2 запускается в порядке: -1. `redis-safety`, `otel-queue-init`, затем local `otel-collector`; -2. `freshclam`, затем `clamd` до состояния healthy; +1. operator KESL runbook: KESL 12.4/database update, затем enable/start broker socket и проверка status/permissions; +2. `redis-safety`, `otel-queue-init`, затем local `otel-collector`; 3. Message Safety API/worker и `bitrix-sync`; 4. nginx — последним, после успешного config test; 5. private HTTPS ВМ1→ВМ2 и capability health проверяются до cutover. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 354079a..a7fb6b4 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -199,7 +199,7 @@ worker.lease_seconds=90 ## Service-owned настройки `message-safety` -Runtime policy хранится в версионированной `message_safety.config_versions`, а не в `.env` и не в `han_app.app_settings`. Сюда входят task lease/deadline/attempts, internal rate/pending limits, retention/cache TTL, URL/DNS pipeline limits, ClamAV policy timeout/signature age и enabled file MIME/size policy. Для ClamAV schema допускает возраст сигнатур не более `720` часов (30 дней), seed `max_signature_age_hours` равен `240` часам (10 дней). Полный schema/seed/activation contract — module-05 §10.1 и §15. +Runtime policy хранится в версионированной `message_safety.config_versions`, а не в `.env` и не в `han_app.app_settings`. Сюда входят task lease/deadline/attempts, internal rate/pending limits, retention/cache TTL, URL/DNS pipeline limits, KESL broker timeout/database age и enabled file MIME/size policy. Schema допускает возраст базы KESL не более `720` часов (30 дней), seed `max_signature_age_hours` равен `240` часам (10 дней). Полный schema/seed/activation contract — module-05 §10.1 и §15. Seed, JSON Schema и referenced artifacts входят в immutable Message Safety image. Их изменение требует одновременно нового pinned image digest и новой @@ -211,7 +211,7 @@ active row на месте. Rollback выполняется новой config ve `han_app.app_settings:chat.attachments.*` остаётся бизнес-настройкой api-backend. Message Safety не получает cross-schema read к `han_app`; файл допускается только при пересечении business allow-list, active safety policy и immutable detector manifest. Active policy может сузить manifest, но не добавить parser и не увеличить hard limit. -В env Message Safety остаются только bootstrap/topology/capacity (`APP_ENV`, worker concurrency, DNS resolver, ClamAV/S3/OTLP endpoints); credentials доставляются secret files. Rules/detector versions вычисляются/проверяются по immutable artifacts. Emergency MOCK остаётся в отдельном read-only bind file `root:han-message-safety 0640` с dedicated GID контейнера и намеренно не переносится в БД. +В env Message Safety остаются только bootstrap/topology/capacity (`APP_ENV`, worker concurrency, DNS resolver, Unix socket KESL broker, S3/OTLP endpoints); credentials доставляются secret files. Rules/detector versions вычисляются/проверяются по immutable artifacts. `scanner_engine=kesl`; `signatures_version` вычисляется как hash KESL version + database date. Emergency MOCK остаётся в отдельном read-only bind file `root:han-message-safety 0640` с dedicated GID контейнера и намеренно не переносится в БД. --- @@ -379,8 +379,7 @@ HAN_APP_SAFETY_RECOVERY_MAX_SEC=1200 # /etc/han-chat/message-safety-mode.env и меняется approved helper-ом. MESSAGE_SAFETY_WORKER_CONCURRENCY=5 MESSAGE_SAFETY_DNS_RESOLVERS= -MESSAGE_SAFETY_CLAMAV_HOST=clamd -MESSAGE_SAFETY_CLAMAV_PORT=3310 +MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock # ============================================================================= # Frontend (nginx) diff --git a/architectory/arch-06-service-hosting-security.md b/architectory/arch-06-service-hosting-security.md index 9353f1f..72378b3 100644 --- a/architectory/arch-06-service-hosting-security.md +++ b/architectory/arch-06-service-hosting-security.md @@ -62,7 +62,7 @@ SigNoz относится к этому классу, если его UI, OTLP - public `443` разрешает только exact `/bitrix/sync/webhook/contact` и `/bitrix/sync/webhook/alert`; остальные paths закрыты; - private ingress `8443/tcp` разрешён только от security group ВМ1 и утверждённого ops path для Message Safety/internal API; - ни один public запрос ВМ2 не проходит через nginx ВМ1; -- `freshclam` имеет egress только к утверждённым источникам сигнатур; +- host KESL имеет egress только к утверждённым источникам обновления из operator KESL runbook; Message Safety и broker не получают общий internet egress; - `bitrix-sync` имеет HTTPS egress только к утверждённому порталу Bitrix24; - Safety worker имеет доступ только к managed PostgreSQL, S3-quarantine и доверенному DNS resolver; - локальный OTEL Collector имеет private egress к SigNoz; @@ -513,23 +513,28 @@ certificate/key проверяются preflight. Успешный hook обяз при ненулевом exit code. Иначе Certbot/оркестратор может пометить успешный renewal как hook error. -### Daemon и updater как разные security-профили +### Host KESL и root-owned broker -Если один vendor image используется для daemon и updater, им задаются разные -сети, mounts и health semantics. Проверенный паттерн ClamAV: +KESL 12.4 standalone и custom integration broker работают на host VM2, вне +Compose. Граница между non-root Message Safety worker и privileged host: -- `clamd` не имеет signature-CDN egress, читает signatures read-only и имеет - healthcheck реального daemon socket; -- `freshclam` один получает ограниченный egress и write к signatures; -- оба используют vendor `init-unprivileged` и только выделенные writable - `/run/clamav`, `/var/log/clamav` и `/tmp`; -- updater запускается как постоянный foreground daemon, чтобы restart policy - не превращала успешный one-shot exit в download loop/rate limit; -- унаследованный healthcheck, проверяющий отсутствующий в updater-контейнере - daemon, отключается; updater контролируется по `Up`, restart count, логам и - возрасту сигнатур; -- security policy разрешает настроить порог возраста не выше `720` часов - (30 дней); production-like seed использует более строгие `240` часов. +- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`; + TCP listener и Docker socket запрещены; +- socket доступен только выделенной группе worker; owner/group/mode + проверяются после каждого restart/reboot; +- worker не получает `kesl-control`, shell или произвольный host path; +- broker принимает bounded request, создаёт контролируемый временный файл, + вызывает только `kesl-control --scan-file --action Inform` и гарантированно + очищает временные данные; +- только однозначный `clean` допускает allow; `infected` даёт deny; timeout, + stale database, неизвестный output/exit code и недоступность scanner дают + retry/`503`; +- KESL обновляет database ежечасно; update egress принадлежит host KESL и + ограничен approved sources; +- policy допускает `max_signature_age_hours` не выше `720`, seed — `240`. + +Формат `kesl-control`, socket permissions, cleanup и throughput этой custom +integration подтверждаются gates на target VM2 с фактическим KESL 12.4. Ошибки `read-only file system` устраняются точечным writable mount. Запрещено лечить их глобальным `read_only: false`, root, `privileged` или broad @@ -638,8 +643,8 @@ Fail2ban обязателен для SSH, временно или постоян содержимому, а не только по декларации Compose; - runtime/migration/config-admin DB roles разделены, временные cross-schema grants выданы и отозваны владельцем; -- healthcheck проверяет процесс, реально присутствующий в контейнере, а - updater freshness контролируется отдельным сигналом; +- healthcheck проверяет процесс, реально присутствующий в контейнере, а KESL + broker/database freshness контролируется отдельным host-сигналом; - healthcheck-команда и все её binaries подтверждены внутри exact pinned digest; отсутствие `curl`/`wget` не обнаруживается впервые в production; - внутренние ports недоступны извне; diff --git a/architectory/arch-10-deployment.md b/architectory/arch-10-deployment.md index aa8fead..f9b13a8 100644 --- a/architectory/arch-10-deployment.md +++ b/architectory/arch-10-deployment.md @@ -39,7 +39,7 @@ - **Safety Service Owner**: API/data contract, capacity result и v2 cutover/rollback sign-off. - **Rule Pack Owner**: rules bundle, corpus, monitor report и version release. - **Product Owner**: mnemonic `safety.chat.blocked` и business acceptance chat flow. -- **Operations Owner**: VM2 alerts, ClamAV signatures, incident/reprovision/restore rehearsal. +- **Operations Owner**: VM2 alerts, host KESL/broker и database updates, incident/reprovision/restore rehearsal. ```text например chat.example.ru @@ -93,7 +93,7 @@ Sizing и load gates конкретной машины — профильный | ВМ2 collector | private SigNoz | TCP 4317 | allow | | ВМ2 workers | S3 endpoints | TCP 443 | allow | | ВМ2 `bitrix-sync` | approved Bitrix portal | TCP 443 | allow | -| ВМ2 `freshclam` | approved signature CDN | TCP 443/80 по vendor manifest | allow | +| host KESL ВМ2 | approved update sources по operator KESL runbook | vendor-required destinations/ports | allow | | ВМ2 | trusted DNS/NTP | UDP/TCP 53, UDP 123 | allow | | internet | managed PG | any | deny | | internet | ВМ2 | any кроме nginx 80/443 | deny ingress | @@ -237,11 +237,20 @@ Host ВМ1 — ``, ВМ2 — ``; private `8 ## 10. Сквозной порядок startup и cutover -1. На ВМ2 unit поднимает Redis Safety и local Collector. -2. Затем `clamd`/`freshclam`, Safety API/worker и `bitrix-sync`. -3. Последним на ВМ2 — nginx public `80/443` и private `8443`. -4. На ВМ1 — Redis/Collector, API, SMS, Keycloak, local app, edge nginx. -5. Только private `MESSAGE_SAFETY_URL` ВМ1 переключается на ВМ2 после Safety gates. Public CRM webhook DNS/routes ВМ2 не требуют изменения ВМ1. +1. На ВМ2 оператор выполняет KESL runbook: KESL 12.4 standalone, database update/canary. +2. Затем enable/start root-owned broker socket/service и проверяет status/permissions `/run/han-kesl/scan.sock`. +3. Unit ВМ2 поднимает Redis Safety и local Collector, затем Safety API/worker и `bitrix-sync`. +4. Последним на ВМ2 — nginx public `80/443` и private `8443`. +5. На ВМ1 — Redis/Collector, API, SMS, Keycloak, local app, edge nginx. +6. Только private `MESSAGE_SAFETY_URL` ВМ1 переключается на ВМ2 после Safety gates. Public CRM webhook DNS/routes ВМ2 не требуют изменения ВМ1. + +Broker — custom integration: до cutover target VM2 обязана подтвердить точный +формат/exit semantics `kesl-control --scan-file --action Inform`, socket +permissions/cleanup и throughput. Только `clean` допускает allow; `infected` +даёт deny; scanner error или stale database остаются retryable и завершаются +`503`, не allow. Runtime фиксирует `scanner_engine=kesl` и +`signatures_version=hash(KESL version + database date)`; KESL update выполняется +ежечасно. Legacy single-VM `docker compose up` не является evidence готовности target ВМ2. Один `message_id` нельзя одновременно отправлять в v1 и v2. После cutover ВМ1 не содержит local Safety/Redis DB2. diff --git a/support¬es/deploy_stories/260904_1.md b/support¬es/deploy_stories/260904_1.md index 3ee2fcb..3319a44 100644 --- a/support¬es/deploy_stories/260904_1.md +++ b/support¬es/deploy_stories/260904_1.md @@ -1,10 +1,10 @@ Логирование операций 1. env: - +editor /etc/han/vm1.env изменение Release_Version + две переменные: -HOST_NAME=devhanapp \ prodhanapp.ihan.ru. +HOST_NAME=prodhanapp.ihan.ru \devhanapp \ TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:9461e2c7213219467e5f775ea8652d15c990afb4d1ca94e62344f2025a7587d1 2. Обновить образы: @@ -25,7 +25,7 @@ tar --exclude=backend/.env ` -czf "vm1-backend-$Release.tar.gz" ` -C C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/codebase backend Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256 -scp "vm1-backend-$Release.tar.gz" devVM1Deploy:/var/lib/han-deploy/incoming/ +scp "vm1-backend-$Release.tar.gz" prodVM1Deploy:/var/lib/han-deploy/incoming/ ``` RELEASE='1.0.1' @@ -91,14 +91,14 @@ sudo apt purge -y dos2unix scp ` - C:/Users/MI/.ssh/devDeploy.pub ` - C:/Users/MI/.ssh/devAdmin.pub ` -devVM1Deploy:/var/lib/han-deploy/incoming/ + C:/Users/MI/.ssh/prodDeploy.pub ` + C:/Users/MI/.ssh/prodAdmin.pub ` +prodVM1Deploy:/var/lib/han-deploy/incoming/ -install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devDeploy.pub /root/bootstrap/deploy.pub -install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devAdmin.pub /root/bootstrap/admin.pub +install -m 0600 -o root -g root /var/lib/han-deploy/incoming/prodDeploy.pub /root/bootstrap/deploy.pub +install -m 0600 -o root -g root /var/lib/han-deploy/incoming/prodAdmin.pub /root/bootstrap/admin.pub -rm -f /var/lib/han-deploy/incoming/devDeploy.pub /var/lib/han-deploy/incoming/devAdmin.pub +rm -f /var/lib/han-deploy/incoming/prodDeploy.pub /var/lib/han-deploy/incoming/prodAdmin.pub cd /opt/han-chat/current/backend @@ -109,6 +109,12 @@ ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ HARDEN_SSH=true SKIP_APT_UPGRADE=true \ /opt/han-chat/current/backend/deployment/scripts/setup-vm.sh +export OTEL_REMOTE_ENDPOINT="$(awk -F= '$1=="OTEL_REMOTE_ENDPOINT"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)" +export OTEL_REMOTE_TLS_INSECURE="$(awk -F= '$1=="OTEL_REMOTE_TLS_INSECURE"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)" +export HOST_NAME="$(awk -F= '$1=="HOST_NAME"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)" +export APP_ENV="$(awk -F= '$1=="APP_ENV"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)" +export RELEASE_VERSION="$(awk -F= '$1=="RELEASE_VERSION"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)" +printf '%s\n' "$OTEL_REMOTE_ENDPOINT" /usr/local/bin/otelcol-contrib validate \ --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml @@ -116,12 +122,16 @@ chmod 0755 /opt/han-chat/current/backend/scripts/validate-env /opt/han-chat/current/backend/deployment/preflight.sh systemctl restart han-stack@production.service -если ошибка systemctl status han-stack@production.service +если ошибка +journalctl -u han-stack@production.service --no-pager -n 80 --output=cat +### !!! (образы могут устареть в будущем) docker pull cr.selcloud.ru/han-images/nginx@sha256:2cd7d32967b400c15bb6aaa18e97e99d429b2b6f2bfd54b965fc7d42a91a228e docker pull cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898 systemctl start han-host-otel-collector@production.service +rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub -Canary test: CONFIG_FILE=/etc/han/vm1.env ./deployment/scripts/verify-observability.sh +Canary test: +chmod 0755 /opt/han-chat/current/backend/deployment/scripts/verify-observability.sh +CONFIG_FILE=/etc/han/vm1.env ./deployment/scripts/verify-observability.sh -rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub \ No newline at end of file diff --git a/support¬es/deploy_stories/260907_1.md b/support¬es/deploy_stories/260907_1.md new file mode 100644 index 0000000..f8e1dfe --- /dev/null +++ b/support¬es/deploy_stories/260907_1.md @@ -0,0 +1,647 @@ +# Ставим KES на ВМ1 + + + + +# Ставим KES на ВМ2 + +Готовим новый образ Message Safety +MESSAGE_SAFETY_IMAGE=cr.selcloud.ru/han-images/han-message-safety@sha256:225808bd111be03112ab6693b5a10e3d97ae8cdbe5370a5d0aa5a8e2278a3044 + +Копируем проект +```powershell +$Release = "1.0.1" +tar --exclude=services/.env ` + --exclude='services/**/__pycache__' ` + --exclude='services/**/.pytest_cache' ` + --exclude='services/**/.ruff_cache' ` + -czf "vm2-services-$Release.tar.gz" -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM2_services\codebase services +Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256 +scp "vm2-services-$Release.tar.gz" prodVM2Deploy:/var/lib/han-deploy/incoming/ + + +scp "vm2-services-$Release.tar.gz" devVM2Deploy:/var/lib/han-deploy/incoming/ + +``` + +Проверить размер диска + +RELEASE='1.0.1' +EXPECTED_SHA256='1030DE6A50BA945FAEC5A45E1D42C47A01808E737AA1955871F3D2ED089F8879' +ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz" +printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - +tar -tvzf "$ARCHIVE" +if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then + echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2 + exit 1 +fi +if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then + echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2 + exit 1 +fi +if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then + echo 'ОШИБКА: архив содержит symlink или hardlink' >&2 + exit 1 +fi +install -d -m 0755 -o root -g root /opt/han-chat/services +STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)" +tar --extract --gzip --file "$ARCHIVE" \ + --directory "$STAGING" --no-same-owner --no-same-permissions +test -f "$STAGING/services/docker-compose.yml" +rsync -a --delete --exclude=.env \ + --chown=root:root --chmod=D755,F644 \ + "$STAGING/services/" /opt/han-chat/services/ +rm -rf -- "$STAGING" +chmod 0755 /opt/han-chat/services/deployment/preflight.sh + + +cd /opt/han-chat/services/ +sudo apt update +sudo apt install -y dos2unix +find . -type f \( \ + -name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' \ +\) -exec dos2unix {} + +find -type f -exec file {} \; | grep -i 'CRLF' + +sudo apt remove -y dos2unix +sudo apt purge -y dos2unix + +Копируем образ на ВМ: +scp "C:\Users\MI\Documents\job\HAN_new_life\HANapp\docs\kes 12.4\049-16-d-01.iso" "C:\Users\MI\Documents\job\HAN_new_life\HANapp\docs\kes 12.4\049-16-d-01.sig" prodVM2Deploy:/var/lib/han-deploy/incoming/ + +Проверка файла: +apt install -y rhash +ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso' +command -v rhash +rhash --gost "$ISO" + +Должно быть 6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3 + +sudo apt remove -y rhash +sudo apt purge -y rhash + +Если совпадёт, извлеките DEB: +```sh +install -d -m 0700 -o root -g root \ + /root/kesl-install /mnt/kesl-iso + +mount -o ro,loop "$ISO" /mnt/kesl-iso + +find /mnt/kesl-iso -type f \ + \( -iname 'kesl_*_amd64.deb' -o -iname '*.deb' \) | + sort +``` + +Затем: +```sh +DEB='/mnt/kesl-iso/kesl/kesl_12.4.0-1225_amd64.deb' + +dpkg-deb -f "$DEB" Package Version Architecture +sha256sum "$DEB" + +install -m 0600 -o root -g root \ + "$DEB" /root/kesl-install/kesl.deb + +umount /mnt/kesl-iso +rmdir /mnt/kesl-iso +``` + +Ожидаемые свойства: kesl, версия 12.4.x, архитектура amd64. +После этого можно выполнять: +apt-get install /root/kesl-install/kesl.deb + + +Далее +```sh +install -m 0600 -o root -g root /dev/null /root/kesl-install/autoinstall.ini + +cat > /root/kesl-install/autoinstall.ini <<'EOF' +KSVLA_MODE=No +ENDPOINT_AGENT_MODE=No +EULA_AGREED=Yes +PRIVACY_POLICY_AGREED=Yes +USE_KSN=No +GROUP_CLEAN=Yes +LOCALE=ru_RU.UTF-8 +INSTALL_LICENSE=None +UPDATER_SOURCE=KLServers +UPDATE_EXECUTE=No +KERNEL_SRCS_INSTALL=No +USE_GUI=No +CONFIGURE_SELINUX=No +DISABLE_PROTECTION=Yes +INTERCEPTOR_MODE=UseFanotify +ENABLE_TRACES_ON_FIRST_STARTUP=No +EOF + +chmod 0600 /root/kesl-install/autoinstall.ini +stat -c '%U:%G:%a %n' /root/kesl-install/autoinstall.ini + +/opt/kaspersky/kesl/bin/kesl-setup.pl \ + --autoinstall=/root/kesl-install/autoinstall.ini +echo "kesl-setup exit=$?" + +systemctl --no-pager status kesl +kesl-control --app-info --json +kesl-control --get-task-list +``` + + +```sh +set +o history +unset HISTFILE +read -rsp 'KESL activation code: ' KESL_CODE; echo +kesl-control --add-active-key "$KESL_CODE" +unset KESL_CODE +set -o history +kesl-control -L --query +``` + +Не стартуйте Update (task 6) и File Threat Protection (task 1), пока не срежете дефолты памяти. На 8 ГБ `ScanMemoryLimit=8192` и `MaxMemory=auto` (до 50% RAM, минимум 2 ГБ) слишком много, тем более пока жив ClamAV. + +Ключ из `-L --query` в evidence/репозиторий не копируйте — достаточно статуса `Active subscription`. + +Дальше — секция 3 runbook. Кандидат для ВМ2: `ScanMemoryLimit=1024`, `MaxMemory=2048MB`. CPU: `OnDemandCPULimit=15`, как на пилоте ВМ1, пока ClamAV не снят. + +**1. Снимок текущих настроек** + +```sh +kesl-control --get-app-settings \ + --file /root/kesl-install/app-settings.before.ini +install -m 0600 -o root -g root \ + /var/opt/kaspersky/kesl/common/kesl.ini \ + /root/kesl-install/kesl.ini.before +awk '/^\[General\]/,/^\[/' /var/opt/kaspersky/kesl/common/kesl.ini | head -n 40 +``` + +**2. Лимит CPU (KESL ещё работает)** + +```sh +kesl-control --set-app-settings \ + UseOnDemandCPULimit=Yes OnDemandCPULimit=15 +``` + +**3. Память — только при остановленном KESL, файл целиком не подменять** + +Сначала посмотрите `[General]` из шага 1. Если там уже есть `ScanMemoryLimit` и `MaxMemory`: + +```sh +systemctl stop kesl +systemctl is-active kesl + +grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini + +sed -i \ + -e 's/^ScanMemoryLimit=8192$/ScanMemoryLimit=1024/' \ + -e 's/^MaxMemory=auto$/MaxMemory=2048MB/' \ + /var/opt/kaspersky/kesl/common/kesl.ini + +grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini + +systemctl start kesl +systemctl is-active kesl +kesl-control --get-app-settings | grep -iE 'UseOnDemandCPULimit|OnDemandCPULimit' +``` + +**Ожидаемо после рестарта** + +- `kesl.service` = `active` +- в settings: `ScanMemoryLimit=1024`, `MaxMemory=2048MB`, `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15` +- task 1 и 6 по-прежнему `Stopped` +- базы всё ещё не загружены + +После этого — загрузка баз: +`kesl-control --start-task 6 -W` + +```sh +kesl-control --get-task-state 6 +kesl-control --app-info --json +START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')" +kesl-control --set-schedule 6 RuleType=Hourly \ + "StartTime=${START}" RunMissedStartRules=No RandomInterval=0 +kesl-control --get-schedule 6 +``` + +Обязательный следующий шаг — рестарт KESL. В --app-info есть: + +Post-update actions: The application module has been updated. Restart the application. + +Task 1 до рестарта не запускайте. +```sh +systemctl restart kesl +systemctl is-active kesl +kesl-control --app-info --json +grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini +kesl-control --get-app-settings | grep -iE 'UseOnDemandCPULimit|OnDemandCPULimit' +kesl-control --get-schedule 6 +kesl-control --get-task-state 1 +kesl-control --get-task-state 6 +``` + +```sh +kesl-control --get-settings 1 \ + --file /root/kesl-install/file-threat.before.ini +kesl-control --set-settings 1 \ + --add-exclusion /var/lib/docker/volumes/han-processing_redis-safety-data/_data +kesl-control --set-settings 1 \ + --add-exclusion /var/lib/docker/volumes/han-processing_otel-queue/_data +kesl-control --start-task 1 +kesl-control --set-settings 1 \ + ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No +kesl-control --get-task-state 1 +kesl-control --get-settings 1 +``` + +Сразу снимите coexistence, пока OAS прогревается: +```sh +date -Is +free -h +swapon --show +vmstat 1 5 +systemctl is-active kesl docker han-processing.service +kesl-control --app-info --json | grep -E 'File Threat|databases loaded|license' +/usr/local/sbin/han-vm2-compose ps +docker stats --no-stream +``` + +Установка брокера +```sh +set -Eeuo pipefail + +BASE=/opt/han-chat/services/deployment/kesl + +for file in \ + kesl_scan_broker.py \ + han-kesl-scan-broker.service \ + han-kesl-scan-broker.socket \ + han-kesl-scan-broker.tmpfiles.conf +do + test -f "$BASE/$file" + test ! -L "$BASE/$file" +done + +if getent group han-message-safety >/dev/null; then + test "$(getent group han-message-safety | cut -d: -f3)" = 10001 +else + ! getent group 10001 >/dev/null + groupadd --system --gid 10001 han-message-safety +fi + +install -d -m 0755 -o root -g root /usr/local/libexec + +install -m 0750 -o root -g root \ + "$BASE/kesl_scan_broker.py" \ + /usr/local/libexec/han-kesl-scan-broker + +install -m 0644 -o root -g root \ + "$BASE/han-kesl-scan-broker.service" \ + /etc/systemd/system/han-kesl-scan-broker.service + +install -m 0644 -o root -g root \ + "$BASE/han-kesl-scan-broker.socket" \ + /etc/systemd/system/han-kesl-scan-broker.socket + +install -m 0644 -o root -g root \ + "$BASE/han-kesl-scan-broker.tmpfiles.conf" \ + /etc/tmpfiles.d/han-kesl-scan-broker.conf + +systemd-tmpfiles --create \ + /etc/tmpfiles.d/han-kesl-scan-broker.conf + +systemctl daemon-reload + +systemd-analyze verify \ + /etc/systemd/system/han-kesl-scan-broker.service \ + /etc/systemd/system/han-kesl-scan-broker.socket +``` + +Если systemd-analyze verify завершился без ошибок, запуск: +```sh +systemctl enable --now han-kesl-scan-broker.socket + +systemctl is-active kesl han-kesl-scan-broker.socket +stat -c '%U:%G:%a %n' /run/han-kesl /run/han-kesl/scan.sock +/usr/local/libexec/han-kesl-scan-broker --probe +``` + +Ожидаемые права: +/run/han-kesl root:han-message-safety:750 +/run/han-kesl/scan.sock root:han-message-safety:660 + +Создаём папку: + +```sh +install -d -m 0700 -o root -g han-message-safety \ + /var/lib/han-kesl-scan \ + /var/lib/han-kesl-scan/staging +``` + +Проверка и запуск socket +``` +systemd-analyze verify \ + /etc/systemd/system/han-kesl-scan-broker.service \ + /etc/systemd/system/han-kesl-scan-broker.socket +systemctl enable --now han-kesl-scan-broker.socket +systemctl is-active kesl han-kesl-scan-broker.socket +stat -c '%U:%G:%a %n' /run/han-kesl /run/han-kesl/scan.sock +/usr/local/libexec/han-kesl-scan-broker --probe +journalctl --since '-10 min' --no-pager -u han-kesl-scan-broker.service +``` + +Обновить .env +editor /opt/han-chat/services/.env +MESSAGE_SAFETY_IMAGE=cr.selcloud.ru/han-images/han-message-safety@sha256:225808bd111be03112ab6693b5a10e3d97ae8cdbe5370a5d0aa5a8e2278a3044 +Удалить +CLAMAV_IMAGE +MESSAGE_SAFETY_CLAMAV_HOST +MESSAGE_SAFETY_CLAMAV_PORT +Добавить +MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock + +Активировать config schema 2 +NEXT_VERSION='2' + +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + validate /app/app/artifacts/seed-config.yaml + +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + create /app/app/artifacts/seed-config.yaml \ + --version "$NEXT_VERSION" --actor '' + +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + activate --version "$NEXT_VERSION" --approved-by '' + +Вернуть затертые IP +editor /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf +allow 195.208.184.200; +allow 89.208.230.2; +allow 195.208.184.34; +allow 89.208.228.219; +deny all; + +editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf +allow 10.0.0.11; +deny all; + +Переключить только Message Safety +cd /opt/han-chat/services/ + +deployment/preflight.sh +/usr/local/sbin/han-vm2-compose config --quiet + +/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \ + message-safety-api message-safety-worker + +/usr/local/sbin/han-vm2-compose ps +/usr/local/sbin/han-vm2-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" + +Тестирование: + +Секция 7 — не один скрипт, а **пять независимых gates**. EICAR только после письменного Security; образец не класть в git, chat и evidence — брать с [eicar.org](https://www.eicar.org). Пункты 4–5 и cutover **нельзя** закрыть текущим образом Message Safety (он ещё на ClamAV): сначала 1–3 на хосте. + +Все команды на `devhanservice` — под **root**. `han-processing.service` не restart/stop. + +--- + +### 0. Перед тестами + +```sh +kesl-control --get-task-state 1 +kesl-control --app-info --json | grep -E 'File Threat|databases loaded|license' +systemctl is-active kesl han-kesl-scan-broker.socket +test -S /run/han-kesl/scan.sock +stat -c '%U:%G:%a %n' /run/han-kesl/scan.sock +/usr/local/libexec/han-kesl-scan-broker --probe +``` + +Нужно: task 1 `Started`, базы `Yes`, socket `root:han-message-safety:660`, probe `"status":"ready"`. Если sock нет — `systemctl restart han-kesl-scan-broker.socket`, не `.service`. + +--- + +### 1. Host OAS (АВЗ.1) + +Каталог **не** в docker volume, `/tmp`, release, staging: + +```sh +install -d -m 0700 -o root -g root /root/kesl-eicar-test +date -Is +kesl-control --get-task-state 1 +``` + +Положите официальный `eicar.com` в `/root/kesl-eicar-test/eicar.com`: +curl -fsSL -o /root/kesl-eicar-test/eicar.com \ + 'https://secure.eicar.org/eicar.com' +chmod 0600 /root/kesl-eicar-test/eicar.com + +```sh +cat /root/kesl-eicar-test/eicar.com >/dev/null +ls -l /root/kesl-eicar-test +kesl-control -E --query -n 20 --reverse +``` + +Ожидаемо: объект удалён/в Backup, в журнале `EICAR-Test-File` / `ThreatDetected`. Если OAS на запись молчит (как на пилоте ВМ1) — явный ODS: + +```sh +kesl-control --scan-file /root/kesl-eicar-test/eicar.com +kesl-control -E --query -n 20 --reverse +``` + +Уборка (не весь Backup): + +```sh +kesl-control -B --query --reverse -n 20 +kesl-control -B --mass-remove --query "DetectName == 'EICAR-Test-File'" +rm -rf /root/kesl-eicar-test +``` + +--- + +### 2. Broker: clean / EICAR / KESL down + +install -d -m 0700 -o root -g root /root/kesl-install +cat > /root/kesl-install/broker-client.py <<'PY' +#!/usr/bin/env python3 +import asyncio, json, struct, sys + +SOCK = "/run/han-kesl/scan.sock" + +async def call(op: str, body: bytes = b"") -> dict: + r, w = await asyncio.open_unix_connection(SOCK) + header = {"op": op} + if op == "scan": + header["size"] = len(body) + raw = json.dumps(header, separators=(",", ":")).encode() + w.write(struct.pack(">I", len(raw)) + raw + body) + await w.drain() + n = struct.unpack(">I", await r.readexactly(4))[0] + resp = json.loads(await r.readexactly(n)) + w.close() + await w.wait_closed() + return resp + +async def main() -> None: + op = sys.argv[1] + body = sys.stdin.buffer.read() if op == "scan" else b"" + print(json.dumps(await call(op, body), ensure_ascii=True, sort_keys=True)) + +if __name__ == "__main__": + if sys.argv[1:] not in (["status"], ["scan"]): + raise SystemExit("usage: broker-client.py status|scan") + asyncio.run(main()) +PY +chmod 0700 /root/kesl-install/broker-client.py + +Проверка позитивного решения +python3 /root/kesl-install/broker-client.py status +printf 'HAN kesl broker clean canary\n' | python3 /root/kesl-install/broker-client.py scan + +Проверка негативного решения +curl -fsSL 'https://secure.eicar.org/eicar.com' | + python3 /root/kesl-install/broker-client.py scan + +Проверка недоступного сканнера +systemctl stop kesl +systemctl is-active kesl || true +python3 /root/kesl-install/broker-client.py status +printf 'error canary\n' | + python3 /root/kesl-install/broker-client.py scan + +Оба запроса должны вернуть scanner_unavailable. +Затем восстановить: +systemctl start kesl +systemctl is-active kesl +kesl-control --start-task 1 +kesl-control --get-task-state 1 +systemctl restart han-kesl-scan-broker.socket +/usr/local/libexec/han-kesl-scan-broker --probe + +--- + +### 3. 5 слотов и ~2 файла/с + +На 4 ГБ DEV это нагрузка: смотрите `free -h` / `vmstat`. Откат OAS: `kesl-control --stop-task 1`. + +```sh +python3 - <<'PY' +import asyncio, json, struct, time, statistics +SOCK = "/run/han-kesl/scan.sock" +BODY = b"HAN kesl throughput canary\n" + +async def scan(): + r, w = await asyncio.open_unix_connection(SOCK) + h = json.dumps({"op": "scan", "size": len(BODY)}, separators=(",", ":")).encode() + t0 = time.perf_counter() + w.write(struct.pack(">I", len(h)) + h + BODY) + await w.drain() + n = struct.unpack(">I", await r.readexactly(4))[0] + resp = json.loads(await r.readexactly(n)) + dt = time.perf_counter() - t0 + w.close(); await w.wait_closed() + return dt, resp + +async def main(): + burst = await asyncio.gather(*[scan() for _ in range(5)]) + print("burst5", [(round(dt, 3), r.get("verdict") or r.get("error")) for dt, r in burst]) + times = [] + for _ in range(20): + dt, r = await scan() + assert r.get("verdict") == "clean", r + times.append(dt) + await asyncio.sleep(0.5) # ~2 file/s + times.sort() + p95 = times[int(0.95 * (len(times) - 1))] + print(f"n={len(times)} p50={times[len(times)//2]:.3f}s p95={p95:.3f}s max={times[-1]:.3f}s") + +asyncio.run(main()) +PY +``` + +Gate: все `clean`, p95 ≤ 60 с. Queue age p95 ≤ 5 с — из метрик Safety (после пункта 4), не из этого скрипта. + +--- + +### 4. Message Safety 200 / 403 / 503 + +Сейчас контейнеры ещё на ClamAV. Пункт 4 — **после** recreate `message-safety-api`/`worker` на образ с bind `/run/han-kesl` (digest из окна 1.0.1). Clamd пока можно не снимать. + +Проверка, что API видит KESL: + +```sh +/usr/local/sbin/han-vm2-compose exec -T message-safety-api \ + python -c 'import json,urllib.request; print(json.dumps(json.load(urllib.request.urlopen("http://127.0.0.1:8080/health/ready")), indent=2))' +``` + +`antivirus=ok`, `files=ready` (не mock). + +- **clean `200`** — text canary с ВМ1, как в `RUNBOOK.ru.md` (`/internal/safety/v2/messages/check`, `content_kind=text`). +- **malware `403` + sticky** — file через **реальный** versioned object в quarantine S3 (выдуманный key нельзя; EICAR в bucket только если Security разрешил). Повтор того же `message_id`+body — тот же `403`. +- **scanner error `503`** — `systemctl stop kesl`, file-check, ожидание `503` / files unavailable; затем start kesl + restart **socket** + `--start-task 1`. + +Пока worker ходит в clamd, это **не** gate KESL. + +--- + +### 5. Smoke, firewall, 24 ч + +```sh +iptables -S HAN-CHAT-VM2 +ufw status verbose +/usr/local/sbin/han-vm2-compose ps +free -h +swapon --show +kesl-control --get-task-state 6 +kesl-control --get-schedule 6 +``` + +С ВМ1: `openssl s_client` + `curl` `/internal/safety/status` из `RUNBOOK.ru.md`. Сравните `HAN-CHAT-VM2` с baseline. 24 ч: без OOM/restart/5xx, hourly Update сам зелёный. + +curl --fail --silent --show-error \ + --cacert /etc/han/ca/vm2-internal-ca.crt \ + https://processing.internal:8443/internal/safety/status + + https://prodhanservice.ihan.ru:8443/internal/safety/status +--- + +### Cutover (только после 1–5) + +Новая **монотонная** config version (не реактивировать старую): + +```sh +cd /opt/han-chat/services/ +NEXT_VERSION='3' +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + create /app/app/artifacts/seed-config.yaml \ + --version "$NEXT_VERSION" --actor '' +/usr/local/sbin/han-vm2-compose --profile ops run --rm \ + --entrypoint message-safety-config message-safety-migrate \ + activate --version "$NEXT_VERSION" --approved-by '' +/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \ + message-safety-api message-safety-worker +``` + +В `.env` / compose не должно остаться `clamd`/`freshclam`. Затем: + +```sh +/usr/local/sbin/han-vm2-compose up -d --remove-orphans +/usr/local/sbin/han-vm2-compose ps +docker ps -a --format '{{.Names}}' | grep -E 'clamd|freshclam' || echo 'no clamd/freshclam' +docker volume ls | grep -i clam || true +``` + +/usr/local/sbin/han-vm2-compose exec -T nginx nginx -t -c /etc/nginx/nginx.conf +NGINX_ID=$(/usr/local/sbin/han-vm2-compose ps --status running --quiet nginx) +docker kill --signal HUP "$NGINX_ID" + +/usr/local/sbin/han-vm2-compose exec -T nginx getent hosts message-safety-api +/usr/local/sbin/han-vm2-compose exec -T message-safety-api \ + python -c 'import json,urllib.request; print(json.dumps(json.load(urllib.request.urlopen("http://127.0.0.1:8080/health/ready")), indent=2))' + + + +Старый signature-CDN egress закрывается отдельно в firewall/SG — не через `compose down -v`. + +На этом DEV-хосте (3.8 ГиБ, swap 391 МиБ) пункт 3 и file-EICAR через Safety лучше не совмещать с чем-то ещё. Если нужно, могу выписать только gate 1+2 компактным блоком под копирование в `260907_1.md`. \ No newline at end of file diff --git a/support¬es/usefull ops/diagnostic_scripts.md b/support¬es/usefull ops/diagnostic_scripts.md index 5cbebe6..64c6f11 100644 --- a/support¬es/usefull ops/diagnostic_scripts.md +++ b/support¬es/usefull ops/diagnostic_scripts.md @@ -5,4 +5,9 @@ | grep -B10 -A40 'bfffb1c2-b938-4443-9c42-5a9b87fcf6b3' /usr/local/sbin/han-vm1-compose logs --since=30m api-backend \ - | grep -B5 -A20 'request.failed' \ No newline at end of file + | grep -B5 -A20 'request.failed' + +--по ошибке отправки файла на ВМ1: +RID=0c44e81c-f2e1-4018-9288-92a97a6d0443 +/usr/local/sbin/han-vm1-compose logs --no-color --since 30m nginx api-backend 2>&1 \ + | grep -F "$RID" \ No newline at end of file diff --git a/support¬es/usefull ops/Обновление clamav образа.md b/support¬es/usefull ops/Обновление clamav образа.md index 2e4aca3..0994a60 100644 --- a/support¬es/usefull ops/Обновление clamav образа.md +++ b/support¬es/usefull ops/Обновление clamav образа.md @@ -1,3 +1,7 @@ +> Архивная инструкция. После перехода ВМ2 на host KESL 12.4 и локальный +> scan-broker ClamAV удалён из целевого Compose. Для эксплуатации использовать +> `VM2_services/codebase/services/deployment/kesl/RUNBOOK.KESL.ru.md`. + ### 1. Получите digest локально В WSL: