Внедрение 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
@@ -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())