Внедрение KESL на ВМ2 + замена CLAMAV на KESL
This commit is contained in:
@@ -960,7 +960,7 @@ Inbound operator file:
|
|||||||
- validate count/size/MIME and URL scheme/host policy;
|
- 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;
|
- 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;
|
- 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;
|
- upload directly to S3-data attachments;
|
||||||
- only then atomically save attachment/message and ack inbox.
|
- only then atomically save attachment/message and ack inbox.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
|
Документ задаёт, **что агент ВМ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.
|
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
|
|||||||
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
||||||
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
|
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
|
||||||
REDIS_IMAGE=redis@sha256:<reviewed-digest>
|
REDIS_IMAGE=redis@sha256:<reviewed-digest>
|
||||||
CLAMAV_IMAGE=clamav/clamav@sha256:<reviewed-digest>
|
|
||||||
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
|
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
|
||||||
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
|
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
|
||||||
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
|
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
|
||||||
@@ -20,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0
|
|||||||
MESSAGE_SAFETY_PORT=8080
|
MESSAGE_SAFETY_PORT=8080
|
||||||
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
||||||
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
|
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
|
||||||
MESSAGE_SAFETY_CLAMAV_HOST=clamd
|
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
|
||||||
MESSAGE_SAFETY_CLAMAV_PORT=3310
|
|
||||||
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
|
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
|
||||||
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
|
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
role. Migration credentials are mounted only into the `ops` profile jobs.
|
||||||
9. The setup script leaves UFW egress open for bootstrap. Before production,
|
9. The setup script leaves UFW egress open for bootstrap. Before production,
|
||||||
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
|
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.
|
Registry/package access exists only during controlled maintenance windows.
|
||||||
|
|
||||||
## Who runs what
|
## Who runs what
|
||||||
@@ -78,8 +80,9 @@ Compose first:
|
|||||||
`bitrix_sync`, separate migration/runtime DSNs; see
|
`bitrix_sync`, separate migration/runtime DSNs; see
|
||||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||||
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
||||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
|
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
|
||||||
otel-collector, Redis exporter and nginx exporter).
|
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
|
4. **Selectel Secrets Manager** — populate all remote names from
|
||||||
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
||||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
|
`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
|
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
|
||||||
`han-processing.service` until Gate 5 completes successfully.
|
`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 |
|
| Gate | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
||||||
@@ -195,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
|
|||||||
briefly.
|
briefly.
|
||||||
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
||||||
timer, unit journals — without secret values.
|
timer, unit journals — without secret values.
|
||||||
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
|
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
|
||||||
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
|
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
|
5. Proceed to controlled Message Safety cutover on VM1 — see
|
||||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
||||||
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
|
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
|
## Failure policy
|
||||||
|
|
||||||
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
||||||
- Stale/unavailable ClamAV signatures disable file capability only; they never
|
- A stale/unavailable KESL database or broker error disables file capability
|
||||||
convert a scan error to allow.
|
only; scan errors remain retryable and eventually return `503`, never allow.
|
||||||
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
|
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
|
||||||
- OTEL outage queues within the bounded volume and must not change verdicts.
|
- OTEL outage queues within the bounded volume and must not change verdicts.
|
||||||
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
|
- 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
|
high-severity alert active until explicit `standard`, then verify normal
|
||||||
text/link/file capabilities and an EICAR canary.
|
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
|
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
|
||||||
digest. Do not weaken `read_only`, capabilities or mounts globally: document
|
images. The Message Safety worker receives only
|
||||||
the smallest writable signature/runtime paths and compensate with network and
|
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
|
||||||
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
|
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
|
||||||
receives none.
|
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).
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
|||||||
`ops`.
|
`ops`.
|
||||||
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
||||||
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
||||||
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
|
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
|
||||||
источников ClamAV. Registry/package repositories оставляйте только на
|
Для host KESL разрешите только источники обновления из
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
|
||||||
controlled maintenance window.
|
controlled maintenance window.
|
||||||
|
|
||||||
## Кто что выполняет
|
## Кто что выполняет
|
||||||
@@ -87,7 +88,8 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
|||||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||||
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
||||||
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
|
получить 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 из
|
4. **Selectel Secrets Manager** — заполнить все remote names из
|
||||||
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
||||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
|
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
|
||||||
@@ -526,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
|||||||
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
|
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
|
||||||
активировать старую версию нельзя. Alembic downgrade запрещён.
|
активировать старую версию нельзя. Alembic downgrade запрещён.
|
||||||
|
|
||||||
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
|
При обновлении KESL policy образ Message Safety должен содержать согласованные
|
||||||
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
|
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
|
||||||
(30 дней). После обновления immutable image digest создайте новую config
|
(30 дней). После обновления immutable image digest создайте новую config
|
||||||
version; существующую active version не редактируйте и не активируйте повторно:
|
version; существующую active version не редактируйте и не активируйте повторно:
|
||||||
@@ -548,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
|
|||||||
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
|
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
|
||||||
message-safety-api message-safety-worker
|
message-safety-api message-safety-worker
|
||||||
/usr/local/sbin/han-vm2-compose ps \
|
/usr/local/sbin/han-vm2-compose ps \
|
||||||
message-safety-api message-safety-worker clamd freshclam
|
message-safety-api message-safety-worker
|
||||||
unset NEXT_VERSION
|
unset NEXT_VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -588,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
|
|||||||
Под `root` на VM2:
|
Под `root` на VM2:
|
||||||
|
|
||||||
```sh
|
```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 redis-safety otel-collector
|
||||||
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
|
|
||||||
/usr/local/sbin/han-vm2-compose up -d \
|
/usr/local/sbin/han-vm2-compose up -d \
|
||||||
message-safety-api message-safety-worker
|
message-safety-api message-safety-worker
|
||||||
/usr/local/sbin/han-vm2-compose up -d \
|
/usr/local/sbin/han-vm2-compose up -d \
|
||||||
@@ -598,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
|
|||||||
/usr/local/sbin/han-vm2-compose ps
|
/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`. Он
|
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
|
||||||
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
|
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
|
||||||
сам Collector стартует только после этого.
|
сам Collector стартует только после этого.
|
||||||
@@ -892,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
|
|||||||
|
|
||||||
- container unhealthy/restart/OOM;
|
- container unhealthy/restart/OOM;
|
||||||
- срок TLS;
|
- срок TLS;
|
||||||
- возраст ClamAV signatures;
|
- KESL version/database date, результат ежечасного update и broker/socket status;
|
||||||
- OTEL queue/export errors;
|
- OTEL queue/export errors;
|
||||||
- disk/RAM;
|
- disk/RAM;
|
||||||
- активный MOCK mode;
|
- активный MOCK mode;
|
||||||
@@ -1107,8 +1123,9 @@ plan.
|
|||||||
|
|
||||||
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
||||||
контент.
|
контент.
|
||||||
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
|
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
|
||||||
capability; ошибка сканирования никогда не превращается в allow.
|
capability; ошибка сканирования никогда не превращается в allow и ведёт к
|
||||||
|
retry/`503`.
|
||||||
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
||||||
истины.
|
истины.
|
||||||
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
||||||
@@ -1134,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
|
|||||||
таймаута: держите high-severity alert активным до явного `standard`, затем
|
таймаута: держите high-severity alert активным до явного `standard`, затем
|
||||||
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
||||||
|
|
||||||
## Известные исключения по образам
|
## Host KESL и broker
|
||||||
|
|
||||||
Образы ClamAV могут потребовать корректировок UID/path после валидации
|
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
|
||||||
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
|
образами. Message Safety worker получает только Unix socket
|
||||||
задокументируйте минимальные writable пути для сигнатур/runtime и
|
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
|
||||||
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
|
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
|
||||||
получает только `freshclam`; `clamd` — нет.
|
hash KESL version + database date. Установка, ежечасное обновление, права
|
||||||
|
socket, clean/EICAR/error/stale gates и rollback выполняются строго по
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=HAN Processing VM2 root Compose stack
|
Description=HAN Processing VM2 root Compose stack
|
||||||
Requires=docker.service han-secrets-vm2.service
|
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
|
||||||
After=docker.service han-secrets-vm2.service network-online.target
|
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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=<Yes_AFTER_APPROVAL>
|
||||||
|
PRIVACY_POLICY_AGREED=<Yes_AFTER_APPROVAL>
|
||||||
|
USE_KSN=<Yes_OR_No_AFTER_APPROVAL>
|
||||||
|
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=<APPROVED_VALUE>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 <REDIS_SAFETY_DATA_MOUNTPOINT>
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d /run/han-kesl 0750 root han-message-safety -
|
||||||
@@ -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]")
|
||||||
@@ -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=<Yes_OR_No_AFTER_SECURITY_AND_LEGAL_APPROVAL>
|
||||||
|
|
||||||
|
[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=<APPROVED_VALUE>
|
||||||
|
|
||||||
|
[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=<REDIS_SAFETY_DATA_MOUNTPOINT>
|
||||||
|
item_0001=<OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -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())
|
||||||
@@ -21,6 +21,24 @@ fail() {
|
|||||||
fail "root-owned Message Safety mode file is missing; initialize standard mode"
|
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}' ||
|
/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"
|
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
|
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}' ||
|
/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) ;;
|
true|false) ;;
|
||||||
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
|
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
|
||||||
esac
|
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")
|
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}$' ||
|
echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' ||
|
||||||
fail "$image_key must be pinned by sha256 digest"
|
fail "$image_key must be pinned by sha256 digest"
|
||||||
@@ -224,7 +242,11 @@ fi
|
|||||||
for protected in \
|
for protected in \
|
||||||
"$ROOT/docker-compose.yml" \
|
"$ROOT/docker-compose.yml" \
|
||||||
"$ROOT/deployment/han-message-safety-mode" \
|
"$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
|
do
|
||||||
[ -f "$protected" ] || continue
|
[ -f "$protected" ] || continue
|
||||||
owner=$(/usr/bin/stat -c '%U:%G' "$protected")
|
owner=$(/usr/bin/stat -c '%U:%G' "$protected")
|
||||||
|
|||||||
@@ -603,6 +603,10 @@ install_release_helpers_if_possible() {
|
|||||||
install -m 0755 -o root -g root \
|
install -m 0755 -o root -g root \
|
||||||
"${deployment}/han-message-safety-mode" \
|
"${deployment}/han-message-safety-mode" \
|
||||||
/usr/local/sbin/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 -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
|
||||||
install -m 0755 -o root -g root \
|
install -m 0755 -o root -g root \
|
||||||
"${deployment}/scripts/ssl-renew-deploy-hook.sh" \
|
"${deployment}/scripts/ssl-renew-deploy-hook.sh" \
|
||||||
@@ -613,6 +617,16 @@ install_release_helpers_if_possible() {
|
|||||||
install -m 0644 -o root -g root \
|
install -m 0644 -o root -g root \
|
||||||
"${deployment}/han-processing.service" \
|
"${deployment}/han-processing.service" \
|
||||||
/etc/systemd/system/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)"
|
safety_sudoers="$(mktemp)"
|
||||||
sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers"
|
sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers"
|
||||||
chmod 0440 "$safety_sudoers"
|
chmod 0440 "$safety_sudoers"
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ x-message-safety-environment: &message-safety-environment
|
|||||||
MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080}
|
MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080}
|
||||||
MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5}
|
MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5}
|
||||||
MESSAGE_SAFETY_DNS_RESOLVERS: ${MESSAGE_SAFETY_DNS_RESOLVERS:?set trusted DNS resolvers}
|
MESSAGE_SAFETY_DNS_RESOLVERS: ${MESSAGE_SAFETY_DNS_RESOLVERS:?set trusted DNS resolvers}
|
||||||
MESSAGE_SAFETY_CLAMAV_HOST: ${MESSAGE_SAFETY_CLAMAV_HOST:-clamd}
|
MESSAGE_SAFETY_ANTIVIRUS_SOCKET: /run/han-kesl/scan.sock
|
||||||
MESSAGE_SAFETY_CLAMAV_PORT: ${MESSAGE_SAFETY_CLAMAV_PORT:-3310}
|
|
||||||
MESSAGE_SAFETY_ARTIFACTS_DIR: ${MESSAGE_SAFETY_ARTIFACTS_DIR:-/app/app/artifacts}
|
MESSAGE_SAFETY_ARTIFACTS_DIR: ${MESSAGE_SAFETY_ARTIFACTS_DIR:-/app/app/artifacts}
|
||||||
MESSAGE_SAFETY_MODE_FILE: /etc/han-chat/message-safety-mode.env
|
MESSAGE_SAFETY_MODE_FILE: /etc/han-chat/message-safety-mode.env
|
||||||
PG_CA_FILE: /run/config/postgresql-ca.pem
|
PG_CA_FILE: /run/config/postgresql-ca.pem
|
||||||
@@ -169,49 +168,6 @@ services:
|
|||||||
mem_limit: 64m
|
mem_limit: 64m
|
||||||
cpus: 0.25
|
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:
|
otel-queue-init:
|
||||||
<<: *hardening
|
<<: *hardening
|
||||||
image: ${REDIS_IMAGE:?set immutable Redis image digest}
|
image: ${REDIS_IMAGE:?set immutable Redis image digest}
|
||||||
@@ -270,6 +226,7 @@ services:
|
|||||||
MESSAGE_SAFETY_PROCESS_ROLE: api
|
MESSAGE_SAFETY_PROCESS_ROLE: api
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
|
- /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
|
- *postgres-ca-volume
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||||
@@ -306,6 +263,7 @@ services:
|
|||||||
MESSAGE_SAFETY_PROCESS_ROLE: worker
|
MESSAGE_SAFETY_PROCESS_ROLE: worker
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
|
- /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
|
- *postgres-ca-volume
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:rw,noexec,nosuid,nodev,size=256m
|
- /tmp:rw,noexec,nosuid,nodev,size=256m
|
||||||
@@ -322,8 +280,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis-safety:
|
redis-safety:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
clamd:
|
|
||||||
condition: service_healthy
|
|
||||||
pids_limit: 400
|
pids_limit: 400
|
||||||
mem_limit: 2g
|
mem_limit: 2g
|
||||||
cpus: 2.0
|
cpus: 2.0
|
||||||
@@ -465,13 +421,10 @@ networks:
|
|||||||
internal: true
|
internal: true
|
||||||
safety-egress:
|
safety-egress:
|
||||||
bitrix-egress:
|
bitrix-egress:
|
||||||
signature-egress:
|
|
||||||
telemetry-egress:
|
telemetry-egress:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-safety-data:
|
redis-safety-data:
|
||||||
clamav-signatures:
|
|
||||||
clamav-runtime:
|
|
||||||
otel-queue:
|
otel-queue:
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -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
|
`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,
|
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
|
and mounts only service-specific secret files plus the broker Unix socket
|
||||||
root-owned emergency mode file.
|
`/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
|
## External release gates
|
||||||
|
|
||||||
@@ -41,8 +49,10 @@ target environment verifies them:
|
|||||||
- Selectel S3 supports version-specific `GetObject`, signed conditional ETag behavior, bucket
|
- Selectel S3 supports version-specific `GetObject`, signed conditional ETag behavior, bucket
|
||||||
versioning, checksum metadata, virtual-host addressing and a read-only IAM policy without
|
versioning, checksum metadata, virtual-host addressing and a read-only IAM policy without
|
||||||
list/write/delete.
|
list/write/delete.
|
||||||
- ClamAV engine/signature metadata is supplied to readiness and task cache keys; freshclam
|
- KESL version/database date is supplied through the broker to readiness and task cache keys;
|
||||||
activate/reload, signature-age alarms and clean/EICAR/malformed corpora pass on VM2.
|
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
|
- 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
|
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.
|
CPU/memory/wall-time isolation must be enforced by the worker container and target runtime.
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/health/ready")
|
@app.get("/health/ready")
|
||||||
async def ready() -> JSONResponse:
|
async def ready() -> JSONResponse:
|
||||||
|
await service.refresh_antivirus()
|
||||||
mode = "mock" if service.mode.mock else "standard"
|
mode = "mock" if service.mode.mock else "standard"
|
||||||
components = {
|
components = {
|
||||||
"postgres": "ok",
|
"postgres": "ok",
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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": {
|
"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}$"},
|
"rules_bundle_ref": {"type": "string", "pattern": "^rules-[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
|
||||||
"detector_manifest_ref": {"const": "detector-2026-08-03"},
|
"detector_manifest_ref": {"const": "detector-2026-08-03"},
|
||||||
"task": {
|
"task": {
|
||||||
@@ -48,9 +48,13 @@
|
|||||||
"pipeline_timeout_sec": {"type": "number", "exclusiveMinimum": 0, "maximum": 5}
|
"pipeline_timeout_sec": {"type": "number", "exclusiveMinimum": 0, "maximum": 5}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clamav": {
|
"antivirus": {
|
||||||
"type": "object", "additionalProperties": false, "required": ["scan_timeout_sec", "max_signature_age_hours"],
|
"type": "object", "additionalProperties": false, "required": ["engine", "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}}
|
"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": {
|
"file_policy": {
|
||||||
"type": "object", "additionalProperties": false, "required": ["enabled_mime_types", "max_size_bytes"],
|
"type": "object", "additionalProperties": false, "required": ["enabled_mime_types", "max_size_bytes"],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
schema_version: 1
|
schema_version: 2
|
||||||
rules_bundle_ref: rules-2026-01-01
|
rules_bundle_ref: rules-2026-01-01
|
||||||
detector_manifest_ref: detector-2026-08-03
|
detector_manifest_ref: detector-2026-08-03
|
||||||
task:
|
task:
|
||||||
@@ -21,8 +21,9 @@ link:
|
|||||||
url_max_length: 2048
|
url_max_length: 2048
|
||||||
dns_lookup_timeout_sec: 1
|
dns_lookup_timeout_sec: 1
|
||||||
pipeline_timeout_sec: 2
|
pipeline_timeout_sec: 2
|
||||||
clamav:
|
antivirus:
|
||||||
scan_timeout_sec: 45
|
engine: kesl
|
||||||
|
scan_timeout_sec: 60
|
||||||
max_signature_age_hours: 240
|
max_signature_age_hours: 240
|
||||||
file_policy:
|
file_policy:
|
||||||
enabled_mime_types:
|
enabled_mime_types:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import json
|
|||||||
import struct
|
import struct
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
@@ -33,7 +35,24 @@ class ObjectReader(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class Antivirus(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)
|
@dataclass(frozen=True)
|
||||||
@@ -119,65 +138,101 @@ async def collect_and_hash(
|
|||||||
return bytes(body), digest.digest()
|
return bytes(body), digest.digest()
|
||||||
|
|
||||||
|
|
||||||
class ClamAvInstream:
|
class KeslSocketScanner:
|
||||||
def __init__(self, host: str, port: int, timeout: float = 45.0) -> None:
|
MAX_HEADER = 4096
|
||||||
self.host, self.port, self.timeout = host, port, timeout
|
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(
|
with tracer.start_as_current_span(
|
||||||
"message_safety.clamav.scan",
|
"message_safety.antivirus.scan",
|
||||||
attributes={"server.address.type": "clamav"},
|
attributes={"message_safety.antivirus.engine": "kesl"},
|
||||||
):
|
):
|
||||||
return await self._scan(chunks)
|
value = await self._request(
|
||||||
|
{"op": "scan", "size": len(body)},
|
||||||
async def _scan(self, chunks: AsyncIterator[bytes]) -> str | None:
|
bytes(body),
|
||||||
async def operation() -> str | None:
|
request_timeout=scan_timeout,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
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:
|
try:
|
||||||
writer.write(b"zVERSION\0")
|
database_date = parsedate_to_datetime(str(value["databases_date"]))
|
||||||
await writer.drain()
|
except (TypeError, ValueError) as exc:
|
||||||
raw = await asyncio.wait_for(reader.readuntil(b"\0"), 2.0)
|
raise DependencyFailure("invalid KESL database date") from exc
|
||||||
finally:
|
if database_date.tzinfo is None:
|
||||||
writer.close()
|
database_date = database_date.replace(tzinfo=UTC)
|
||||||
await writer.wait_closed()
|
engine = value.get("engine_version")
|
||||||
except (OSError, TimeoutError) as exc:
|
signatures = value.get("signatures_version")
|
||||||
raise DependencyFailure("ClamAV unavailable") from exc
|
if not isinstance(engine, str) or not isinstance(signatures, str):
|
||||||
value = raw.rstrip(b"\0")
|
raise DependencyFailure("incomplete KESL status")
|
||||||
if not value.startswith(b"ClamAV ") or len(value) > 512:
|
return AntivirusStatus(engine, signatures, database_date.astimezone(UTC))
|
||||||
raise DependencyFailure("invalid ClamAV version response")
|
|
||||||
return "sha256:" + hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
async def one_chunk(data: bytes) -> AsyncIterator[bytes]:
|
async def one_chunk(data: bytes) -> AsyncIterator[bytes]:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.adapters import TrustedDnsResolver
|
|||||||
from app.api import create_app
|
from app.api import create_app
|
||||||
from app.config import ActiveConfig, validate_config
|
from app.config import ActiveConfig, validate_config
|
||||||
from app.db import engine_and_sessions
|
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.repository import Repository
|
||||||
from app.service import SafetyService
|
from app.service import SafetyService
|
||||||
from app.settings import BootstrapSettings, EmergencyMode
|
from app.settings import BootstrapSettings, EmergencyMode
|
||||||
@@ -29,13 +29,14 @@ async def build_runtime() -> tuple[object, object]:
|
|||||||
resolver = TrustedDnsResolver(
|
resolver = TrustedDnsResolver(
|
||||||
[item.strip() for item in settings.dns_resolvers.split(",") if item.strip()]
|
[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:
|
if mode.mock:
|
||||||
signatures_version = "unavailable"
|
signatures_version = "unavailable"
|
||||||
files_ready = False
|
files_ready = False
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
signatures_version = await clamav.signatures_version()
|
status = await antivirus.status()
|
||||||
|
signatures_version = status.signatures_version
|
||||||
files_ready = True
|
files_ready = True
|
||||||
except DependencyFailure:
|
except DependencyFailure:
|
||||||
signatures_version = "unavailable"
|
signatures_version = "unavailable"
|
||||||
@@ -47,6 +48,7 @@ async def build_runtime() -> tuple[object, object]:
|
|||||||
resolver,
|
resolver,
|
||||||
files_ready=files_ready,
|
files_ready=files_ready,
|
||||||
signatures_version=signatures_version,
|
signatures_version=signatures_version,
|
||||||
|
antivirus=antivirus,
|
||||||
)
|
)
|
||||||
app = create_app(service, settings.service_token.get_secret_value())
|
app = create_app(service, settings.service_token.get_secret_value())
|
||||||
instrument_fastapi(app)
|
instrument_fastapi(app)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class Repository:
|
|||||||
FileVerdictCache.config_version == config.version,
|
FileVerdictCache.config_version == config.version,
|
||||||
FileVerdictCache.rules_version == config.rules_version,
|
FileVerdictCache.rules_version == config.rules_version,
|
||||||
FileVerdictCache.detector_version == config.detector.version,
|
FileVerdictCache.detector_version == config.detector.version,
|
||||||
FileVerdictCache.scanner_engine == "clamav",
|
FileVerdictCache.scanner_engine == "kesl",
|
||||||
FileVerdictCache.signatures_version == signatures_version,
|
FileVerdictCache.signatures_version == signatures_version,
|
||||||
FileVerdictCache.expires_at > func.now(),
|
FileVerdictCache.expires_at > func.now(),
|
||||||
)
|
)
|
||||||
@@ -262,9 +262,29 @@ class Repository:
|
|||||||
return result.rowcount == 1
|
return result.rowcount == 1
|
||||||
|
|
||||||
async def finish(
|
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:
|
) -> bool:
|
||||||
now = datetime.now(UTC)
|
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:
|
async with self.sessions.begin() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(SafetyTask)
|
update(SafetyTask)
|
||||||
@@ -275,17 +295,7 @@ class Repository:
|
|||||||
SafetyTask.lease_generation == generation,
|
SafetyTask.lease_generation == generation,
|
||||||
SafetyTask.lease_until > func.now(),
|
SafetyTask.lease_until > func.now(),
|
||||||
)
|
)
|
||||||
.values(
|
.values(**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 result.rowcount == 1:
|
if result.rowcount == 1:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -14,7 +15,7 @@ from app.db import (
|
|||||||
TaskStatus,
|
TaskStatus,
|
||||||
TextRulesCache,
|
TextRulesCache,
|
||||||
)
|
)
|
||||||
from app.file_pipeline import validate_metadata
|
from app.file_pipeline import Antivirus, DependencyFailure, validate_metadata
|
||||||
from app.fingerprint import fingerprint
|
from app.fingerprint import fingerprint
|
||||||
from app.normalization import normalize_text
|
from app.normalization import normalize_text
|
||||||
from app.rate_limit import ConservativeRateLimiter
|
from app.rate_limit import ConservativeRateLimiter
|
||||||
@@ -52,6 +53,7 @@ class SafetyService:
|
|||||||
links_ready: bool = True,
|
links_ready: bool = True,
|
||||||
files_ready: bool = True,
|
files_ready: bool = True,
|
||||||
signatures_version: str = "unverified",
|
signatures_version: str = "unverified",
|
||||||
|
antivirus: Antivirus | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -60,11 +62,40 @@ class SafetyService:
|
|||||||
self.links_ready = links_ready
|
self.links_ready = links_ready
|
||||||
self.files_ready = files_ready
|
self.files_ready = files_ready
|
||||||
self.signatures_version = signatures_version
|
self.signatures_version = signatures_version
|
||||||
|
self.antivirus = antivirus
|
||||||
|
self._antivirus_checked_at = 0.0
|
||||||
|
self._antivirus_check_lock = asyncio.Lock()
|
||||||
self.rate_limiter = ConservativeRateLimiter(
|
self.rate_limiter = ConservativeRateLimiter(
|
||||||
config.document["rate"]["text_rps"], config.document["rate"]["file_rps"]
|
config.document["rate"]["text_rps"], config.document["rate"]["file_rps"]
|
||||||
)
|
)
|
||||||
record_runtime_state("mock" if mode.mock else "standard", config.version)
|
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(
|
def _verdict(
|
||||||
self,
|
self,
|
||||||
allow: bool,
|
allow: bool,
|
||||||
@@ -222,7 +253,7 @@ class SafetyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _check_file(self, request: FileCheck, digest: bytes) -> Verdict | Pending:
|
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")
|
raise CapabilityUnavailable("files")
|
||||||
rule = validate_metadata(
|
rule = validate_metadata(
|
||||||
request.attachment,
|
request.attachment,
|
||||||
@@ -274,7 +305,7 @@ class SafetyService:
|
|||||||
declared_checksum=request.attachment.checksum,
|
declared_checksum=request.attachment.checksum,
|
||||||
rules_version=self.config.rules_version,
|
rules_version=self.config.rules_version,
|
||||||
detector_version=self.config.detector.version,
|
detector_version=self.config.detector.version,
|
||||||
scanner_engine="clamav",
|
scanner_engine="kesl",
|
||||||
signatures_version=self.signatures_version,
|
signatures_version=self.signatures_version,
|
||||||
origin_trace_id=trace_id,
|
origin_trace_id=trace_id,
|
||||||
origin_span_id=span_id,
|
origin_span_id=span_id,
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ class BootstrapSettings(BaseSettings):
|
|||||||
default=5, ge=1, le=32, alias="MESSAGE_SAFETY_WORKER_CONCURRENCY"
|
default=5, ge=1, le=32, alias="MESSAGE_SAFETY_WORKER_CONCURRENCY"
|
||||||
)
|
)
|
||||||
dns_resolvers: str = Field(default="", alias="MESSAGE_SAFETY_DNS_RESOLVERS")
|
dns_resolvers: str = Field(default="", alias="MESSAGE_SAFETY_DNS_RESOLVERS")
|
||||||
clamav_host: str = Field(default="clamd", alias="MESSAGE_SAFETY_CLAMAV_HOST")
|
antivirus_socket: Path = Field(
|
||||||
clamav_port: int = Field(default=3310, ge=1, le=65535, alias="MESSAGE_SAFETY_CLAMAV_PORT")
|
default=Path("/run/han-kesl/scan.sock"),
|
||||||
|
alias="MESSAGE_SAFETY_ANTIVIRUS_SOCKET",
|
||||||
|
)
|
||||||
s3_endpoint_url: str = Field(alias="SELECTEL_S3_ENDPOINT_URL")
|
s3_endpoint_url: str = Field(alias="SELECTEL_S3_ENDPOINT_URL")
|
||||||
s3_bucket: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
|
s3_bucket: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
|
||||||
artifacts_dir: Path = Field(
|
artifacts_dir: Path = Field(
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from app.config import validate_config
|
|||||||
from app.contracts import Attachment
|
from app.contracts import Attachment
|
||||||
from app.db import FileVerdictCache, SafetyAudit, engine_and_sessions
|
from app.db import FileVerdictCache, SafetyAudit, engine_and_sessions
|
||||||
from app.file_pipeline import (
|
from app.file_pipeline import (
|
||||||
ClamAvInstream,
|
Antivirus,
|
||||||
DependencyFailure,
|
DependencyFailure,
|
||||||
|
KeslSocketScanner,
|
||||||
ObjectChanged,
|
ObjectChanged,
|
||||||
collect_and_hash,
|
collect_and_hash,
|
||||||
detect_format,
|
detect_format,
|
||||||
@@ -34,7 +35,7 @@ tracer = trace.get_tracer("message-safety.worker")
|
|||||||
|
|
||||||
class Worker:
|
class Worker:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, repository: Repository, reader: S3VersionReader, antivirus: ClamAvInstream, artifacts
|
self, repository: Repository, reader: S3VersionReader, antivirus: Antivirus, artifacts
|
||||||
) -> None:
|
) -> None:
|
||||||
self.repository, self.reader, self.antivirus, self.artifacts = (
|
self.repository, self.reader, self.antivirus, self.artifacts = (
|
||||||
repository,
|
repository,
|
||||||
@@ -94,10 +95,15 @@ class Worker:
|
|||||||
)
|
)
|
||||||
record_dependency("s3", "get_object", "success")
|
record_dependency("s3", "get_object", "success")
|
||||||
rule = detect_format(body, attachment.mime_type)
|
rule = detect_format(body, attachment.mime_type)
|
||||||
|
signatures_version = task.signatures_version
|
||||||
if not rule:
|
if not rule:
|
||||||
malware = await self.antivirus.scan(one_chunk(body))
|
scan = await self.antivirus.scan(
|
||||||
record_dependency("clamav", "scan", "success")
|
one_chunk(body),
|
||||||
rule = "file.malware_detected" if malware else None
|
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"):
|
with tracer.start_as_current_span("message_safety.worker.finalize"):
|
||||||
finished = await self.repository.finish(
|
finished = await self.repository.finish(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -105,6 +111,7 @@ class Worker:
|
|||||||
task.lease_generation,
|
task.lease_generation,
|
||||||
allow=rule is None,
|
allow=rule is None,
|
||||||
rule_id=rule or "safety.all_checks_passed",
|
rule_id=rule or "safety.all_checks_passed",
|
||||||
|
signatures_version=signatures_version,
|
||||||
)
|
)
|
||||||
if finished:
|
if finished:
|
||||||
record_worker("allow" if rule is None else "deny", task_age)
|
record_worker("allow" if rule is None else "deny", task_age)
|
||||||
@@ -116,7 +123,7 @@ class Worker:
|
|||||||
rules_version=task.rules_version,
|
rules_version=task.rules_version,
|
||||||
detector_version=task.detector_version,
|
detector_version=task.detector_version,
|
||||||
scanner_engine=task.scanner_engine,
|
scanner_engine=task.scanner_engine,
|
||||||
signatures_version=task.signatures_version,
|
signatures_version=signatures_version,
|
||||||
verdict="allow" if rule is None else "deny",
|
verdict="allow" if rule is None else "deny",
|
||||||
rule_id=rule or "safety.all_checks_passed",
|
rule_id=rule or "safety.all_checks_passed",
|
||||||
reason_code=None if rule is None else "message_blocked",
|
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_access_key.get_secret_value(),
|
||||||
settings.s3_secret_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,
|
settings.artifacts_dir,
|
||||||
)
|
)
|
||||||
async with asyncio.TaskGroup() as group:
|
async with asyncio.TaskGroup() as group:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -7,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from app.api import create_app
|
from app.api import create_app
|
||||||
from app.db import TaskStatus
|
from app.db import TaskStatus
|
||||||
|
from app.file_pipeline import AntivirusStatus
|
||||||
from app.repository import ConflictError
|
from app.repository import ConflictError
|
||||||
from app.service import SafetyService
|
from app.service import SafetyService
|
||||||
from app.settings import EmergencyMode
|
from app.settings import EmergencyMode
|
||||||
@@ -54,6 +56,15 @@ class ForbiddenResolver:
|
|||||||
raise AssertionError("MOCK must not call DNS")
|
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:
|
def body(kind: str, message_id=None) -> dict:
|
||||||
value = {
|
value = {
|
||||||
"message_id": str(message_id or uuid4()),
|
"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.status_code == 200
|
||||||
assert final.json()["config_version"] == 99
|
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
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ def test_config_cross_field_and_manifest_subset(artifacts: Path) -> None:
|
|||||||
validate_config(bad, artifacts)
|
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)
|
document = seed(artifacts)
|
||||||
assert document["clamav"]["max_signature_age_hours"] == 240
|
assert document["antivirus"]["engine"] == "kesl"
|
||||||
document["clamav"]["max_signature_age_hours"] = 720
|
assert document["antivirus"]["max_signature_age_hours"] == 240
|
||||||
|
document["antivirus"]["max_signature_age_hours"] = 720
|
||||||
validate_config(document, artifacts)
|
validate_config(document, artifacts)
|
||||||
document["clamav"]["max_signature_age_hours"] = 721
|
document["antivirus"]["max_signature_age_hours"] = 721
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
validate_config(document, artifacts)
|
validate_config(document, artifacts)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from app.contracts import Attachment
|
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:
|
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()
|
assert body == data and digest == hashlib.sha256(data).digest()
|
||||||
with pytest.raises(ObjectChanged):
|
with pytest.raises(ObjectChanged):
|
||||||
await collect_and_hash(Reader(data), attachment(data, size=len(data) + 1), max_size=100)
|
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"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Test-only правила по первому символу, случайные
|
|||||||
| Safety Service Owner | lifecycle v2, API/data contracts, service-owned config, capacity и cutover sign-off |
|
| 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 |
|
| Rule Pack Owner | версия rules bundle, corpus, monitor rollout и release notes |
|
||||||
| Security Owner | approval `monitor → deny` и config changes, ослабляющих policy; threat model, egress/secrets и risk acceptance |
|
| 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.
|
Один человек может выполнять несколько ролей, но для каждого production release роли и approvals должны быть записаны в release checklist.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ Test-only правила по первому символу, случайные
|
|||||||
- валидацию file metadata и фактического формата;
|
- валидацию file metadata и фактического формата;
|
||||||
- чтение файла из S3-quarantine по read-only credentials;
|
- чтение файла из S3-quarantine по read-only credentials;
|
||||||
- вычисление authoritative SHA-256;
|
- вычисление authoritative SHA-256;
|
||||||
- антивирусную проверку файла через ClamAV;
|
- антивирусную проверку файла через root-owned fail-closed broker по Unix socket `/run/han-kesl/scan.sock`; broker вызывает host KESL 12.4 standalone;
|
||||||
- выбор sync/async режима;
|
- выбор sync/async режима;
|
||||||
- создание и исполнение async safety tasks;
|
- создание и исполнение async safety tasks;
|
||||||
- sticky final verdict, verdict cache и audit в схеме `message_safety`;
|
- 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=true` | forced `403 deny` | forced `200 allow` |
|
||||||
| `MOCK=true`, `TEXT_FREE=false`, `FILE_FREE=false` | forced `403 deny` | forced `403 deny` |
|
| `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 посередине обработки.
|
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 | Сверка DTO с allow-list и лимитами | `403 deny` |
|
||||||
| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` |
|
| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` |
|
||||||
| Подмена содержимого после complete | Version-specific read + ETag + полный SHA-256 | `403 deny` |
|
| Подмена содержимого после complete | Version-specific read + ETag + полный SHA-256 | `403 deny` |
|
||||||
| Malware | ClamAV scan актуальными сигнатурами | `403 deny` |
|
| Malware | Host KESL 12.4 scan через fail-closed broker с актуальной базой | `403 deny` |
|
||||||
| Архивная бомба/ресурсное истощение | Лимиты размера, stream scan, ClamAV limits/timeouts | deny при policy hit; error при сбое |
|
| Архивная бомба/ресурсное истощение | Лимиты размера, bounded staging и KESL/broker limits/timeouts | deny при policy hit; error при сбое |
|
||||||
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
|
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
|
||||||
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
|
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ flowchart TD
|
|||||||
task --> worker[File_worker]
|
task --> worker[File_worker]
|
||||||
worker --> objectRead[S3_stream_and_SHA256]
|
worker --> objectRead[S3_stream_and_SHA256]
|
||||||
objectRead --> formatCheck[Format_validation]
|
objectRead --> formatCheck[Format_validation]
|
||||||
formatCheck --> avScan[ClamAV_scan]
|
formatCheck --> avScan[KESL_broker_scan]
|
||||||
avScan --> finalVerdict[Persist_sticky_verdict]
|
avScan --> finalVerdict[Persist_sticky_verdict]
|
||||||
finalVerdict --> taskGet[GET_task_200_or_403]
|
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.encrypted_content` | file_content | deny | encrypted/password-protected container |
|
||||||
| `file.active_content` | file_content | deny | PDF JavaScript/OpenAction/Launch/XFA/embedded |
|
| `file.active_content` | file_content | deny | PDF JavaScript/OpenAction/Launch/XFA/embedded |
|
||||||
| `file.parser_limit` | file_content | deny | parser resource/decompression/object limit |
|
| `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`.
|
Все domain deny используют `reason_code=message_blocked`; детализация остаётся во внутреннем `rule_id`. DTO/schema/authorization errors не получают `rule_id`.
|
||||||
|
|
||||||
@@ -389,29 +389,28 @@ Worker:
|
|||||||
5. определяет реальный формат по содержимому;
|
5. определяет реальный формат по содержимому;
|
||||||
6. сверяет detector result с declared MIME;
|
6. сверяет detector result с declared MIME;
|
||||||
7. запрещает encrypted/password-protected и неподдерживаемые containers;
|
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;
|
9. сохраняет sticky final verdict и audit;
|
||||||
10. записывает cache только для terminal результата;
|
10. записывает cache только для terminal результата;
|
||||||
11. завершает lease.
|
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:
|
||||||
|
|
||||||
- порт не публикуется наружу;
|
- KESL и broker не входят в Compose; `clamd`/`freshclam`, их volumes, healthchecks и egress из Compose удалены;
|
||||||
- сигнатуры обновляет `freshclam`;
|
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`; ожидаемые права socket — `root:han-message-safety 0660`, socket монтируется в worker, TCP listener отсутствует;
|
||||||
- readiness требует daemon PING и допустимый возраст signatures;
|
- broker fail-closed: принимает только bounded scan request, не принимает произвольные command/arguments/path traversal и вызывает фиксированный `kesl-control --scan-file --action Inform`;
|
||||||
- `max_signature_age_hours` допускается в диапазоне `1..720` часов
|
- `clean` разрешает продолжить allow-ветку, `infected` даёт domain deny; неизвестный формат/exit code, timeout, недоступность KESL и stale database дают retry, затем `503`;
|
||||||
(не более 30 дней); seed policy использует `240` часов (10 дней);
|
- readiness проверяет broker, KESL version/database date и допустимый возраст базы; `max_signature_age_hours` допускается в диапазоне `1..720`, seed — `240`;
|
||||||
- limits согласованы с максимальным размером файла;
|
- KESL обновляет базы на host ежечасно по операторскому KESL runbook; egress к источникам обновления принадлежит host KESL, не Compose;
|
||||||
- контейнер non-root, read-only root filesystem где возможно, отдельный writable volume только для signatures/runtime;
|
- worker не передаёт object key, имя пользователя или иные PII; временный файл и broker state очищаются по завершении.
|
||||||
- worker не передаёт в clamd object key, имя пользователя или иные PII.
|
|
||||||
|
|
||||||
Недоступность 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. Исполнимая матрица форматов
|
### 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.
|
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
|
## 8. Internal API
|
||||||
|
|
||||||
@@ -697,14 +696,14 @@ Public `422` содержит стандартный error envelope без inter
|
|||||||
- strict DTO, body/text/URL/file limits и запрет unknown fields;
|
- strict DTO, body/text/URL/file limits и запрет unknown fields;
|
||||||
- read-only S3-quarantine access, без list/write/delete;
|
- read-only S3-quarantine access, без list/write/delete;
|
||||||
- **запрет HTTP fetch/render/redirect follow** к пользовательским URL; link pipeline использует только доверенный DNS;
|
- **запрет 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`;
|
- parameterized SQL и least-privilege DB role только на schema `message_safety`;
|
||||||
- Redis Safety ACL только prefixes `han:safety:*`;
|
- Redis Safety ACL только prefixes `han:safety:*`;
|
||||||
- dependency pinning, SBOM/image scanning;
|
- dependency pinning, SBOM/image scanning;
|
||||||
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities, no-new-privileges;
|
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities, no-new-privileges;
|
||||||
- OpenAPI UI выключен в production;
|
- OpenAPI UI выключен в production;
|
||||||
- безопасные generic errors без stack/internal addresses;
|
- безопасные 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.
|
- MOCK не имеет runtime/public API; mode file root-owned, а `deploy` имеет sudo только на argument-validating helper и approved restart.
|
||||||
|
|
||||||
## 13. Observability и privacy
|
## 13. Observability и privacy
|
||||||
@@ -726,10 +725,10 @@ Metrics:
|
|||||||
- `message_safety_mock_enabled` gauge и forced outcomes по `text|file`/`allow|deny`;
|
- `message_safety_mock_enabled` gauge и forced outcomes по `text|file`/`allow|deny`;
|
||||||
- rule hits по low-cardinality `rule_id`;
|
- rule hits по low-cardinality `rule_id`;
|
||||||
- task queue/age/attempts/lease conflicts/timeouts;
|
- task queue/age/attempts/lease conflicts/timeouts;
|
||||||
- scan latency/bytes buckets/AV outcome;
|
- scan latency/bytes buckets/KESL outcome;
|
||||||
- signatures age/version info;
|
- signatures age/version info;
|
||||||
- cache hit/miss;
|
- 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;
|
- active/used `config_version`, activation result и config refresh age;
|
||||||
- auth rejects/rate limit/readiness.
|
- auth rejects/rate limit/readiness.
|
||||||
|
|
||||||
@@ -748,7 +747,7 @@ Telemetry collector unavailable не влияет на safety verdict и readine
|
|||||||
- Redis Safety status как degraded accelerator, не core gate;
|
- Redis Safety status как degraded accelerator, не core gate;
|
||||||
- worker heartbeat/lease processing;
|
- worker heartbeat/lease processing;
|
||||||
- S3-quarantine Head/Get read permission на безопасный canary object;
|
- S3-quarantine Head/Get read permission на безопасный canary object;
|
||||||
- ClamAV PING и допустимый возраст signatures;
|
- broker socket, KESL status и допустимый возраст database;
|
||||||
- trusted DNS resolver для `links`;
|
- trusted DNS resolver для `links`;
|
||||||
- OpenAPI/runtime parity проверяется на startup/CI, а не сетевым probe каждого ready request.
|
- 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`.
|
В 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`
|
### 15.1. Seed active config в `message_safety`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
rules_bundle_ref: rules-2026-01-01
|
rules_bundle_ref: rules-2026-01-01
|
||||||
detector_manifest_ref: detector-2026-08-03
|
detector_manifest_ref: detector-2026-08-03
|
||||||
task:
|
task:
|
||||||
@@ -820,7 +819,7 @@ link:
|
|||||||
url_max_length: 2048
|
url_max_length: 2048
|
||||||
dns_lookup_timeout_sec: 1
|
dns_lookup_timeout_sec: 1
|
||||||
pipeline_timeout_sec: 2
|
pipeline_timeout_sec: 2
|
||||||
clamav:
|
kesl:
|
||||||
scan_timeout_sec: 45
|
scan_timeout_sec: 45
|
||||||
max_signature_age_hours: 240
|
max_signature_age_hours: 240
|
||||||
file_policy:
|
file_policy:
|
||||||
@@ -850,8 +849,7 @@ activation. Image-only rollback после активации несовмест
|
|||||||
APP_ENV=production-like
|
APP_ENV=production-like
|
||||||
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
||||||
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
|
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
|
||||||
MESSAGE_SAFETY_CLAMAV_HOST=clamd
|
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
|
||||||
MESSAGE_SAFETY_CLAMAV_PORT=3310
|
|
||||||
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
||||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
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 с |
|
| 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 |
|
| 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.
|
Превышение 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;
|
- PostgreSQL claim/lease/fencing concurrency/recovery; Redis cache loss;
|
||||||
- pending=100 backpressure, token buckets и 5 worker slots;
|
- pending=100 backpressure, token buckets и 5 worker slots;
|
||||||
- S3 versioned read, ETag/version changed/missing/timeout;
|
- 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;
|
- full SHA-256 and size mismatch;
|
||||||
- duplicate concurrent request and worker retry;
|
- duplicate concurrent request and worker retry;
|
||||||
- dependency recovery without changed final verdict.
|
- dependency recovery without changed final verdict.
|
||||||
@@ -972,10 +970,10 @@ Runtime schema сравнивается с committed artifact contract test. Ter
|
|||||||
### 18.2. Definition of Done
|
### 18.2. Definition of Done
|
||||||
|
|
||||||
- production v2 `200/202/403` contract реализован без stub divergence;
|
- 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;
|
- text-rules cache, text rules, link cache и URL pipeline покрывают threat model и false-positive corpus;
|
||||||
- fetch/render/redirect follow к пользовательским URL отсутствует по design и тестам;
|
- 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;
|
- final task verdict sticky и durable;
|
||||||
- idempotency/concurrency/recovery доказаны тестами;
|
- idempotency/concurrency/recovery доказаны тестами;
|
||||||
- PG schema `message_safety`, Redis Safety и S3 version-specific read работают по least privilege;
|
- 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`;
|
- mock mode сохраняется internal, не раскрывается public; forced file allow получает `scan_status=bypassed`, не `clean`;
|
||||||
- public deny использует `safety.chat.blocked` и не раскрывает internal rule;
|
- public deny использует `safety.chat.blocked` и не раскрывает internal rule;
|
||||||
- blocked/failed content не попадает в Bitrix и не promote-ится;
|
- 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;
|
- performance acceptance §15.4 пройден на target sizing;
|
||||||
- immutable/versioned S3 negative tests и conditional promote пройдены;
|
- immutable/versioned S3 negative tests и conditional promote пройдены;
|
||||||
- OpenAPI 3.1 и runtime parity зелёные;
|
- OpenAPI 3.1 и runtime parity зелёные;
|
||||||
- logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
|
- logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
|
||||||
- hardened containers запускаются без public port;
|
- 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 испытаны.
|
- `deploy` может выполнить все пять exact mode commands, но не читать/писать mode config и не получить Docker/general sudo; helper rollback, persistent MOCK alert и возврат в standard испытаны.
|
||||||
|
|
||||||
## 19. Размещение проекта и вынос на отдельную ВМ
|
## 19. Размещение проекта и вынос на отдельную ВМ
|
||||||
@@ -1016,7 +1014,7 @@ Stub в `codebase/backend/message-safety/` не является целевой
|
|||||||
|
|
||||||
### 19.2. Целевая топология
|
### 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -1030,7 +1028,7 @@ flowchart LR
|
|||||||
gateway[VM2_nginx_public_private]
|
gateway[VM2_nginx_public_private]
|
||||||
safety[message-safety_api_worker]
|
safety[message-safety_api_worker]
|
||||||
sync[bitrix-sync]
|
sync[bitrix-sync]
|
||||||
clamav[clamd]
|
kesl[KESL_12_4_and_broker]
|
||||||
redis2[Redis_Safety]
|
redis2[Redis_Safety]
|
||||||
collector[otel-collector]
|
collector[otel-collector]
|
||||||
end
|
end
|
||||||
@@ -1046,13 +1044,13 @@ flowchart LR
|
|||||||
safety --> redis2
|
safety --> redis2
|
||||||
safety --> managedPg
|
safety --> managedPg
|
||||||
safety --> s3q
|
safety --> s3q
|
||||||
safety --> clamav
|
safety -->|"Unix socket"| kesl
|
||||||
sync --> managedPg
|
sync --> managedPg
|
||||||
sync --> bitrix
|
sync --> bitrix
|
||||||
collector --> signoz
|
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
|
### 19.3. Исключение из docker compose HAN_CHAT
|
||||||
|
|
||||||
@@ -1079,7 +1077,7 @@ flowchart LR
|
|||||||
| `arch-03` | один Compose ВМ1 | один root Compose на каждой VM |
|
| `arch-03` | один Compose ВМ1 | один root Compose на каждой VM |
|
||||||
| `arch-04` | local Docker URLs | remote HTTPS URL ВМ1; service-specific env/secrets ВМ2 |
|
| `arch-04` | local Docker URLs | remote HTTPS URL ВМ1; service-specific env/secrets ВМ2 |
|
||||||
| CI/CD | build из backend tree | отдельный pipeline artifact `codebase/message-safety` |
|
| 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.
|
Схема **`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 запрещены;
|
- отдельный 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;
|
- 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` наружу не публикуются;
|
- capability health Safety доступен api-backend и ops как exact `/internal/safety/status` на private `8443`; container `/health/live` и `/health/ready` наружу не публикуются;
|
||||||
- при недоступности ВМ2 `api-backend` возвращает `503 dependency_unavailable`, не отправляет сообщения в Bitrix.
|
- при недоступности ВМ2 `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.
|
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.
|
4. **URL policy** — parser/IDNA/IP/DNS, cache split, NXDOMAIN monitor и запрет fetch.
|
||||||
5. **File/S3** — immutable version precondition, authoritative checksum, detector manifest и hard matrix.
|
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.
|
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.
|
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.
|
9. **Acceptance** — contract/security/failure/load tests §15–18 и S3 negative gate.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Документ задаёт, **что агент ВМ2 реализует в Compose, коде, тестах и алертах этой машины**.
|
Документ задаёт, **что агент ВМ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.
|
Агент ВМ2 не добавляет scrape, дашборды и алерты сервисов ВМ1.
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| Redis Safety | `redis` |
|
| Redis Safety | `redis` |
|
||||||
| local Collector | `otel-collector` |
|
| 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`.
|
Различать экземпляр от ВМ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;
|
- server spans API с route template; poll `202` не маскирует финальный verdict;
|
||||||
- worker spans claim/process/finalize и span links на origin `request_id` / `trace_id` caller;
|
- 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;
|
- `/health/live` исключить из traces; capability/readiness — metrics и sampled logs;
|
||||||
- stub mode (`400`, non-sticky) маркируется как `stub`; production SLO Safety на stub недостоверен.
|
- 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
|
### 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
|
## 5. Метрики бизнес-потоков ВМ2
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ UUID/user/session/dialog/task/message id не labels.
|
|||||||
В SigNoz, с filter `host.name` / environment ВМ2:
|
В 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.
|
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`.
|
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.
|
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;
|
- Redis Safety unavailable, AOF error или sustained evictions;
|
||||||
- Collector ВМ2 exporter queue >80%, dropped/refused telemetry >0 sustained;
|
- Collector ВМ2 exporter queue >80%, dropped/refused telemetry >0 sustained;
|
||||||
- TLS expiry public webhook host и private `8443` <14 дней warning, <7 дней page;
|
- 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
|
### Ticket/warning
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ Optional profile `observability-local` на ВМ2 по умолчанию вык
|
|||||||
### Высокая latency сообщения (hop ВМ2)
|
### Высокая latency сообщения (hop ВМ2)
|
||||||
|
|
||||||
1. Найти task/span по `request_id` caller.
|
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.
|
3. Проверить circuit, queue age, PG leases/fencing и Redis Safety.
|
||||||
4. Не повторять ambiguous scan/send без исходного idempotency key.
|
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).
|
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 не используется;
|
- Collector ВМ2 validate + up в root Compose; hostname collector ВМ1 не используется;
|
||||||
- инструментированы `message-safety` API/worker и `bitrix-sync`;
|
- инструментированы `message-safety` API/worker и `bitrix-sync`;
|
||||||
- nginx JSON parsing, webhook reject metric и private `8443` correlation проверены;
|
- 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;
|
- дашборды и alerts §6–§7 provisioned либо явно TBD до SigNoz packaging;
|
||||||
- stub/MOCK маркируются; production Safety SLO не объявляется на stub;
|
- stub/MOCK маркируются; production Safety SLO не объявляется на stub;
|
||||||
- до sync cutover dashboard `sync_disabled`;
|
- до sync cutover dashboard `sync_disabled`;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 1. Границы
|
## 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 здесь не разворачиваются.
|
||||||
|
|
||||||
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ2. Public ACME host — `<PROCESSING_PUBLIC_HOST>`. Private DNS `processing.internal` не публикуется.
|
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ2. Public ACME host — `<PROCESSING_PUBLIC_HOST>`. Private DNS `processing.internal` не публикуется.
|
||||||
|
|
||||||
@@ -23,15 +23,15 @@
|
|||||||
- 100 pending принимаются; 101-й file POST — retryable `503` без новой task;
|
- 100 pending принимаются; 101-й file POST — retryable `503` без новой task;
|
||||||
- RPS overflow — `429 + Retry-After`;
|
- RPS overflow — `429 + Retry-After`;
|
||||||
- long Safety poll не блокирует WS/read API ВМ1;
|
- 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
|
## 3. Hardening и egress
|
||||||
|
|
||||||
Arch-10 §5 / arch-06. Public Docker ports — `80,443`. Private `8443` не internet SG.
|
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.
|
Отдельный IAM principal Selectel: только VM2 secret names.
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ Checkout exact SHA. Структура: root Compose, `nginx`, Safety, `bitrix-s
|
|||||||
|
|
||||||
## 6. Root Compose ВМ2
|
## 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.
|
Compose gate — arch-10 применительно к этому Compose.
|
||||||
|
|
||||||
@@ -108,9 +108,11 @@ Rollback: закрыть webhook или `503`; `BITRIX_SYNC_ENABLED=false`; ВМ
|
|||||||
|
|
||||||
## 10. Ordered startup ВМ2
|
## 10. Ordered startup ВМ2
|
||||||
|
|
||||||
1. Redis Safety, `otel-queue-init`, Collector;
|
1. Выполнить operator KESL runbook: проверить KESL 12.4 standalone, database date и ежечасное обновление.
|
||||||
2. `clamd`/`freshclam`, Safety API/worker, `bitrix-sync`;
|
2. `enable/start` broker socket/service; проверить status и owner/group/mode `/run/han-kesl/scan.sock`.
|
||||||
3. nginx последним: оба TLS, exact webhook, capability health, signature age, negative ingress/egress.
|
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.
|
До 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.
|
Rollback ВМ2 не требует изменения nginx ВМ1. Caller rollback — runbook ВМ1.
|
||||||
|
|
||||||
## 14. Emergency MOCK и Freshclam
|
## 14. Emergency MOCK и KESL
|
||||||
|
|
||||||
`deploy` без root login:
|
`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.
|
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
|
## 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.
|
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;
|
- private `8443` fail-closed до cutover, затем только источники ВМ1;
|
||||||
- webhook allow-list и preflight `BITRIX_SYNC_ENABLED` согласованы;
|
- webhook allow-list и preflight `BITRIX_SYNC_ENABLED` согласованы;
|
||||||
- capability/load gates module-05;
|
- capability/load gates module-05;
|
||||||
- MOCK helper и Freshclam rehearsal;
|
- MOCK helper и KESL/broker rehearsal;
|
||||||
- observability ВМ2 + redaction (нет query/body/file content);
|
- observability ВМ2 + redaction (нет query/body/file content);
|
||||||
- cutover §13 не объявлен выполненным только документацией.
|
- cutover §13 не объявлен выполненным только документацией.
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
## Каноническое размещение production-контуров
|
## Каноническое размещение production-контуров
|
||||||
|
|
||||||
- **ВМ1 HAN Chat** — самостоятельная публичная точка входа приложения: nginx, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контур, Redis DB0/DB1 и локальный OTEL Collector.
|
- **ВМ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 через несколько хостов.
|
- На каждой VM действует один root Compose project и отдельный root-owned systemd deployment unit. «Единый Compose» означает один проект **на VM**, а не один общий project через несколько хостов.
|
||||||
- Bitrix24 вызывает CRM webhook напрямую на nginx ВМ2; ВМ1 в route не участвует. ВМ1 вызывает только Message Safety по private HTTPS.
|
- Bitrix24 вызывает CRM webhook напрямую на nginx ВМ2; ВМ1 в route не участвует. ВМ1 вызывает только Message Safety по private HTTPS.
|
||||||
- При росте нагрузки `bitrix-sync` может быть перенесён на ВМ3 без изменения API и границ схем PostgreSQL.
|
- При росте нагрузки `bitrix-sync` может быть перенесён на ВМ3 без изменения API и границ схем PostgreSQL.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ HAN Chat - приложение для мигрантов, где стартов
|
|||||||
Production-like backend разделён на два контура в одной private network/VPC:
|
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`;
|
- **ВМ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;
|
- каждая VM имеет один root Compose project и отдельный root-owned systemd deployment unit;
|
||||||
- ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress на своих nginx; ВМ2 публикует только exact CRM webhook;
|
- ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress на своих nginx; ВМ2 публикует только exact CRM webhook;
|
||||||
- ВМ1 вызывает ВМ2 по private HTTPS с проверкой internal CA, service token, cloud SG и host firewall;
|
- ВМ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]
|
api -->|"HTTPS 8443 + service token"| privateGateway[VM2_PrivateListener]
|
||||||
privateGateway --> safety[MessageSafetyApi]
|
privateGateway --> safety[MessageSafetyApi]
|
||||||
safety --> worker[SafetyWorker]
|
safety --> worker[SafetyWorker]
|
||||||
worker --> clamd[Clamd]
|
worker -->|"Unix socket /run/han-kesl/scan.sock"| keslBroker[KESLBroker]
|
||||||
|
keslBroker --> hostKesl[HostKESL12_4]
|
||||||
worker --> s3q[S3Quarantine]
|
worker --> s3q[S3Quarantine]
|
||||||
worker --> pg[ManagedPostgreSQL]
|
worker --> pg[ManagedPostgreSQL]
|
||||||
sync --> pg
|
sync --> pg
|
||||||
@@ -590,7 +591,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
|
|||||||
Минимальный целевой real-SMS контур разделён на два stack:
|
Минимальный целевой real-SMS контур разделён на два stack:
|
||||||
|
|
||||||
- ВМ1: `nginx`, `api-backend`, `keycloak`, `sms-service`/worker, `bitrix-local-app`, Redis DB0/DB1, `otel-collector`;
|
- ВМ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.
|
До SMS rollout сервисы SMS могут отсутствовать, но Keycloak обязан оставаться в mock mode.
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ processing/
|
|||||||
nginx-internal/docker-compose.yml
|
nginx-internal/docker-compose.yml
|
||||||
message-safety/docker-compose.yml
|
message-safety/docker-compose.yml
|
||||||
bitrix-sync/docker-compose.yml
|
bitrix-sync/docker-compose.yml
|
||||||
clamav/docker-compose.yml
|
|
||||||
redis/docker-compose.yml
|
redis/docker-compose.yml
|
||||||
observability/docker-compose.yml
|
observability/docker-compose.yml
|
||||||
```
|
```
|
||||||
@@ -93,7 +92,7 @@ volumes:
|
|||||||
redis-data:
|
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-файлов
|
### Правила для сервисных compose-файлов
|
||||||
|
|
||||||
@@ -254,6 +253,7 @@ Python FastAPI backend.
|
|||||||
- runtime role читает immutable active `message_safety.config_versions`; создавать/активировать config может только отдельный migration/config-admin job;
|
- runtime role читает immutable active `message_safety.config_versions`; создавать/активировать config может только отдельный migration/config-admin job;
|
||||||
- использует локальный Redis Safety только для hot cache/rate/wakeup; PostgreSQL владеет task queue/leases;
|
- использует локальный Redis Safety только для hot cache/rate/wakeup; PostgreSQL владеет task queue/leases;
|
||||||
- запускает async workers для file scan из S3-quarantine;
|
- запускает 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
|
- API container получает read-only
|
||||||
`/etc/han-chat/message-safety-mode.env` с host contract
|
`/etc/han-chat/message-safety-mode.env` с host contract
|
||||||
`root:han-message-safety 0640` и GID `10001`; менять его и перезапускать
|
`root:han-message-safety 0640` и GID `10001`; менять его и перезапускать
|
||||||
@@ -262,29 +262,26 @@ Python FastAPI backend.
|
|||||||
- экспортирует traces/logs в `otel-collector`;
|
- экспортирует 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_*`).
|
- таймауты: 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, но разные
|
KESL 12.4 работает standalone на host и не является Compose service. Отдельный
|
||||||
security-профили:
|
root-owned custom broker слушает только `/run/han-kesl/scan.sock` и вызывает
|
||||||
|
фиксированный `kesl-control --scan-file --action Inform`.
|
||||||
|
|
||||||
- оба запускаются через vendor `init-unprivileged`, а не root entrypoint;
|
- socket монтируется только в Message Safety worker и доступен выделенной
|
||||||
- `clamd` читает volume signatures read-only, не подключён к signature CDN и
|
группе; worker не получает host binary, shell, Docker socket или host root;
|
||||||
имеет healthcheck daemon socket;
|
- broker принимает только bounded scan request и fail-closed сопоставляет
|
||||||
- `freshclam` один пишет в signatures и имеет только разрешённый egress к CDN;
|
`clean → allow`, `infected → deny`, а timeout, stale database, неизвестный
|
||||||
- `/run/clamav` — отдельный runtime volume, `/var/log/clamav` и `/tmp` —
|
output/exit code и недоступность KESL → retry/`503`;
|
||||||
ограниченные tmpfs с UID/GID ClamAV;
|
- `scanner_engine=kesl`; `signatures_version` — hash KESL version + database
|
||||||
- `freshclam` работает как foreground daemon с заданным interval; inherited
|
date;
|
||||||
healthcheck `clamd` отключён, потому что updater не поднимает daemon socket;
|
- KESL обновляет database ежечасно на host; update egress не подключает
|
||||||
- работоспособность updater подтверждается состоянием `Up`, отсутствием
|
контейнеры к internet;
|
||||||
restart loop и отдельным контролем возраста/signature version, а не
|
- schema допускает `max_signature_age_hours` до `720`, seed — `240`.
|
||||||
искусственным container healthcheck.
|
|
||||||
- Архитектурная верхняя граница допустимого возраста signatures — `720` часов
|
|
||||||
(30 дней); активный seed-порог `max_signature_age_hours` — `240` часов
|
|
||||||
(10 дней), то есть строже предельного значения.
|
|
||||||
|
|
||||||
Смена digest ClamAV требует повторной проверки entrypoint, UID/GID, writable
|
Формат результата `kesl-control`, socket permissions, cleanup и throughput
|
||||||
paths, `clamd` health и фактического обновления signatures. Нельзя менять
|
этой custom integration подтверждаются gates на target VM2 с фактическим KESL
|
||||||
только tag/digest, считая security contract image неизменным.
|
12.4; repository-only/Compose health не считается достаточным evidence.
|
||||||
|
|
||||||
### bitrix-sync
|
### bitrix-sync
|
||||||
|
|
||||||
@@ -400,9 +397,9 @@ Identity provider. **Обязателен** в compose-контуре с пер
|
|||||||
|
|
||||||
- ВМ1 `public`: edge nginx, Keycloak proxy и frontend entrypoint.
|
- ВМ1 `public`: edge nginx, Keycloak proxy и frontend entrypoint.
|
||||||
- ВМ1 `backend`: `api-backend`, `bitrix-local-app`, Keycloak, SMS API и Redis DB0/DB1.
|
- ВМ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.
|
- ВМ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.
|
- `observability` существует отдельно на каждой VM и ведёт в её local collector.
|
||||||
|
|
||||||
Базы данных, Redis, OTLP receivers и internal service ports не публикуются. Cross-host calls идут через private network, точные SG и TLS.
|
Базы данных, Redis, OTLP receivers и internal service ports не публикуются. Cross-host calls идут через private network, точные SG и TLS.
|
||||||
@@ -412,8 +409,8 @@ Identity provider. **Обязателен** в compose-контуре с пер
|
|||||||
Минимальные persistent volumes:
|
Минимальные persistent volumes:
|
||||||
|
|
||||||
- ВМ1: Redis DB0/DB1 data, local OTEL queue;
|
- ВМ1: Redis DB0/DB1 data, local OTEL queue;
|
||||||
- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime,
|
- ВМ2: Redis Safety data (rebuildable), internal TLS secrets, local OTEL queue.
|
||||||
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 остаётся
|
Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся
|
||||||
на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные
|
на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные
|
||||||
@@ -561,7 +558,7 @@ WAF не заменяет обязательные лимиты, валидац
|
|||||||
|
|
||||||
- `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response;
|
- `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;
|
- `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-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`;
|
- `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`;
|
||||||
- `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL;
|
- `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL;
|
||||||
@@ -591,8 +588,8 @@ host-side secrets/TLS materialized, controlled migrations и seed заверше
|
|||||||
|
|
||||||
ВМ2 запускается в порядке:
|
ВМ2 запускается в порядке:
|
||||||
|
|
||||||
1. `redis-safety`, `otel-queue-init`, затем local `otel-collector`;
|
1. operator KESL runbook: KESL 12.4/database update, затем enable/start broker socket и проверка status/permissions;
|
||||||
2. `freshclam`, затем `clamd` до состояния healthy;
|
2. `redis-safety`, `otel-queue-init`, затем local `otel-collector`;
|
||||||
3. Message Safety API/worker и `bitrix-sync`;
|
3. Message Safety API/worker и `bitrix-sync`;
|
||||||
4. nginx — последним, после успешного config test;
|
4. nginx — последним, после успешного config test;
|
||||||
5. private HTTPS ВМ1→ВМ2 и capability health проверяются до cutover.
|
5. private HTTPS ВМ1→ВМ2 и capability health проверяются до cutover.
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ worker.lease_seconds=90
|
|||||||
|
|
||||||
## Service-owned настройки `message-safety`
|
## 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
|
Seed, JSON Schema и referenced artifacts входят в immutable Message Safety
|
||||||
image. Их изменение требует одновременно нового pinned image digest и новой
|
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.
|
`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-ом.
|
# /etc/han-chat/message-safety-mode.env и меняется approved helper-ом.
|
||||||
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
||||||
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
|
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
|
||||||
MESSAGE_SAFETY_CLAMAV_HOST=clamd
|
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
|
||||||
MESSAGE_SAFETY_CLAMAV_PORT=3310
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Frontend (nginx)
|
# Frontend (nginx)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ SigNoz относится к этому классу, если его UI, OTLP
|
|||||||
- public `443` разрешает только exact `/bitrix/sync/webhook/contact` и `/bitrix/sync/webhook/alert`; остальные paths закрыты;
|
- 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;
|
- private ingress `8443/tcp` разрешён только от security group ВМ1 и утверждённого ops path для Message Safety/internal API;
|
||||||
- ни один public запрос ВМ2 не проходит через nginx ВМ1;
|
- ни один public запрос ВМ2 не проходит через nginx ВМ1;
|
||||||
- `freshclam` имеет egress только к утверждённым источникам сигнатур;
|
- host KESL имеет egress только к утверждённым источникам обновления из operator KESL runbook; Message Safety и broker не получают общий internet egress;
|
||||||
- `bitrix-sync` имеет HTTPS egress только к утверждённому порталу Bitrix24;
|
- `bitrix-sync` имеет HTTPS egress только к утверждённому порталу Bitrix24;
|
||||||
- Safety worker имеет доступ только к managed PostgreSQL, S3-quarantine и доверенному DNS resolver;
|
- Safety worker имеет доступ только к managed PostgreSQL, S3-quarantine и доверенному DNS resolver;
|
||||||
- локальный OTEL Collector имеет private egress к SigNoz;
|
- локальный OTEL Collector имеет private egress к SigNoz;
|
||||||
@@ -513,23 +513,28 @@ certificate/key проверяются preflight. Успешный hook обяз
|
|||||||
при ненулевом exit code. Иначе Certbot/оркестратор может пометить успешный
|
при ненулевом exit code. Иначе Certbot/оркестратор может пометить успешный
|
||||||
renewal как hook error.
|
renewal как hook error.
|
||||||
|
|
||||||
### Daemon и updater как разные security-профили
|
### Host KESL и root-owned broker
|
||||||
|
|
||||||
Если один vendor image используется для daemon и updater, им задаются разные
|
KESL 12.4 standalone и custom integration broker работают на host VM2, вне
|
||||||
сети, mounts и health semantics. Проверенный паттерн ClamAV:
|
Compose. Граница между non-root Message Safety worker и privileged host:
|
||||||
|
|
||||||
- `clamd` не имеет signature-CDN egress, читает signatures read-only и имеет
|
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`;
|
||||||
healthcheck реального daemon socket;
|
TCP listener и Docker socket запрещены;
|
||||||
- `freshclam` один получает ограниченный egress и write к signatures;
|
- socket доступен только выделенной группе worker; owner/group/mode
|
||||||
- оба используют vendor `init-unprivileged` и только выделенные writable
|
проверяются после каждого restart/reboot;
|
||||||
`/run/clamav`, `/var/log/clamav` и `/tmp`;
|
- worker не получает `kesl-control`, shell или произвольный host path;
|
||||||
- updater запускается как постоянный foreground daemon, чтобы restart policy
|
- broker принимает bounded request, создаёт контролируемый временный файл,
|
||||||
не превращала успешный one-shot exit в download loop/rate limit;
|
вызывает только `kesl-control --scan-file --action Inform` и гарантированно
|
||||||
- унаследованный healthcheck, проверяющий отсутствующий в updater-контейнере
|
очищает временные данные;
|
||||||
daemon, отключается; updater контролируется по `Up`, restart count, логам и
|
- только однозначный `clean` допускает allow; `infected` даёт deny; timeout,
|
||||||
возрасту сигнатур;
|
stale database, неизвестный output/exit code и недоступность scanner дают
|
||||||
- security policy разрешает настроить порог возраста не выше `720` часов
|
retry/`503`;
|
||||||
(30 дней); production-like seed использует более строгие `240` часов.
|
- 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 file system` устраняются точечным writable mount. Запрещено
|
||||||
лечить их глобальным `read_only: false`, root, `privileged` или broad
|
лечить их глобальным `read_only: false`, root, `privileged` или broad
|
||||||
@@ -638,8 +643,8 @@ Fail2ban обязателен для SSH, временно или постоян
|
|||||||
содержимому, а не только по декларации Compose;
|
содержимому, а не только по декларации Compose;
|
||||||
- runtime/migration/config-admin DB roles разделены, временные cross-schema
|
- runtime/migration/config-admin DB roles разделены, временные cross-schema
|
||||||
grants выданы и отозваны владельцем;
|
grants выданы и отозваны владельцем;
|
||||||
- healthcheck проверяет процесс, реально присутствующий в контейнере, а
|
- healthcheck проверяет процесс, реально присутствующий в контейнере, а KESL
|
||||||
updater freshness контролируется отдельным сигналом;
|
broker/database freshness контролируется отдельным host-сигналом;
|
||||||
- healthcheck-команда и все её binaries подтверждены внутри exact pinned
|
- healthcheck-команда и все её binaries подтверждены внутри exact pinned
|
||||||
digest; отсутствие `curl`/`wget` не обнаруживается впервые в production;
|
digest; отсутствие `curl`/`wget` не обнаруживается впервые в production;
|
||||||
- внутренние ports недоступны извне;
|
- внутренние ports недоступны извне;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
- **Safety Service Owner**: API/data contract, capacity result и v2 cutover/rollback sign-off.
|
- **Safety Service Owner**: API/data contract, capacity result и v2 cutover/rollback sign-off.
|
||||||
- **Rule Pack Owner**: rules bundle, corpus, monitor report и version release.
|
- **Rule Pack Owner**: rules bundle, corpus, monitor report и version release.
|
||||||
- **Product Owner**: mnemonic `safety.chat.blocked` и business acceptance chat flow.
|
- **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
|
```text
|
||||||
<PUBLIC_HOST> например chat.example.ru
|
<PUBLIC_HOST> например chat.example.ru
|
||||||
@@ -93,7 +93,7 @@ Sizing и load gates конкретной машины — профильный
|
|||||||
| ВМ2 collector | private SigNoz | TCP 4317 | allow |
|
| ВМ2 collector | private SigNoz | TCP 4317 | allow |
|
||||||
| ВМ2 workers | S3 endpoints | TCP 443 | allow |
|
| ВМ2 workers | S3 endpoints | TCP 443 | allow |
|
||||||
| ВМ2 `bitrix-sync` | approved Bitrix portal | 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 |
|
| ВМ2 | trusted DNS/NTP | UDP/TCP 53, UDP 123 | allow |
|
||||||
| internet | managed PG | any | deny |
|
| internet | managed PG | any | deny |
|
||||||
| internet | ВМ2 | any кроме nginx 80/443 | deny ingress |
|
| internet | ВМ2 | any кроме nginx 80/443 | deny ingress |
|
||||||
@@ -237,11 +237,20 @@ Host ВМ1 — `<PUBLIC_HOST>`, ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8
|
|||||||
|
|
||||||
## 10. Сквозной порядок startup и cutover
|
## 10. Сквозной порядок startup и cutover
|
||||||
|
|
||||||
1. На ВМ2 unit поднимает Redis Safety и local Collector.
|
1. На ВМ2 оператор выполняет KESL runbook: KESL 12.4 standalone, database update/canary.
|
||||||
2. Затем `clamd`/`freshclam`, Safety API/worker и `bitrix-sync`.
|
2. Затем enable/start root-owned broker socket/service и проверяет status/permissions `/run/han-kesl/scan.sock`.
|
||||||
3. Последним на ВМ2 — nginx public `80/443` и private `8443`.
|
3. Unit ВМ2 поднимает Redis Safety и local Collector, затем Safety API/worker и `bitrix-sync`.
|
||||||
4. На ВМ1 — Redis/Collector, API, SMS, Keycloak, local app, edge nginx.
|
4. Последним на ВМ2 — nginx public `80/443` и private `8443`.
|
||||||
5. Только private `MESSAGE_SAFETY_URL` ВМ1 переключается на ВМ2 после Safety gates. Public CRM webhook DNS/routes ВМ2 не требуют изменения ВМ1.
|
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.
|
Legacy single-VM `docker compose up` не является evidence готовности target ВМ2. Один `message_id` нельзя одновременно отправлять в v1 и v2. После cutover ВМ1 не содержит local Safety/Redis DB2.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
Логирование операций
|
Логирование операций
|
||||||
|
|
||||||
1. env:
|
1. env:
|
||||||
|
editor /etc/han/vm1.env
|
||||||
изменение Release_Version
|
изменение 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
|
TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:9461e2c7213219467e5f775ea8652d15c990afb4d1ca94e62344f2025a7587d1
|
||||||
|
|
||||||
2. Обновить образы:
|
2. Обновить образы:
|
||||||
@@ -25,7 +25,7 @@ tar --exclude=backend/.env `
|
|||||||
-czf "vm1-backend-$Release.tar.gz" `
|
-czf "vm1-backend-$Release.tar.gz" `
|
||||||
-C C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/codebase backend
|
-C C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/codebase backend
|
||||||
Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256
|
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'
|
RELEASE='1.0.1'
|
||||||
@@ -91,14 +91,14 @@ sudo apt purge -y dos2unix
|
|||||||
|
|
||||||
|
|
||||||
scp `
|
scp `
|
||||||
C:/Users/MI/.ssh/devDeploy.pub `
|
C:/Users/MI/.ssh/prodDeploy.pub `
|
||||||
C:/Users/MI/.ssh/devAdmin.pub `
|
C:/Users/MI/.ssh/prodAdmin.pub `
|
||||||
devVM1Deploy:/var/lib/han-deploy/incoming/
|
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/prodDeploy.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/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
|
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 \
|
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
|
||||||
/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh
|
/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 \
|
/usr/local/bin/otelcol-contrib validate \
|
||||||
--config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
|
--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
|
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||||
|
|
||||||
systemctl restart han-stack@production.service
|
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/nginx@sha256:2cd7d32967b400c15bb6aaa18e97e99d429b2b6f2bfd54b965fc7d42a91a228e
|
||||||
docker pull cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898
|
docker pull cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898
|
||||||
|
|
||||||
systemctl start han-host-otel-collector@production.service
|
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
|
|
||||||
@@ -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 '<OPERATOR>'
|
||||||
|
|
||||||
|
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||||
|
--entrypoint message-safety-config message-safety-migrate \
|
||||||
|
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
|
||||||
|
|
||||||
|
Вернуть затертые 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 '<OPERATOR>'
|
||||||
|
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||||||
|
--entrypoint message-safety-config message-safety-migrate \
|
||||||
|
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
|
||||||
|
/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`.
|
||||||
@@ -5,4 +5,9 @@
|
|||||||
| grep -B10 -A40 'bfffb1c2-b938-4443-9c42-5a9b87fcf6b3'
|
| grep -B10 -A40 'bfffb1c2-b938-4443-9c42-5a9b87fcf6b3'
|
||||||
|
|
||||||
/usr/local/sbin/han-vm1-compose logs --since=30m api-backend \
|
/usr/local/sbin/han-vm1-compose logs --since=30m api-backend \
|
||||||
| grep -B5 -A20 'request.failed'
|
| 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"
|
||||||
@@ -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 локально
|
### 1. Получите digest локально
|
||||||
|
|
||||||
В WSL:
|
В WSL:
|
||||||
|
|||||||
Reference in New Issue
Block a user