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

This commit is contained in:
mi
2026-09-08 01:39:37 +03:00
parent 85df788f2d
commit fdfdeaffb4
43 changed files with 2210 additions and 329 deletions
+1 -3
View File
@@ -7,7 +7,6 @@ MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
NGINX_IMAGE=nginxinc/nginx-unprivileged@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>
REDIS_EXPORTER_IMAGE=oliver006/redis_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_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
@@ -46,7 +46,9 @@ The full step-by-step procedure with gates and copy-paste commands is in
role. Migration credentials are mounted only into the `ops` profile jobs.
9. The setup script leaves UFW egress open for bootstrap. Before production,
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
S3, Secrets Manager, Bitrix24, DNS/NTP and SigNoz destinations. Host KESL
receives only update-source egress approved by
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
Registry/package access exists only during controlled maintenance windows.
## Who runs what
@@ -78,8 +80,9 @@ Compose first:
`bitrix_sync`, separate migration/runtime DSNs; see
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
otel-collector, Redis exporter and nginx exporter).
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
otel-collector, Redis exporter and nginx exporter). `clamd`/`freshclam` are
absent from Compose; KESL 12.4 standalone and its broker run on the host.
4. **Selectel Secrets Manager** — populate all remote names from
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
@@ -157,6 +160,13 @@ Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
`han-processing.service` until Gate 5 completes successfully.
Before Message Safety starts in Gate 5, complete the operator KESL runbook,
then enable/start `han-kesl-scan-broker.socket` and verify KESL/socket status
plus `root:han-message-safety 0660` on `/run/han-kesl/scan.sock`. Only then
start Message Safety. The broker is a custom integration: exact output/exit
semantics of `kesl-control --scan-file --action Inform`, cleanup and throughput
must pass on the target VM2.
| Gate | Purpose |
| --- | --- |
| 1 | Secrets materialized via `han-secrets-vm2.service` |
@@ -195,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
briefly.
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
timer, unit journals — without secret values.
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
version/database date, hourly update, broker/socket status, OTEL queue,
disk/RAM, MOCK mode, private Safety API).
5. Proceed to controlled Message Safety cutover on VM1 — see
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
@@ -209,8 +220,8 @@ Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
- Stale/unavailable ClamAV signatures disable file capability only; they never
convert a scan error to allow.
- A stale/unavailable KESL database or broker error disables file capability
only; scan errors remain retryable and eventually return `503`, never allow.
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
- OTEL outage queues within the bounded volume and must not change verdicts.
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
@@ -234,10 +245,12 @@ health, and restores the previous mode on failure. MOCK has no timeout: keep a
high-severity alert active until explicit `standard`, then verify normal
text/link/file capabilities and an EICAR canary.
## Known image exceptions
## Host KESL and broker
ClamAV images may require UID/path adjustments after validating the exact
digest. Do not weaken `read_only`, capabilities or mounts globally: document
the smallest writable signature/runtime paths and compensate with network and
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
receives none.
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
images. The Message Safety worker receives only
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
of KESL version plus database date. Installation, hourly database updates,
socket permissions, clean/EICAR/error/stale gates and rollback follow
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -51,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
`ops`.
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
production ограничьте egress правилами Selectel SG/NAT/proxy до
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
источников ClamAV. Registry/package repositories оставляйте только на
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
Для host KESL разрешите только источники обновления из
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
controlled maintenance window.
## Кто что выполняет
@@ -87,7 +88,8 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
clamav, otel-collector, Redis exporter, nginx exporter).
otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в
Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host.
4. **Selectel Secrets Manager** — заполнить все remote names из
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
@@ -526,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
При обновлении KESL policy образ Message Safety должен содержать согласованные
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
@@ -548,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
message-safety-api message-safety-worker
unset NEXT_VERSION
```
@@ -588,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
Под `root` на VM2:
```sh
cd /opt/han-chat/services
# Сначала полностью выполните operator runbook:
# deployment/kesl/RUNBOOK.KESL.ru.md
systemctl enable --now han-kesl-scan-broker.socket
systemctl --no-pager status kesl han-kesl-scan-broker.socket
test -S /run/han-kesl/scan.sock
stat -c '%U:%G %a %n' /run/han-kesl/scan.sock
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
@@ -598,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
/usr/local/sbin/han-vm2-compose ps
```
До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4
standalone, успешное ежечасное обновление, допустимый database date и canary.
Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним
UID. Broker является custom
integration: неизвестный output/exit code `kesl-control --scan-file --action Inform`
считается scanner error, а не clean. Формат и throughput проверяются на target VM2.
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
сам Collector стартует только после этого.
@@ -892,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- KESL version/database date, результат ежечасного update и broker/socket status;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
@@ -1107,8 +1123,9 @@ plan.
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
capability; ошибка сканирования никогда не превращается в allow и ведёт к
retry/`503`.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
@@ -1134,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
## Host KESL и broker
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
образами. Message Safety worker получает только Unix socket
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
hash KESL version + database date. Установка, ежечасное обновление, права
socket, clean/EICAR/error/stale gates и rollback выполняются строго по
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -1,7 +1,7 @@
[Unit]
Description=HAN Processing VM2 root Compose stack
Requires=docker.service han-secrets-vm2.service
After=docker.service han-secrets-vm2.service network-online.target
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
[Service]
Type=oneshot
@@ -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"
/usr/bin/getent group han-message-safety | /usr/bin/awk -F: '$3 == 10001 {found=1} END {exit !found}' ||
fail "han-message-safety group with GID 10001 is missing"
[ -x /opt/kaspersky/kesl/bin/kesl-control ] ||
fail "KESL 12.4 control utility is missing"
/usr/bin/systemctl is-active --quiet kesl.service ||
fail "kesl.service is not active"
/usr/bin/systemctl is-active --quiet han-kesl-scan-broker.socket ||
fail "KESL scan broker socket is not active"
[ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl 2>/dev/null || true)" = \
"root:han-message-safety:750" ] ||
fail "KESL broker runtime directory must be root:han-message-safety 0750"
[ -S /run/han-kesl/scan.sock ] || fail "KESL scan broker Unix socket is missing"
if [ -S /run/han-kesl/scan.sock ]; then
[ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl/scan.sock)" = \
"root:han-message-safety:660" ] ||
fail "KESL broker socket must be root:han-message-safety 0660"
fi
[ -x /usr/local/libexec/han-kesl-scan-broker ] &&
/usr/local/libexec/han-kesl-scan-broker --probe >/dev/null ||
fail "KESL scan broker readiness probe failed"
public_tls_dir=/var/lib/han-chat/public-tls
/usr/bin/getent group han-nginx-tls | /usr/bin/awk -F: '$3 == 11001 {found=1} END {exit !found}' ||
@@ -59,7 +77,7 @@ if [ -f "$ENV_FILE" ]; then
true|false) ;;
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
esac
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do
image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE")
echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' ||
fail "$image_key must be pinned by sha256 digest"
@@ -224,7 +242,11 @@ fi
for protected in \
"$ROOT/docker-compose.yml" \
"$ROOT/deployment/han-message-safety-mode" \
"$ROOT/deployment/han-processing.service"
"$ROOT/deployment/han-processing.service" \
"$ROOT/deployment/kesl/kesl_scan_broker.py" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.service" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.socket" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf"
do
[ -f "$protected" ] || continue
owner=$(/usr/bin/stat -c '%U:%G' "$protected")
@@ -603,6 +603,10 @@ install_release_helpers_if_possible() {
install -m 0755 -o root -g root \
"${deployment}/han-message-safety-mode" \
/usr/local/sbin/han-message-safety-mode
install -d -m 0755 -o root -g root /usr/local/libexec
install -m 0750 -o root -g root \
"${deployment}/kesl/kesl_scan_broker.py" \
/usr/local/libexec/han-kesl-scan-broker
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
install -m 0755 -o root -g root \
"${deployment}/scripts/ssl-renew-deploy-hook.sh" \
@@ -613,6 +617,16 @@ install_release_helpers_if_possible() {
install -m 0644 -o root -g root \
"${deployment}/han-processing.service" \
/etc/systemd/system/han-processing.service
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.service" \
/etc/systemd/system/han-kesl-scan-broker.service
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.socket" \
/etc/systemd/system/han-kesl-scan-broker.socket
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.tmpfiles.conf" \
/etc/tmpfiles.d/han-kesl-scan-broker.conf
systemd-tmpfiles --create /etc/tmpfiles.d/han-kesl-scan-broker.conf
safety_sudoers="$(mktemp)"
sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers"
chmod 0440 "$safety_sudoers"
@@ -22,8 +22,7 @@ x-message-safety-environment: &message-safety-environment
MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080}
MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5}
MESSAGE_SAFETY_DNS_RESOLVERS: ${MESSAGE_SAFETY_DNS_RESOLVERS:?set trusted DNS resolvers}
MESSAGE_SAFETY_CLAMAV_HOST: ${MESSAGE_SAFETY_CLAMAV_HOST:-clamd}
MESSAGE_SAFETY_CLAMAV_PORT: ${MESSAGE_SAFETY_CLAMAV_PORT:-3310}
MESSAGE_SAFETY_ANTIVIRUS_SOCKET: /run/han-kesl/scan.sock
MESSAGE_SAFETY_ARTIFACTS_DIR: ${MESSAGE_SAFETY_ARTIFACTS_DIR:-/app/app/artifacts}
MESSAGE_SAFETY_MODE_FILE: /etc/han-chat/message-safety-mode.env
PG_CA_FILE: /run/config/postgresql-ca.pem
@@ -169,49 +168,6 @@ services:
mem_limit: 64m
cpus: 0.25
clamd:
<<: *hardening
image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest}
entrypoint: ["/init-unprivileged"]
user: "100:101"
command: ["clamd", "--foreground=true"]
volumes:
- clamav-signatures:/var/lib/clamav:ro
- clamav-runtime:/run/clamav
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
- /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750
networks: [backend]
expose: ["3310"]
healthcheck:
test: ["CMD-SHELL", "clamdscan --ping 1 >/dev/null 2>&1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
pids_limit: 300
mem_limit: 2g
cpus: 2.0
freshclam:
<<: *hardening
image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest}
entrypoint: ["/init-unprivileged"]
user: "100:101"
command: ["freshclam", "--daemon", "--foreground", "--checks=12"]
volumes:
- clamav-signatures:/var/lib/clamav
- clamav-runtime:/run/clamav
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
- /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750
healthcheck:
disable: true
networks: [signature-egress]
pids_limit: 100
mem_limit: 256m
cpus: 0.5
otel-queue-init:
<<: *hardening
image: ${REDIS_IMAGE:?set immutable Redis image digest}
@@ -270,6 +226,7 @@ services:
MESSAGE_SAFETY_PROCESS_ROLE: api
volumes:
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
- /run/han-kesl:/run/han-kesl:ro
- *postgres-ca-volume
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
@@ -306,6 +263,7 @@ services:
MESSAGE_SAFETY_PROCESS_ROLE: worker
volumes:
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
- /run/han-kesl:/run/han-kesl:ro
- *postgres-ca-volume
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=256m
@@ -322,8 +280,6 @@ services:
depends_on:
redis-safety:
condition: service_healthy
clamd:
condition: service_healthy
pids_limit: 400
mem_limit: 2g
cpus: 2.0
@@ -465,13 +421,10 @@ networks:
internal: true
safety-egress:
bitrix-egress:
signature-egress:
telemetry-egress:
volumes:
redis-safety-data:
clamav-signatures:
clamav-runtime:
otel-queue:
secrets:
@@ -30,8 +30,16 @@ message-safety-config activate --version 1 --approved-by security-owner
`docker-compose.fragment.yml` is an include fragment for the root VM2 Compose. It publishes no
host port, runs API and worker as UID 10001 with a read-only filesystem, drops all capabilities,
and mounts only service-specific secret files. The root project owns networks/secrets and the
root-owned emergency mode file.
and mounts only service-specific secret files plus the broker Unix socket
`/run/han-kesl/scan.sock` into the worker. The root project owns networks/secrets and the
root-owned emergency mode file. KESL 12.4 standalone and its root-owned fail-closed broker are
host services, not Compose services; `clamd` and `freshclam` are absent from Compose.
The broker invokes the fixed host command `kesl-control --scan-file --action Inform`. A clean
result may continue to allow, an infected result denies, and scanner errors, unknown output,
timeouts or a stale KESL database remain retryable and eventually return `503` rather than
allowing content. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash of KESL
version plus database date. KESL database updates run hourly under the operator KESL runbook.
## External release gates
@@ -41,8 +49,10 @@ target environment verifies them:
- Selectel S3 supports version-specific `GetObject`, signed conditional ETag behavior, bucket
versioning, checksum metadata, virtual-host addressing and a read-only IAM policy without
list/write/delete.
- ClamAV engine/signature metadata is supplied to readiness and task cache keys; freshclam
activate/reload, signature-age alarms and clean/EICAR/malformed corpora pass on VM2.
- KESL version/database date is supplied through the broker to readiness and task cache keys;
hourly update, stale-database alarms and clean/EICAR/malformed corpora pass on VM2.
- The broker is a custom integration: exact `kesl-control` output/exit semantics, socket
permissions, cleanup and throughput must pass gates on the target VM2 with KESL 12.4.
- HEIF native decoding and PDF parser sandbox resource limits pass the approved corpus. The
in-process detector is bounded by 5 MiB and validates active/encrypted PDF markers, but OS-level
CPU/memory/wall-time isolation must be enforced by the worker container and target runtime.
@@ -96,6 +96,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
@app.get("/health/ready")
async def ready() -> JSONResponse:
await service.refresh_antivirus()
mode = "mock" if service.mode.mock else "standard"
components = {
"postgres": "ok",
@@ -2,9 +2,9 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "clamav", "file_policy"],
"required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "antivirus", "file_policy"],
"properties": {
"schema_version": {"const": 1},
"schema_version": {"const": 2},
"rules_bundle_ref": {"type": "string", "pattern": "^rules-[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
"detector_manifest_ref": {"const": "detector-2026-08-03"},
"task": {
@@ -48,9 +48,13 @@
"pipeline_timeout_sec": {"type": "number", "exclusiveMinimum": 0, "maximum": 5}
}
},
"clamav": {
"type": "object", "additionalProperties": false, "required": ["scan_timeout_sec", "max_signature_age_hours"],
"properties": {"scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 120}, "max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720}}
"antivirus": {
"type": "object", "additionalProperties": false, "required": ["engine", "scan_timeout_sec", "max_signature_age_hours"],
"properties": {
"engine": {"const": "kesl"},
"scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 300},
"max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720}
}
},
"file_policy": {
"type": "object", "additionalProperties": false, "required": ["enabled_mime_types", "max_size_bytes"],
@@ -1,4 +1,4 @@
schema_version: 1
schema_version: 2
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
@@ -21,8 +21,9 @@ link:
url_max_length: 2048
dns_lookup_timeout_sec: 1
pipeline_timeout_sec: 2
clamav:
scan_timeout_sec: 45
antivirus:
engine: kesl
scan_timeout_sec: 60
max_signature_age_hours: 240
file_policy:
enabled_mime_types:
@@ -7,6 +7,8 @@ import json
import struct
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Protocol
@@ -33,7 +35,24 @@ class ObjectReader(Protocol):
class Antivirus(Protocol):
async def scan(self, chunks: AsyncIterator[bytes]) -> str | None: ...
async def scan(
self, chunks: AsyncIterator[bytes], *, scan_timeout: float
) -> AntivirusScanResult: ...
async def status(self) -> AntivirusStatus: ...
@dataclass(frozen=True)
class AntivirusScanResult:
threat: str | None
signatures_version: str
@dataclass(frozen=True)
class AntivirusStatus:
engine_version: str
signatures_version: str
databases_date: datetime
@dataclass(frozen=True)
@@ -119,65 +138,101 @@ async def collect_and_hash(
return bytes(body), digest.digest()
class ClamAvInstream:
def __init__(self, host: str, port: int, timeout: float = 45.0) -> None:
self.host, self.port, self.timeout = host, port, timeout
class KeslSocketScanner:
MAX_HEADER = 4096
MAX_RESPONSE = 16 * 1024
async def scan(self, chunks: AsyncIterator[bytes]) -> str | None:
def __init__(self, socket_path: Path, timeout: float = 60.0) -> None:
self.socket_path = socket_path
self.timeout = timeout
async def _request(
self,
header: dict[str, object],
body: bytes = b"",
*,
request_timeout: float | None = None,
) -> dict[str, object]:
encoded = json.dumps(header, separators=(",", ":")).encode()
if len(encoded) > self.MAX_HEADER:
raise DependencyFailure("KESL request header is too large")
async def operation() -> dict[str, object]:
reader, writer = await asyncio.open_unix_connection(str(self.socket_path))
try:
writer.write(struct.pack(">I", len(encoded)) + encoded + body)
await writer.drain()
(size,) = struct.unpack(">I", await reader.readexactly(4))
if size < 2 or size > self.MAX_RESPONSE:
raise DependencyFailure("invalid KESL broker response size")
value = json.loads((await reader.readexactly(size)).decode())
if not isinstance(value, dict):
raise DependencyFailure("invalid KESL broker response")
return value
finally:
writer.close()
await writer.wait_closed()
try:
return await asyncio.wait_for(operation(), request_timeout or self.timeout)
except (OSError, TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError) as exc:
raise DependencyFailure("KESL unavailable") from exc
async def scan(
self, chunks: AsyncIterator[bytes], *, scan_timeout: float
) -> AntivirusScanResult:
body = bytearray()
async for chunk in chunks:
body.extend(chunk)
with tracer.start_as_current_span(
"message_safety.clamav.scan",
attributes={"server.address.type": "clamav"},
"message_safety.antivirus.scan",
attributes={"message_safety.antivirus.engine": "kesl"},
):
return await self._scan(chunks)
async def _scan(self, chunks: AsyncIterator[bytes]) -> str | None:
async def operation() -> str | None:
reader, writer = await asyncio.open_connection(self.host, self.port)
try:
writer.write(b"zINSTREAM\0")
async for chunk in chunks:
writer.write(struct.pack(">I", len(chunk)) + chunk)
await writer.drain()
writer.write(struct.pack(">I", 0))
await writer.drain()
result = await reader.readuntil(b"\0")
text = result.rstrip(b"\0").decode("utf-8", "replace")
if text.endswith(" OK"):
return None
if text.endswith(" FOUND"):
return text.rsplit(": ", 1)[-1].removesuffix(" FOUND")
raise DependencyFailure("invalid ClamAV response")
finally:
writer.close()
await writer.wait_closed()
try:
return await asyncio.wait_for(operation(), self.timeout)
except (OSError, TimeoutError) as exc:
raise DependencyFailure("ClamAV unavailable") from exc
async def signatures_version(self) -> str:
with tracer.start_as_current_span("message_safety.clamav.version"):
return await self._signatures_version()
async def _signatures_version(self) -> str:
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port), 2.0
value = await self._request(
{"op": "scan", "size": len(body)},
bytes(body),
request_timeout=scan_timeout,
)
if value.get("status") != "completed" or value.get("verdict") not in {
"clean",
"infected",
}:
raise DependencyFailure("KESL scan failed")
signatures = value.get("signatures_version")
if not isinstance(signatures, str) or not signatures.startswith("sha256:"):
raise DependencyFailure("KESL signatures version is missing")
threat = value.get("threat")
if value["verdict"] == "infected" and not isinstance(threat, str):
raise DependencyFailure("KESL infected verdict has no threat")
return AntivirusScanResult(
threat=threat if isinstance(threat, str) else None,
signatures_version=signatures,
)
async def status(self) -> AntivirusStatus:
with tracer.start_as_current_span(
"message_safety.antivirus.status",
attributes={"message_safety.antivirus.engine": "kesl"},
):
value = await self._request({"op": "status"})
if value.get("status") != "ready":
raise DependencyFailure("KESL is not ready")
try:
database_date = datetime.fromisoformat(
str(value["databases_date"]).replace("Z", "+00:00")
)
except ValueError:
try:
writer.write(b"zVERSION\0")
await writer.drain()
raw = await asyncio.wait_for(reader.readuntil(b"\0"), 2.0)
finally:
writer.close()
await writer.wait_closed()
except (OSError, TimeoutError) as exc:
raise DependencyFailure("ClamAV unavailable") from exc
value = raw.rstrip(b"\0")
if not value.startswith(b"ClamAV ") or len(value) > 512:
raise DependencyFailure("invalid ClamAV version response")
return "sha256:" + hashlib.sha256(value).hexdigest()
database_date = parsedate_to_datetime(str(value["databases_date"]))
except (TypeError, ValueError) as exc:
raise DependencyFailure("invalid KESL database date") from exc
if database_date.tzinfo is None:
database_date = database_date.replace(tzinfo=UTC)
engine = value.get("engine_version")
signatures = value.get("signatures_version")
if not isinstance(engine, str) or not isinstance(signatures, str):
raise DependencyFailure("incomplete KESL status")
return AntivirusStatus(engine, signatures, database_date.astimezone(UTC))
async def one_chunk(data: bytes) -> AsyncIterator[bytes]:
@@ -8,7 +8,7 @@ from app.adapters import TrustedDnsResolver
from app.api import create_app
from app.config import ActiveConfig, validate_config
from app.db import engine_and_sessions
from app.file_pipeline import ClamAvInstream, DependencyFailure
from app.file_pipeline import DependencyFailure, KeslSocketScanner
from app.repository import Repository
from app.service import SafetyService
from app.settings import BootstrapSettings, EmergencyMode
@@ -29,13 +29,14 @@ async def build_runtime() -> tuple[object, object]:
resolver = TrustedDnsResolver(
[item.strip() for item in settings.dns_resolvers.split(",") if item.strip()]
)
clamav = ClamAvInstream(settings.clamav_host, settings.clamav_port)
antivirus = KeslSocketScanner(settings.antivirus_socket)
if mode.mock:
signatures_version = "unavailable"
files_ready = False
else:
try:
signatures_version = await clamav.signatures_version()
status = await antivirus.status()
signatures_version = status.signatures_version
files_ready = True
except DependencyFailure:
signatures_version = "unavailable"
@@ -47,6 +48,7 @@ async def build_runtime() -> tuple[object, object]:
resolver,
files_ready=files_ready,
signatures_version=signatures_version,
antivirus=antivirus,
)
app = create_app(service, settings.service_token.get_secret_value())
instrument_fastapi(app)
@@ -90,7 +90,7 @@ class Repository:
FileVerdictCache.config_version == config.version,
FileVerdictCache.rules_version == config.rules_version,
FileVerdictCache.detector_version == config.detector.version,
FileVerdictCache.scanner_engine == "clamav",
FileVerdictCache.scanner_engine == "kesl",
FileVerdictCache.signatures_version == signatures_version,
FileVerdictCache.expires_at > func.now(),
)
@@ -262,9 +262,29 @@ class Repository:
return result.rowcount == 1
async def finish(
self, task_id: uuid.UUID, owner: str, generation: int, *, allow: bool, rule_id: str
self,
task_id: uuid.UUID,
owner: str,
generation: int,
*,
allow: bool,
rule_id: str,
signatures_version: str | None = None,
) -> bool:
now = datetime.now(UTC)
values = {
"status": TaskStatus.allowed if allow else TaskStatus.denied,
"verdict": "allow" if allow else "deny",
"rule_id": rule_id,
"reason_code": None if allow else "message_blocked",
"finished_at": now,
"purge_after": now + timedelta(days=30),
"updated_at": now,
"lease_owner": None,
"lease_until": None,
}
if signatures_version is not None:
values["signatures_version"] = signatures_version
async with self.sessions.begin() as session:
result = await session.execute(
update(SafetyTask)
@@ -275,17 +295,7 @@ class Repository:
SafetyTask.lease_generation == generation,
SafetyTask.lease_until > func.now(),
)
.values(
status=TaskStatus.allowed if allow else TaskStatus.denied,
verdict="allow" if allow else "deny",
rule_id=rule_id,
reason_code=None if allow else "message_blocked",
finished_at=now,
purge_after=now + timedelta(days=30),
updated_at=now,
lease_owner=None,
lease_until=None,
)
.values(**values)
)
if result.rowcount == 1:
await session.execute(
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
from time import perf_counter
@@ -14,7 +15,7 @@ from app.db import (
TaskStatus,
TextRulesCache,
)
from app.file_pipeline import validate_metadata
from app.file_pipeline import Antivirus, DependencyFailure, validate_metadata
from app.fingerprint import fingerprint
from app.normalization import normalize_text
from app.rate_limit import ConservativeRateLimiter
@@ -52,6 +53,7 @@ class SafetyService:
links_ready: bool = True,
files_ready: bool = True,
signatures_version: str = "unverified",
antivirus: Antivirus | None = None,
) -> None:
self.repository = repository
self.config = config
@@ -60,11 +62,40 @@ class SafetyService:
self.links_ready = links_ready
self.files_ready = files_ready
self.signatures_version = signatures_version
self.antivirus = antivirus
self._antivirus_checked_at = 0.0
self._antivirus_check_lock = asyncio.Lock()
self.rate_limiter = ConservativeRateLimiter(
config.document["rate"]["text_rps"], config.document["rate"]["file_rps"]
)
record_runtime_state("mock" if mode.mock else "standard", config.version)
async def refresh_antivirus(self) -> bool:
if self.mode.mock:
self.files_ready = False
return False
if self.antivirus is None:
return self.files_ready
if perf_counter() - self._antivirus_checked_at < 5:
return self.files_ready
async with self._antivirus_check_lock:
if perf_counter() - self._antivirus_checked_at < 5:
return self.files_ready
try:
status = await self.antivirus.status()
maximum_age = timedelta(
hours=self.config.document["antivirus"]["max_signature_age_hours"]
)
if datetime.now(UTC) - status.databases_date > maximum_age:
raise DependencyFailure("KESL databases are stale")
self.signatures_version = status.signatures_version
self.files_ready = True
except DependencyFailure:
self.signatures_version = "unavailable"
self.files_ready = False
self._antivirus_checked_at = perf_counter()
return self.files_ready
def _verdict(
self,
allow: bool,
@@ -222,7 +253,7 @@ class SafetyService:
)
async def _check_file(self, request: FileCheck, digest: bytes) -> Verdict | Pending:
if not self.files_ready:
if not await self.refresh_antivirus():
raise CapabilityUnavailable("files")
rule = validate_metadata(
request.attachment,
@@ -274,7 +305,7 @@ class SafetyService:
declared_checksum=request.attachment.checksum,
rules_version=self.config.rules_version,
detector_version=self.config.detector.version,
scanner_engine="clamav",
scanner_engine="kesl",
signatures_version=self.signatures_version,
origin_trace_id=trace_id,
origin_span_id=span_id,
@@ -32,8 +32,10 @@ class BootstrapSettings(BaseSettings):
default=5, ge=1, le=32, alias="MESSAGE_SAFETY_WORKER_CONCURRENCY"
)
dns_resolvers: str = Field(default="", alias="MESSAGE_SAFETY_DNS_RESOLVERS")
clamav_host: str = Field(default="clamd", alias="MESSAGE_SAFETY_CLAMAV_HOST")
clamav_port: int = Field(default=3310, ge=1, le=65535, alias="MESSAGE_SAFETY_CLAMAV_PORT")
antivirus_socket: Path = Field(
default=Path("/run/han-kesl/scan.sock"),
alias="MESSAGE_SAFETY_ANTIVIRUS_SOCKET",
)
s3_endpoint_url: str = Field(alias="SELECTEL_S3_ENDPOINT_URL")
s3_bucket: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
artifacts_dir: Path = Field(
@@ -12,8 +12,9 @@ from app.config import validate_config
from app.contracts import Attachment
from app.db import FileVerdictCache, SafetyAudit, engine_and_sessions
from app.file_pipeline import (
ClamAvInstream,
Antivirus,
DependencyFailure,
KeslSocketScanner,
ObjectChanged,
collect_and_hash,
detect_format,
@@ -34,7 +35,7 @@ tracer = trace.get_tracer("message-safety.worker")
class Worker:
def __init__(
self, repository: Repository, reader: S3VersionReader, antivirus: ClamAvInstream, artifacts
self, repository: Repository, reader: S3VersionReader, antivirus: Antivirus, artifacts
) -> None:
self.repository, self.reader, self.antivirus, self.artifacts = (
repository,
@@ -94,10 +95,15 @@ class Worker:
)
record_dependency("s3", "get_object", "success")
rule = detect_format(body, attachment.mime_type)
signatures_version = task.signatures_version
if not rule:
malware = await self.antivirus.scan(one_chunk(body))
record_dependency("clamav", "scan", "success")
rule = "file.malware_detected" if malware else None
scan = await self.antivirus.scan(
one_chunk(body),
scan_timeout=row.config["antivirus"]["scan_timeout_sec"],
)
signatures_version = scan.signatures_version
record_dependency("antivirus", "scan", "success")
rule = "file.malware_detected" if scan.threat else None
with tracer.start_as_current_span("message_safety.worker.finalize"):
finished = await self.repository.finish(
task.id,
@@ -105,6 +111,7 @@ class Worker:
task.lease_generation,
allow=rule is None,
rule_id=rule or "safety.all_checks_passed",
signatures_version=signatures_version,
)
if finished:
record_worker("allow" if rule is None else "deny", task_age)
@@ -116,7 +123,7 @@ class Worker:
rules_version=task.rules_version,
detector_version=task.detector_version,
scanner_engine=task.scanner_engine,
signatures_version=task.signatures_version,
signatures_version=signatures_version,
verdict="allow" if rule is None else "deny",
rule_id=rule or "safety.all_checks_passed",
reason_code=None if rule is None else "message_blocked",
@@ -192,7 +199,7 @@ async def serve() -> None:
settings.s3_access_key.get_secret_value(),
settings.s3_secret_key.get_secret_value(),
),
ClamAvInstream(settings.clamav_host, settings.clamav_port),
KeslSocketScanner(settings.antivirus_socket),
settings.artifacts_dir,
)
async with asyncio.TaskGroup() as group:
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import httpx
@@ -7,6 +8,7 @@ import pytest
from app.api import create_app
from app.db import TaskStatus
from app.file_pipeline import AntivirusStatus
from app.repository import ConflictError
from app.service import SafetyService
from app.settings import EmergencyMode
@@ -54,6 +56,15 @@ class ForbiddenResolver:
raise AssertionError("MOCK must not call DNS")
class StaleAntivirus:
async def status(self):
return AntivirusStatus(
"12.4",
"sha256:" + "a" * 64,
datetime.now(UTC) - timedelta(hours=241),
)
def body(kind: str, message_id=None) -> dict:
value = {
"message_id": str(message_id or uuid4()),
@@ -188,3 +199,27 @@ async def test_final_task_response_keeps_task_config_snapshot(active_config) ->
assert final.status_code == 200
assert final.json()["config_version"] == 99
async def test_stale_kesl_databases_disable_only_files(active_config) -> None:
repo = FakeRepository()
service = SafetyService(
repo,
active_config,
EmergencyMode(),
ForbiddenResolver(),
antivirus=StaleAntivirus(),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=create_app(service, "secret")),
base_url="http://test",
headers={"X-Service-Token": "secret"},
) as client:
file_response = await client.post(
"/internal/safety/v2/messages/check", json=body("file")
)
text_response = await client.post(
"/internal/safety/v2/messages/check", json=body("text")
)
assert file_response.status_code == 503
assert text_response.status_code == 200
@@ -35,12 +35,13 @@ def test_config_cross_field_and_manifest_subset(artifacts: Path) -> None:
validate_config(bad, artifacts)
def test_clamav_signature_age_policy_bounds(artifacts: Path) -> None:
def test_kesl_signature_age_policy_bounds(artifacts: Path) -> None:
document = seed(artifacts)
assert document["clamav"]["max_signature_age_hours"] == 240
document["clamav"]["max_signature_age_hours"] = 720
assert document["antivirus"]["engine"] == "kesl"
assert document["antivirus"]["max_signature_age_hours"] == 240
document["antivirus"]["max_signature_age_hours"] = 720
validate_config(document, artifacts)
document["clamav"]["max_signature_age_hours"] = 721
document["antivirus"]["max_signature_age_hours"] = 721
with pytest.raises(ValidationError):
validate_config(document, artifacts)
@@ -1,14 +1,24 @@
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import struct
from pathlib import Path
from uuid import UUID
import pytest
from PIL import Image
from app.contracts import Attachment
from app.file_pipeline import ObjectChanged, collect_and_hash, detect_format
from app.file_pipeline import (
KeslSocketScanner,
ObjectChanged,
collect_and_hash,
detect_format,
one_chunk,
)
def image_bytes(format_name: str) -> bytes:
@@ -71,3 +81,65 @@ async def test_authoritative_stream_hash_and_size() -> None:
assert body == data and digest == hashlib.sha256(data).digest()
with pytest.raises(ObjectChanged):
await collect_and_hash(Reader(data), attachment(data, size=len(data) + 1), max_size=100)
class FakeWriter:
def __init__(self) -> None:
self.request = bytearray()
def write(self, value: bytes) -> None:
self.request.extend(value)
async def drain(self) -> None:
return None
def close(self) -> None:
return None
async def wait_closed(self) -> None:
return None
def framed(value: dict[str, object]):
body = json.dumps(value).encode()
reader = __import__("asyncio").StreamReader()
reader.feed_data(struct.pack(">I", len(body)) + body)
reader.feed_eof()
return reader
async def test_kesl_socket_clean_infected_and_status(monkeypatch, tmp_path: Path) -> None:
responses = [
{
"status": "completed",
"verdict": "clean",
"threat": None,
"engine_version": "12.4",
"signatures_version": "sha256:" + "a" * 64,
},
{
"status": "completed",
"verdict": "infected",
"threat": "EICAR-Test-File",
"engine_version": "12.4",
"signatures_version": "sha256:" + "b" * 64,
},
{
"status": "ready",
"engine_version": "12.4",
"databases_date": "2026-09-07T11:25:00+00:00",
"signatures_version": "sha256:" + "c" * 64,
},
]
async def connect(_):
return framed(responses.pop(0)), FakeWriter()
monkeypatch.setattr(asyncio, "open_unix_connection", connect, raising=False)
scanner = KeslSocketScanner(tmp_path / "scan.sock")
clean = await scanner.scan(one_chunk(b"clean"), scan_timeout=1)
infected = await scanner.scan(one_chunk(b"eicar"), scan_timeout=1)
status = await scanner.status()
assert clean.threat is None
assert infected.threat == "EICAR-Test-File"
assert status.engine_version == "12.4"