remove infra.md.example, infra.md is the source of truth
This commit is contained in:
0
ayn-antivirus/ayn_antivirus/monitor/__init__.py
Normal file
0
ayn-antivirus/ayn_antivirus/monitor/__init__.py
Normal file
265
ayn-antivirus/ayn_antivirus/monitor/realtime.py
Normal file
265
ayn-antivirus/ayn_antivirus/monitor/realtime.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Real-time file-system monitor for AYN Antivirus.
|
||||
|
||||
Uses the ``watchdog`` library to observe directories for file creation,
|
||||
modification, and move events, then immediately scans the affected files
|
||||
through the :class:`ScanEngine`. Supports debouncing, auto-quarantine,
|
||||
and thread-safe operation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from ayn_antivirus.config import Config
|
||||
from ayn_antivirus.core.engine import ScanEngine, FileScanResult
|
||||
from ayn_antivirus.core.event_bus import EventType, event_bus
|
||||
from ayn_antivirus.quarantine.vault import QuarantineVault
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File suffixes that are almost always transient / editor artefacts.
|
||||
_SKIP_SUFFIXES = frozenset((
|
||||
".tmp", ".swp", ".swx", ".swo", ".lock", ".part",
|
||||
".crdownload", ".kate-swp", ".~lock.", ".bak~",
|
||||
))
|
||||
|
||||
# Minimum seconds between re-scanning the same path (debounce).
|
||||
_DEBOUNCE_SECONDS = 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watchdog event handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FileEventHandler(FileSystemEventHandler):
|
||||
"""Internal handler that bridges watchdog events to the scan engine.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
monitor:
|
||||
The owning :class:`RealtimeMonitor` instance.
|
||||
"""
|
||||
|
||||
def __init__(self, monitor: RealtimeMonitor) -> None:
|
||||
super().__init__()
|
||||
self._monitor = monitor
|
||||
|
||||
# Only react to file events (not directories).
|
||||
|
||||
def on_created(self, event: FileSystemEvent) -> None:
|
||||
if not event.is_directory:
|
||||
self._monitor._on_file_event(event.src_path, "created")
|
||||
|
||||
def on_modified(self, event: FileSystemEvent) -> None:
|
||||
if not event.is_directory:
|
||||
self._monitor._on_file_event(event.src_path, "modified")
|
||||
|
||||
def on_moved(self, event: FileSystemEvent) -> None:
|
||||
if not event.is_directory:
|
||||
dest = getattr(event, "dest_path", None)
|
||||
if dest:
|
||||
self._monitor._on_file_event(dest, "moved")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RealtimeMonitor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RealtimeMonitor:
|
||||
"""Watch directories and scan new / changed files in real time.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config:
|
||||
Application configuration.
|
||||
scan_engine:
|
||||
A pre-built :class:`ScanEngine` instance used to scan files.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, scan_engine: ScanEngine) -> None:
|
||||
self.config = config
|
||||
self.engine = scan_engine
|
||||
|
||||
self._observer: Optional[Observer] = None
|
||||
self._lock = threading.Lock()
|
||||
self._recent: Dict[str, float] = {} # path → last-scan timestamp
|
||||
self._running = False
|
||||
|
||||
# Optional auto-quarantine vault.
|
||||
self._vault: Optional[QuarantineVault] = None
|
||||
if config.auto_quarantine:
|
||||
self._vault = QuarantineVault(config.quarantine_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self, paths: Optional[List[str]] = None, recursive: bool = True) -> None:
|
||||
"""Begin monitoring *paths* (defaults to ``config.scan_paths``).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
paths:
|
||||
Directories to watch.
|
||||
recursive:
|
||||
Watch subdirectories as well.
|
||||
"""
|
||||
watch_paths = paths or self.config.scan_paths
|
||||
|
||||
with self._lock:
|
||||
if self._running:
|
||||
logger.warning("RealtimeMonitor is already running")
|
||||
return
|
||||
|
||||
self._observer = Observer()
|
||||
handler = _FileEventHandler(self)
|
||||
|
||||
for p in watch_paths:
|
||||
pp = Path(p)
|
||||
if not pp.is_dir():
|
||||
logger.warning("Skipping non-existent path: %s", p)
|
||||
continue
|
||||
self._observer.schedule(handler, str(pp), recursive=recursive)
|
||||
logger.info("Watching: %s (recursive=%s)", pp, recursive)
|
||||
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
|
||||
logger.info("RealtimeMonitor started — watching %d path(s)", len(watch_paths))
|
||||
event_bus.publish(EventType.SCAN_STARTED, {
|
||||
"type": "realtime_monitor",
|
||||
"paths": watch_paths,
|
||||
})
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop monitoring and wait for the observer thread to exit."""
|
||||
with self._lock:
|
||||
if not self._running or self._observer is None:
|
||||
return
|
||||
self._observer.stop()
|
||||
|
||||
self._observer.join(timeout=10)
|
||||
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._observer = None
|
||||
|
||||
logger.info("RealtimeMonitor stopped")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
with self._lock:
|
||||
return self._running
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks (called by _FileEventHandler)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_file_created(self, path: str) -> None:
|
||||
"""Scan a newly created file."""
|
||||
self._scan_file(path, "created")
|
||||
|
||||
def on_file_modified(self, path: str) -> None:
|
||||
"""Scan a modified file."""
|
||||
self._scan_file(path, "modified")
|
||||
|
||||
def on_file_moved(self, path: str) -> None:
|
||||
"""Scan a file that was moved/renamed into a watched directory."""
|
||||
self._scan_file(path, "moved")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_file_event(self, path: str, event_type: str) -> None:
|
||||
"""Central dispatcher invoked by the watchdog handler."""
|
||||
if self._should_skip(path):
|
||||
return
|
||||
|
||||
if self._is_debounced(path):
|
||||
return
|
||||
|
||||
logger.debug("File event: %s %s", event_type, path)
|
||||
|
||||
# Dispatch to the named callback (also usable directly).
|
||||
if event_type == "created":
|
||||
self.on_file_created(path)
|
||||
elif event_type == "modified":
|
||||
self.on_file_modified(path)
|
||||
elif event_type == "moved":
|
||||
self.on_file_moved(path)
|
||||
|
||||
def _scan_file(self, path: str, reason: str) -> None:
|
||||
"""Run the scan engine against a single file and handle results."""
|
||||
fp = Path(path)
|
||||
if not fp.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
result: FileScanResult = self.engine.scan_file(fp)
|
||||
except Exception:
|
||||
logger.exception("Error scanning %s", fp)
|
||||
return
|
||||
|
||||
if result.threats:
|
||||
logger.warning(
|
||||
"THREAT detected (%s) in %s: %s",
|
||||
reason,
|
||||
path,
|
||||
", ".join(t.threat_name for t in result.threats),
|
||||
)
|
||||
|
||||
# Auto-quarantine if enabled.
|
||||
if self._vault and fp.exists():
|
||||
try:
|
||||
threat = result.threats[0]
|
||||
qid = self._vault.quarantine_file(fp, {
|
||||
"threat_name": threat.threat_name,
|
||||
"threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type),
|
||||
"severity": threat.severity.name if hasattr(threat.severity, "name") else str(threat.severity),
|
||||
"file_hash": result.file_hash,
|
||||
})
|
||||
logger.info("Auto-quarantined %s → %s", path, qid)
|
||||
except Exception:
|
||||
logger.exception("Auto-quarantine failed for %s", path)
|
||||
else:
|
||||
logger.debug("Clean: %s (%s)", path, reason)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debounce & skip logic
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_debounced(self, path: str) -> bool:
|
||||
"""Return ``True`` if *path* was scanned within the debounce window."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
last = self._recent.get(path, 0.0)
|
||||
if now - last < _DEBOUNCE_SECONDS:
|
||||
return True
|
||||
self._recent[path] = now
|
||||
|
||||
# Prune stale entries periodically.
|
||||
if len(self._recent) > 5000:
|
||||
cutoff = now - _DEBOUNCE_SECONDS * 2
|
||||
self._recent = {
|
||||
k: v for k, v in self._recent.items() if v > cutoff
|
||||
}
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _should_skip(path: str) -> bool:
|
||||
"""Return ``True`` for temporary / lock / editor backup files."""
|
||||
name = Path(path).name.lower()
|
||||
if any(name.endswith(s) for s in _SKIP_SUFFIXES):
|
||||
return True
|
||||
# Hidden editor temp files like .#foo or 4913 (vim temp).
|
||||
if name.startswith(".#"):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user