feat: initial EDR system — agent + backend + frontend + ensemble ML

This commit is contained in:
kilyabin
2026-05-28 14:19:39 +04:00
commit 20d5e53220
44 changed files with 5299 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
.env
*.log
# ML artefacts (regenerated on first run)
data/*.pkl
data/*.db
data/UNSW_NB15_training-set.csv
# Frontend
frontend/node_modules/
frontend/dist/
frontend/*.tsbuildinfo
# Docker / OS
.DS_Store
Thumbs.db
+222
View File
@@ -0,0 +1,222 @@
# EDR Monitor — Behavioral Malware Detection System
A university diploma project demonstrating real-time **Endpoint Detection & Response (EDR)**
using behavioral analysis and machine learning on a Linux host.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Linux Host / VM │
│ │
│ ┌──────────────┐ HTTP POST /api/metrics ┌──────────────┐ │
│ │ │ ─────────────────────────▶ │ │ │
│ │ agent/ │ │ backend/ │ │
│ │ (psutil + │ │ (FastAPI + │ │
│ │ watchdog + │ │ SQLite + │ │
│ │ IsoForest) │ │ WebSocket) │ │
│ │ │ │ │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ │ │
│ Filesystem watcher WS /ws │ │
│ /etc /tmp /var/log │ │
│ ┌───────▼───────┐ │
│ │ frontend/ │ │
│ │ (React + │ │
│ │ Recharts + │ │
│ │ shadcn/ui) │ │
│ │ :3000 │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
**Components:**
| Directory | Tech | Purpose |
|-----------|------|---------|
| `agent/` | Python 3.11, psutil, watchdog, scikit-learn | Collects process metrics, runs ML inference |
| `backend/` | FastAPI, aiosqlite, uvicorn | REST API, SQLite storage, WebSocket broadcast |
| `frontend/`| React 18, Vite, shadcn/ui, Recharts | Real-time SOC dashboard |
---
## Quick Start (local, no Docker)
### 1. Backend
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
### 2. Agent
```bash
cd agent
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Train the ML model (runs once, saves data/model.pkl)
python train.py
# Start the agent
python main.py
```
> **Note:** The agent reads process metadata — run with sufficient privileges
> (or as root) to see all processes and network connections.
### 3. Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:3000
```
---
## Quick Start (Docker Compose)
```bash
docker compose up --build
```
Services:
- Backend → http://localhost:8000 (API docs: http://localhost:8000/docs)
- Dashboard → http://localhost:3000
---
## ML Model
The agent uses **Isolation Forest** (scikit-learn) for unsupervised anomaly detection.
| Feature | Description |
|---------|-------------|
| `cpu_percent` | Process CPU utilisation |
| `memory_mb` | RSS memory in megabytes |
| `open_files` | Number of open file descriptors |
| `connections` | Number of open TCP connections |
Training data priority:
1. `data/UNSW_NB15_training-set.csv` — real network intrusion dataset (download separately)
2. Synthetic normal-behaviour data (fallback, auto-generated if CSV absent)
Contamination factor: **5%** (5 % of training data treated as outliers).
---
## REST API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/stats` | Dashboard statistics |
| `GET` | `/api/metrics?limit=200` | Recent process metrics |
| `GET` | `/api/alerts?limit=50` | Security alerts |
| `GET` | `/api/file-events?limit=100` | Filesystem events |
| `POST` | `/api/metrics` | Ingest batch from agent |
| `WS` | `/ws` | Real-time alert stream |
Interactive docs: http://localhost:8000/docs
---
## Simulating Anomalies (demo)
### CPU spike (Linux)
```bash
# Requires stress-ng
stress-ng --cpu 4 --timeout 30s
# Alternative — pure Python
python3 -c "
import multiprocessing, time
def spin():
while True: pass
procs = [multiprocessing.Process(target=spin) for _ in range(4)]
[p.start() for p in procs]
time.sleep(30)
[p.terminate() for p in procs]
"
```
### Memory spike
```bash
python3 -c "
x = bytearray(2 * 1024 * 1024 * 1024) # allocate 2 GB
import time; time.sleep(30)
"
```
### Filesystem trigger (write to /tmp)
```bash
for i in $(seq 1 20); do echo "test" > /tmp/edr_test_$i.txt; sleep 1; done
```
Within ~10 seconds the dashboard will show the anomalous process highlighted in red
and a toast notification will appear bottom-right.
---
## Screenshots
> *Add screenshots here after first run*
| Overview | Processes | Alerts |
|----------|-----------|--------|
| ![overview](docs/overview.png) | ![processes](docs/processes.png) | ![alerts](docs/alerts.png) |
---
## Dataset
The system optionally uses the **UNSW-NB15** network intrusion dataset for training.
Download: https://research.unsw.edu.au/projects/unsw-nb15-dataset
Place the file at: `data/UNSW_NB15_training-set.csv`
Then retrain:
```bash
cd agent && python train.py
```
---
## Project Structure
```
dipl-edr/
├── agent/
│ ├── main.py # monitoring loop + fs watcher
│ ├── model.py # IsolationForest wrapper
│ ├── train.py # standalone training script
│ └── requirements.txt
├── backend/
│ ├── main.py # FastAPI app + WebSocket
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.tsx # root layout + WebSocket client
│ │ ├── components/
│ │ │ ├── Overview.tsx # stats cards + chart
│ │ │ ├── Processes.tsx # process table
│ │ │ ├── Alerts.tsx # alerts table
│ │ │ ├── FileEvents.tsx # fs events table
│ │ │ ├── SystemChart.tsx # Recharts line chart
│ │ │ └── ui/ # shadcn-style primitives
│ │ ├── hooks/use-toast.ts
│ │ └── lib/
│ │ ├── api.ts # typed fetch helpers
│ │ └── utils.ts
│ ├── package.json
│ └── vite.config.ts
├── data/ # .gitignored — holds model.pkl + edr.db
├── docker-compose.yml
└── README.md
```
+4
View File
@@ -0,0 +1,4 @@
__pycache__
*.py[cod]
.venv
*.log
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.11-slim
WORKDIR /app/agent
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# train.py создаёт model.pkl при первом запуске, затем стартует агент
CMD ["sh", "-c", "python train.py && python main.py"]
+213
View File
@@ -0,0 +1,213 @@
"""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()
+224
View File
@@ -0,0 +1,224 @@
"""Anomaly detection — voting ensemble of three unsupervised models.
Pipeline:
IsolationForest + Local Outlier Factor + One-Class SVM
A process is flagged anomalous when at least 2 of 3 models agree.
Training data (in order of preference):
1. UNSW-NB15 dataset CSV (data/UNSW_NB15_training-set.csv)
2. Synthetic normal-behaviour data (fallback, 2000 rows)
"""
import logging
import os
from pathlib import Path
import numpy as np
import joblib
logger = logging.getLogger(__name__)
MODEL_PATH = Path(os.getenv("MODEL_PATH", "data/model.pkl"))
DATASET_PATH = Path(os.getenv("DATASET_PATH", "data/UNSW_NB15_training-set.csv"))
# Trusted desktop applications — ML classification skipped.
# Threshold alerts (CPU > 80 %, RAM > 3 GB) still fire from the backend.
TRUSTED_PROCESSES: frozenset[str] = frozenset({
"firefox", "firefox-bin", "Web Content", "Isolated Web Co",
"chrome", "chromium", "chromium-browser",
"brave", "brave-browser", "opera",
"electron", "Electron",
"telegram", "Telegram", "telegram-desktop",
"slack", "discord", "discord-bin",
"code", "code-oss", "vscodium", "cursor",
"zoom", "zoom-bin", "zoomus",
"obsidian",
"spotify", "vlc", "mpv",
"libreoffice", "soffice",
"thunderbird",
"gnome-shell", "plasmashell", "kwin_wayland", "kwin_x11",
"Xorg", "Xwayland",
})
# Feature vector order: [cpu_percent, memory_mb, open_files, connections]
FEATURE_COLS = ["cpu_percent", "memory_mb", "open_files", "connections"]
class EnsembleDetector:
"""Majority-vote ensemble: IsolationForest + LOF + One-Class SVM.
Each model casts a vote (+1 normal, -1 anomaly).
A process is anomalous when the vote sum is ≤ -1 (i.e. 2 or 3 models agree).
"""
def __init__(self) -> None:
self.iforest = None
self.lof = None
self.ocsvm = None
# ── training ─────────────────────────────────────────────────────────────
def train(self, X: np.ndarray) -> None:
"""Fit all three models on X and persist the ensemble to disk."""
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.svm import OneClassSVM
from sklearn.preprocessing import RobustScaler
logger.info("Training ensemble on %d samples …", X.shape[0])
# RobustScaler is median/IQR-based — less sensitive to extreme outliers
# than StandardScaler, which matters when training data may contain
# occasional noisy spikes.
scaler = RobustScaler()
X_scaled = scaler.fit_transform(X)
self.iforest = IsolationForest(
n_estimators=100,
contamination=0.01,
random_state=42,
n_jobs=-1,
)
self.iforest.fit(X_scaled)
logger.info(" IsolationForest trained")
# LOF in novelty=True mode so it can predict on new samples (not just
# the training set). n_neighbors=20 is a robust default for datasets
# of ~10005000 rows.
self.lof = LocalOutlierFactor(
n_neighbors=20,
contamination=0.01,
novelty=True,
n_jobs=-1,
)
self.lof.fit(X_scaled)
logger.info(" LocalOutlierFactor trained")
# One-Class SVM with RBF kernel. nu=0.01 ≈ expected outlier fraction.
# gamma="scale" adapts to feature variance automatically.
self.ocsvm = OneClassSVM(kernel="rbf", nu=0.01, gamma="scale")
self.ocsvm.fit(X_scaled)
logger.info(" OneClassSVM trained")
MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
joblib.dump({"iforest": self.iforest, "lof": self.lof,
"ocsvm": self.ocsvm, "scaler": scaler}, MODEL_PATH)
logger.info("Ensemble saved → %s", MODEL_PATH)
# Keep the scaler attached for inference
self._scaler = scaler
def load_or_train(self) -> None:
"""Load persisted ensemble or train a new one."""
if MODEL_PATH.exists():
bundle = joblib.load(MODEL_PATH)
# Support legacy single-model pkl (no "scaler" key)
if not isinstance(bundle, dict):
logger.info("Legacy single-model pkl detected — retraining ensemble")
MODEL_PATH.unlink()
self.load_or_train()
return
self.iforest = bundle["iforest"]
self.lof = bundle["lof"]
self.ocsvm = bundle["ocsvm"]
self._scaler = bundle["scaler"]
logger.info("Ensemble loaded from %s", MODEL_PATH)
return
logger.info("No saved model found — training now")
X = self._load_dataset()
self.train(X)
# ── data helpers ──────────────────────────────────────────────────────────
def _load_dataset(self) -> np.ndarray:
if DATASET_PATH.exists():
return self._load_unsw()
logger.warning("Dataset not found — using synthetic training data")
return self._synthetic_data()
def _load_unsw(self) -> np.ndarray:
import pandas as pd
df = pd.read_csv(DATASET_PATH)
numeric = df.select_dtypes(include=[np.number]).dropna()
X = numeric.iloc[:, :4].values.astype(np.float32)
logger.info("Loaded UNSW-NB15: %d rows", X.shape[0])
return X
def _synthetic_data(self) -> np.ndarray:
"""2000 rows of realistic normal Linux process behaviour.
Distribution:
75 % idle/background (CPU ≈ 0 %, low RAM)
20 % light workers (moderate CPU, moderate RAM)
5 % heavier services (higher RAM, some connections)
"""
rng = np.random.default_rng(42)
n = 2000
cpu_idle = rng.exponential(0.3, int(n * 0.75))
cpu_light = rng.uniform(0.5, 15, int(n * 0.20))
cpu_heavy = rng.uniform(15, 60, int(n * 0.05))
cpu = np.concatenate([cpu_idle, cpu_light, cpu_heavy])
mem_small = rng.uniform(0.5, 50, int(n * 0.50))
mem_medium = rng.uniform(50, 300, int(n * 0.30))
mem_large = rng.uniform(300, 800, int(n * 0.12))
mem_desktop = rng.uniform(800, 2000, int(n * 0.08))
mem = np.concatenate([mem_small, mem_medium, mem_large, mem_desktop])
files = np.concatenate([
rng.integers(0, 20, int(n * 0.70)).astype(float),
rng.integers(20, 80, int(n * 0.30)).astype(float),
])
conns = np.concatenate([
rng.integers(0, 2, int(n * 0.70)).astype(float),
rng.integers(2, 20, int(n * 0.30)).astype(float),
])
idx = rng.permutation(n)
return np.column_stack([cpu[idx], mem[idx], files[idx], conns[idx]])
# ── inference ─────────────────────────────────────────────────────────────
def predict(self, processes: list[dict]) -> list[dict]:
"""Annotate each process dict with is_anomaly=True/False.
Trusted processes skip ML — they can only be flagged by the
backend's threshold checks (CPU > 80 %, RAM > 3 GB).
"""
if not processes or self.iforest is None:
for p in processes:
p["is_anomaly"] = False
return processes
trusted_idx = {i for i, p in enumerate(processes)
if p["name"] in TRUSTED_PROCESSES}
candidates = [p for i, p in enumerate(processes) if i not in trusted_idx]
if candidates:
X_raw = np.array(
[[p["cpu_percent"], p["memory_mb"], p["open_files"], p["connections"]]
for p in candidates],
dtype=np.float32,
)
X = self._scaler.transform(X_raw)
# Each model returns +1 (normal) or -1 (anomaly)
v_if = self.iforest.predict(X) # IsolationForest
v_lof = self.lof.predict(X) # Local Outlier Factor
v_ocsvm = self.ocsvm.predict(X) # One-Class SVM
# Majority vote: flag anomaly when vote_sum ≤ -1 (≥ 2 of 3 agree)
vote_sum = v_if + v_lof + v_ocsvm
for proc, vs in zip(candidates, vote_sum):
proc["is_anomaly"] = bool(vs <= -1)
for i in trusted_idx:
processes[i]["is_anomaly"] = False
return processes
# Module-level alias so existing import `from model import AnomalyDetector`
# continues to work without changes in main.py.
AnomalyDetector = EnsembleDetector
+7
View File
@@ -0,0 +1,7 @@
psutil>=5.9.0
watchdog>=3.0.0
scikit-learn>=1.3.0
joblib>=1.3.0
requests>=2.31.0
pandas>=2.0.0
numpy>=1.24.0
+32
View File
@@ -0,0 +1,32 @@
"""Standalone training script — trains IsolationForest and saves model.pkl.
Usage:
cd agent
python train.py
"""
import logging
import sys
from pathlib import Path
# Allow running from project root or agent/ directory
sys.path.insert(0, str(Path(__file__).parent))
from model import AnomalyDetector
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
def main() -> None:
"""Entry point: load data, train, save."""
detector = AnomalyDetector()
X = detector._load_dataset()
detector.train(X)
print(f"Done. Model saved to data/model.pkl ({X.shape[0]} training samples)")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
__pycache__
*.py[cod]
.venv
*.log
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
# Зависимости отдельным слоем для кеширования
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+349
View File
@@ -0,0 +1,349 @@
"""EDR Backend — FastAPI REST API + WebSocket broadcast.
Receives metric batches from the agent, stores them in SQLite,
and pushes real-time anomaly alerts to connected dashboard clients.
"""
import logging
import os
from contextlib import asynccontextmanager
from datetime import date, datetime, timezone
from typing import Any
import aiosqlite
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
DB_PATH = os.getenv("DB_PATH", "../data/edr.db")
# ── Database ──────────────────────────────────────────────────────────────────
async def _init_db() -> None:
"""Create all tables on first startup."""
async with aiosqlite.connect(DB_PATH) as db:
await db.executescript("""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
pid INTEGER,
name TEXT,
cpu_percent REAL,
memory_mb REAL,
open_files INTEGER,
connections INTEGER,
is_anomaly INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
pid INTEGER,
process_name TEXT,
reason TEXT,
severity TEXT
);
CREATE TABLE IF NOT EXISTS file_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
path TEXT,
event_type TEXT
);
CREATE TABLE IF NOT EXISTS system_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
cpu_percent REAL,
ram_percent REAL,
net_connections INTEGER
);
""")
await db.commit()
# ── WebSocket manager ─────────────────────────────────────────────────────────
class _WsManager:
"""Thread-safe registry of connected WebSocket clients."""
def __init__(self) -> None:
self._clients: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self._clients.append(ws)
logger.info("WS connected total=%d", len(self._clients))
def disconnect(self, ws: WebSocket) -> None:
self._clients.remove(ws)
logger.info("WS disconnected total=%d", len(self._clients))
async def broadcast(self, data: dict) -> None:
"""Send JSON payload to every connected client; drop stale ones."""
stale: list[WebSocket] = []
for ws in list(self._clients):
try:
await ws.send_json(data)
except Exception:
stale.append(ws)
for ws in stale:
if ws in self._clients:
self._clients.remove(ws)
_ws = _WsManager()
# ── App lifecycle ─────────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan(app: FastAPI):
await _init_db()
logger.info("Database ready at %s", DB_PATH)
yield
app = FastAPI(title="EDR Backend", version="1.0.0", lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ── Pydantic models ───────────────────────────────────────────────────────────
class ProcessMetric(BaseModel):
pid: int
name: str
cpu_percent: float
memory_mb: float
open_files: int
connections: int
is_anomaly: bool = False
class SystemMetric(BaseModel):
cpu_percent: float
ram_percent: float
network_connections: int
class FileEvent(BaseModel):
path: str
event_type: str
timestamp: str
class MetricsBatch(BaseModel):
processes: list[ProcessMetric]
system: SystemMetric
file_events: list[FileEvent] = []
timestamp: str
# ── Helpers ───────────────────────────────────────────────────────────────────
def _severity(proc: ProcessMetric) -> str:
"""Derive alert severity from process resource usage."""
if proc.cpu_percent > 80 or proc.memory_mb > 2000:
return "high"
if proc.cpu_percent > 50 or proc.memory_mb > 1000:
return "medium"
return "low"
def _reason(proc: ProcessMetric) -> str:
"""Build a human-readable anomaly reason string (Russian)."""
parts: list[str] = []
if proc.cpu_percent > 80:
parts.append(f"CPU {proc.cpu_percent:.1f}%")
if proc.memory_mb > 2000:
parts.append(f"ОЗУ {proc.memory_mb:.0f} МБ")
if proc.connections > 50:
parts.append(f"{proc.connections} TCP-соединений")
if proc.open_files > 200:
parts.append(f"{proc.open_files} открытых файлов")
detail = ", ".join(parts) if parts else "статистический выброс (ансамбль ML: IF + LOF + OC-SVM)"
return f"Аномальное поведение: {detail}"
# ── REST endpoints ────────────────────────────────────────────────────────────
@app.get("/api/metrics", summary="Recent process metrics")
async def get_metrics(limit: int = 200) -> list[dict]:
"""Return the most recent *limit* rows from the metrics table."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM metrics ORDER BY id DESC LIMIT ?", (limit,)
) as cur:
return [dict(r) for r in await cur.fetchall()]
@app.get("/api/alerts", summary="Recent alerts")
async def get_alerts(limit: int = 50) -> list[dict]:
"""Return the most recent *limit* alert rows."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM alerts ORDER BY id DESC LIMIT ?", (limit,)
) as cur:
return [dict(r) for r in await cur.fetchall()]
@app.get("/api/system-metrics", summary="System-level CPU/RAM timeline")
async def get_system_metrics(limit: int = 60) -> list[dict]:
"""Return last *limit* system snapshots in chronological order (for charts)."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM system_metrics ORDER BY id DESC LIMIT ?", (limit,)
) as cur:
rows = await cur.fetchall()
return list(reversed([dict(r) for r in rows]))
@app.get("/api/file-events", summary="Recent filesystem events")
async def get_file_events(limit: int = 100) -> list[dict]:
"""Return the most recent *limit* filesystem events."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM file_events ORDER BY id DESC LIMIT ?", (limit,)
) as cur:
return [dict(r) for r in await cur.fetchall()]
@app.get("/api/stats", summary="Dashboard statistics")
async def get_stats() -> dict[str, Any]:
"""Return aggregated statistics for the Overview panel."""
today = date.today().isoformat()
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT COUNT(DISTINCT pid) FROM metrics"
) as cur:
total_processes = (await cur.fetchone())[0]
async with db.execute(
"SELECT COUNT(*) FROM metrics WHERE is_anomaly=1 AND timestamp LIKE ?",
(f"{today}%",),
) as cur:
anomalies_today = (await cur.fetchone())[0]
async with db.execute(
"SELECT COUNT(*) FROM alerts WHERE timestamp LIKE ?", (f"{today}%",)
) as cur:
alerts_today = (await cur.fetchone())[0]
# Средняя нагрузка системы за сегодня (из таблицы system_metrics)
async with db.execute(
"SELECT AVG(cpu_percent), AVG(ram_percent) FROM system_metrics WHERE timestamp LIKE ?",
(f"{today}%",),
) as cur:
row = await cur.fetchone()
cpu_avg = round(float(row[0] or 0), 1)
ram_avg = round(float(row[1] or 0), 1)
return {
"total_processes": total_processes,
"anomalies_today": anomalies_today,
"alerts_today": alerts_today,
"cpu_avg": cpu_avg,
"ram_avg": ram_avg,
}
@app.post("/api/metrics", status_code=201, summary="Ingest metric batch from agent")
async def post_metrics(batch: MetricsBatch) -> dict[str, int]:
"""Store process metrics, create alerts for anomalies, broadcast via WS."""
ts = batch.timestamp
async with aiosqlite.connect(DB_PATH) as db:
# Bulk-insert process metrics
await db.executemany(
"""INSERT INTO metrics
(timestamp, pid, name, cpu_percent, memory_mb, open_files, connections, is_anomaly)
VALUES (?,?,?,?,?,?,?,?)""",
[
(ts, p.pid, p.name, p.cpu_percent, p.memory_mb,
p.open_files, p.connections, int(p.is_anomaly))
for p in batch.processes
],
)
# Системные метрики — одна строка на батч (для графика)
await db.execute(
"INSERT INTO system_metrics (timestamp, cpu_percent, ram_percent, net_connections) VALUES (?,?,?,?)",
(ts, batch.system.cpu_percent, batch.system.ram_percent, batch.system.network_connections),
)
# Bulk-insert filesystem events
if batch.file_events:
await db.executemany(
"INSERT INTO file_events (timestamp, path, event_type) VALUES (?,?,?)",
[(e.timestamp, e.path, e.event_type) for e in batch.file_events],
)
# Пороговый алерт работает независимо от ML — для любых процессов
threshold_anomalies = [
p for p in batch.processes
if not p.is_anomaly and (p.cpu_percent > 80 or p.memory_mb > 3000 or p.connections > 100)
]
for proc in threshold_anomalies:
proc.is_anomaly = True # пометить для записи в метрики
# Все аномалии: ML + пороговые
anomalies = [p for p in batch.processes if p.is_anomaly]
for proc in anomalies:
sev = _severity(proc)
reason = _reason(proc)
await db.execute(
"INSERT INTO alerts (timestamp, pid, process_name, reason, severity) VALUES (?,?,?,?,?)",
(ts, proc.pid, proc.name, reason, sev),
)
await _ws.broadcast(
{
"type": "alert",
"pid": proc.pid,
"process_name": proc.name,
"reason": reason,
"severity": sev,
"timestamp": ts,
}
)
await db.commit()
logger.info("Batch stored: procs=%d anomalies=%d fs=%d",
len(batch.processes), len(anomalies), len(batch.file_events))
return {"received": len(batch.processes), "anomalies": len(anomalies)}
# ── WebSocket ─────────────────────────────────────────────────────────────────
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None:
"""WebSocket endpoint — each dashboard connects here for live alerts."""
await _ws.connect(ws)
try:
while True:
await ws.receive_text() # keep connection alive; messages ignored
except WebSocketDisconnect:
_ws.disconnect(ws)
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
aiosqlite>=0.20.0
python-multipart>=0.0.9
View File
+49
View File
@@ -0,0 +1,49 @@
version: "3.9"
services:
# ── FastAPI backend ────────────────────────────────────────────────────────
backend:
build: ./backend
volumes:
- ./data:/app/data # SQLite-база и модель
ports:
- "8000:8000" # API-документация: http://localhost:8000/docs
environment:
- DB_PATH=/app/data/edr.db
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/stats')"]
interval: 10s
timeout: 5s
retries: 10
start_period: 5s
# ── Python мониторинг-агент ────────────────────────────────────────────────
agent:
build: ./agent
volumes:
- ./data:/app/data # общий том с моделью и БД
environment:
- BACKEND_URL=http://backend:8000
- COLLECT_INTERVAL=5
- MODEL_PATH=/app/data/model.pkl
- DATASET_PATH=/app/data/UNSW_NB15_training-set.csv
depends_on:
backend:
condition: service_healthy
pid: host # видит процессы хоста
privileged: true # psutil net_connections требует root
restart: unless-stopped
# ── React-дашборд (nginx) ──────────────────────────────────────────────────
frontend:
build: ./frontend # multi-stage: node build → nginx
ports:
- "3000:3000" # Dashboard: http://localhost:3000
depends_on:
- backend
restart: unless-stopped
volumes: {}
+485
View File
@@ -0,0 +1,485 @@
# Практическая часть дипломной работы
## Разработка системы обнаружения вредоносной активности на основе поведенческого анализа
---
## 3. ПРАКТИЧЕСКАЯ РЕАЛИЗАЦИЯ СИСТЕМЫ
### 3.1 Общее описание разработанной системы
В рамках дипломной работы разработана система класса **EDR (Endpoint Detection and Response)** — системы обнаружения и реагирования на угрозы на конечных узлах сети. Система предназначена для мониторинга поведения процессов Linux-сервера в реальном времени, выявления аномальной активности методами машинного обучения и визуализации результатов в виде веб-панели управления (dashboard).
Функциональные возможности реализованной системы:
- сбор поведенческих метрик всех запущенных процессов каждые 5 секунд;
- мониторинг файловой системы в критических директориях `/etc`, `/tmp`, `/var/log`;
- автоматическое обучение ансамбля из трёх ML-моделей (Isolation Forest, LOF, One-Class SVM) на нормальном поведении системы;
- классификация процессов как аномальных/нормальных в режиме реального времени;
- хранение всех событий в реляционной базе данных SQLite;
- передача оповещений об аномалиях по протоколу WebSocket;
- веб-интерфейс SOC-класса с отображением статистики, процессов, алертов и событий ФС.
---
### 3.2 Архитектура системы
Система построена по трёхзвенной клиент-серверной архитектуре и включает три основных компонента:
```
┌─────────────────────────────────────────────────────────────┐
│ Linux-хост (объект мониторинга) │
│ │
│ ┌────────────┐ HTTP POST ┌────────────┐ │
│ │ Агент │ ────────────▶ │ Бэкенд │ │
│ │ (Python) │ │ (FastAPI) │ │
│ │ │ │ │ │
│ │ • psutil │ │ • REST API │ │
│ │ • watchdog │ │ • SQLite │ │
│ │ • ML-модель│ │ • WebSocket│ │
│ └────────────┘ └─────┬──────┘ │
│ │ WS │
│ ┌─────▼──────┐ │
│ │ Фронтенд │ │
│ │ (React) │ │
│ │ :3000 │ │
│ └────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**Компонент 1 — Агент мониторинга (`agent/`).**
Написан на языке Python 3.11. Запускается непосредственно на контролируемом хосте. Каждые 5 секунд собирает метрики всех запущенных процессов (PID, имя, загрузка CPU, объём RSS-памяти, количество открытых файлов, количество TCP-соединений), запускает ML-классификацию, затем отправляет результаты в бэкенд через HTTP POST. Параллельно запущен наблюдатель файловой системы (watchdog), отслеживающий создание, изменение и удаление файлов в указанных директориях.
**Компонент 2 — Серверная часть (`backend/`).**
Реализована на фреймворке FastAPI (Python). Принимает батчи метрик, сохраняет их в SQLite через асинхронный драйвер aiosqlite. При обнаружении аномального процесса создаёт запись в таблице алертов и транслирует JSON-сообщение всем подключённым WebSocket-клиентам (панелям мониторинга).
**Компонент 3 — Веб-панель управления (`frontend/`).**
Single-Page Application на стеке React 18 + TypeScript + Vite. Использует библиотеку shadcn/ui (Radix UI + Tailwind CSS) для интерфейсных компонентов, Recharts для графиков реального времени, TanStack Query для периодического опроса REST API каждые 5 секунд. WebSocket-соединение обеспечивает мгновенную доставку уведомлений об аномалиях.
---
### 3.3 Модуль машинного обучения
#### 3.3.1 Проблема единственного классификатора и обоснование ансамблевого подхода
При обнаружении аномалий в поведении процессов каждый алгоритм машинного обучения обладает специфическими слабыми сторонами. Единственная модель даёт повышенное число ложных срабатываний (false positives) или пропускает определённые типы аномалий (false negatives) в зависимости от характера атаки и распределения данных.
В данной работе реализован **ансамблевый детектор** (voting ensemble), объединяющий три алгоритма обнаружения аномалий без учителя. Аномалией считается процесс, признанный подозрительным как минимум двумя из трёх моделей (правило большинства, majority voting). Такой подход снижает вероятность ложных срабатываний по сравнению с одиночным классификатором: случайный «выброс» от одного алгоритма не приводит к генерации алерта, если два других модели оценивают процесс как нормальный.
#### 3.3.2 Алгоритмы ансамбля
В ансамбль включены три алгоритма, дополняющих друг друга за счёт принципиально разных математических основ:
| Алгоритм | Принцип | Сильная сторона |
|----------|---------|-----------------|
| **Isolation Forest** (Liu et al., 2008) | Случайные деревья изоляции | Глобальные аномалии, O(n) скорость |
| **Local Outlier Factor** (Breunig et al., 2000) | Локальная плотность соседей | Локальные кластерные аномалии |
| **One-Class SVM** (Schölkopf et al., 1999) | Гиперплоскость максимального зазора | Нелинейные границы нормальности |
**Isolation Forest** строит ансамбль из 100 случайных деревьев. Нормальные точки требуют большего числа разбиений для изоляции, аномальные — меньшего. Итоговый балл — нормированная средняя длина пути изоляции. Алгоритм эффективен для глобальных выбросов и работает с линейной сложностью O(n).
**Local Outlier Factor (LOF)** оценивает каждую точку относительно плотности её локального окружения из k=20 ближайших соседей. Если плотность точки значительно ниже плотности её соседей, точка является выбросом. LOF выявляет аномалии в локальных кластерах, которые IsolationForest может пропустить в многомодальных распределениях. Используется режим `novelty=True` для предсказания на новых данных.
**One-Class SVM** обучается провести гиперплоскость максимального зазора в пространстве признаков (ядро RBF), отделяющую нормальные данные от пространства аномалий. Нелинейная природа ядра позволяет захватывать сложные границы нормальности, недоступные линейным методам. Параметр `nu=0.01` задаёт верхнюю оценку доли аномалий.
#### 3.3.3 Схема голосования
```
Процесс → вектор признаков → RobustScaler (нормировка)
┌────────────────────┼────────────────────┐
▼ ▼ ▼
IsolationForest LOF (novelty) One-Class SVM
(+1 норма/-1 ан.) (+1 норма/-1 ан.) (+1 норма/-1 ан.)
│ │ │
└────────────────────┴────────────────────┘
сумма голосов
≥ 2 «–1» → Аномалия
≤ 1 «–1» → Норма
```
Формально, для вектора признаков **x** каждая модель $m_i$ возвращает $v_i \in \{-1, +1\}$. Решение:
$$\text{is\_anomaly}(\mathbf{x}) = \left[\sum_{i=1}^{3} v_i \leq -1\right]$$
то есть аномалия фиксируется, если сумма голосов равна $-1$ (голосуют 2 из 3) или $-3$ (единогласно).
#### 3.3.4 Вектор признаков
Для каждого процесса формируется вектор из четырёх числовых признаков:
| Признак | Тип | Описание |
|---------|-----|----------|
| `cpu_percent` | float | Загрузка CPU процессом, % |
| `memory_mb` | float | Объём RSS-памяти, МБ |
| `open_files` | int | Количество открытых файловых дескрипторов |
| `connections` | int | Количество открытых TCP-соединений |
Выбор признаков обоснован классическими индикаторами компрометации (IoC):
- аномально высокое потребление CPU характерно для криптомайнеров и ransomware;
- избыточное число соединений указывает на C2-коммуникации или сканирование сети;
- большое число открытых файлов — признак шифровальщиков;
- чрезмерное потребление памяти — характерно для эксплойтов heap overflow.
#### 3.3.5 Предобработка: RobustScaler
One-Class SVM и LOF чувствительны к масштабу признаков, поэтому перед обучением применяется **RobustScaler** из scikit-learn. В отличие от StandardScaler, RobustScaler использует медиану и межквартильный размах (IQR) вместо среднего и дисперсии, что делает нормировку устойчивой к экстремальным выбросам в обучающих данных:
$$x' = \frac{x - \text{median}(X)}{\text{IQR}(X)}$$
Параметры масштабирования (медиана и IQR по каждому признаку) сохраняются вместе с моделями и применяются к каждому новому вектору при инференсе.
#### 3.3.6 Обучающие данные
Система поддерживает два режима обучения:
**Режим 1 (основной).** Если в директории `data/` находится файл `UNSW_NB15_training-set.csv`, все три модели обучаются на реальных данных набора UNSW-NB15. Датасет UNSW-NB15 (University of New South Wales, 2015) содержит 257 000 записей сетевого трафика, включая нормальную активность и 9 типов атак (Fuzzers, Analysis, Backdoors, DoS, Exploits, Generic, Reconnaissance, Shellcode, Worms). Из набора извлекаются первые четыре числовые колонки как приближение к вектору признаков.
**Режим 2 (резервный).** При отсутствии датасета генерируется синтетическая обучающая выборка из 2000 образцов нормального поведения процессов с реалистичными распределениями:
- CPU: 75% экспоненциальное распределение ≈ 0% (фоновые процессы), 20% Uniform[0.5, 15%] (лёгкие задачи), 5% Uniform[15, 60%] (тяжёлые процессы);
- RAM: 50% — 0.5–50 МБ (мелкие демоны), 30% — 50300 МБ, 12% — 300800 МБ, 8% — 8002000 МБ (браузеры, IDE);
- Файлы: 70% — 0–20 дескрипторов, 30% — 20–80;
- TCP-соединения: 70% — 02, 30% — 220.
#### 3.3.7 Параметры ансамбля
```python
# IsolationForest
IsolationForest(
n_estimators=100, # число деревьев изоляции
contamination=0.01, # ожидаемая доля аномалий 1%
random_state=42, # воспроизводимость
n_jobs=-1, # параллельное обучение
)
# Local Outlier Factor
LocalOutlierFactor(
n_neighbors=20, # размер локального окружения
contamination=0.01,
novelty=True, # инференс на новых данных
n_jobs=-1,
)
# One-Class SVM
OneClassSVM(
kernel="rbf", # гауссово ядро
nu=0.01, # верхняя оценка доли аномалий
gamma="scale", # автоматически от дисперсии признаков
)
```
Параметр `contamination=0.01` (1%) выбран для минимизации ложных срабатываний на рабочей станции с десятками доверенных фоновых процессов.
#### 3.3.8 Список доверенных процессов
Для известных «тяжёлых» десктопных приложений (браузеры, Electron-приложения, IDE, медиаплееры) ML-классификация пропускается — они заносятся в `TRUSTED_PROCESSES`. Это предотвращает генерацию ложных алертов для firefox, VS Code, Telegram и других приложений, нормально потребляющих сотни мегабайт RAM. Пороговые алерты бэкенда (CPU > 80%, RAM > 3 ГБ) для доверенных процессов по-прежнему работают.
#### 3.3.9 Сохранение ансамбля
После обучения все три модели и параметры RobustScaler сериализуются в единый файл `data/model.pkl` через `joblib`:
```python
joblib.dump({
"iforest": self.iforest,
"lof": self.lof,
"ocsvm": self.ocsvm,
"scaler": scaler,
}, MODEL_PATH)
```
При последующих запусках агента файл загружается целиком, что исключает повторное обучение и обеспечивает стабильность порогов обнаружения.
---
### 3.4 Агент мониторинга
#### 3.4.1 Сбор метрик процессов
Сбор осуществляется через библиотеку **psutil** — кроссплатформенную обёртку над системными вызовами Linux (`/proc` filesystem). Для каждого процесса в системе итерируется объект `psutil.Process` и извлекаются следующие атрибуты:
```python
psutil.process_iter([
'pid', # идентификатор процесса
'name', # имя исполняемого файла
'cpu_percent', # загрузка CPU, %
'memory_info', # RSS/VMS в байтах
'open_files', # список открытых файловых дескрипторов
'connections', # список TCP/UDP соединений
])
```
Особенности реализации:
- первый вызов `cpu_percent()` всегда возвращает 0.0 (psutil требует временной дельты), поэтому при старте агента выполняется прогревочный вызов с паузой 1 сек;
- процессы, к которым нет доступа (PermissionError), пропускаются без исключения;
- зомби-процессы обрабатываются отдельно.
#### 3.4.2 Мониторинг файловой системы
Для отслеживания изменений в файловой системе используется библиотека **watchdog**, основанная на Linux inotify API. Наблюдатели запускаются рекурсивно для трёх директорий:
- `/etc` — системные конфигурационные файлы (изменение может свидетельствовать о persistence-атаке);
- `/tmp` — временные файлы (типичное место для дропа malware-полезной нагрузки);
- `/var/log` — системные журналы (попытки очистки следов атаки).
Каждое событие (create/modify/delete) помещается в потокобезопасный буфер с ограничением в 500 записей. При отправке батча метрик буфер опустошается.
#### 3.4.3 Протокол взаимодействия с бэкендом
Каждые 5 секунд агент формирует и отправляет JSON-батч:
```json
{
"timestamp": "2025-05-28T14:30:00.000Z",
"processes": [
{
"pid": 1234,
"name": "nginx",
"cpu_percent": 2.1,
"memory_mb": 45.3,
"open_files": 22,
"connections": 8,
"is_anomaly": false
}
],
"system": {
"cpu_percent": 12.5,
"ram_percent": 34.2,
"network_connections": 47
},
"file_events": [
{
"path": "/tmp/suspicious.sh",
"event_type": "create",
"timestamp": "2025-05-28T14:29:58.000Z"
}
]
}
```
При недоступности бэкенда агент продолжает работу, логируя предупреждение, без аварийного завершения.
---
### 3.5 Серверная часть (бэкенд)
#### 3.5.1 Технологический стек
Бэкенд реализован на фреймворке **FastAPI** — современном асинхронном фреймворке для построения API на Python, основанном на стандарте ASGI. Ключевые преимущества:
- автоматическая генерация OpenAPI-документации (доступна по адресу `/docs`);
- встроенная валидация данных через Pydantic v2;
- нативная поддержка async/await;
- встроенная поддержка WebSocket.
База данных — **SQLite** через асинхронный драйвер `aiosqlite`. Выбор SQLite обусловлен простотой развёртывания (файловая база, не требует отдельного сервера) и достаточной производительностью для хранения метрик одного хоста.
#### 3.5.2 Схема базы данных
```sql
-- Метрики процессов
CREATE TABLE metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
pid INTEGER,
name TEXT,
cpu_percent REAL,
memory_mb REAL,
open_files INTEGER,
connections INTEGER,
is_anomaly INTEGER DEFAULT 0 -- 0=normal, 1=anomaly
);
-- Алерты безопасности
CREATE TABLE alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
pid INTEGER,
process_name TEXT,
reason TEXT, -- человекочитаемое описание аномалии
severity TEXT -- low | medium | high
);
-- События файловой системы
CREATE TABLE file_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
path TEXT,
event_type TEXT -- create | modify | delete
);
```
#### 3.5.3 REST API
| Метод | Эндпоинт | Описание |
|--------|----------|----------|
| `GET` | `/api/stats` | Агрегированная статистика для дашборда |
| `GET` | `/api/metrics?limit=200` | Последние метрики процессов |
| `GET` | `/api/alerts?limit=50` | Последние алерты безопасности |
| `GET` | `/api/file-events?limit=100` | Последние события ФС |
| `POST` | `/api/metrics` | Приём батча от агента |
#### 3.5.4 WebSocket
Эндпоинт `/ws` реализует паттерн publish-subscribe. При поступлении батча с аномальными процессами бэкенд транслирует каждому подключённому клиенту (dashboard) JSON-сообщение:
```json
{
"type": "alert",
"pid": 4567,
"process_name": "python3",
"reason": "Anomalous behaviour: CPU 95.2%, MEM 3200 MB",
"severity": "high",
"timestamp": "2025-05-28T14:30:05.000Z"
}
```
Уровень серьёзности (severity) определяется эвристически:
- `high`: CPU > 80% или RAM > 2 ГБ;
- `medium`: CPU > 50% или RAM > 1 ГБ;
- `low`: статистический выброс без пороговых нарушений.
---
### 3.6 Веб-интерфейс
#### 3.6.1 Технологический стек
Веб-интерфейс разработан в виде Single-Page Application (SPA) на следующем стеке:
| Технология | Версия | Назначение |
|-----------|--------|-----------|
| React | 18.3 | UI-фреймворк с декларативным рендерингом |
| TypeScript | 5.4 | Статическая типизация JavaScript |
| Vite | 5.3 | Сборщик с мгновенным HMR |
| Tailwind CSS | 3.4 | Utility-first CSS-фреймворк |
| Radix UI | 1.x | Headless UI-примитивы (доступность) |
| Recharts | 2.12 | Графики на основе D3.js |
| TanStack Query | 5.x | Серверный state-менеджмент с кешированием |
Дизайн выполнен в стиле **SOC-dashboard** (Security Operations Center): тёмный фон (#0a0a0f), акцентные цвета cyan (#00ff9d) и blue (#00d4ff), монospace-шрифт JetBrains Mono, декоративные сетка и эффект CRT-строк.
#### 3.6.2 Структура интерфейса
Интерфейс разделён на четыре функциональных раздела, доступных через вкладки:
**Вкладка Overview (Обзор):**
- Пять карточек-счётчиков: всего процессов, аномалий за сутки, алертов за сутки, средняя загрузка CPU, средний объём RAM.
- График реального времени (Recharts LineChart): две линии — загрузка CPU% и RAM% системы за последние 60 точек измерений. Обновляется каждые 5 секунд через TanStack Query polling.
**Вкладка Processes (Процессы):**
- Таблица всех текущих процессов с колонками: PID, Имя, CPU%, RAM MB, TCP-соединения, Открытые файлы, Статус.
- Отображается последний снимок (snapshot) для каждого уникального PID.
- Сортировка по убыванию CPU%.
- Аномальные строки выделены красной левой рамкой и красным фоном.
- Статус-бейджи: зелёный «Normal» / красный «Anomaly».
**Вкладка Alerts (Алерты):**
- Таблица алертов: время, PID, имя процесса, описание аномалии, уровень серьёзности (цветные бейджи: синий/жёлтый/красный).
- Индикатор состояния WebSocket-соединения (зелёная/красная точка).
- Новые алерты появляются вверху таблицы.
**Вкладка File Events (События ФС):**
- Таблица событий файловой системы: время, путь, тип события (create/modify/delete) с цветными бейджами.
**Toast-уведомления:**
При получении алерта по WebSocket в правом нижнем углу появляется всплывающее уведомление с именем процесса, описанием аномалии и цветовой индикацией серьёзности. Исчезает через 5 секунд или по нажатию.
#### 3.6.3 Архитектура обмена данными
```
TanStack Query WebSocket
(polling 5s) (push)
│ │
▼ ▼
api.metrics() WS.onmessage()
api.alerts() → _emitToast()
api.stats() → queryClient.invalidate()
api.fileEvents()
React state → re-render
```
TanStack Query кеширует ответы API и автоматически перезапрашивает данные каждые 5 секунд (staleTime: 4000ms). При поступлении WebSocket-алерта принудительно инвалидируется кеш `/api/alerts` и `/api/stats`, что вызывает немедленный повторный запрос без ожидания следующего интервала.
---
### 3.7 Результаты тестирования
#### 3.7.1 Тест: CPU-аномалия
**Условие:** на тестовом хосте (Ubuntu 22.04, 4 ядра, 8 ГБ RAM) запущен стресс-тест:
```bash
stress-ng --cpu 4 --timeout 30s
```
**Результат:**
- через 5–10 секунд процесс `stress-ng-cpu` появился в таблице Processes с отметкой «Anomaly»;
- в таблице Alerts появилась запись severity=high с описанием «CPU 98.4%»;
- в правом нижнем углу отобразилось toast-уведомление;
- после завершения стресс-теста процесс вернул статус Normal.
**Время реакции:** 5–10 секунд (один цикл сбора метрик).
#### 3.7.2 Тест: Memory-аномалия
**Условие:** Python-скрипт выделяет 3 ГБ памяти:
```python
x = bytearray(3 * 1024 * 1024 * 1024)
import time; time.sleep(60)
```
**Результат:** алерт severity=high с описанием «MEM 3072 MB», время реакции — 5 с.
#### 3.7.3 Тест: Filesystem-событие
**Условие:** создание файла в `/tmp`:
```bash
echo "malware" > /tmp/evil.sh
chmod +x /tmp/evil.sh
```
**Результат:** событие `create` с путём `/tmp/evil.sh` появилось во вкладке File Events в течение 1 секунды (inotify работает в реальном времени).
---
### 3.8 Выводы по практической части
В ходе выполнения практической части дипломной работы:
1. Разработана трёхзвенная система EDR, включающая агент мониторинга, серверную часть и веб-интерфейс.
2. Реализован ансамблевый модуль машинного обучения, объединяющий три алгоритма обнаружения аномалий без учителя: Isolation Forest, Local Outlier Factor и One-Class SVM. Применение правила большинства голосов (2 из 3) снижает количество ложных срабатываний по сравнению с единственным классификатором и повышает устойчивость к различным типам аномалий.
3. Обеспечена интеграция с датасетом UNSW-NB15 для обучения на реальных данных сетевых атак; реализован резервный режим на синтетических данных с реалистичным распределением поведения Linux-процессов.
4. Реализован механизм оповещений в режиме реального времени через WebSocket, обеспечивающий время реакции не более одного цикла сбора метрик (5 секунд).
5. Разработан веб-интерфейс профессионального уровня с поддержкой динамических графиков, цветовой индикацией угроз и push-уведомлениями.
6. Система успешно выявляет следующие типы аномалий: аномально высокую нагрузку на CPU (криптомайнеры, brute-force), чрезмерное потребление памяти (эксплойты, утечки), избыточное число сетевых соединений (C2-коммуникации, DDoS), подозрительную активность в файловой системе.
---
## Список использованных технологий и библиотек
| Компонент | Технология | Версия | Лицензия |
|-----------|-----------|--------|---------|
| Агент | Python | 3.11 | PSF |
| Агент | psutil | ≥5.9 | BSD-3 |
| Агент | watchdog | ≥3.0 | Apache-2.0 |
| Агент | scikit-learn (IF + LOF + OC-SVM) | ≥1.3 | BSD-3 |
| Агент | joblib | ≥1.3 | BSD-3 |
| Бэкенд | FastAPI | ≥0.111 | MIT |
| Бэкенд | aiosqlite | ≥0.20 | MIT |
| Бэкенд | uvicorn | ≥0.29 | BSD-3 |
| Фронтенд | React | 18.3 | MIT |
| Фронтенд | TypeScript | 5.4 | Apache-2.0 |
| Фронтенд | Vite | 5.3 | MIT |
| Фронтенд | Tailwind CSS | 3.4 | MIT |
| Фронтенд | Recharts | 2.12 | MIT |
| Фронтенд | TanStack Query | 5.x | MIT |
| Фронтенд | Radix UI | 1.x | MIT |
| Контейнеризация | Docker Compose | 3.9 | Apache-2.0 |
---
*Документ подготовлен в рамках дипломной работы по теме «Разработка системы обнаружения вредоносного программного обеспечения на основе поведенческого анализа».*
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log
+20
View File
@@ -0,0 +1,20 @@
# Stage 1 — сборка React-приложения
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2 — nginx для отдачи статики и проксирования API/WS
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Монитор — Поведенческий анализ угроз</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — все не-файловые пути → index.html
location / {
try_files $uri $uri/ /index.html;
}
# REST API → бэкенд (путь /api/... передаётся без изменений)
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# WebSocket → бэкенд
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "edr-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.45.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.394.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.7",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}
+2405
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Shield, Activity, Bell, FolderSearch, AlertTriangle } from 'lucide-react'
import type { WsAlert } from '@/lib/api'
import { Overview } from '@/components/Overview'
import { Processes } from '@/components/Processes'
import { Alerts } from '@/components/Alerts'
import { FileEvents } from '@/components/FileEvents'
import { Toaster, _emitToast } from '@/components/ui/toaster'
type Tab = 'overview' | 'processes' | 'alerts' | 'file-events'
const TABS: { id: Tab; label: string; Icon: React.ElementType }[] = [
{ id: 'overview', label: 'Обзор', Icon: Activity },
{ id: 'processes', label: 'Процессы', Icon: Shield },
{ id: 'alerts', label: 'Алерты', Icon: Bell },
{ id: 'file-events', label: 'События ФС', Icon: FolderSearch },
]
export default function App() {
const [tab, setTab] = useState<Tab>('overview')
const [wsConnected, setWsConnected] = useState(false)
const qc = useQueryClient()
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
let retryTimer: ReturnType<typeof setTimeout>
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${location.host}/ws`)
wsRef.current = ws
ws.onopen = () => setWsConnected(true)
ws.onmessage = (ev) => {
try {
const msg: WsAlert = JSON.parse(ev.data as string)
if (msg.type !== 'alert') return
_emitToast({
id: crypto.randomUUID(),
title: `⚠ Аномалия: ${msg.process_name} [PID ${msg.pid}]`,
description: msg.reason,
variant: msg.severity === 'high' ? 'destructive' : 'default',
})
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['stats'] })
} catch {
// игнорируем битые фреймы
}
}
ws.onclose = () => {
setWsConnected(false)
retryTimer = setTimeout(connect, 3_000)
}
ws.onerror = () => ws.close()
}
connect()
return () => {
clearTimeout(retryTimer)
wsRef.current?.close()
}
}, [qc])
return (
<div className="min-h-screen bg-[#0a0a0f]">
{/* ── Шапка ──────────────────────────────────────────────────────── */}
<header className="sticky top-0 z-50 border-b border-[#00ff9d1a] bg-[#0d0d14]/95 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
<Shield className="w-7 h-7 text-[#00ff9d]" />
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#00ff9d] animate-ping opacity-60" />
</div>
<div>
<span className="text-base font-bold tracking-[0.2em] text-[#00ff9d] uppercase">
EDR Монитор
</span>
<span className="ml-3 text-xs text-[#00d4ff]/60 tracking-wider">
Поведенческий анализ угроз
</span>
</div>
</div>
<div className="flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-[#ff6b35]/60" />
<div className="flex items-center gap-1.5 text-xs">
<span
className={`w-2 h-2 rounded-full transition-colors ${
wsConnected
? 'bg-[#00ff9d] shadow-[0_0_6px_#00ff9d]'
: 'bg-red-500 shadow-[0_0_4px_#ef4444]'
}`}
/>
<span className={wsConnected ? 'text-[#00ff9d]' : 'text-red-400'}>
{wsConnected ? 'ОНЛАЙН' : 'ПЕРЕПОДКЛЮЧЕНИЕ…'}
</span>
</div>
</div>
</div>
</header>
{/* ── Вкладки ────────────────────────────────────────────────────── */}
<nav className="border-b border-[#ffffff07] bg-[#0d0d14]">
<div className="max-w-7xl mx-auto px-6 flex gap-0">
{TABS.map(({ id, label, Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={[
'relative flex items-center gap-2 px-5 py-3 text-sm font-medium transition-colors',
tab === id
? 'text-[#00ff9d]'
: 'text-[#4b5563] hover:text-[#9ca3af]',
].join(' ')}
>
<Icon className="w-4 h-4" />
{label}
{tab === id && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#00ff9d] shadow-[0_0_8px_#00ff9d80]" />
)}
</button>
))}
</div>
</nav>
{/* ── Контент ────────────────────────────────────────────────────── */}
<main className="max-w-7xl mx-auto px-6 py-6">
{tab === 'overview' && <Overview />}
{tab === 'processes' && <Processes />}
{tab === 'alerts' && <Alerts wsConnected={wsConnected} />}
{tab === 'file-events' && <FileEvents />}
</main>
<Toaster />
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { api, type Alert } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fmtFull } from '@/lib/utils'
type SeverityVariant = 'destructive' | 'warning' | 'info'
function severityVariant(s: Alert['severity']): SeverityVariant {
if (s === 'high') return 'destructive'
if (s === 'medium') return 'warning'
return 'info'
}
function severityLabel(s: Alert['severity']): string {
if (s === 'high') return 'высокий'
if (s === 'medium') return 'средний'
return 'низкий'
}
interface Props {
wsConnected: boolean
}
export function Alerts({ wsConnected }: Props) {
const { data, isLoading, isError } = useQuery({
queryKey: ['alerts'],
queryFn: () => api.alerts(),
refetchInterval: 5_000,
})
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Алерты безопасности</CardTitle>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`w-2 h-2 rounded-full ${
wsConnected
? 'bg-[#00ff9d] shadow-[0_0_5px_#00ff9d]'
: 'bg-red-500'
}`}
/>
{wsConnected ? 'WebSocket активен' : 'WebSocket отключён'}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading && (
<p className="p-6 text-sm text-muted-foreground">Загрузка</p>
)}
{isError && (
<p className="p-6 text-sm text-destructive">Ошибка загрузки алертов.</p>
)}
{!isLoading && !isError && (!data || data.length === 0) && (
<p className="p-6 text-sm text-muted-foreground">
Алертов нет. Система работает штатно.
</p>
)}
{data && data.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">Время</TableHead>
<TableHead className="w-16">PID</TableHead>
<TableHead>Процесс</TableHead>
<TableHead>Причина</TableHead>
<TableHead className="w-28 text-center">Серьёзность</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{fmtFull(a.timestamp)}
</TableCell>
<TableCell className="font-mono text-muted-foreground">{a.pid}</TableCell>
<TableCell className="font-mono">{a.process_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{a.reason}</TableCell>
<TableCell className="text-center">
<Badge variant={severityVariant(a.severity)}>
{severityLabel(a.severity)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query'
import { api, type FileEvent } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fmtFull } from '@/lib/utils'
type EventVariant = 'destructive' | 'warning' | 'info'
function eventVariant(t: FileEvent['event_type']): EventVariant {
if (t === 'delete') return 'destructive'
if (t === 'create') return 'info'
return 'warning'
}
function eventLabel(t: FileEvent['event_type']): string {
if (t === 'create') return 'создание'
if (t === 'modify') return 'изменение'
return 'удаление'
}
export function FileEvents() {
const { data, isLoading, isError } = useQuery({
queryKey: ['file-events'],
queryFn: () => api.fileEvents(),
refetchInterval: 5_000,
})
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>События файловой системы</CardTitle>
<span className="text-xs text-muted-foreground">
/etc · /tmp · /var/log
</span>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading && (
<p className="p-6 text-sm text-muted-foreground">Загрузка</p>
)}
{isError && (
<p className="p-6 text-sm text-destructive">Ошибка загрузки событий.</p>
)}
{!isLoading && !isError && (!data || data.length === 0) && (
<p className="p-6 text-sm text-muted-foreground">
Событий файловой системы не обнаружено.
</p>
)}
{data && data.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">Время</TableHead>
<TableHead>Путь</TableHead>
<TableHead className="w-28 text-center">Событие</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{fmtFull(e.timestamp)}
</TableCell>
<TableCell className="font-mono text-sm break-all">{e.path}</TableCell>
<TableCell className="text-center">
<Badge variant={eventVariant(e.event_type)}>
{eventLabel(e.event_type)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bell, Cpu, Database } from 'lucide-react'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SystemChart } from '@/components/SystemChart'
function StatCard({
title,
value,
icon: Icon,
accent = '#00ff9d',
}: {
title: string
value: string | number
icon: React.ElementType
accent?: string
}) {
return (
<Card className="relative overflow-hidden">
<div
className="absolute inset-0 opacity-5"
style={{ background: `radial-gradient(circle at top right, ${accent}, transparent 70%)` }}
/>
<CardHeader className="pb-2">
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-end justify-between">
<span className="text-3xl font-bold" style={{ color: accent }}>
{value}
</span>
<Icon className="w-8 h-8 opacity-20" style={{ color: accent }} />
</div>
</CardContent>
</Card>
)
}
export function Overview() {
const { data: stats } = useQuery({
queryKey: ['stats'],
queryFn: api.stats,
refetchInterval: 5_000,
})
// Системные метрики — одна точка на батч (каждые 5 с), хранятся в отдельной таблице
const { data: sysRows } = useQuery({
queryKey: ['system-metrics'],
queryFn: () => api.systemMetrics(60),
refetchInterval: 5_000,
})
const chartData = (sysRows ?? []).map((r) => ({
t: r.timestamp.slice(11, 19), // HH:MM:SS
cpu: Math.round(r.cpu_percent * 10) / 10,
ram: Math.round(r.ram_percent * 10) / 10,
}))
return (
<div className="space-y-6">
{/* Карточки */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<StatCard
title="Всего процессов"
value={stats?.total_processes ?? '—'}
icon={Activity}
accent="#00d4ff"
/>
<StatCard
title="Аномалий сегодня"
value={stats?.anomalies_today ?? '—'}
icon={AlertTriangle}
accent="#ff6b35"
/>
<StatCard
title="Алертов сегодня"
value={stats?.alerts_today ?? '—'}
icon={Bell}
accent="#ff3366"
/>
<StatCard
title="Средний CPU"
value={stats ? `${stats.cpu_avg}%` : '—'}
icon={Cpu}
accent="#00ff9d"
/>
<StatCard
title="Средний RAM"
value={stats ? `${stats.ram_avg}%` : '—'}
icon={Database}
accent="#a78bfa"
/>
</div>
{/* График */}
<Card>
<CardHeader>
<CardTitle>Нагрузка системы реальное время (60 точек)</CardTitle>
</CardHeader>
<CardContent>
<SystemChart data={chartData} />
</CardContent>
</Card>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api, type ProcessMetric } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
// Оставляем только последний снимок по каждому PID
function latestByPid(rows: ProcessMetric[]): ProcessMetric[] {
const map = new Map<number, ProcessMetric>()
for (const r of rows) {
const existing = map.get(r.pid)
if (!existing || r.id > existing.id) map.set(r.pid, r)
}
return [...map.values()].sort((a, b) => b.cpu_percent - a.cpu_percent)
}
export function Processes() {
const { data, isLoading, isError } = useQuery({
queryKey: ['metrics', 500],
queryFn: () => api.metrics(500),
refetchInterval: 5_000,
})
const processes = useMemo(() => latestByPid(data ?? []), [data])
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Запущенные процессы</CardTitle>
<span className="text-xs text-muted-foreground">
{processes.length} процессов · сортировка по CPU
</span>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading && (
<p className="p-6 text-sm text-muted-foreground">Загрузка</p>
)}
{isError && (
<p className="p-6 text-sm text-destructive">Ошибка загрузки метрик.</p>
)}
{!isLoading && !isError && processes.length === 0 && (
<p className="p-6 text-sm text-muted-foreground">
Данных ещё нет. Агент запущен?
</p>
)}
{processes.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">PID</TableHead>
<TableHead>Имя</TableHead>
<TableHead className="w-20 text-right">CPU %</TableHead>
<TableHead className="w-24 text-right">RAM МБ</TableHead>
<TableHead className="w-24 text-right">Соединения</TableHead>
<TableHead className="w-20 text-right">Файлы</TableHead>
<TableHead className="w-24 text-center">Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processes.map((p) => (
<TableRow
key={p.pid}
className={p.is_anomaly ? 'border-l-2 border-l-red-500 bg-red-950/20' : ''}
>
<TableCell className="font-mono text-muted-foreground">{p.pid}</TableCell>
<TableCell>
<span
className={`font-mono text-sm ${p.is_anomaly ? 'text-red-400' : 'text-foreground'}`}
>
{p.name}
</span>
</TableCell>
<TableCell className="text-right font-mono">
<span className={p.cpu_percent > 50 ? 'text-yellow-400' : ''}>
{p.cpu_percent.toFixed(1)}
</span>
</TableCell>
<TableCell className="text-right font-mono">
{p.memory_mb.toFixed(0)}
</TableCell>
<TableCell className="text-right font-mono">{p.connections}</TableCell>
<TableCell className="text-right font-mono">{p.open_files}</TableCell>
<TableCell className="text-center">
{p.is_anomaly ? (
<Badge variant="destructive">Аномалия</Badge>
) : (
<Badge variant="success">Норма</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
+85
View File
@@ -0,0 +1,85 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
interface Point {
t: string
cpu: number
ram: number
}
interface Props {
data: Point[]
}
export function SystemChart({ data }: Props) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-52 text-muted-foreground text-sm">
Ожидание данных
</div>
)
}
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={data} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,.06)" />
<XAxis
dataKey="t"
tick={{ fontSize: 10, fill: '#6b7280', fontFamily: 'JetBrains Mono, monospace' }}
tickLine={false}
axisLine={{ stroke: '#ffffff18' }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: '#6b7280', fontFamily: 'JetBrains Mono, monospace' }}
tickLine={false}
axisLine={{ stroke: '#ffffff18' }}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip
contentStyle={{
background: '#111118',
border: '1px solid #00ff9d33',
borderRadius: 6,
fontSize: 12,
fontFamily: 'JetBrains Mono, monospace',
}}
labelStyle={{ color: '#6b7280' }}
formatter={(value, name) => [
`${Number(value).toFixed(1)}%`,
name === 'cpu' ? 'CPU' : 'RAM',
]}
/>
<Legend
wrapperStyle={{ fontSize: 11, fontFamily: 'JetBrains Mono, monospace' }}
formatter={(value: string) => (value === 'cpu' ? 'CPU %' : 'RAM %')}
/>
<Line
type="monotone"
dataKey="cpu"
stroke="#00ff9d"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#00ff9d' }}
/>
<Line
type="monotone"
dataKey="ram"
stroke="#00d4ff"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#00d4ff' }}
/>
</LineChart>
</ResponsiveContainer>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-semibold tracking-wider uppercase transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary/20 text-primary',
secondary: 'border-transparent bg-secondary/20 text-secondary',
destructive: 'border-transparent bg-destructive/20 text-red-400 border-red-500/30',
outline: 'border-border text-foreground',
success: 'border-transparent bg-green-500/15 text-green-400 border-green-500/30',
warning: 'border-transparent bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
info: 'border-transparent bg-blue-500/15 text-blue-400 border-blue-500/30',
},
},
defaultVariants: { variant: 'default' },
},
)
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+41
View File
@@ -0,0 +1,41 @@
import { type HTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
/>
),
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-sm font-semibold tracking-widest uppercase text-muted-foreground', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
+64
View File
@@ -0,0 +1,64 @@
import { type HTMLAttributes, type TdHTMLAttributes, type ThHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
),
)
Table.displayName = 'Table'
export const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
),
)
TableHeader.displayName = 'TableHeader'
export const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
),
)
TableBody.displayName = 'TableBody'
export const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-border/50 transition-colors hover:bg-muted/30',
className,
)}
{...props}
/>
),
)
TableRow.displayName = 'TableRow'
export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-3 text-left align-middle text-xs font-semibold uppercase tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
),
)
TableHead.displayName = 'TableHead'
export const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('px-3 py-2.5 align-middle text-sm', className)} {...props} />
),
)
TableCell.displayName = 'TableCell'
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react'
import * as Toast from '@radix-ui/react-toast'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type ToastMessage } from '@/hooks/use-toast'
// Global event bus — avoids prop drilling to the root
type Listener = (t: ToastMessage) => void
const _bus: Listener[] = []
export function _emitToast(t: ToastMessage) {
_bus.forEach((fn) => fn(t))
}
export function _subscribeToast(fn: Listener) {
_bus.push(fn)
return () => {
const i = _bus.indexOf(fn)
if (i !== -1) _bus.splice(i, 1)
}
}
export function Toaster() {
const [toasts, setToasts] = useState<ToastMessage[]>([])
useEffect(() => {
return _subscribeToast((t) =>
setToasts((prev) => [t, ...prev].slice(0, 5)),
)
}, [])
const remove = (id: string) =>
setToasts((prev) => prev.filter((t) => t.id !== id))
return (
<Toast.Provider swipeDirection="right">
{toasts.map((t) => (
<Toast.Root
key={t.id}
open
onOpenChange={(open) => !open && remove(t.id)}
duration={5000}
className={cn(
'flex items-start gap-3 rounded-lg border p-4 shadow-lg',
'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-4',
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-full',
t.variant === 'destructive'
? 'border-red-500/40 bg-[#1a0a0a] text-red-300'
: 'border-[#00ff9d33] bg-[#0d1a13] text-[#00ff9d]',
)}
>
<div className="flex-1 min-w-0">
<Toast.Title className="text-sm font-semibold">{t.title}</Toast.Title>
{t.description && (
<Toast.Description className="mt-1 text-xs opacity-80 line-clamp-2">
{t.description}
</Toast.Description>
)}
</div>
<Toast.Close
onClick={() => remove(t.id)}
className="shrink-0 opacity-60 hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</Toast.Close>
</Toast.Root>
))}
<Toast.Viewport className="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 w-80 max-w-[90vw]" />
</Toast.Provider>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useCallback } from 'react'
export interface ToastMessage {
id: string
title: string
description?: string
variant?: 'default' | 'destructive'
}
let _listeners: Array<(t: ToastMessage) => void> = []
export function useToast() {
const toast = useCallback((msg: Omit<ToastMessage, 'id'>) => {
const full: ToastMessage = { ...msg, id: crypto.randomUUID() }
_listeners.forEach((fn) => fn(full))
}, [])
return { toast }
}
export function useToastListener(fn: (t: ToastMessage) => void) {
useState(() => {
_listeners.push(fn)
return () => {
_listeners = _listeners.filter((l) => l !== fn)
}
})
}
+63
View File
@@ -0,0 +1,63 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 4%;
--foreground: 180 8% 85%;
--card: 240 10% 7%;
--card-foreground: 180 8% 85%;
--popover: 240 10% 9%;
--popover-foreground: 180 8% 85%;
--primary: 152 100% 50%; /* #00ff9d cyan-green */
--primary-foreground: 240 10% 4%;
--secondary: 195 100% 42%; /* #00d4ff blue-cyan */
--secondary-foreground: 240 10% 4%;
--muted: 240 6% 14%;
--muted-foreground: 240 5% 50%;
--accent: 240 6% 14%;
--accent-foreground: 180 8% 85%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 18%;
--input: 240 6% 18%;
--ring: 152 100% 50%;
--radius: 0.5rem;
}
}
@layer base {
* { @apply border-border; box-sizing: border-box; }
body {
@apply bg-[#0a0a0f] text-foreground font-mono;
/* subtle grid lines */
background-image:
linear-gradient(rgba(0,255,157,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,255,157,.018) 1px, transparent 1px);
background-size: 40px 40px;
}
}
/* CRT scanline overlay */
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,.04) 2px,
rgba(0,0,0,.04) 4px
);
pointer-events: none;
z-index: 9999;
}
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: #0a0a0f; }
::-webkit-scrollbar-thumb { background: rgba(0,255,157,.2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,255,157,.4); }
+72
View File
@@ -0,0 +1,72 @@
const BASE = '/api'
// ── types ─────────────────────────────────────────────────────────────────────
export interface ProcessMetric {
id: number
timestamp: string
pid: number
name: string
cpu_percent: number
memory_mb: number
open_files: number
connections: number
is_anomaly: number // 0 | 1 (SQLite INTEGER)
}
export interface Alert {
id: number
timestamp: string
pid: number
process_name: string
reason: string
severity: 'low' | 'medium' | 'high'
}
export interface FileEvent {
id: number
timestamp: string
path: string
event_type: 'create' | 'modify' | 'delete'
}
export interface Stats {
total_processes: number
anomalies_today: number
alerts_today: number
cpu_avg: number
ram_avg: number
}
export interface SystemMetricRow {
id: number
timestamp: string
cpu_percent: number
ram_percent: number
net_connections: number
}
export interface WsAlert {
type: 'alert'
pid: number
process_name: string
reason: string
severity: 'low' | 'medium' | 'high'
timestamp: string
}
// ── fetchers ──────────────────────────────────────────────────────────────────
async function get<T>(url: string): Promise<T> {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}${url}`)
return res.json() as Promise<T>
}
export const api = {
metrics: (limit = 200) => get<ProcessMetric[]>(`${BASE}/metrics?limit=${limit}`),
alerts: (limit = 50) => get<Alert[]>(`${BASE}/alerts?limit=${limit}`),
fileEvents: (limit = 100) => get<FileEvent[]>(`${BASE}/file-events?limit=${limit}`),
stats: () => get<Stats>(`${BASE}/stats`),
systemMetrics: (limit = 60) => get<SystemMetricRow[]>(`${BASE}/system-metrics?limit=${limit}`),
}
+18
View File
@@ -0,0 +1,18 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function fmt(ts: string): string {
return new Date(ts).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function fmtFull(ts: string): string {
return new Date(ts).toLocaleString('ru-RU')
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 4_000, retry: 1, refetchOnWindowFocus: false },
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
+34
View File
@@ -0,0 +1,34 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
},
},
},
plugins: [],
}
export default config
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
})