Files

214 lines
7.1 KiB
Python

"""EDR monitoring agent.
Collects process and system metrics every 5 seconds using psutil,
monitors filesystem paths with watchdog, and POSTs batches to the backend.
"""
import logging
import os
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
import psutil
import requests
# Normalise per-process cpu_percent to 0-100% of *total* machine capacity.
# psutil returns cumulative core-time (can exceed 100% on multi-core).
_CPU_COUNT: int = psutil.cpu_count(logical=True) or 1
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
sys.path.insert(0, str(Path(__file__).parent))
from model import AnomalyDetector
# ── configuration ─────────────────────────────────────────────────────────────
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
COLLECT_INTERVAL = int(os.getenv("COLLECT_INTERVAL", "5"))
WATCH_PATHS = [p for p in ["/etc", "/tmp", "/var/log"] if Path(p).exists()]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
# ── shared fs-event buffer ────────────────────────────────────────────────────
_fs_events: list[dict] = []
_fs_lock = threading.Lock()
class _FSHandler(FileSystemEventHandler):
"""Records create/modify/delete events into the shared buffer."""
def on_created(self, event) -> None:
if not event.is_directory:
self._push(event.src_path, "create")
def on_modified(self, event) -> None:
if not event.is_directory:
self._push(event.src_path, "modify")
def on_deleted(self, event) -> None:
self._push(event.src_path, "delete")
@staticmethod
def _push(path: str, event_type: str) -> None:
with _fs_lock:
_fs_events.append(
{
"path": path,
"event_type": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
# Bound buffer to avoid unbounded growth between flushes
if len(_fs_events) > 500:
_fs_events.pop(0)
def _drain_fs_events() -> list[dict]:
"""Return accumulated events and clear the buffer."""
with _fs_lock:
out = list(_fs_events)
_fs_events.clear()
return out
# ── metric collection ─────────────────────────────────────────────────────────
def _proc_connections(proc: psutil.Process) -> int:
"""Return TCP connection count, compatible with all psutil versions."""
for method in ("net_connections", "connections"):
fn = getattr(proc, method, None)
if fn is None:
continue
try:
return len(fn())
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
return 0
return 0
def collect_processes() -> list[dict]:
"""Return per-process metrics for all running processes."""
result = []
# 'connections' was removed from process_iter attrs in psutil 6.x
attrs = ["pid", "name", "cpu_percent", "memory_info", "open_files"]
for proc in psutil.process_iter(attrs):
try:
info = proc.info
mem_mb = (info["memory_info"].rss / 1024 / 1024
if info["memory_info"] else 0.0)
open_files = len(info["open_files"]) if info["open_files"] else 0
connections = _proc_connections(proc)
result.append(
{
"pid": info["pid"],
"name": info["name"] or "unknown",
"cpu_percent": round(float(info["cpu_percent"] or 0.0) / _CPU_COUNT, 2),
"memory_mb": round(mem_mb, 2),
"open_files": open_files,
"connections": connections,
}
)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
return result
def collect_system() -> dict:
"""Return system-wide CPU, RAM, and network connection totals."""
return {
"cpu_percent": psutil.cpu_percent(interval=None),
"ram_percent": psutil.virtual_memory().percent,
"network_connections": len(psutil.net_connections()),
}
# ── backend communication ─────────────────────────────────────────────────────
def send_batch(detector: AnomalyDetector) -> None:
"""Collect metrics, annotate anomalies, and POST to backend."""
processes = collect_processes()
system = collect_system()
fs_events = _drain_fs_events()
processes = detector.predict(processes)
anomaly_count = sum(1 for p in processes if p.get("is_anomaly"))
payload = {
"processes": processes,
"system": system,
"file_events": fs_events,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
try:
resp = requests.post(
f"{BACKEND_URL}/api/metrics",
json=payload,
timeout=10,
)
resp.raise_for_status()
logger.info(
"Sent %d procs | anomalies=%d | fs_events=%d",
len(processes),
anomaly_count,
len(fs_events),
)
except requests.exceptions.ConnectionError:
logger.warning("Backend unreachable — skipping batch")
except requests.exceptions.RequestException as exc:
logger.error("POST /api/metrics failed: %s", exc)
# ── entry point ───────────────────────────────────────────────────────────────
def main() -> None:
"""Start filesystem watcher and run the collection loop."""
logger.info("EDR Agent starting (backend=%s, interval=%ds)", BACKEND_URL, COLLECT_INTERVAL)
# Load or train the anomaly model
detector = AnomalyDetector()
detector.load_or_train()
# Start filesystem observer
handler = _FSHandler()
observer = Observer()
for path in WATCH_PATHS:
observer.schedule(handler, path, recursive=True)
logger.info("Watching path: %s", path)
observer.start()
# Warm up cpu_percent (first call always returns 0.0)
psutil.cpu_percent(interval=None)
for proc in psutil.process_iter(["cpu_percent"]):
try:
proc.cpu_percent(interval=None)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
time.sleep(1)
try:
while True:
send_batch(detector)
time.sleep(COLLECT_INTERVAL)
except KeyboardInterrupt:
logger.info("Agent stopped by user")
finally:
observer.stop()
observer.join()
if __name__ == "__main__":
main()