131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""Virus scan through clamd (ClamAV), optional. Off when CLAMAV_HOST is empty.
|
|||
|
|
|
||
|
|
The file is streamed to clamd with zINSTREAM: the command, then chunks each led by a 4-byte big-endian length, then a
|
||
|
|
zero length. clamd answers `stream: OK` or `stream: <signature> FOUND`. When a scanner is configured and cannot be
|
||
|
|
reached, the upload is refused (fail closed).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import socket
|
||
|
|
import struct
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from ..api.errors import ApiError
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
CHUNK = 256 * 1024
|
||
|
|
REPLY_MAX = 4096
|
||
|
|
MSG_INFECTED = "That file failed the virus scan and was not stored."
|
||
|
|
MSG_UNAVAILABLE = "The virus scanner is not reachable. Try again in a minute."
|
||
|
|
|
||
|
|
|
||
|
|
class ScanError(Exception):
|
||
|
|
"""The scanner could not be reached or answered badly."""
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ScanResult:
|
||
|
|
clean: bool
|
||
|
|
signature: str | None
|
||
|
|
|
||
|
|
|
||
|
|
class ClamdScanner:
|
||
|
|
def __init__(self, host: str, port: int = 3310, timeout: float = 30.0):
|
||
|
|
self.host = host
|
||
|
|
self.port = int(port)
|
||
|
|
self.timeout = float(timeout)
|
||
|
|
|
||
|
|
def _connect(self, timeout: float | None = None) -> socket.socket:
|
||
|
|
try:
|
||
|
|
return socket.create_connection((self.host, self.port), timeout=timeout or self.timeout)
|
||
|
|
except OSError as e:
|
||
|
|
raise ScanError(f"can't reach clamd at {self.host}:{self.port}: {type(e).__name__}") from None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _reply(sock: socket.socket) -> str:
|
||
|
|
"""Read one NUL-terminated reply (or up to the close)."""
|
||
|
|
buf = b""
|
||
|
|
while b"\0" not in buf and len(buf) < REPLY_MAX:
|
||
|
|
part = sock.recv(1024)
|
||
|
|
if not part:
|
||
|
|
break
|
||
|
|
buf += part
|
||
|
|
return buf.split(b"\0", 1)[0].decode("utf-8", "replace").strip()
|
||
|
|
|
||
|
|
def ping(self, timeout: float | None = None) -> bool:
|
||
|
|
"""True when clamd answers PING with PONG."""
|
||
|
|
try:
|
||
|
|
with self._connect(timeout) as sock:
|
||
|
|
sock.sendall(b"zPING\0")
|
||
|
|
return self._reply(sock) == "PONG"
|
||
|
|
except (ScanError, OSError) as e:
|
||
|
|
log.info("clamd ping failed: %s", type(e).__name__)
|
||
|
|
return False
|
||
|
|
|
||
|
|
def scan_path(self, path: Path) -> ScanResult:
|
||
|
|
"""Stream the file to clamd. Raises ScanError when clamd is down or its answer is not OK or FOUND."""
|
||
|
|
try:
|
||
|
|
with self._connect() as sock, Path(path).open("rb") as f:
|
||
|
|
sock.sendall(b"zINSTREAM\0")
|
||
|
|
while True:
|
||
|
|
chunk = f.read(CHUNK)
|
||
|
|
if not chunk:
|
||
|
|
break
|
||
|
|
sock.sendall(struct.pack(">I", len(chunk)) + chunk)
|
||
|
|
sock.sendall(struct.pack(">I", 0))
|
||
|
|
reply = self._reply(sock)
|
||
|
|
except ScanError:
|
||
|
|
raise
|
||
|
|
except OSError as e:
|
||
|
|
raise ScanError(f"clamd scan failed: {type(e).__name__}") from None
|
||
|
|
return parse_reply(reply)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_reply(reply: str) -> ScanResult:
|
||
|
|
"""`stream: OK` is clean; `stream: <sig> FOUND` is a hit; anything else is a ScanError."""
|
||
|
|
text = reply.strip()
|
||
|
|
if text.startswith("stream:"):
|
||
|
|
text = text[len("stream:") :].strip()
|
||
|
|
if text == "OK":
|
||
|
|
return ScanResult(clean=True, signature=None)
|
||
|
|
if text.endswith(" FOUND"):
|
||
|
|
sig = text[: -len(" FOUND")].strip()[:200]
|
||
|
|
return ScanResult(clean=False, signature=sig or "unknown")
|
||
|
|
raise ScanError(f"clamd answered: {text[:200]}")
|
||
|
|
|
||
|
|
|
||
|
|
def scanner_for(settings: Any) -> ClamdScanner | None:
|
||
|
|
"""The configured scanner, or None when CLAMAV_HOST is empty."""
|
||
|
|
host = str(getattr(settings, "clamav_host", "") or "").strip()
|
||
|
|
if not host:
|
||
|
|
return None
|
||
|
|
return ClamdScanner(
|
||
|
|
host,
|
||
|
|
int(getattr(settings, "clamav_port", 3310)),
|
||
|
|
float(getattr(settings, "clamav_timeout_seconds", 30.0)),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def check(settings: Any, path: Path) -> ScanResult | None:
|
||
|
|
"""Scan a complete file. None when no scanner is configured.
|
||
|
|
|
||
|
|
Raises 422 infected (with the signature) on a hit and 503 scan_unavailable when the scanner fails.
|
||
|
|
"""
|
||
|
|
scanner = scanner_for(settings)
|
||
|
|
if scanner is None:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
result = scanner.scan_path(path)
|
||
|
|
except ScanError as e:
|
||
|
|
log.warning("virus scan unavailable: %s", e)
|
||
|
|
raise ApiError(503, "scan_unavailable", MSG_UNAVAILABLE) from None
|
||
|
|
if not result.clean:
|
||
|
|
log.warning("upload refused by the virus scan: %s", result.signature)
|
||
|
|
raise ApiError(422, "infected", MSG_INFECTED, extra={"signature": result.signature})
|
||
|
|
return result
|