Внедрение KESL на ВМ2 + замена CLAMAV на KESL
This commit is contained in:
@@ -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]")
|
||||
Reference in New Issue
Block a user