"""Pluggable virus scanning for uploaded files. Configured via ``INFRASYNTH_FILES``: * ``VIRUS_SCANNER``: ``"noop"`` (default) or ``"clamav"``. * ``CLAMAV_SOCKET``: ``clamd`` socket path or ``host:port`` (when ``clamav``). * ``REQUIRE_VIRUS_SCAN``: when ``True``, a ``noop`` scanner is rejected at call time so a production deployment cannot silently skip scanning. A custom scanner is any object with ``scan(data: bytes) -> ScanResult``; set ``VIRUS_SCANNER`` to its dotted path and it is imported on demand. """ from __future__ import annotations import io from dataclasses import dataclass from typing import Protocol, runtime_checkable from django.core.exceptions import ImproperlyConfigured from infrasynth.shared.settings_utils import get_setting __all__ = ["ScanResult", "VirusScanner", "NoOpScanner", "ClamAVScanner", "get_scanner"] @dataclass(frozen=True) class ScanResult: clean: bool threat: str = "" scanner: str = "" @runtime_checkable class VirusScanner(Protocol): def scan(self, data: bytes) -> ScanResult: ... class NoOpScanner: """Pass-through scanner. Explicit, logged, and refused when required.""" name = "noop" def scan(self, data: bytes) -> ScanResult: if get_setting("INFRASYNTH_FILES", "REQUIRE_VIRUS_SCAN", False): raise ImproperlyConfigured("REQUIRE_VIRUS_SCAN is enabled but no virus scanner is configured.") return ScanResult(clean=True, scanner=self.name) class ClamAVScanner: """ClamAV scanner over a Unix socket or a TCP endpoint (needs ``clamd``).""" name = "clamav" def __init__(self, socket: str | None = None): try: import clamd # type: ignore[import-not-found] except ImportError as exc: # pragma: no cover - optional dependency raise ImproperlyConfigured("VIRUS_SCANNER='clamav' requires the 'clamd' package to be installed.") from exc socket = socket or get_setting("INFRASYNTH_FILES", "CLAMAV_SOCKET", "/var/run/clamav/clamd.ctl") if ":" in socket and not socket.startswith("/"): host, port = socket.rsplit(":", 1) self._client = clamd.ClamdNetworkSocket(host=host, port=int(port)) else: self._client = clamd.ClamdUnixSocket(path=socket) def scan(self, data: bytes) -> ScanResult: result = self._client.instream(io.BytesIO(data)) status, signature = result.get("stream", ("ERROR", "unknown")) if status == "OK": return ScanResult(clean=True, scanner=self.name) return ScanResult(clean=False, threat=signature or "unknown threat", scanner=self.name) def get_scanner() -> VirusScanner: configured = get_setting("INFRASYNTH_FILES", "VIRUS_SCANNER", "noop") if configured in ("noop", None): return NoOpScanner() if configured == "clamav": return ClamAVScanner() from django.utils.module_loading import import_string return import_string(configured)()