remove infra.md.example, infra.md is the source of truth
This commit is contained in:
917
ayn-antivirus/ayn_antivirus/core/engine.py
Normal file
917
ayn-antivirus/ayn_antivirus/core/engine.py
Normal file
@@ -0,0 +1,917 @@
|
||||
"""Core scan engine for AYN Antivirus.
|
||||
|
||||
Orchestrates file-system, process, and network scanning by delegating to
|
||||
pluggable detectors (hash lookup, YARA, heuristic) and emitting events via
|
||||
the :pymod:`event_bus`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol
|
||||
|
||||
from ayn_antivirus.config import Config
|
||||
from ayn_antivirus.core.event_bus import EventType, event_bus
|
||||
from ayn_antivirus.utils.helpers import hash_file as _hash_file_util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ThreatType(Enum):
|
||||
"""Classification of a detected threat."""
|
||||
|
||||
VIRUS = auto()
|
||||
MALWARE = auto()
|
||||
SPYWARE = auto()
|
||||
MINER = auto()
|
||||
ROOTKIT = auto()
|
||||
|
||||
|
||||
class Severity(Enum):
|
||||
"""Threat severity level, ordered low → critical."""
|
||||
|
||||
LOW = 1
|
||||
MEDIUM = 2
|
||||
HIGH = 3
|
||||
CRITICAL = 4
|
||||
|
||||
|
||||
class ScanType(Enum):
|
||||
"""Kind of scan that was executed."""
|
||||
|
||||
FULL = "full"
|
||||
QUICK = "quick"
|
||||
DEEP = "deep"
|
||||
SINGLE_FILE = "single_file"
|
||||
TARGETED = "targeted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThreatInfo:
|
||||
"""A single threat detected during a file scan."""
|
||||
|
||||
path: str
|
||||
threat_name: str
|
||||
threat_type: ThreatType
|
||||
severity: Severity
|
||||
detector_name: str
|
||||
details: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
file_hash: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileScanResult:
|
||||
"""Result of scanning a single file."""
|
||||
|
||||
path: str
|
||||
scanned: bool = True
|
||||
file_hash: str = ""
|
||||
size: int = 0
|
||||
threats: List[ThreatInfo] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return len(self.threats) == 0 and self.error is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessThreat:
|
||||
"""A suspicious process discovered at runtime."""
|
||||
|
||||
pid: int
|
||||
name: str
|
||||
cmdline: str
|
||||
cpu_percent: float
|
||||
memory_percent: float
|
||||
threat_type: ThreatType
|
||||
severity: Severity
|
||||
details: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkThreat:
|
||||
"""A suspicious network connection."""
|
||||
|
||||
local_addr: str
|
||||
remote_addr: str
|
||||
pid: Optional[int]
|
||||
process_name: str
|
||||
threat_type: ThreatType
|
||||
severity: Severity
|
||||
details: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Aggregated result of a path / multi-file scan."""
|
||||
|
||||
scan_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||
start_time: datetime = field(default_factory=datetime.utcnow)
|
||||
end_time: Optional[datetime] = None
|
||||
files_scanned: int = 0
|
||||
files_skipped: int = 0
|
||||
threats: List[ThreatInfo] = field(default_factory=list)
|
||||
scan_path: str = ""
|
||||
scan_type: ScanType = ScanType.FULL
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
if self.end_time is None:
|
||||
return 0.0
|
||||
return (self.end_time - self.start_time).total_seconds()
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return len(self.threats) == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessScanResult:
|
||||
"""Aggregated result of a process scan."""
|
||||
|
||||
processes_scanned: int = 0
|
||||
threats: List[ProcessThreat] = field(default_factory=list)
|
||||
scan_duration: float = 0.0
|
||||
|
||||
@property
|
||||
def total_processes(self) -> int:
|
||||
"""Alias for processes_scanned (backward compat)."""
|
||||
return self.processes_scanned
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return len(self.threats) == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkScanResult:
|
||||
"""Aggregated result of a network scan."""
|
||||
|
||||
connections_scanned: int = 0
|
||||
threats: List[NetworkThreat] = field(default_factory=list)
|
||||
scan_duration: float = 0.0
|
||||
|
||||
@property
|
||||
def total_connections(self) -> int:
|
||||
"""Alias for connections_scanned (backward compat)."""
|
||||
return self.connections_scanned
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return len(self.threats) == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FullScanResult:
|
||||
"""Combined results from a full scan (files + processes + network + containers)."""
|
||||
|
||||
file_scan: ScanResult = field(default_factory=ScanResult)
|
||||
process_scan: ProcessScanResult = field(default_factory=ProcessScanResult)
|
||||
network_scan: NetworkScanResult = field(default_factory=NetworkScanResult)
|
||||
container_scan: Any = None # Optional[ContainerScanResult]
|
||||
|
||||
@property
|
||||
def total_threats(self) -> int:
|
||||
count = (
|
||||
len(self.file_scan.threats)
|
||||
+ len(self.process_scan.threats)
|
||||
+ len(self.network_scan.threats)
|
||||
)
|
||||
if self.container_scan is not None:
|
||||
count += len(self.container_scan.threats)
|
||||
return count
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return self.total_threats == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detector protocol (for type hints & documentation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Detector(Protocol):
|
||||
"""Any object with a ``detect()`` method matching the BaseDetector API."""
|
||||
|
||||
def detect(
|
||||
self,
|
||||
file_path: str | Path,
|
||||
file_content: Optional[bytes] = None,
|
||||
file_hash: Optional[str] = None,
|
||||
) -> list: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: file hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _hash_file(filepath: Path, algo: str = "sha256") -> str:
|
||||
"""Return the hex digest of *filepath*.
|
||||
|
||||
Delegates to :func:`ayn_antivirus.utils.helpers.hash_file`.
|
||||
"""
|
||||
return _hash_file_util(filepath, algo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detector result → engine dataclass mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_THREAT_TYPE_MAP = {
|
||||
"VIRUS": ThreatType.VIRUS,
|
||||
"MALWARE": ThreatType.MALWARE,
|
||||
"SPYWARE": ThreatType.SPYWARE,
|
||||
"MINER": ThreatType.MINER,
|
||||
"ROOTKIT": ThreatType.ROOTKIT,
|
||||
"HEURISTIC": ThreatType.MALWARE,
|
||||
}
|
||||
|
||||
_SEVERITY_MAP = {
|
||||
"CRITICAL": Severity.CRITICAL,
|
||||
"HIGH": Severity.HIGH,
|
||||
"MEDIUM": Severity.MEDIUM,
|
||||
"LOW": Severity.LOW,
|
||||
}
|
||||
|
||||
|
||||
def _map_threat_type(raw: str) -> ThreatType:
|
||||
"""Convert a detector's threat-type string to :class:`ThreatType`."""
|
||||
return _THREAT_TYPE_MAP.get(raw.upper(), ThreatType.MALWARE)
|
||||
|
||||
|
||||
def _map_severity(raw: str) -> Severity:
|
||||
"""Convert a detector's severity string to :class:`Severity`."""
|
||||
return _SEVERITY_MAP.get(raw.upper(), Severity.MEDIUM)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quick-scan target directories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QUICK_SCAN_PATHS = [
|
||||
"/tmp",
|
||||
"/var/tmp",
|
||||
"/dev/shm",
|
||||
"/usr/local/bin",
|
||||
"/var/spool/cron",
|
||||
"/etc/cron.d",
|
||||
"/etc/cron.daily",
|
||||
"/etc/crontab",
|
||||
"/var/www",
|
||||
"/srv",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScanEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ScanEngine:
|
||||
"""Central orchestrator for all AYN scanning activities.
|
||||
|
||||
The engine walks the file system, delegates to pluggable detectors, tracks
|
||||
statistics, and publishes events on the global :pydata:`event_bus`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config:
|
||||
Application configuration instance.
|
||||
max_workers:
|
||||
Thread pool size for parallel file scanning. Defaults to
|
||||
``min(os.cpu_count(), 8)``.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, max_workers: int | None = None) -> None:
|
||||
self.config = config
|
||||
self.max_workers = max_workers or min(os.cpu_count() or 4, 8)
|
||||
|
||||
# Detector registry — populated by external plug-ins via register_detector().
|
||||
# Each detector is a callable: (filepath: Path, cfg: Config) -> List[ThreatInfo]
|
||||
self._detectors: List[_Detector] = []
|
||||
|
||||
self._init_builtin_detectors()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detector registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register_detector(self, detector: _Detector) -> None:
|
||||
"""Add a detector to the scanning pipeline."""
|
||||
self._detectors.append(detector)
|
||||
|
||||
def _init_builtin_detectors(self) -> None:
|
||||
"""Register all built-in detection engines."""
|
||||
from ayn_antivirus.detectors.signature_detector import SignatureDetector
|
||||
from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector
|
||||
from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector
|
||||
from ayn_antivirus.detectors.spyware_detector import SpywareDetector
|
||||
from ayn_antivirus.detectors.rootkit_detector import RootkitDetector
|
||||
|
||||
try:
|
||||
sig_det = SignatureDetector(db_path=self.config.db_path)
|
||||
self.register_detector(sig_det)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load SignatureDetector: %s", e)
|
||||
|
||||
try:
|
||||
self.register_detector(HeuristicDetector())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load HeuristicDetector: %s", e)
|
||||
|
||||
try:
|
||||
self.register_detector(CryptominerDetector())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load CryptominerDetector: %s", e)
|
||||
|
||||
try:
|
||||
self.register_detector(SpywareDetector())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load SpywareDetector: %s", e)
|
||||
|
||||
try:
|
||||
self.register_detector(RootkitDetector())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load RootkitDetector: %s", e)
|
||||
|
||||
if self.config.enable_yara:
|
||||
try:
|
||||
from ayn_antivirus.detectors.yara_detector import YaraDetector
|
||||
yara_det = YaraDetector()
|
||||
self.register_detector(yara_det)
|
||||
except Exception as e:
|
||||
logger.debug("YARA detector not available: %s", e)
|
||||
|
||||
logger.info("Registered %d detectors", len(self._detectors))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_file(self, filepath: str | Path) -> FileScanResult:
|
||||
"""Scan a single file through every registered detector.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath:
|
||||
Absolute or relative path to the file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FileScanResult
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
result = FileScanResult(path=str(filepath))
|
||||
|
||||
if not filepath.is_file():
|
||||
result.scanned = False
|
||||
result.error = "Not a file or does not exist"
|
||||
return result
|
||||
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
except OSError as exc:
|
||||
result.scanned = False
|
||||
result.error = str(exc)
|
||||
return result
|
||||
|
||||
result.size = stat.st_size
|
||||
|
||||
if result.size > self.config.max_file_size:
|
||||
result.scanned = False
|
||||
result.error = f"File exceeds max size ({result.size} > {self.config.max_file_size})"
|
||||
return result
|
||||
|
||||
# Hash the file — needed by hash-based detectors and for recording.
|
||||
try:
|
||||
result.file_hash = _hash_file(filepath)
|
||||
except OSError as exc:
|
||||
result.scanned = False
|
||||
result.error = f"Cannot read file: {exc}"
|
||||
return result
|
||||
|
||||
# Enrich with FileScanner metadata (type classification).
|
||||
try:
|
||||
from ayn_antivirus.scanners.file_scanner import FileScanner
|
||||
file_scanner = FileScanner(max_file_size=self.config.max_file_size)
|
||||
file_info = file_scanner.scan(str(filepath))
|
||||
result._file_info = file_info # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
logger.debug("FileScanner enrichment skipped for %s", filepath)
|
||||
|
||||
# Run every registered detector.
|
||||
for detector in self._detectors:
|
||||
try:
|
||||
detections = detector.detect(filepath, file_hash=result.file_hash)
|
||||
for d in detections:
|
||||
threat = ThreatInfo(
|
||||
path=str(filepath),
|
||||
threat_name=d.threat_name,
|
||||
threat_type=_map_threat_type(d.threat_type),
|
||||
severity=_map_severity(d.severity),
|
||||
detector_name=d.detector_name,
|
||||
details=d.details,
|
||||
file_hash=result.file_hash,
|
||||
)
|
||||
result.threats.append(threat)
|
||||
except Exception:
|
||||
logger.exception("Detector %r failed on %s", detector, filepath)
|
||||
|
||||
# Publish per-file events.
|
||||
event_bus.publish(EventType.FILE_SCANNED, result)
|
||||
if result.threats:
|
||||
for threat in result.threats:
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path scanning (recursive)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_path(
|
||||
self,
|
||||
path: str | Path,
|
||||
recursive: bool = True,
|
||||
quick: bool = False,
|
||||
callback: Optional[Callable[[FileScanResult], None]] = None,
|
||||
) -> ScanResult:
|
||||
"""Walk *path* and scan every eligible file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
Root directory (or single file) to scan.
|
||||
recursive:
|
||||
Descend into subdirectories.
|
||||
quick:
|
||||
If ``True``, only scan :pydata:`QUICK_SCAN_PATHS` that exist
|
||||
under *path* (or the quick-scan list itself when *path* is ``/``).
|
||||
callback:
|
||||
Optional function called after each file is scanned — useful for
|
||||
progress reporting.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ScanResult
|
||||
"""
|
||||
scan_type = ScanType.QUICK if quick else ScanType.FULL
|
||||
result = ScanResult(
|
||||
scan_path=str(path),
|
||||
scan_type=scan_type,
|
||||
start_time=datetime.utcnow(),
|
||||
)
|
||||
|
||||
event_bus.publish(EventType.SCAN_STARTED, {
|
||||
"scan_id": result.scan_id,
|
||||
"scan_type": scan_type.value,
|
||||
"path": str(path),
|
||||
})
|
||||
|
||||
# Collect files to scan.
|
||||
files = self._collect_files(Path(path), recursive=recursive, quick=quick)
|
||||
|
||||
# Parallel scan.
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
|
||||
futures = {pool.submit(self.scan_file, fp): fp for fp in files}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
file_result = future.result()
|
||||
except Exception:
|
||||
result.files_skipped += 1
|
||||
logger.exception("Unhandled error scanning %s", futures[future])
|
||||
continue
|
||||
|
||||
if file_result.scanned:
|
||||
result.files_scanned += 1
|
||||
else:
|
||||
result.files_skipped += 1
|
||||
|
||||
result.threats.extend(file_result.threats)
|
||||
|
||||
if callback is not None:
|
||||
try:
|
||||
callback(file_result)
|
||||
except Exception:
|
||||
logger.exception("Scan callback raised an exception")
|
||||
|
||||
result.end_time = datetime.utcnow()
|
||||
|
||||
event_bus.publish(EventType.SCAN_COMPLETED, {
|
||||
"scan_id": result.scan_id,
|
||||
"files_scanned": result.files_scanned,
|
||||
"threats": len(result.threats),
|
||||
"duration": result.duration_seconds,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_processes(self) -> ProcessScanResult:
|
||||
"""Inspect all running processes for known miners and anomalies.
|
||||
|
||||
Delegates to :class:`~ayn_antivirus.scanners.process_scanner.ProcessScanner`
|
||||
for detection and converts results to engine dataclasses.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ProcessScanResult
|
||||
"""
|
||||
from ayn_antivirus.scanners.process_scanner import ProcessScanner
|
||||
|
||||
result = ProcessScanResult()
|
||||
start = time.monotonic()
|
||||
|
||||
proc_scanner = ProcessScanner()
|
||||
scan_data = proc_scanner.scan()
|
||||
|
||||
result.processes_scanned = scan_data.get("total", 0)
|
||||
|
||||
# Known miner matches.
|
||||
for s in scan_data.get("suspicious", []):
|
||||
threat = ProcessThreat(
|
||||
pid=s["pid"],
|
||||
name=s.get("name", ""),
|
||||
cmdline=" ".join(s.get("cmdline") or []),
|
||||
cpu_percent=s.get("cpu_percent", 0.0),
|
||||
memory_percent=0.0,
|
||||
threat_type=ThreatType.MINER,
|
||||
severity=Severity.CRITICAL,
|
||||
details=s.get("reason", "Known miner process"),
|
||||
)
|
||||
result.threats.append(threat)
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
# High-CPU anomalies (skip duplicates already caught as miners).
|
||||
miner_pids = {t.pid for t in result.threats}
|
||||
for h in scan_data.get("high_cpu", []):
|
||||
if h["pid"] in miner_pids:
|
||||
continue
|
||||
threat = ProcessThreat(
|
||||
pid=h["pid"],
|
||||
name=h.get("name", ""),
|
||||
cmdline=" ".join(h.get("cmdline") or []),
|
||||
cpu_percent=h.get("cpu_percent", 0.0),
|
||||
memory_percent=0.0,
|
||||
threat_type=ThreatType.MINER,
|
||||
severity=Severity.HIGH,
|
||||
details=h.get("reason", "Abnormally high CPU usage"),
|
||||
)
|
||||
result.threats.append(threat)
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
# Hidden processes (possible rootkit).
|
||||
for hp in scan_data.get("hidden", []):
|
||||
threat = ProcessThreat(
|
||||
pid=hp["pid"],
|
||||
name=hp.get("name", ""),
|
||||
cmdline=hp.get("cmdline", ""),
|
||||
cpu_percent=0.0,
|
||||
memory_percent=0.0,
|
||||
threat_type=ThreatType.ROOTKIT,
|
||||
severity=Severity.CRITICAL,
|
||||
details=hp.get("reason", "Hidden process"),
|
||||
)
|
||||
result.threats.append(threat)
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
# Optional memory scan for suspicious PIDs.
|
||||
try:
|
||||
from ayn_antivirus.scanners.memory_scanner import MemoryScanner
|
||||
mem_scanner = MemoryScanner()
|
||||
suspicious_pids = {t.pid for t in result.threats}
|
||||
for pid in suspicious_pids:
|
||||
try:
|
||||
mem_result = mem_scanner.scan(pid)
|
||||
rwx_regions = mem_result.get("rwx_regions") or []
|
||||
if rwx_regions:
|
||||
result.threats.append(ProcessThreat(
|
||||
pid=pid,
|
||||
name="",
|
||||
cmdline="",
|
||||
cpu_percent=0.0,
|
||||
memory_percent=0.0,
|
||||
threat_type=ThreatType.ROOTKIT,
|
||||
severity=Severity.HIGH,
|
||||
details=(
|
||||
f"Injected code detected in PID {pid}: "
|
||||
f"{len(rwx_regions)} RWX region(s)"
|
||||
),
|
||||
))
|
||||
except Exception:
|
||||
pass # Memory scan for individual PID is best-effort
|
||||
except Exception as exc:
|
||||
logger.debug("Memory scan skipped: %s", exc)
|
||||
|
||||
result.scan_duration = time.monotonic() - start
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Network scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_network(self) -> NetworkScanResult:
|
||||
"""Scan active network connections for mining pool traffic.
|
||||
|
||||
Delegates to :class:`~ayn_antivirus.scanners.network_scanner.NetworkScanner`
|
||||
for detection and converts results to engine dataclasses.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NetworkScanResult
|
||||
"""
|
||||
from ayn_antivirus.scanners.network_scanner import NetworkScanner
|
||||
|
||||
result = NetworkScanResult()
|
||||
start = time.monotonic()
|
||||
|
||||
net_scanner = NetworkScanner()
|
||||
scan_data = net_scanner.scan()
|
||||
|
||||
result.connections_scanned = scan_data.get("total", 0)
|
||||
|
||||
# Suspicious connections (mining pools, suspicious ports).
|
||||
for s in scan_data.get("suspicious", []):
|
||||
sev = _map_severity(s.get("severity", "HIGH"))
|
||||
threat = NetworkThreat(
|
||||
local_addr=s.get("local_addr", "?"),
|
||||
remote_addr=s.get("remote_addr", "?"),
|
||||
pid=s.get("pid"),
|
||||
process_name=(s.get("process", {}) or {}).get("name", ""),
|
||||
threat_type=ThreatType.MINER,
|
||||
severity=sev,
|
||||
details=s.get("reason", "Suspicious connection"),
|
||||
)
|
||||
result.threats.append(threat)
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
# Unexpected listening ports.
|
||||
for lp in scan_data.get("unexpected_listeners", []):
|
||||
threat = NetworkThreat(
|
||||
local_addr=lp.get("local_addr", f"?:{lp.get('port', '?')}"),
|
||||
remote_addr="",
|
||||
pid=lp.get("pid"),
|
||||
process_name=lp.get("process_name", ""),
|
||||
threat_type=ThreatType.MALWARE,
|
||||
severity=_map_severity(lp.get("severity", "MEDIUM")),
|
||||
details=lp.get("reason", "Unexpected listener"),
|
||||
)
|
||||
result.threats.append(threat)
|
||||
event_bus.publish(EventType.THREAT_FOUND, threat)
|
||||
|
||||
# Enrich with IOC database lookups — flag connections to known-bad IPs.
|
||||
try:
|
||||
from ayn_antivirus.signatures.db.ioc_db import IOCDatabase
|
||||
ioc_db = IOCDatabase(self.config.db_path)
|
||||
ioc_db.initialize()
|
||||
malicious_ips = ioc_db.get_all_malicious_ips()
|
||||
|
||||
if malicious_ips:
|
||||
import psutil as _psutil
|
||||
already_flagged = {
|
||||
t.remote_addr for t in result.threats
|
||||
}
|
||||
try:
|
||||
for conn in _psutil.net_connections(kind="inet"):
|
||||
if not conn.raddr:
|
||||
continue
|
||||
remote_ip = conn.raddr.ip
|
||||
remote_str = f"{remote_ip}:{conn.raddr.port}"
|
||||
if remote_ip in malicious_ips and remote_str not in already_flagged:
|
||||
ioc_info = ioc_db.lookup_ip(remote_ip) or {}
|
||||
result.threats.append(NetworkThreat(
|
||||
local_addr=(
|
||||
f"{conn.laddr.ip}:{conn.laddr.port}"
|
||||
if conn.laddr else ""
|
||||
),
|
||||
remote_addr=remote_str,
|
||||
pid=conn.pid or 0,
|
||||
process_name=self._get_proc_name(conn.pid),
|
||||
threat_type=ThreatType.MALWARE,
|
||||
severity=Severity.CRITICAL,
|
||||
details=(
|
||||
f"Connection to known malicious IP {remote_ip} "
|
||||
f"(threat: {ioc_info.get('threat_name', 'IOC match')})"
|
||||
),
|
||||
))
|
||||
except (_psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
ioc_db.close()
|
||||
except Exception as exc:
|
||||
logger.debug("IOC network enrichment skipped: %s", exc)
|
||||
|
||||
result.scan_duration = time.monotonic() - start
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _get_proc_name(pid: int) -> str:
|
||||
"""Best-effort process name lookup for a PID."""
|
||||
if not pid:
|
||||
return ""
|
||||
try:
|
||||
import psutil as _ps
|
||||
return _ps.Process(pid).name()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Container scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_containers(
|
||||
self,
|
||||
runtime: str = "all",
|
||||
container_id: Optional[str] = None,
|
||||
):
|
||||
"""Scan containers for threats.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
runtime:
|
||||
Container runtime to target (``"all"``, ``"docker"``,
|
||||
``"podman"``, ``"lxc"``).
|
||||
container_id:
|
||||
If provided, scan only this specific container.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ContainerScanResult
|
||||
"""
|
||||
from ayn_antivirus.scanners.container_scanner import ContainerScanner
|
||||
|
||||
scanner = ContainerScanner()
|
||||
if container_id:
|
||||
return scanner.scan_container(container_id)
|
||||
return scanner.scan(runtime)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Composite scans
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def full_scan(
|
||||
self,
|
||||
callback: Optional[Callable[[FileScanResult], None]] = None,
|
||||
) -> FullScanResult:
|
||||
"""Run a complete scan: files, processes, and network.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
callback:
|
||||
Optional per-file progress callback.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FullScanResult
|
||||
"""
|
||||
full = FullScanResult()
|
||||
|
||||
# File scan across all configured paths.
|
||||
aggregate = ScanResult(scan_type=ScanType.FULL, start_time=datetime.utcnow())
|
||||
for scan_path in self.config.scan_paths:
|
||||
partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback)
|
||||
aggregate.files_scanned += partial.files_scanned
|
||||
aggregate.files_skipped += partial.files_skipped
|
||||
aggregate.threats.extend(partial.threats)
|
||||
aggregate.end_time = datetime.utcnow()
|
||||
full.file_scan = aggregate
|
||||
|
||||
# Process + network.
|
||||
full.process_scan = self.scan_processes()
|
||||
full.network_scan = self.scan_network()
|
||||
|
||||
# Containers (best-effort — skipped if no runtimes available).
|
||||
try:
|
||||
container_result = self.scan_containers()
|
||||
if container_result.containers_found > 0:
|
||||
full.container_scan = container_result
|
||||
except Exception:
|
||||
logger.debug("Container scanning skipped", exc_info=True)
|
||||
|
||||
return full
|
||||
|
||||
def quick_scan(
|
||||
self,
|
||||
callback: Optional[Callable[[FileScanResult], None]] = None,
|
||||
) -> ScanResult:
|
||||
"""Scan only high-risk directories.
|
||||
|
||||
Targets :pydata:`QUICK_SCAN_PATHS` and any additional web roots
|
||||
or crontab locations.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ScanResult
|
||||
"""
|
||||
aggregate = ScanResult(scan_type=ScanType.QUICK, start_time=datetime.utcnow())
|
||||
|
||||
event_bus.publish(EventType.SCAN_STARTED, {
|
||||
"scan_id": aggregate.scan_id,
|
||||
"scan_type": "quick",
|
||||
"paths": QUICK_SCAN_PATHS,
|
||||
})
|
||||
|
||||
for scan_path in QUICK_SCAN_PATHS:
|
||||
p = Path(scan_path)
|
||||
if not p.exists():
|
||||
continue
|
||||
partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback)
|
||||
aggregate.files_scanned += partial.files_scanned
|
||||
aggregate.files_skipped += partial.files_skipped
|
||||
aggregate.threats.extend(partial.threats)
|
||||
|
||||
aggregate.end_time = datetime.utcnow()
|
||||
|
||||
event_bus.publish(EventType.SCAN_COMPLETED, {
|
||||
"scan_id": aggregate.scan_id,
|
||||
"files_scanned": aggregate.files_scanned,
|
||||
"threats": len(aggregate.threats),
|
||||
"duration": aggregate.duration_seconds,
|
||||
})
|
||||
|
||||
return aggregate
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _collect_files(
|
||||
self,
|
||||
root: Path,
|
||||
recursive: bool = True,
|
||||
quick: bool = False,
|
||||
) -> List[Path]:
|
||||
"""Walk *root* and return a list of scannable file paths.
|
||||
|
||||
Respects ``config.exclude_paths`` and ``config.max_file_size``.
|
||||
"""
|
||||
targets: List[Path] = []
|
||||
|
||||
if quick:
|
||||
# In quick mode, only descend into known-risky subdirectories.
|
||||
roots = [
|
||||
root / rel
|
||||
for rel in (
|
||||
"tmp", "var/tmp", "dev/shm", "usr/local/bin",
|
||||
"var/spool/cron", "etc/cron.d", "etc/cron.daily",
|
||||
"var/www", "srv",
|
||||
)
|
||||
if (root / rel).exists()
|
||||
]
|
||||
# Also include the quick-scan list itself if root is /.
|
||||
if str(root) == "/":
|
||||
roots = [Path(p) for p in QUICK_SCAN_PATHS if Path(p).exists()]
|
||||
else:
|
||||
roots = [root]
|
||||
|
||||
exclude = set(self.config.exclude_paths)
|
||||
|
||||
for r in roots:
|
||||
if r.is_file():
|
||||
targets.append(r)
|
||||
continue
|
||||
iterator = r.rglob("*") if recursive else r.iterdir()
|
||||
try:
|
||||
for entry in iterator:
|
||||
if not entry.is_file():
|
||||
continue
|
||||
# Exclude check.
|
||||
entry_str = str(entry)
|
||||
if any(entry_str.startswith(ex) for ex in exclude):
|
||||
continue
|
||||
try:
|
||||
if entry.stat().st_size > self.config.max_file_size:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
targets.append(entry)
|
||||
except PermissionError:
|
||||
logger.warning("Permission denied: %s", r)
|
||||
|
||||
return targets
|
||||
Reference in New Issue
Block a user