poc2_worked
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def score_rule(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn alert history into a conservative tuning recommendation.
|
||||
|
||||
This module never disables signatures automatically. It only proposes
|
||||
thresholding when observed noise is high and incident usefulness is low.
|
||||
"""
|
||||
item = dict(row)
|
||||
hits = max(0, int(item.get("hits") or 0))
|
||||
rows = max(0, int(item.get("rows") or 0))
|
||||
unique_src = max(0, int(item.get("unique_src") or 0))
|
||||
unique_dst = max(0, int(item.get("unique_dst") or 0))
|
||||
incidents = max(0, int(item.get("incidents") or 0))
|
||||
blocked = max(0, int(item.get("blocked") or 0))
|
||||
severity = max(1, min(4, int(item.get("severity") or 4)))
|
||||
|
||||
incident_ratio = incidents / rows if rows else 0.0
|
||||
duplicate_ratio = max(0.0, min(1.0, (hits - rows) / hits)) if hits else 0.0
|
||||
concentration = hits / max(1, unique_src + unique_dst)
|
||||
|
||||
raw = min(48.0, math.log10(hits + 1) * 16.0)
|
||||
raw += duplicate_ratio * 28.0
|
||||
raw += min(18.0, math.log2(concentration + 1) * 4.0)
|
||||
raw -= min(32.0, incident_ratio * 55.0)
|
||||
raw -= 10.0 if severity == 1 else 4.0 if severity == 2 else 0.0
|
||||
raw -= 8.0 if blocked else 0.0
|
||||
noise_score = max(0, min(100, round(raw)))
|
||||
|
||||
recommendation = "keep"
|
||||
reason = "Useful/low-volume signature"
|
||||
proposed = None
|
||||
if hits >= 100 and noise_score >= 70 and incidents == 0:
|
||||
recommendation = "limit"
|
||||
count = 1 if hits >= 1000 else 3 if hits >= 300 else 5
|
||||
seconds = 60 if hits >= 300 else 120
|
||||
proposed = {"type": "limit", "track": "by_src", "count": count, "seconds": seconds}
|
||||
reason = "High alert volume with no incident correlation"
|
||||
elif hits >= 40 and noise_score >= 55 and incident_ratio < 0.05:
|
||||
recommendation = "review"
|
||||
reason = "Repeated signature with low incident correlation"
|
||||
elif incident_ratio >= 0.25 or severity == 1:
|
||||
recommendation = "keep"
|
||||
reason = "High-value or frequently incident-correlated signature"
|
||||
|
||||
item.update({
|
||||
"noise_score": noise_score,
|
||||
"incident_ratio": round(incident_ratio, 4),
|
||||
"duplicate_ratio": round(duplicate_ratio, 4),
|
||||
"recommendation": recommendation,
|
||||
"recommendation_reason": reason,
|
||||
"proposed_threshold": proposed,
|
||||
})
|
||||
return item
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .live import RedisUnavailableError, TrafficHistory
|
||||
from .store import AlertStore
|
||||
|
||||
|
||||
SUMMARY_WINDOWS = (900, 3600, 21600, 86400)
|
||||
|
||||
|
||||
class AnalyticsSnapshotCache:
|
||||
"""Redis-backed dashboard snapshots refreshed only for windows actually in use.
|
||||
|
||||
Older builds rebuilt all four windows every minute, which meant scanning and
|
||||
decoding the complete 24-hour Redis history even when the browser displayed
|
||||
only 15 minutes. This cache keeps persisted snapshots, but refreshes only
|
||||
recently requested windows and uses a slower cadence for wider ranges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AlertStore,
|
||||
history: TrafficHistory,
|
||||
stop_event: threading.Event,
|
||||
interval_seconds: int = 60,
|
||||
) -> None:
|
||||
# AlertStore stays in the signature for backwards compatibility with the
|
||||
# application wiring, but traffic analytics are Redis-only.
|
||||
self.store = store
|
||||
self.history = history
|
||||
self.stop_event = stop_event
|
||||
self.interval_seconds = max(15, int(interval_seconds))
|
||||
self._thread = threading.Thread(target=self._run, name="analytics-snapshots", daemon=True)
|
||||
self._wake = threading.Event()
|
||||
self._lock = threading.RLock()
|
||||
self._requested_at: dict[int, float] = {}
|
||||
self._last_refresh: dict[int, float] = {}
|
||||
self._errors = 0
|
||||
self._refreshes = 0
|
||||
# If a browser has not used a window for this long, stop rebuilding it.
|
||||
self._active_ttl_seconds = max(300, self.interval_seconds * 10)
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._thread.is_alive():
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self._wake.set()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=timeout)
|
||||
|
||||
def get(self, window_seconds: int) -> dict[str, Any]:
|
||||
window = self._normalise_window(window_seconds)
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._requested_at[window] = now
|
||||
try:
|
||||
cached = self.history.snapshot(window)
|
||||
except RedisUnavailableError:
|
||||
raise
|
||||
|
||||
cadence = self._refresh_interval(window)
|
||||
if cached is not None:
|
||||
cached = self._decorate(cached, "redis-cache")
|
||||
age = float(cached.get("snapshot_age_seconds") or 0)
|
||||
stale = age > cadence * 1.5
|
||||
cached["snapshot_stale"] = stale
|
||||
cached["snapshot_refreshing"] = stale
|
||||
cached["snapshot_refresh_interval_seconds"] = cadence
|
||||
self._overlay_current_throughput(cached)
|
||||
if stale:
|
||||
self._wake.set()
|
||||
return cached
|
||||
|
||||
# First request after an empty Redis volume returns a shell immediately;
|
||||
# only this requested range is built in the background.
|
||||
self._wake.set()
|
||||
now_ms = int(time.time() * 1000)
|
||||
bins_count = 60
|
||||
bin_ms = max(1000, int(window * 1000 / bins_count))
|
||||
return {
|
||||
"window_seconds": window,
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"alerts": 0,
|
||||
"blocked": 0,
|
||||
"timeline": [
|
||||
{
|
||||
"ts_ms": now_ms - window * 1000 + idx * bin_ms,
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"alerts": 0,
|
||||
"bps": 0,
|
||||
"in_bps": 0,
|
||||
"out_bps": 0,
|
||||
"other_bps": 0,
|
||||
"pps": 0,
|
||||
}
|
||||
for idx in range(bins_count)
|
||||
],
|
||||
"snapshot_source": "redis-background",
|
||||
"snapshot_age_seconds": 0,
|
||||
"snapshot_loading": True,
|
||||
"snapshot_refreshing": True,
|
||||
"snapshot_refresh_interval_seconds": cadence,
|
||||
}
|
||||
|
||||
def refresh_all(self) -> None:
|
||||
"""Explicit maintenance/test operation; normal background work is demand-driven."""
|
||||
self.refresh_windows(SUMMARY_WINDOWS)
|
||||
|
||||
def refresh_windows(self, windows: Iterable[int]) -> None:
|
||||
normalized = sorted({self._normalise_window(window) for window in windows})
|
||||
if not normalized:
|
||||
return
|
||||
try:
|
||||
# analytics_many scans only the widest requested window once.
|
||||
snapshots = self.history.analytics_many(normalized)
|
||||
now_wall = time.time()
|
||||
for window in normalized:
|
||||
self.history.save_snapshot(window, snapshots[window])
|
||||
with self._lock:
|
||||
self._last_refresh[window] = now_wall
|
||||
self._refreshes += 1
|
||||
except (RedisUnavailableError, KeyError, ValueError, OSError):
|
||||
with self._lock:
|
||||
self._errors += 1
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._errors += 1
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
persisted = self.history.snapshot_status(SUMMARY_WINDOWS)
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
active = [
|
||||
window for window, requested in self._requested_at.items()
|
||||
if now - requested <= self._active_ttl_seconds
|
||||
]
|
||||
refreshes = self._refreshes
|
||||
errors = self._errors
|
||||
return {
|
||||
"backend": "redis",
|
||||
"interval_seconds": self.interval_seconds,
|
||||
"windows": list(SUMMARY_WINDOWS),
|
||||
"persisted": persisted,
|
||||
"active_windows": sorted(active),
|
||||
"cadence_seconds": {str(window): self._refresh_interval(window) for window in SUMMARY_WINDOWS},
|
||||
"refreshes": refreshes,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
# Do not scan 24h on process startup. The first browser/API request marks
|
||||
# its selected range active and wakes this worker.
|
||||
while not self.stop_event.is_set():
|
||||
self._wake.wait(timeout=min(5.0, float(self.interval_seconds)))
|
||||
self._wake.clear()
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
due = self._due_windows()
|
||||
if due:
|
||||
self.refresh_windows(due)
|
||||
|
||||
def _due_windows(self) -> list[int]:
|
||||
now_mono = time.monotonic()
|
||||
now_wall = time.time()
|
||||
due: list[int] = []
|
||||
with self._lock:
|
||||
requests = dict(self._requested_at)
|
||||
last_refresh = dict(self._last_refresh)
|
||||
persisted = {
|
||||
int(row["window_seconds"]): row.get("generated_at")
|
||||
for row in self.history.snapshot_status(requests.keys())
|
||||
}
|
||||
for window, requested_at in requests.items():
|
||||
if now_mono - requested_at > self._active_ttl_seconds:
|
||||
continue
|
||||
cadence = self._refresh_interval(window)
|
||||
last = last_refresh.get(window, 0.0)
|
||||
if last <= 0 and window in persisted:
|
||||
last = self._generated_epoch(persisted[window])
|
||||
if last <= 0 or now_wall - last >= cadence:
|
||||
due.append(window)
|
||||
return sorted(due)
|
||||
|
||||
def _refresh_interval(self, window: int) -> int:
|
||||
base = self.interval_seconds
|
||||
if window <= 900:
|
||||
return base
|
||||
if window <= 3600:
|
||||
return max(base * 2, 120)
|
||||
if window <= 21600:
|
||||
return max(base * 5, 300)
|
||||
return max(base * 15, 900)
|
||||
|
||||
def _overlay_current_throughput(self, payload: dict[str, Any]) -> None:
|
||||
"""Keep the 'now' rate fresh without rescanning the selected history window."""
|
||||
try:
|
||||
sample = self.history.latest_throughput()
|
||||
except RedisUnavailableError:
|
||||
return
|
||||
if not sample:
|
||||
return
|
||||
now_ms = int(time.time() * 1000)
|
||||
ts_ms = int(sample.get("ts_ms") or 0)
|
||||
interval = max(float(sample.get("interval_ms") or 1000) / 1000.0, 0.001)
|
||||
age_ms = max(0, now_ms - ts_ms)
|
||||
if age_ms > max(3000, round(interval * 3000)):
|
||||
current = current_in = current_out = current_pps = 0
|
||||
else:
|
||||
current = round(max(int(sample.get("bytes_total") or 0), 0) * 8 / interval)
|
||||
current_in = round(max(int(sample.get("bytes_in") or 0), 0) * 8 / interval)
|
||||
current_out = round(max(int(sample.get("bytes_out") or 0), 0) * 8 / interval)
|
||||
current_pps = round(max(int(sample.get("packets_total") or 0), 0) / interval, 2)
|
||||
payload["current_bps"] = current
|
||||
payload["current_in_bps"] = current_in
|
||||
payload["current_out_bps"] = current_out
|
||||
payload["current_other_bps"] = max(0, current - current_in - current_out)
|
||||
payload["current_pps"] = current_pps
|
||||
|
||||
@staticmethod
|
||||
def _generated_epoch(value: Any) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _normalise_window(value: int) -> int:
|
||||
value = int(value)
|
||||
if value in SUMMARY_WINDOWS:
|
||||
return value
|
||||
return min(SUMMARY_WINDOWS, key=lambda item: abs(item - value))
|
||||
|
||||
@staticmethod
|
||||
def _decorate(payload: dict[str, Any], source: str) -> dict[str, Any]:
|
||||
result = dict(payload)
|
||||
generated = result.get("generated_at")
|
||||
age = 0.0
|
||||
if generated:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(generated).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
age = max(0.0, (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds())
|
||||
except ValueError:
|
||||
age = 0.0
|
||||
result["snapshot_source"] = source
|
||||
result["snapshot_age_seconds"] = round(age, 1)
|
||||
return result
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Any
|
||||
|
||||
from .config import Config
|
||||
from .store import AlertStore
|
||||
|
||||
|
||||
SESSION_COOKIE = "mikrosuricata_session"
|
||||
|
||||
|
||||
class SessionAuth:
|
||||
"""Small dependency-free username/password session manager backed by SQLite."""
|
||||
|
||||
def __init__(self, config: Config, store: AlertStore) -> None:
|
||||
self.config = config
|
||||
self.store = store
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._password())
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
expected_password = self._password()
|
||||
if not expected_password:
|
||||
return False
|
||||
return hmac.compare_digest(username, self.config.admin_username) and hmac.compare_digest(
|
||||
password, expected_password
|
||||
)
|
||||
|
||||
def create_session(self, username: str) -> tuple[str, dict[str, Any]]:
|
||||
token = secrets.token_urlsafe(36)
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=self.config.session_hours)
|
||||
self.store.create_web_session(self._hash(token), username, csrf, expires_at)
|
||||
session = self.store.get_web_session(self._hash(token), touch=False)
|
||||
if session is None:
|
||||
raise RuntimeError("could not create web session")
|
||||
return token, session
|
||||
|
||||
def session_from_cookie(self, cookie_header: str, *, touch: bool = True) -> dict[str, Any] | None:
|
||||
token = self.cookie_token(cookie_header)
|
||||
if not token:
|
||||
return None
|
||||
session = self.store.get_web_session(self._hash(token), touch=touch)
|
||||
if session is not None:
|
||||
session["token_hash"] = self._hash(token)
|
||||
return session
|
||||
|
||||
def delete_session_from_cookie(self, cookie_header: str) -> None:
|
||||
token = self.cookie_token(cookie_header)
|
||||
if token:
|
||||
self.store.delete_web_session(self._hash(token))
|
||||
|
||||
def cookie_header(self, token: str) -> str:
|
||||
max_age = self.config.session_hours * 3600
|
||||
parts = [
|
||||
f"{SESSION_COOKIE}={token}",
|
||||
"Path=/",
|
||||
f"Max-Age={max_age}",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
]
|
||||
if self.config.session_cookie_secure:
|
||||
parts.append("Secure")
|
||||
return "; ".join(parts)
|
||||
|
||||
def clear_cookie_header(self) -> str:
|
||||
parts = [
|
||||
f"{SESSION_COOKIE}=",
|
||||
"Path=/",
|
||||
"Max-Age=0",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
]
|
||||
if self.config.session_cookie_secure:
|
||||
parts.append("Secure")
|
||||
return "; ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def cookie_token(cookie_header: str) -> str:
|
||||
if not cookie_header:
|
||||
return ""
|
||||
cookie = SimpleCookie()
|
||||
try:
|
||||
cookie.load(cookie_header)
|
||||
except Exception:
|
||||
return ""
|
||||
morsel = cookie.get(SESSION_COOKIE)
|
||||
return morsel.value if morsel else ""
|
||||
|
||||
@staticmethod
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
def _password(self) -> str:
|
||||
# ADMIN_TOKEN remains a migration fallback only; the UI no longer stores or sends it.
|
||||
return self.config.admin_password or self.config.admin_token
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Create bounded portable backups of persistent IDS state.
|
||||
|
||||
Runtime-heavy data (Redis AOF/RDB, EVE logs and PCAP ring) is intentionally
|
||||
excluded. Those are caches/evidence streams, not configuration state. The
|
||||
SQLite database is copied with SQLite's online backup API for consistency.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str, data_dir: str = "/data", keep: int = 8) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.data_dir = Path(data_dir)
|
||||
self.backup_dir = self.data_dir / "backups"
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.keep = max(2, min(30, int(keep)))
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def create(self, label: str = "manual") -> dict[str, Any]:
|
||||
safe_label = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(label or "manual"))[:40].strip("-") or "manual"
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
name = f"mikrosuricata-{stamp}-{safe_label}-{uuid.uuid4().hex[:6]}.tar.gz"
|
||||
target = self.backup_dir / name
|
||||
with self._lock, tempfile.TemporaryDirectory(prefix="ms-backup-") as td:
|
||||
root = Path(td)
|
||||
db_copy = root / "ids.db"
|
||||
self._sqlite_backup(db_copy)
|
||||
manifest = {
|
||||
"format": 1,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"label": safe_label,
|
||||
"contents": ["ids.db", "suricata/", "lib/suricata/update/sources/", "lib/suricata/rules/suricata.rules"],
|
||||
"excluded": ["redis/", "logs/", "pcap/", "backups/"],
|
||||
}
|
||||
(root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
with tarfile.open(target, "w:gz") as tar:
|
||||
tar.add(db_copy, arcname="ids.db", recursive=False)
|
||||
tar.add(root / "manifest.json", arcname="manifest.json", recursive=False)
|
||||
self._add_if_exists(tar, self.data_dir / "suricata", "suricata")
|
||||
self._add_if_exists(tar, self.data_dir / "lib" / "suricata" / "update" / "sources", "lib/suricata/update/sources")
|
||||
self._add_if_exists(tar, self.data_dir / "lib" / "suricata" / "rules" / "suricata.rules", "lib/suricata/rules/suricata.rules")
|
||||
os.chmod(target, 0o600)
|
||||
self._prune()
|
||||
return self.info(name) or {"id": name, "path": str(target)}
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
paths = self._paths()
|
||||
out = []
|
||||
for path in paths:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"id": path.name,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
||||
})
|
||||
return out
|
||||
|
||||
def info(self, backup_id: str) -> dict[str, Any] | None:
|
||||
path = self.path(backup_id)
|
||||
if path is None:
|
||||
return None
|
||||
stat = path.stat()
|
||||
return {
|
||||
"id": path.name,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
def path(self, backup_id: str) -> Path | None:
|
||||
name = os.path.basename(str(backup_id or ""))
|
||||
if not name.startswith("mikrosuricata-") or not name.endswith(".tar.gz"):
|
||||
return None
|
||||
path = (self.backup_dir / name).resolve()
|
||||
if path.parent != self.backup_dir.resolve() or not path.is_file():
|
||||
return None
|
||||
return path
|
||||
|
||||
def delete(self, backup_id: str) -> bool:
|
||||
path = self.path(backup_id)
|
||||
if path is None:
|
||||
return False
|
||||
with self._lock:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _sqlite_backup(self, destination: Path) -> None:
|
||||
source = sqlite3.connect(str(self.db_path), timeout=10)
|
||||
target = sqlite3.connect(str(destination))
|
||||
try:
|
||||
source.backup(target)
|
||||
target.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
target.commit()
|
||||
finally:
|
||||
target.close()
|
||||
source.close()
|
||||
|
||||
@staticmethod
|
||||
def _add_if_exists(tar: tarfile.TarFile, source: Path, arcname: str) -> None:
|
||||
if source.exists():
|
||||
tar.add(source, arcname=arcname, recursive=True)
|
||||
|
||||
def _paths(self) -> list[Path]:
|
||||
try:
|
||||
return sorted(self.backup_dir.glob("mikrosuricata-*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def _prune(self) -> None:
|
||||
for path in self._paths()[self.keep:]:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
+95
-3
@@ -18,6 +18,13 @@ def _int(name: str, default: int) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def _float(name: str, default: float) -> float:
|
||||
value = os.getenv(name)
|
||||
if value is None or not value.strip():
|
||||
return default
|
||||
return float(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
tzsp_bind: str
|
||||
@@ -25,17 +32,20 @@ class Config:
|
||||
tap_name: str
|
||||
tap_mtu: int
|
||||
suricata_config: str
|
||||
suricata_output_config: str
|
||||
suricata_home_net: str
|
||||
suricata_local_rules: str
|
||||
suricata_extra_rules_glob: str
|
||||
suricata_custom_rules: str
|
||||
suricata_threshold_config: str
|
||||
suricata_persist_lib_dir: str
|
||||
update_rules_on_start: bool
|
||||
rule_update_interval_hours: int
|
||||
web_bind: str
|
||||
web_port: int
|
||||
db_path: str
|
||||
eve_path: str
|
||||
suricata_log_max_mb: int
|
||||
alert_retention_days: int
|
||||
alert_max_severity: int
|
||||
alert_dedup_window_seconds: int
|
||||
@@ -53,6 +63,32 @@ class Config:
|
||||
routeros_address_list: str
|
||||
routeros_http_timeout: int
|
||||
admin_token: str
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
session_hours: int
|
||||
session_cookie_secure: bool
|
||||
analytics_snapshot_interval_seconds: int
|
||||
redis_url: str
|
||||
redis_managed: bool
|
||||
redis_data_dir: str
|
||||
redis_port: int
|
||||
redis_maxmemory_mb: int
|
||||
redis_snapshot_seconds: int
|
||||
redis_aof: bool
|
||||
traffic_retention_hours: int
|
||||
traffic_max_events: int
|
||||
traffic_memory_events: int
|
||||
websocket_queue_size: int
|
||||
live_flow_update_seconds: float
|
||||
ndr_enabled: bool
|
||||
ndr_correlation_window_seconds: int
|
||||
behavior_min_observations: int
|
||||
ndr_auto_block: bool
|
||||
ndr_auto_block_risk: int
|
||||
routeros_inventory_interval_seconds: int
|
||||
notify_webhook_url: str
|
||||
notify_min_risk: int
|
||||
notify_timeout_seconds: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
@@ -62,6 +98,9 @@ class Config:
|
||||
tap_name=os.getenv("TAP_NAME", "suritap0"),
|
||||
tap_mtu=_int("TAP_MTU", 9000),
|
||||
suricata_config=os.getenv("SURICATA_CONFIG", "/etc/suricata/suricata.yaml"),
|
||||
suricata_output_config=os.getenv(
|
||||
"SURICATA_OUTPUT_CONFIG", "/opt/ids/suricata/ids-output.yaml"
|
||||
),
|
||||
suricata_home_net=os.getenv(
|
||||
"SURICATA_HOME_NET",
|
||||
"[192.168.0.0/16,10.0.0.0/8,172.16.0.0/12]",
|
||||
@@ -78,12 +117,16 @@ class Config:
|
||||
suricata_threshold_config=os.getenv(
|
||||
"SURICATA_THRESHOLD_CONFIG", "/data/suricata/threshold.config"
|
||||
),
|
||||
suricata_persist_lib_dir=os.getenv(
|
||||
"SURICATA_PERSIST_LIB_DIR", "/data/lib/suricata"
|
||||
),
|
||||
update_rules_on_start=_bool("UPDATE_RULES_ON_START", False),
|
||||
rule_update_interval_hours=_int("RULE_UPDATE_INTERVAL_HOURS", 24),
|
||||
web_bind=os.getenv("WEB_BIND", "0.0.0.0"),
|
||||
web_port=_int("WEB_PORT", 8080),
|
||||
db_path=os.getenv("DB_PATH", "/data/ids.db"),
|
||||
eve_path=os.getenv("EVE_PATH", "/var/log/suricata/eve.json"),
|
||||
eve_path=os.getenv("EVE_PATH", "/data/logs/suricata/eve.json"),
|
||||
suricata_log_max_mb=_int("SURICATA_LOG_MAX_MB", 512),
|
||||
alert_retention_days=_int("ALERT_RETENTION_DAYS", 14),
|
||||
# Suricata severity uses 1 as the most important value. Keeping
|
||||
# 1-2 by default removes low-priority informational noise from the
|
||||
@@ -94,7 +137,7 @@ class Config:
|
||||
alert_ignore_categories=os.getenv("ALERT_IGNORE_CATEGORIES", ""),
|
||||
auto_block=_bool("AUTO_BLOCK", False),
|
||||
auto_block_max_severity=_int("AUTO_BLOCK_MAX_SEVERITY", 1),
|
||||
monitored_networks=os.getenv("MONITORED_NETWORKS", "192.168.100.0/24"),
|
||||
monitored_networks=os.getenv("MONITORED_NETWORKS", "192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"),
|
||||
never_block=os.getenv("NEVER_BLOCK", ""),
|
||||
block_timeout=os.getenv("BLOCK_TIMEOUT", "1h"),
|
||||
routeros_url=os.getenv("ROUTEROS_URL", "https://172.31.255.1").rstrip("/"),
|
||||
@@ -104,6 +147,36 @@ class Config:
|
||||
routeros_address_list=os.getenv("ROUTEROS_ADDRESS_LIST", "IDS-BLOCK"),
|
||||
routeros_http_timeout=_int("ROUTEROS_HTTP_TIMEOUT", 5),
|
||||
admin_token=os.getenv("ADMIN_TOKEN", ""),
|
||||
admin_username=os.getenv("ADMIN_USERNAME", "admin").strip() or "admin",
|
||||
admin_password=os.getenv("ADMIN_PASSWORD", ""),
|
||||
session_hours=max(1, _int("SESSION_HOURS", 168)),
|
||||
session_cookie_secure=_bool("SESSION_COOKIE_SECURE", False),
|
||||
analytics_snapshot_interval_seconds=max(
|
||||
15, _int("ANALYTICS_SNAPSHOT_INTERVAL_SECONDS", 60)
|
||||
),
|
||||
redis_url=os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"),
|
||||
redis_managed=_bool("REDIS_MANAGED", True),
|
||||
redis_data_dir=os.getenv("REDIS_DATA_DIR", "/data/redis"),
|
||||
redis_port=_int("REDIS_PORT", 6379),
|
||||
# Managed Redis is the sole traffic-history store. Do not evict by
|
||||
# count/memory; retention time is the authoritative bound.
|
||||
redis_maxmemory_mb=0,
|
||||
redis_snapshot_seconds=_int("REDIS_SNAPSHOT_SECONDS", 1800),
|
||||
redis_aof=_bool("REDIS_AOF", True),
|
||||
traffic_retention_hours=_int("TRAFFIC_RETENTION_HOURS", 24),
|
||||
traffic_max_events=0,
|
||||
traffic_memory_events=0,
|
||||
websocket_queue_size=_int("WEBSOCKET_QUEUE_SIZE", 512),
|
||||
live_flow_update_seconds=_float("LIVE_FLOW_UPDATE_SECONDS", 2.0),
|
||||
ndr_enabled=_bool("NDR_ENABLED", True),
|
||||
ndr_correlation_window_seconds=max(300, _int("NDR_CORRELATION_WINDOW_SECONDS", 1800)),
|
||||
behavior_min_observations=max(10, _int("BEHAVIOR_MIN_OBSERVATIONS", 50)),
|
||||
ndr_auto_block=_bool("NDR_AUTO_BLOCK", False),
|
||||
ndr_auto_block_risk=max(70, min(100, _int("NDR_AUTO_BLOCK_RISK", 92))),
|
||||
routeros_inventory_interval_seconds=max(60, _int("ROUTEROS_INVENTORY_INTERVAL_SECONDS", 300)),
|
||||
notify_webhook_url=os.getenv("NOTIFY_WEBHOOK_URL", "").strip(),
|
||||
notify_min_risk=max(1, min(100, _int("NOTIFY_MIN_RISK", 80))),
|
||||
notify_timeout_seconds=max(1, min(30, _int("NOTIFY_TIMEOUT_SECONDS", 5))),
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict:
|
||||
@@ -117,6 +190,7 @@ class Config:
|
||||
"web_port": self.web_port,
|
||||
"rule_update_interval_hours": self.rule_update_interval_hours,
|
||||
"alert_retention_days": self.alert_retention_days,
|
||||
"suricata_log_max_mb": self.suricata_log_max_mb,
|
||||
"alert_max_severity": self.alert_max_severity,
|
||||
"alert_dedup_window_seconds": self.alert_dedup_window_seconds,
|
||||
"alert_ignore_sids": self.alert_ignore_sids,
|
||||
@@ -130,5 +204,23 @@ class Config:
|
||||
"routeros_user": self.routeros_user,
|
||||
"routeros_verify_tls": self.routeros_verify_tls,
|
||||
"routeros_address_list": self.routeros_address_list,
|
||||
"admin_actions_enabled": bool(self.admin_token),
|
||||
"auth_enabled": bool(self.admin_password or self.admin_token),
|
||||
"admin_username": self.admin_username,
|
||||
"session_hours": self.session_hours,
|
||||
"analytics_snapshot_interval_seconds": self.analytics_snapshot_interval_seconds,
|
||||
"traffic_retention_hours": self.traffic_retention_hours,
|
||||
"redis_managed": self.redis_managed,
|
||||
"redis_snapshot_seconds": self.redis_snapshot_seconds,
|
||||
"redis_aof": self.redis_aof,
|
||||
"traffic_max_events": self.traffic_max_events,
|
||||
"traffic_memory_events": self.traffic_memory_events,
|
||||
"live_flow_update_seconds": self.live_flow_update_seconds,
|
||||
"ndr_enabled": self.ndr_enabled,
|
||||
"ndr_correlation_window_seconds": self.ndr_correlation_window_seconds,
|
||||
"behavior_min_observations": self.behavior_min_observations,
|
||||
"ndr_auto_block": self.ndr_auto_block,
|
||||
"ndr_auto_block_risk": self.ndr_auto_block_risk,
|
||||
"routeros_inventory_interval_seconds": self.routeros_inventory_interval_seconds,
|
||||
"notify_webhook_enabled": bool(self.notify_webhook_url),
|
||||
"notify_min_risk": self.notify_min_risk,
|
||||
}
|
||||
|
||||
+64
-1
@@ -7,7 +7,9 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .analytics_cache import AnalyticsSnapshotCache
|
||||
from .config import Config
|
||||
from .live import EventBus, LiveEventPipeline, TrafficHistory
|
||||
from .maintenance import storage_info
|
||||
from .rules import RuleManager
|
||||
from .state import RuntimeStats
|
||||
@@ -67,6 +69,43 @@ def main() -> int:
|
||||
|
||||
routeros_host, routeros_port = _routeros_target(cfg)
|
||||
rule_manager = RuleManager(cfg, pid_provider=lambda: None, suricata_available=False)
|
||||
event_bus = EventBus(
|
||||
history_size=5000,
|
||||
subscriber_queue_size=cfg.websocket_queue_size,
|
||||
)
|
||||
traffic_history = TrafficHistory(
|
||||
"",
|
||||
cfg.traffic_retention_hours,
|
||||
200000,
|
||||
5000,
|
||||
allow_memory_fallback=True,
|
||||
)
|
||||
live_pipeline = LiveEventPipeline(event_bus, traffic_history)
|
||||
analytics_cache = AnalyticsSnapshotCache(
|
||||
store,
|
||||
traffic_history,
|
||||
stop_event,
|
||||
cfg.analytics_snapshot_interval_seconds,
|
||||
)
|
||||
if _bool_env("DEV_SEED_DATA", False):
|
||||
now_ms = int(time.time() * 1000)
|
||||
live_pipeline.publish({
|
||||
"id": "dev-flow",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ts_ms": now_ms,
|
||||
"type": "flow",
|
||||
"flow_id": "dev-flow",
|
||||
"src_ip": "192.168.100.10",
|
||||
"src_port": 51515,
|
||||
"dest_ip": "203.0.113.10",
|
||||
"dest_port": 443,
|
||||
"proto": "TCP",
|
||||
"app_proto": "tls",
|
||||
"direction": "outbound",
|
||||
"bytes": 8192,
|
||||
"packets": 12,
|
||||
"flow_state": "established",
|
||||
})
|
||||
|
||||
def health() -> dict:
|
||||
db = store.database_info()
|
||||
@@ -117,6 +156,16 @@ def main() -> int:
|
||||
"status": "up",
|
||||
"details": f"{db['path']}; {db['rows']} incidents; WAL={db['journal_mode']}",
|
||||
},
|
||||
"traffic_history": {
|
||||
"name": "Live traffic history",
|
||||
"status": "up",
|
||||
"details": "Bounded RAM history in web-only development mode",
|
||||
},
|
||||
"analytics_cache": {
|
||||
"name": "Persistent dashboard summaries",
|
||||
"status": "up",
|
||||
"details": f"Redis snapshots for 15m/1h/6h/24h every {cfg.analytics_snapshot_interval_seconds}s",
|
||||
},
|
||||
"storage": {
|
||||
"name": "Persistent storage",
|
||||
"status": "up",
|
||||
@@ -162,7 +211,17 @@ def main() -> int:
|
||||
"runtime": stats.snapshot(),
|
||||
}
|
||||
|
||||
web = WebServer(cfg, store, health, stats=stats, rule_manager=rule_manager)
|
||||
web = WebServer(
|
||||
cfg,
|
||||
store,
|
||||
health,
|
||||
stats=stats,
|
||||
rule_manager=rule_manager,
|
||||
traffic_history=traffic_history,
|
||||
event_bus=event_bus,
|
||||
live_pipeline=live_pipeline,
|
||||
analytics_cache=analytics_cache,
|
||||
)
|
||||
|
||||
def request_stop(_signum=None, _frame=None) -> None:
|
||||
stop_event.set()
|
||||
@@ -170,6 +229,8 @@ def main() -> int:
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
|
||||
live_pipeline.start()
|
||||
analytics_cache.start()
|
||||
web.start()
|
||||
print(f"[dev] web-only mode active at http://{cfg.web_bind}:{cfg.web_port}", flush=True)
|
||||
|
||||
@@ -182,6 +243,8 @@ def main() -> int:
|
||||
try:
|
||||
web.stop()
|
||||
finally:
|
||||
live_pipeline.stop()
|
||||
analytics_cache.stop()
|
||||
store.close()
|
||||
|
||||
return 0
|
||||
|
||||
+30
-1
@@ -6,6 +6,8 @@ import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .live import LiveEventPipeline, TrafficNormalizer, is_dashboard_noise
|
||||
from .ndr import NDRAnalyzer
|
||||
from .policy import PolicyEngine
|
||||
from .routeros import RouterOSClient
|
||||
from .state import RuntimeStats
|
||||
@@ -25,6 +27,9 @@ class EVEWatcher(threading.Thread):
|
||||
dedup_window_seconds: int,
|
||||
stats: RuntimeStats,
|
||||
stop_event: threading.Event,
|
||||
normalizer: TrafficNormalizer | None = None,
|
||||
live_pipeline: LiveEventPipeline | None = None,
|
||||
ndr_analyzer: NDRAnalyzer | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="eve-watcher", daemon=True)
|
||||
self.path = path
|
||||
@@ -36,6 +41,9 @@ class EVEWatcher(threading.Thread):
|
||||
self.dedup_window_seconds = max(0, int(dedup_window_seconds))
|
||||
self.stats = stats
|
||||
self.stop_event = stop_event
|
||||
self.normalizer = normalizer
|
||||
self.live_pipeline = live_pipeline
|
||||
self.ndr_analyzer = ndr_analyzer
|
||||
self._initial_seek_done = False
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -88,7 +96,9 @@ class EVEWatcher(threading.Thread):
|
||||
if isinstance(raw_stats, dict):
|
||||
self.stats.update_suricata(raw_stats, str(event.get("timestamp") or ""))
|
||||
return
|
||||
|
||||
if event_type != "alert":
|
||||
self._publish_live(event)
|
||||
return
|
||||
|
||||
self.stats.inc("eve_alerts")
|
||||
@@ -99,12 +109,15 @@ class EVEWatcher(threading.Thread):
|
||||
self.stats.inc("alerts_filtered")
|
||||
key = f"alerts_filtered_{tuning.reason}"
|
||||
self.stats.inc(key)
|
||||
# A filtered alert is deliberately excluded from the dashboard and
|
||||
# Redis traffic history. The original EVE record stays on disk.
|
||||
return
|
||||
|
||||
duplicate_id = self.store.find_recent_duplicate(event, self.dedup_window_seconds)
|
||||
if duplicate_id is not None:
|
||||
self.store.bump_duplicate(duplicate_id, event)
|
||||
self.stats.inc("alerts_deduplicated")
|
||||
self._publish_live(event, deduplicated=True, incident_id=duplicate_id)
|
||||
return
|
||||
|
||||
decision = self.policy.evaluate(event)
|
||||
@@ -125,4 +138,20 @@ class EVEWatcher(threading.Thread):
|
||||
reason = result.message if result.success else f"{decision.reason}; {result.message}"
|
||||
self.stats.inc("block_success" if result.success else "block_errors")
|
||||
|
||||
self.store.insert_alert(event, blocked, decision.target, reason)
|
||||
incident_id = self.store.insert_alert(event, blocked, decision.target, reason)
|
||||
self._publish_live(
|
||||
event,
|
||||
blocked=blocked,
|
||||
block_target=decision.target,
|
||||
block_reason=reason,
|
||||
incident_id=incident_id,
|
||||
)
|
||||
|
||||
def _publish_live(self, event: dict[str, Any], **extra: Any) -> None:
|
||||
if self.normalizer is None or self.live_pipeline is None:
|
||||
return
|
||||
record = self.normalizer.normalize(event, **extra)
|
||||
if record is not None and not is_dashboard_noise(record):
|
||||
if self.ndr_analyzer is not None:
|
||||
self.ndr_analyzer.observe(record, int(extra["incident_id"]) if extra.get("incident_id") is not None else None)
|
||||
self.live_pipeline.publish(record)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .live import LiveEventPipeline, TrafficNormalizer
|
||||
|
||||
_ETH_IPV4 = 0x0800
|
||||
_ETH_IPV6 = 0x86DD
|
||||
_VLAN_TYPES = {0x8100, 0x88A8, 0x9100}
|
||||
_IP_PROTO_NAMES = {1: "ICMP", 6: "TCP", 17: "UDP", 58: "ICMPV6"}
|
||||
_IPV6_EXTENSIONS = {0, 43, 44, 51, 60}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FlowState:
|
||||
stable_id: str
|
||||
src_ip: str
|
||||
src_port: int
|
||||
dest_ip: str
|
||||
dest_port: int
|
||||
proto: str
|
||||
app_proto: str
|
||||
first_seen: float
|
||||
last_seen: float
|
||||
last_published: float
|
||||
bytes_to_server: int = 0
|
||||
bytes_to_client: int = 0
|
||||
packets_to_server: int = 0
|
||||
packets_to_client: int = 0
|
||||
|
||||
|
||||
class FlowTracker:
|
||||
"""Bounded L3/L4 session tracker used only for immediate dashboard updates.
|
||||
|
||||
Suricata remains the source of durable EVE history. This tracker emits
|
||||
non-persistent updates from TZSP frames so long-lived sessions are visible
|
||||
before Suricata closes and writes the final flow event.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
normalizer: TrafficNormalizer,
|
||||
pipeline: LiveEventPipeline,
|
||||
update_interval_seconds: float = 1.0,
|
||||
idle_seconds: float = 120.0,
|
||||
max_flows: int = 20000,
|
||||
) -> None:
|
||||
self.normalizer = normalizer
|
||||
self.pipeline = pipeline
|
||||
self.update_interval = max(0.25, float(update_interval_seconds))
|
||||
self.idle_seconds = max(10.0, float(idle_seconds))
|
||||
self.max_flows = max(1000, int(max_flows))
|
||||
self._flows: collections.OrderedDict[tuple[Any, ...], _FlowState] = collections.OrderedDict()
|
||||
self._last_cleanup = time.monotonic()
|
||||
self._published = 0
|
||||
self._evicted = 0
|
||||
self._parse_errors = 0
|
||||
self._throughput_samples = 0
|
||||
self._rate_started = time.monotonic()
|
||||
self._rate_counters = {
|
||||
"bytes_total": 0, "bytes_in": 0, "bytes_out": 0,
|
||||
"bytes_internal": 0, "bytes_external": 0,
|
||||
"packets_total": 0, "packets_in": 0, "packets_out": 0,
|
||||
}
|
||||
|
||||
def observe(self, frame: bytes) -> None:
|
||||
parsed = _parse_frame(frame)
|
||||
if parsed is None:
|
||||
self._parse_errors += 1
|
||||
return
|
||||
src_ip, src_port, dest_ip, dest_port, proto = parsed
|
||||
now = time.monotonic()
|
||||
self._record_throughput(src_ip, dest_ip, len(frame), now)
|
||||
|
||||
# Building per-flow state is only needed for the optional Live Sessions
|
||||
# stream. The overview throughput counters above stay active at all times,
|
||||
# but when no browser requested live streaming we avoid OrderedDict churn,
|
||||
# hashing and periodic synthetic flow updates for every captured packet.
|
||||
live_needed = getattr(self.pipeline, "has_live_subscribers", None)
|
||||
if callable(live_needed) and not live_needed():
|
||||
if self._flows and now - self._last_cleanup >= 10.0:
|
||||
self._flows.clear()
|
||||
self._last_cleanup = now
|
||||
return
|
||||
|
||||
key = _canonical_key(src_ip, src_port, dest_ip, dest_port, proto)
|
||||
state = self._flows.get(key)
|
||||
if state is None:
|
||||
stable_id = "live-" + hashlib.blake2s(repr(key).encode("utf-8"), digest_size=10).hexdigest()
|
||||
state = _FlowState(
|
||||
stable_id=stable_id,
|
||||
src_ip=src_ip,
|
||||
src_port=src_port,
|
||||
dest_ip=dest_ip,
|
||||
dest_port=dest_port,
|
||||
proto=proto,
|
||||
app_proto=_guess_app(proto, src_port, dest_port),
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
last_published=0.0,
|
||||
)
|
||||
self._flows[key] = state
|
||||
else:
|
||||
state.last_seen = now
|
||||
self._flows.move_to_end(key)
|
||||
|
||||
frame_bytes = len(frame)
|
||||
if src_ip == state.src_ip and src_port == state.src_port:
|
||||
state.bytes_to_server += frame_bytes
|
||||
state.packets_to_server += 1
|
||||
else:
|
||||
state.bytes_to_client += frame_bytes
|
||||
state.packets_to_client += 1
|
||||
|
||||
if state.last_published == 0.0 or now - state.last_published >= self.update_interval:
|
||||
self._publish(state, now)
|
||||
|
||||
if len(self._flows) > self.max_flows:
|
||||
while len(self._flows) > self.max_flows:
|
||||
self._flows.popitem(last=False)
|
||||
self._evicted += 1
|
||||
if now - self._last_cleanup >= 10.0:
|
||||
self._cleanup(now)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"active_flows": len(self._flows),
|
||||
"max_flows": self.max_flows,
|
||||
"published_updates": self._published,
|
||||
"evicted_flows": self._evicted,
|
||||
"parse_errors": self._parse_errors,
|
||||
"throughput_samples": self._throughput_samples,
|
||||
"update_interval_seconds": self.update_interval,
|
||||
}
|
||||
|
||||
def _record_throughput(self, src_ip: str, dest_ip: str, frame_bytes: int, now: float) -> None:
|
||||
direction = self.normalizer._direction(src_ip, dest_ip)
|
||||
counters = self._rate_counters
|
||||
counters["bytes_total"] += frame_bytes
|
||||
counters["packets_total"] += 1
|
||||
if direction == "inbound":
|
||||
counters["bytes_in"] += frame_bytes
|
||||
counters["packets_in"] += 1
|
||||
elif direction == "outbound":
|
||||
counters["bytes_out"] += frame_bytes
|
||||
counters["packets_out"] += 1
|
||||
elif direction == "internal":
|
||||
counters["bytes_internal"] += frame_bytes
|
||||
else:
|
||||
counters["bytes_external"] += frame_bytes
|
||||
|
||||
elapsed = now - self._rate_started
|
||||
if elapsed < 1.0:
|
||||
return
|
||||
sample = dict(counters)
|
||||
sample["ts_ms"] = int(time.time() * 1000)
|
||||
sample["interval_ms"] = max(1, round(elapsed * 1000))
|
||||
self.pipeline.publish_throughput(sample)
|
||||
self._throughput_samples += 1
|
||||
for key in counters:
|
||||
counters[key] = 0
|
||||
self._rate_started = now
|
||||
|
||||
def _publish(self, state: _FlowState, now: float) -> None:
|
||||
state.last_published = now
|
||||
event = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"event_type": "flow",
|
||||
"flow_id": state.stable_id,
|
||||
"src_ip": state.src_ip,
|
||||
"src_port": state.src_port or None,
|
||||
"dest_ip": state.dest_ip,
|
||||
"dest_port": state.dest_port or None,
|
||||
"proto": state.proto,
|
||||
"app_proto": state.app_proto,
|
||||
"flow": {
|
||||
"bytes_toserver": state.bytes_to_server,
|
||||
"bytes_toclient": state.bytes_to_client,
|
||||
"pkts_toserver": state.packets_to_server,
|
||||
"pkts_toclient": state.packets_to_client,
|
||||
"state": "live",
|
||||
"reason": "tzsp",
|
||||
},
|
||||
}
|
||||
record = self.normalizer.normalize(
|
||||
event,
|
||||
id=state.stable_id,
|
||||
live=True,
|
||||
source="tzsp",
|
||||
age_seconds=round(now - state.first_seen, 3),
|
||||
)
|
||||
if record is not None:
|
||||
self.pipeline.publish(record, persist=False)
|
||||
self._published += 1
|
||||
|
||||
def _cleanup(self, now: float) -> None:
|
||||
cutoff = now - self.idle_seconds
|
||||
while self._flows:
|
||||
_key, state = next(iter(self._flows.items()))
|
||||
if state.last_seen >= cutoff:
|
||||
break
|
||||
self._flows.popitem(last=False)
|
||||
self._last_cleanup = now
|
||||
|
||||
|
||||
def _canonical_key(src: str, src_port: int, dst: str, dst_port: int, proto: str) -> tuple[Any, ...]:
|
||||
left = (src, src_port)
|
||||
right = (dst, dst_port)
|
||||
if left <= right:
|
||||
return proto, left, right
|
||||
return proto, right, left
|
||||
|
||||
|
||||
def _parse_frame(frame: bytes) -> tuple[str, int, str, int, str] | None:
|
||||
if len(frame) < 14:
|
||||
return None
|
||||
offset = 14
|
||||
ethertype = struct.unpack_from("!H", frame, 12)[0]
|
||||
for _ in range(2):
|
||||
if ethertype not in _VLAN_TYPES or len(frame) < offset + 4:
|
||||
break
|
||||
ethertype = struct.unpack_from("!H", frame, offset + 2)[0]
|
||||
offset += 4
|
||||
|
||||
if ethertype == _ETH_IPV4:
|
||||
return _parse_ipv4(frame, offset)
|
||||
if ethertype == _ETH_IPV6:
|
||||
return _parse_ipv6(frame, offset)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ipv4(frame: bytes, offset: int) -> tuple[str, int, str, int, str] | None:
|
||||
if len(frame) < offset + 20:
|
||||
return None
|
||||
version_ihl = frame[offset]
|
||||
if version_ihl >> 4 != 4:
|
||||
return None
|
||||
header_len = (version_ihl & 0x0F) * 4
|
||||
if header_len < 20 or len(frame) < offset + header_len:
|
||||
return None
|
||||
protocol = frame[offset + 9]
|
||||
src = socket.inet_ntop(socket.AF_INET, frame[offset + 12 : offset + 16])
|
||||
dst = socket.inet_ntop(socket.AF_INET, frame[offset + 16 : offset + 20])
|
||||
frag = struct.unpack_from("!H", frame, offset + 6)[0] & 0x1FFF
|
||||
l4_offset = offset + header_len
|
||||
src_port, dst_port = _ports(frame, l4_offset, protocol) if frag == 0 else (0, 0)
|
||||
return src, src_port, dst, dst_port, _IP_PROTO_NAMES.get(protocol, f"IP{protocol}")
|
||||
|
||||
|
||||
def _parse_ipv6(frame: bytes, offset: int) -> tuple[str, int, str, int, str] | None:
|
||||
if len(frame) < offset + 40 or frame[offset] >> 4 != 6:
|
||||
return None
|
||||
next_header = frame[offset + 6]
|
||||
src = socket.inet_ntop(socket.AF_INET6, frame[offset + 8 : offset + 24])
|
||||
dst = socket.inet_ntop(socket.AF_INET6, frame[offset + 24 : offset + 40])
|
||||
l4_offset = offset + 40
|
||||
fragmented_nonzero = False
|
||||
|
||||
for _ in range(6):
|
||||
if next_header not in _IPV6_EXTENSIONS:
|
||||
break
|
||||
if next_header == 44: # Fragment header: fixed 8 bytes.
|
||||
if len(frame) < l4_offset + 8:
|
||||
return src, 0, dst, 0, "IPV6"
|
||||
fragment_bits = struct.unpack_from("!H", frame, l4_offset + 2)[0]
|
||||
fragmented_nonzero = (fragment_bits >> 3) != 0
|
||||
next_header = frame[l4_offset]
|
||||
l4_offset += 8
|
||||
continue
|
||||
if next_header == 51: # Authentication Header length is in 32-bit words minus 2.
|
||||
if len(frame) < l4_offset + 2:
|
||||
return src, 0, dst, 0, "IPV6"
|
||||
following = frame[l4_offset]
|
||||
header_len = (frame[l4_offset + 1] + 2) * 4
|
||||
else:
|
||||
if len(frame) < l4_offset + 2:
|
||||
return src, 0, dst, 0, "IPV6"
|
||||
following = frame[l4_offset]
|
||||
header_len = (frame[l4_offset + 1] + 1) * 8
|
||||
if header_len <= 0 or len(frame) < l4_offset + header_len:
|
||||
return src, 0, dst, 0, "IPV6"
|
||||
next_header = following
|
||||
l4_offset += header_len
|
||||
|
||||
src_port, dst_port = (0, 0) if fragmented_nonzero else _ports(frame, l4_offset, next_header)
|
||||
return src, src_port, dst, dst_port, _IP_PROTO_NAMES.get(next_header, f"IP{next_header}")
|
||||
|
||||
|
||||
def _ports(frame: bytes, offset: int, protocol: int) -> tuple[int, int]:
|
||||
if protocol not in {6, 17} or len(frame) < offset + 4:
|
||||
return 0, 0
|
||||
return struct.unpack_from("!HH", frame, offset)
|
||||
|
||||
|
||||
def _guess_app(proto: str, src_port: int, dst_port: int) -> str:
|
||||
ports = {src_port, dst_port}
|
||||
if 53 in ports:
|
||||
return "dns"
|
||||
if proto == "UDP" and 443 in ports:
|
||||
return "quic"
|
||||
if 443 in ports:
|
||||
return "tls"
|
||||
if 80 in ports or 8080 in ports:
|
||||
return "http"
|
||||
if 22 in ports:
|
||||
return "ssh"
|
||||
if 3389 in ports:
|
||||
return "rdp"
|
||||
if 445 in ports:
|
||||
return "smb"
|
||||
if 8291 in ports:
|
||||
return "winbox"
|
||||
if 123 in ports:
|
||||
return "ntp"
|
||||
return ""
|
||||
+1479
File diff suppressed because it is too large
Load Diff
+183
-12
@@ -11,10 +11,17 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .analytics_cache import AnalyticsSnapshotCache
|
||||
from .backup import BackupManager
|
||||
from .config import Config
|
||||
from .eve import EVEWatcher
|
||||
from .maintenance import storage_info
|
||||
from .flow_tracker import FlowTracker
|
||||
from .live import EventBus, LiveEventPipeline, TrafficHistory, TrafficNormalizer
|
||||
from .maintenance import clear_suricata_logs, storage_info
|
||||
from .ndr import NDRAnalyzer, ThreatIntelManager
|
||||
from .notifier import WebhookNotifier
|
||||
from .policy import PolicyEngine
|
||||
from .redis_service import RedisSupervisor
|
||||
from .routeros import RouterOSClient
|
||||
from .rules import RuleManager
|
||||
from .state import RuntimeStats
|
||||
@@ -42,6 +49,8 @@ def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
|
||||
return [
|
||||
"-c",
|
||||
cfg.suricata_config,
|
||||
"--include",
|
||||
cfg.suricata_output_config,
|
||||
"-l",
|
||||
log_dir,
|
||||
# Suricata exposes one additive -s signature path; use its supported
|
||||
@@ -52,6 +61,16 @@ def _suricata_common_args(cfg: Config, log_dir: str) -> list[str]:
|
||||
f"vars.address-groups.HOME_NET={cfg.suricata_home_net}",
|
||||
"--set",
|
||||
f"threshold-file={cfg.suricata_threshold_config}",
|
||||
"--set",
|
||||
f"default-rule-path={cfg.suricata_persist_lib_dir}/rules",
|
||||
# These fingerprints are useful IDS pivots but remain opt-in in the
|
||||
# upstream configuration unless a rule explicitly needs them.
|
||||
"--set",
|
||||
"app-layer.protocols.tls.ja3-fingerprints=yes",
|
||||
"--set",
|
||||
"app-layer.protocols.tls.ja4-fingerprints=yes",
|
||||
"--set",
|
||||
"app-layer.protocols.ssh.hassh=yes",
|
||||
]
|
||||
|
||||
|
||||
@@ -67,6 +86,8 @@ def main() -> int:
|
||||
_ensure_suricata_state(cfg)
|
||||
|
||||
store = AlertStore(cfg.db_path)
|
||||
backup_manager = BackupManager(cfg.db_path, os.path.dirname(cfg.db_path) or ".")
|
||||
threat_intel = ThreatIntelManager(store, os.path.dirname(cfg.suricata_custom_rules))
|
||||
purged_tests = store.purge_builtin_test_incidents()
|
||||
if purged_tests:
|
||||
print(f"[db] removed {purged_tests} legacy pipeline-test incidents", flush=True)
|
||||
@@ -138,8 +159,53 @@ def main() -> int:
|
||||
cfg.routeros_address_list,
|
||||
cfg.routeros_http_timeout,
|
||||
)
|
||||
notifier = WebhookNotifier(cfg.notify_webhook_url, cfg.notify_min_risk, cfg.notify_timeout_seconds)
|
||||
ndr_analyzer = NDRAnalyzer(
|
||||
store, threat_intel, routeros, cfg.monitored_networks, cfg.never_block, cfg.block_timeout,
|
||||
enabled=cfg.ndr_enabled,
|
||||
correlation_window_seconds=cfg.ndr_correlation_window_seconds,
|
||||
behavior_min_observations=cfg.behavior_min_observations,
|
||||
auto_block=cfg.ndr_auto_block,
|
||||
auto_block_risk=cfg.ndr_auto_block_risk,
|
||||
notifier=notifier,
|
||||
)
|
||||
redis_supervisor = RedisSupervisor(
|
||||
cfg.redis_managed,
|
||||
cfg.redis_data_dir,
|
||||
cfg.redis_port,
|
||||
cfg.redis_maxmemory_mb,
|
||||
cfg.redis_snapshot_seconds,
|
||||
cfg.redis_aof,
|
||||
)
|
||||
if cfg.redis_managed and not redis_supervisor.start(wait_ready_seconds=15):
|
||||
raise RuntimeError(
|
||||
f"managed Redis failed to start: {redis_supervisor.status().get('last_error') or 'unknown error'}"
|
||||
)
|
||||
event_bus = EventBus(
|
||||
history_size=0,
|
||||
subscriber_queue_size=cfg.websocket_queue_size,
|
||||
)
|
||||
traffic_history = TrafficHistory(
|
||||
cfg.redis_url,
|
||||
cfg.traffic_retention_hours,
|
||||
0,
|
||||
0,
|
||||
require_redis=True,
|
||||
allow_memory_fallback=False,
|
||||
)
|
||||
analytics_cache = AnalyticsSnapshotCache(
|
||||
store,
|
||||
traffic_history,
|
||||
stop_event,
|
||||
cfg.analytics_snapshot_interval_seconds,
|
||||
)
|
||||
live_pipeline = LiveEventPipeline(event_bus, traffic_history)
|
||||
normalizer = TrafficNormalizer(cfg.monitored_networks)
|
||||
flow_tracker = FlowTracker(normalizer, live_pipeline, update_interval_seconds=cfg.live_flow_update_seconds)
|
||||
|
||||
receiver = TZSPReceiver(cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event)
|
||||
receiver = TZSPReceiver(
|
||||
cfg.tzsp_bind, cfg.tzsp_port, tap.write, stats, stop_event, frame_observer=flow_tracker.observe
|
||||
)
|
||||
watcher = EVEWatcher(
|
||||
cfg.eve_path,
|
||||
store,
|
||||
@@ -150,6 +216,9 @@ def main() -> int:
|
||||
cfg.alert_dedup_window_seconds,
|
||||
stats,
|
||||
stop_event,
|
||||
normalizer=normalizer,
|
||||
live_pipeline=live_pipeline,
|
||||
ndr_analyzer=ndr_analyzer,
|
||||
)
|
||||
rule_manager = RuleManager(
|
||||
cfg,
|
||||
@@ -167,6 +236,14 @@ def main() -> int:
|
||||
db = store.database_info()
|
||||
storage = storage_info(cfg.db_path, cfg.eve_path)
|
||||
rules = rule_manager.status()
|
||||
runtime = stats.snapshot()
|
||||
suri_stats = runtime.get("suricata") or {}
|
||||
kernel_packets = int(suri_stats.get("capture.kernel_packets", 0) or 0)
|
||||
kernel_drops = int(suri_stats.get("capture.kernel_drops", 0) or 0)
|
||||
alert_overflow = int(suri_stats.get("detect.alert_queue_overflow", 0) or 0)
|
||||
inject_errors = int(runtime.get("inject_errors", 0) or 0)
|
||||
drop_pct = round((kernel_drops / kernel_packets) * 100.0, 3) if kernel_packets else 0.0
|
||||
sensor_degraded = (kernel_packets >= 1000 and drop_pct >= 1.0) or alert_overflow > 0 or inject_errors > 0
|
||||
core_up = suricata_up and tzsp_up and tap_up and eve_up and db["ok"]
|
||||
routeros_required_ok = (not cfg.auto_block) or routeros.configured
|
||||
operational = core_up and routeros_required_ok
|
||||
@@ -182,6 +259,7 @@ def main() -> int:
|
||||
"suricata_pid": suricata.pid,
|
||||
"auto_block": cfg.auto_block,
|
||||
"routeros_configured": routeros.configured,
|
||||
"ndr": {**ndr_analyzer.status(), **store.ndr_summary()},
|
||||
"database": db,
|
||||
"storage": storage,
|
||||
"rules": rules,
|
||||
@@ -206,6 +284,11 @@ def main() -> int:
|
||||
"status": "up" if suricata_up else "down",
|
||||
"details": f"PID {suricata.pid}" if suricata_up else f"Process exited with code {suricata.poll()}",
|
||||
},
|
||||
"sensor_quality": {
|
||||
"name": "Sensor quality / packet loss",
|
||||
"status": "degraded" if sensor_degraded else "up",
|
||||
"details": f"capture packets={kernel_packets}; kernel drops={kernel_drops} ({drop_pct}%); alert queue overflow={alert_overflow}; inject errors={inject_errors}",
|
||||
},
|
||||
"eve": {
|
||||
"name": "EVE JSON watcher",
|
||||
"status": "up" if eve_up else "down",
|
||||
@@ -221,6 +304,44 @@ def main() -> int:
|
||||
"status": "up" if storage["free_bytes"] > 0 else "down",
|
||||
"details": f"{storage['path']}; {storage['used_percent']}% used",
|
||||
},
|
||||
"live_flows": {
|
||||
"name": "Immediate TZSP sessions",
|
||||
"status": "up" if tzsp_up else "down",
|
||||
"details": f"{flow_tracker.status()['active_flows']} active; non-persistent {flow_tracker.status()['update_interval_seconds']:g}s updates",
|
||||
},
|
||||
"traffic_history": {
|
||||
"name": "Live traffic history",
|
||||
"status": "up" if traffic_history.status().get("redis_ok") else "degraded",
|
||||
"details": f"Redis-only persistent history; retention={cfg.traffic_retention_hours}h; no event-count cap",
|
||||
},
|
||||
"analytics_cache": {
|
||||
"name": "Persistent dashboard summaries",
|
||||
"status": "up",
|
||||
"details": f"Redis snapshots for 15m/1h/6h/24h every {cfg.analytics_snapshot_interval_seconds}s",
|
||||
},
|
||||
"ndr": {
|
||||
"name": "MikroSuricata NDR correlation",
|
||||
"status": "up" if ndr_analyzer.status().get("running") else "disabled" if not cfg.ndr_enabled else "degraded",
|
||||
"details": f"assets={store.ndr_summary()['assets']}; incidents={store.ndr_summary()['incidents']}; IOC={store.ndr_summary()['enabled_iocs']}; queue={ndr_analyzer.status()['queue']}",
|
||||
},
|
||||
"notifications": {
|
||||
"name": "High-risk webhook notifications",
|
||||
"status": "up" if notifier.status().get("running") else "disabled" if not notifier.enabled else "degraded",
|
||||
"details": f"min risk={cfg.notify_min_risk}; sent={notifier.status()['sent']}; failed={notifier.status()['failed']}; queue={notifier.status()['queue']}",
|
||||
},
|
||||
"redis": {
|
||||
"name": "Managed Redis",
|
||||
"status": (
|
||||
"up" if redis_supervisor.status().get("running")
|
||||
else "disabled" if not cfg.redis_managed
|
||||
else "degraded"
|
||||
),
|
||||
"details": (
|
||||
f"{cfg.redis_data_dir}; maxmemory=unlimited; persistence={redis_supervisor.status().get('persistence')}"
|
||||
if cfg.redis_managed
|
||||
else "Managed Redis disabled; REDIS_URL may point to an external server"
|
||||
),
|
||||
},
|
||||
"rules": {
|
||||
"name": "Managed rules",
|
||||
"status": "up" if rules["available"] else "disabled",
|
||||
@@ -261,23 +382,64 @@ def main() -> int:
|
||||
"runtime": stats.snapshot(),
|
||||
}
|
||||
|
||||
web = WebServer(cfg, store, health, stats=stats, rule_manager=rule_manager)
|
||||
web = WebServer(
|
||||
cfg,
|
||||
store,
|
||||
health,
|
||||
stats=stats,
|
||||
rule_manager=rule_manager,
|
||||
traffic_history=traffic_history,
|
||||
event_bus=event_bus,
|
||||
live_pipeline=live_pipeline,
|
||||
routeros=routeros,
|
||||
analytics_cache=analytics_cache,
|
||||
threat_intel=threat_intel,
|
||||
ndr_analyzer=ndr_analyzer,
|
||||
backup_manager=backup_manager,
|
||||
)
|
||||
|
||||
def housekeeping() -> None:
|
||||
interval_seconds = max(0, cfg.rule_update_interval_hours) * 3600
|
||||
next_rule_update = time.monotonic() + interval_seconds if interval_seconds else None
|
||||
while not stop_event.wait(3600):
|
||||
try:
|
||||
removed = store.purge_older_than(cfg.alert_retention_days)
|
||||
if removed:
|
||||
print(f"[db] purged {removed} expired incidents", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[housekeeping] alert retention failed: {exc}", file=sys.stderr, flush=True)
|
||||
if next_rule_update is not None and time.monotonic() >= next_rule_update:
|
||||
next_retention = time.monotonic() + 3600
|
||||
next_routeros_inventory = time.monotonic() + 10
|
||||
log_limit_bytes = max(0, cfg.suricata_log_max_mb) * 1024 * 1024
|
||||
while not stop_event.wait(60):
|
||||
now = time.monotonic()
|
||||
if log_limit_bytes:
|
||||
try:
|
||||
current_storage = storage_info(cfg.db_path, cfg.eve_path)
|
||||
if int(current_storage.get("suricata_log_bytes", 0)) > log_limit_bytes:
|
||||
result = clear_suricata_logs(cfg.eve_path)
|
||||
stats.inc("log_auto_truncations")
|
||||
print(
|
||||
f"[housekeeping] Suricata logs exceeded {cfg.suricata_log_max_mb}MB; "
|
||||
f"freed {result['bytes_freed']} bytes",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[housekeeping] log cap failed: {exc}", file=sys.stderr, flush=True)
|
||||
if now >= next_retention:
|
||||
try:
|
||||
removed = store.purge_older_than(cfg.alert_retention_days)
|
||||
if removed:
|
||||
print(f"[db] purged {removed} expired incidents", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[housekeeping] alert retention failed: {exc}", file=sys.stderr, flush=True)
|
||||
next_retention = now + 3600
|
||||
if now >= next_routeros_inventory:
|
||||
try:
|
||||
result = ndr_analyzer.sync_routeros_inventory()
|
||||
if result.get("assets"):
|
||||
print(f"[ndr] RouterOS inventory: {result['assets']} assets (DHCP={result['dhcp']}, ARP={result['arp']})", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[housekeeping] RouterOS inventory sync failed: {exc}", file=sys.stderr, flush=True)
|
||||
next_routeros_inventory = now + cfg.routeros_inventory_interval_seconds
|
||||
if next_rule_update is not None and now >= next_rule_update:
|
||||
result = rule_manager.update_vendor_rules()
|
||||
stream = sys.stdout if result.ok else sys.stderr
|
||||
print(f"[rules] scheduled update: {result.message}", file=stream, flush=True)
|
||||
next_rule_update = time.monotonic() + interval_seconds
|
||||
next_rule_update = now + interval_seconds
|
||||
|
||||
housekeeping_thread = threading.Thread(target=housekeeping, name="housekeeping", daemon=True)
|
||||
|
||||
@@ -287,6 +449,10 @@ def main() -> int:
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
|
||||
live_pipeline.start()
|
||||
analytics_cache.start()
|
||||
notifier.start()
|
||||
ndr_analyzer.start()
|
||||
receiver.start()
|
||||
watcher.start()
|
||||
housekeeping_thread.start()
|
||||
@@ -320,6 +486,11 @@ def main() -> int:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
tap.close()
|
||||
live_pipeline.stop()
|
||||
analytics_cache.stop()
|
||||
ndr_analyzer.stop()
|
||||
notifier.stop()
|
||||
redis_supervisor.stop()
|
||||
store.close()
|
||||
|
||||
return rc
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
TACTICS = {
|
||||
"recon": ("TA0043", "Reconnaissance"),
|
||||
"initial-access": ("TA0001", "Initial Access"),
|
||||
"credential-access": ("TA0006", "Credential Access"),
|
||||
"lateral-movement": ("TA0008", "Lateral Movement"),
|
||||
"command-and-control": ("TA0011", "Command and Control"),
|
||||
"exfiltration": ("TA0010", "Exfiltration"),
|
||||
"network-spoofing": ("TA0006", "Credential Access"),
|
||||
"dns-anomaly": ("TA0011", "Command and Control"),
|
||||
"threat-intel": ("TA0011", "Command and Control"),
|
||||
}
|
||||
|
||||
TECHNIQUES = {
|
||||
"recon": ("T1595", "Active Scanning"),
|
||||
"credential-access": ("T1110", "Brute Force"),
|
||||
"lateral-movement": ("T1021", "Remote Services"),
|
||||
"network-spoofing": ("T1557", "Adversary-in-the-Middle"),
|
||||
"command-and-control": ("T1071", "Application Layer Protocol"),
|
||||
"dns-anomaly": ("T1071.004", "DNS"),
|
||||
"exfiltration": ("T1041", "Exfiltration Over C2 Channel"),
|
||||
}
|
||||
|
||||
|
||||
def classify(stage: str, summary: str = "", record: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||||
"""Return conservative ATT&CK annotations for one network-observable signal."""
|
||||
stage = str(stage or "").strip().lower()
|
||||
text = f"{stage} {summary or ''}".lower()
|
||||
record = record or {}
|
||||
tactic = TACTICS.get(stage)
|
||||
technique = TECHNIQUES.get(stage)
|
||||
|
||||
if stage == "initial-access":
|
||||
if any(token in text for token in ("exploit", "cve-", "web application", "public-facing")):
|
||||
technique = ("T1190", "Exploit Public-Facing Application")
|
||||
elif any(token in text for token in ("phishing", "smtp", "malicious file")):
|
||||
technique = ("T1566", "Phishing")
|
||||
elif stage in {"command-and-control", "threat-intel"}:
|
||||
if record.get("dns_query") or " dns" in text or "domain" in text:
|
||||
technique = ("T1071.004", "DNS")
|
||||
elif record.get("http_host") or "http" in text:
|
||||
technique = ("T1071.001", "Web Protocols")
|
||||
elif record.get("tls_sni") or record.get("quic_sni") or "tls" in text or "quic" in text:
|
||||
technique = ("T1071", "Application Layer Protocol")
|
||||
elif stage == "lateral-movement":
|
||||
if "rdp" in text or int(record.get("dest_port") or 0) == 3389:
|
||||
technique = ("T1021.001", "Remote Desktop Protocol")
|
||||
elif "smb" in text or int(record.get("dest_port") or 0) in {139, 445}:
|
||||
technique = ("T1021.002", "SMB/Windows Admin Shares")
|
||||
elif "ssh" in text or int(record.get("dest_port") or 0) == 22:
|
||||
technique = ("T1021.004", "SSH")
|
||||
elif stage == "exfiltration":
|
||||
if record.get("dns_query") or "dns" in text or "tunnel" in text:
|
||||
technique = ("T1048", "Exfiltration Over Alternative Protocol")
|
||||
|
||||
if not tactic:
|
||||
return []
|
||||
item = {"tactic_id": tactic[0], "tactic": tactic[1]}
|
||||
if technique:
|
||||
item.update({"technique_id": technique[0], "technique": technique[1]})
|
||||
return [item]
|
||||
|
||||
|
||||
def merge(existing: list[dict[str, str]], additions: list[dict[str, str]], limit: int = 24) -> list[dict[str, str]]:
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in list(existing or []) + list(additions or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = (str(item.get("tactic_id") or ""), str(item.get("technique_id") or ""))
|
||||
if key in seen or not key[0]:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({k: str(v) for k, v in item.items() if v not in (None, "")})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import ipaddress
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .routeros import RouterOSClient
|
||||
from .mitre import classify as classify_mitre
|
||||
from .store import AlertStore
|
||||
|
||||
|
||||
SENSITIVE_PORTS = {21, 22, 23, 25, 110, 135, 139, 445, 1433, 3306, 3389, 5432, 6379, 8291, 9200, 27017}
|
||||
LOCAL_STAGE_BY_SID = {
|
||||
1000101: "credential-access", 1000102: "credential-access", 1000103: "credential-access",
|
||||
1000104: "recon", 1000105: "recon", 1000106: "initial-access",
|
||||
1000107: "dns-anomaly", 1000109: "dns-anomaly", 1000110: "dns-anomaly",
|
||||
1000111: "exfiltration", 1000112: "exfiltration", 1000113: "exfiltration",
|
||||
1000114: "initial-access", 1000115: "lateral-movement", 1000116: "initial-access",
|
||||
1000120: "recon", 1000121: "initial-access", 1000122: "recon", 1000123: "lateral-movement",
|
||||
1000201: "threat-intel", 1000202: "threat-intel", 1000203: "threat-intel", 1000204: "threat-intel",
|
||||
1000205: "threat-intel", 1000206: "threat-intel", 1000207: "threat-intel", 1000208: "threat-intel", 1000209: "threat-intel",
|
||||
1000210: "threat-intel", 1000211: "threat-intel", 1000212: "threat-intel", 1000213: "threat-intel", 1000214: "threat-intel", 1000215: "threat-intel",
|
||||
}
|
||||
|
||||
|
||||
def _parse_networks(raw: str) -> list[ipaddress._BaseNetwork]:
|
||||
out = []
|
||||
for item in (raw or "").split(","):
|
||||
try:
|
||||
if item.strip():
|
||||
out.append(ipaddress.ip_network(item.strip(), strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _dt(value: Any) -> float:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return time.time()
|
||||
|
||||
|
||||
def _entropy(text: str) -> float:
|
||||
if not text:
|
||||
return 0.0
|
||||
counts = collections.Counter(text)
|
||||
length = len(text)
|
||||
return -sum((n / length) * math.log2(n / length) for n in counts.values())
|
||||
|
||||
|
||||
class ThreatIntelManager:
|
||||
"""Persistent IOC repository plus Suricata dataset materialization."""
|
||||
|
||||
def __init__(self, store: AlertStore, state_dir: str) -> None:
|
||||
self.store = store
|
||||
self.state_dir = Path(state_dir)
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._loaded_at = 0.0
|
||||
self._cache: dict[str, list[dict[str, Any]]] = {}
|
||||
self.sync_suricata_datasets()
|
||||
|
||||
def refresh(self, force: bool = False) -> None:
|
||||
with self._lock:
|
||||
if not force and time.monotonic() - self._loaded_at < 30:
|
||||
return
|
||||
grouped: dict[str, list[dict[str, Any]]] = collections.defaultdict(list)
|
||||
for row in self.store.list_iocs(5000, enabled_only=True):
|
||||
grouped[str(row["indicator_type"])].append(row)
|
||||
self._cache = dict(grouped)
|
||||
self._loaded_at = time.monotonic()
|
||||
|
||||
def match(self, record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
self.refresh()
|
||||
values: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for key in ("src_ip", "dest_ip"):
|
||||
if record.get(key): values["ip"].add(str(record[key]))
|
||||
for key in ("dns_query", "tls_sni", "quic_sni", "http_host"):
|
||||
value = str(record.get(key) or "").lower().rstrip(".")
|
||||
if value: values["domain"].add(value)
|
||||
if record.get("file_sha256"): values["sha256"].add(str(record["file_sha256"]).lower())
|
||||
for key, kind in (("tls_ja3", "ja3"), ("quic_ja3", "ja3"), ("tls_ja4", "ja4"), ("quic_ja4", "ja4"), ("ssh_hassh_client", "hassh"), ("ssh_hassh_server", "hassh")):
|
||||
if record.get(key): values[kind].add(str(record[key]).lower())
|
||||
hits = []
|
||||
with self._lock:
|
||||
cache = dict(self._cache)
|
||||
for kind, candidates in values.items():
|
||||
for ioc in cache.get(kind, []):
|
||||
indicator = str(ioc["indicator"]).lower()
|
||||
matched = any(
|
||||
candidate == indicator or (kind == "domain" and candidate.endswith("." + indicator))
|
||||
for candidate in candidates
|
||||
)
|
||||
if matched:
|
||||
hits.append(ioc)
|
||||
if len(hits) >= 4:
|
||||
return hits
|
||||
return hits
|
||||
|
||||
def sync_suricata_datasets(self) -> dict[str, int]:
|
||||
self.refresh(force=True)
|
||||
with self._lock:
|
||||
rows = {kind: list(self._cache.get(kind, [])) for kind in ("ip", "domain", "sha256", "ja3", "ja4", "hassh")}
|
||||
|
||||
files = {
|
||||
"ip": self.state_dir / "ti-ips.lst",
|
||||
"domain": self.state_dir / "ti-domains.lst",
|
||||
"sha256": self.state_dir / "ti-sha256.lst",
|
||||
"ja3": self.state_dir / "ti-ja3.lst",
|
||||
"ja4": self.state_dir / "ti-ja4.lst",
|
||||
"hassh": self.state_dir / "ti-hassh.lst",
|
||||
}
|
||||
|
||||
def indicators(kind: str) -> list[str]:
|
||||
return sorted({str(x["indicator"]).strip().lower() for x in rows[kind] if str(x.get("indicator") or "").strip()})
|
||||
|
||||
files["ip"].write_text("\n".join(indicators("ip")) + ("\n" if rows["ip"] else ""), encoding="ascii")
|
||||
files["sha256"].write_text("\n".join(indicators("sha256")) + ("\n" if rows["sha256"] else ""), encoding="ascii")
|
||||
for kind in ("domain", "ja3", "ja4", "hassh"):
|
||||
encoded = [base64.b64encode(value.encode("utf-8")).decode("ascii") for value in indicators(kind)]
|
||||
files[kind].write_text("\n".join(encoded) + ("\n" if encoded else ""), encoding="ascii")
|
||||
|
||||
rules_file = self.state_dir / "threat-intel.rules"
|
||||
rules = ["# Managed by MikroSuricata NDR. Do not edit manually."]
|
||||
if rows["ip"]:
|
||||
rules += [
|
||||
'alert ip $EXTERNAL_NET any -> $HOME_NET any (msg:"MIKROSURICATA TI inbound IOC IP"; ip.src; dataset:isset,ms-ti-ips,type ip,load ti-ips.lst; classtype:trojan-activity; priority:1; sid:1000201; rev:1;)',
|
||||
'alert ip $HOME_NET any -> $EXTERNAL_NET any (msg:"MIKROSURICATA TI outbound IOC IP"; ip.dst; dataset:isset,ms-ti-ips,type ip,load ti-ips.lst; classtype:trojan-activity; priority:1; sid:1000202; rev:1;)',
|
||||
]
|
||||
if rows["domain"]:
|
||||
rules += [
|
||||
'alert dns $HOME_NET any -> any any (msg:"MIKROSURICATA TI DNS IOC domain"; dns.query; domain; dataset:isset,ms-ti-domains,type string,load ti-domains.lst; classtype:trojan-activity; priority:1; sid:1000203; rev:1;)',
|
||||
'alert tls $HOME_NET any -> any any (msg:"MIKROSURICATA TI TLS SNI IOC domain"; tls.sni; domain; dataset:isset,ms-ti-domains,type string,load ti-domains.lst; classtype:trojan-activity; priority:1; sid:1000204; rev:1;)',
|
||||
]
|
||||
if rows["ja3"]:
|
||||
rules.append('alert tls $HOME_NET any -> any any (msg:"MIKROSURICATA TI JA3 IOC"; ja3.hash; dataset:isset,ms-ti-ja3,type string,load ti-ja3.lst; classtype:trojan-activity; priority:1; sid:1000205; rev:1;)')
|
||||
if rows["ja4"]:
|
||||
rules += [
|
||||
'alert tls $HOME_NET any -> any any (msg:"MIKROSURICATA TI TLS JA4 IOC"; ja4.hash; dataset:isset,ms-ti-ja4,type string,load ti-ja4.lst; classtype:trojan-activity; priority:1; sid:1000206; rev:1;)',
|
||||
'alert quic $HOME_NET any -> any any (msg:"MIKROSURICATA TI QUIC JA4 IOC"; ja4.hash; dataset:isset,ms-ti-ja4,type string,load ti-ja4.lst; classtype:trojan-activity; priority:1; sid:1000207; rev:1;)',
|
||||
]
|
||||
if rows["hassh"]:
|
||||
rules += [
|
||||
'alert ssh $HOME_NET any -> any any (msg:"MIKROSURICATA TI SSH HASSH client IOC"; ssh.hassh; dataset:isset,ms-ti-hassh,type string,load ti-hassh.lst; classtype:trojan-activity; priority:1; sid:1000208; rev:1;)',
|
||||
'alert ssh any any -> $HOME_NET any (msg:"MIKROSURICATA TI SSH HASSH server IOC"; ssh.hassh.server; dataset:isset,ms-ti-hassh,type string,load ti-hassh.lst; classtype:trojan-activity; priority:1; sid:1000209; rev:1;)',
|
||||
]
|
||||
if rows["sha256"]:
|
||||
file_protocols = (("http", 1000210), ("http2", 1000211), ("smtp", 1000212), ("ftp-data", 1000213), ("nfs", 1000214), ("smb", 1000215))
|
||||
for proto, sid in file_protocols:
|
||||
rules.append(f'alert {proto} any any -> any any (msg:"MIKROSURICATA TI malicious file SHA256 via {proto}"; filesha256:ti-sha256.lst; classtype:trojan-activity; priority:1; sid:{sid}; rev:1;)')
|
||||
rules_file.write_text("\n".join(rules) + "\n", encoding="utf-8")
|
||||
for path in (*files.values(), rules_file):
|
||||
try:
|
||||
os.chmod(path, 0o644)
|
||||
except OSError:
|
||||
pass
|
||||
return {**{kind: len(rows[kind]) for kind in rows}, "rules": len(rules) - 1}
|
||||
|
||||
|
||||
|
||||
class NDRAnalyzer:
|
||||
"""Async asset intelligence, behavior analytics and incident correlation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AlertStore,
|
||||
threat_intel: ThreatIntelManager,
|
||||
routeros: RouterOSClient,
|
||||
monitored_networks: str,
|
||||
never_block: str,
|
||||
block_timeout: str,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
correlation_window_seconds: int = 1800,
|
||||
behavior_min_observations: int = 50,
|
||||
auto_block: bool = False,
|
||||
auto_block_risk: int = 92,
|
||||
notifier: Any | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.threat_intel = threat_intel
|
||||
self.routeros = routeros
|
||||
self.networks = _parse_networks(monitored_networks)
|
||||
self.never_block = _parse_networks(never_block)
|
||||
self.block_timeout = block_timeout
|
||||
self.enabled = enabled
|
||||
self.correlation_window_seconds = max(300, int(correlation_window_seconds))
|
||||
self.behavior_min_observations = max(10, int(behavior_min_observations))
|
||||
self.auto_block = auto_block
|
||||
self.auto_block_risk = max(70, min(100, int(auto_block_risk)))
|
||||
self.notifier = notifier
|
||||
self._queue: queue.Queue[tuple[dict[str, Any], int | None]] = queue.Queue(maxsize=20000)
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="ndr-analyzer", daemon=True)
|
||||
self._dropped = 0
|
||||
self._processed = 0
|
||||
self._signals = 0
|
||||
self._ioc_hits = 0
|
||||
self._behavior_hits = 0
|
||||
self._beacon: dict[tuple[str, str], collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=12))
|
||||
self._scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
|
||||
self._out_scan: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
|
||||
self._egress: dict[str, collections.deque[tuple[float, int, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=512))
|
||||
self._dga: dict[str, collections.deque[tuple[float, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=64))
|
||||
self._nxdomain: dict[str, collections.deque[tuple[float, str]]] = collections.defaultdict(lambda: collections.deque(maxlen=128))
|
||||
self._dns_tunnel: dict[str, collections.deque[tuple[float, str, int]]] = collections.defaultdict(lambda: collections.deque(maxlen=96))
|
||||
self._identity_changes: dict[str, collections.deque[float]] = collections.defaultdict(lambda: collections.deque(maxlen=8))
|
||||
self._cooldown: dict[tuple[str, str], float] = {}
|
||||
self._block_attempted: set[int] = set()
|
||||
self._routeros_inventory_syncs = 0
|
||||
self._routeros_inventory_assets = 0
|
||||
self._routeros_inventory_last_at = ""
|
||||
|
||||
def start(self) -> None:
|
||||
if self.enabled and not self._thread.is_alive():
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 3.0) -> None:
|
||||
self._stop.set()
|
||||
if self._thread.is_alive(): self._thread.join(timeout=timeout)
|
||||
|
||||
def observe(self, record: dict[str, Any], alert_id: int | None = None) -> None:
|
||||
if not self.enabled: return
|
||||
try:
|
||||
self._queue.put_nowait((dict(record), alert_id))
|
||||
except queue.Full:
|
||||
self._dropped += 1
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled, "running": self._thread.is_alive(), "queue": self._queue.qsize(),
|
||||
"dropped": self._dropped, "processed": self._processed, "signals": self._signals,
|
||||
"ioc_hits": self._ioc_hits, "behavior_hits": self._behavior_hits,
|
||||
"auto_block": self.auto_block, "auto_block_risk": self.auto_block_risk,
|
||||
"routeros_inventory_syncs": self._routeros_inventory_syncs,
|
||||
"routeros_inventory_assets": self._routeros_inventory_assets,
|
||||
"routeros_inventory_last_at": self._routeros_inventory_last_at,
|
||||
}
|
||||
|
||||
def sync_routeros_inventory(self) -> dict[str, int]:
|
||||
"""Merge RouterOS ARP and DHCP identity tables into the passive asset inventory."""
|
||||
if not self.enabled or not self.routeros.configured:
|
||||
return {"arp": 0, "dhcp": 0, "assets": 0}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
seen: set[str] = set()
|
||||
dhcp_count = 0
|
||||
arp_count = 0
|
||||
for lease in self.routeros.list_dhcp_leases():
|
||||
ip = str(lease.get("address") or "")
|
||||
if not ip or not self._local(ip):
|
||||
continue
|
||||
record = {
|
||||
"timestamp": now, "type": "routeros-dhcp", "direction": "outbound",
|
||||
"src_ip": ip, "dhcp_assigned_ip": ip,
|
||||
"dhcp_client_mac": lease.get("mac") or "",
|
||||
"dhcp_hostname": lease.get("hostname") or "",
|
||||
}
|
||||
if self.store.observe_asset(record):
|
||||
dhcp_count += 1; seen.add(ip)
|
||||
for row in self.routeros.list_arp():
|
||||
ip = str(row.get("address") or "")
|
||||
if not ip or not self._local(ip):
|
||||
continue
|
||||
record = {
|
||||
"timestamp": now, "type": "routeros-arp", "direction": "outbound",
|
||||
"src_ip": ip, "arp_src_ip": ip, "arp_src_mac": row.get("mac") or "",
|
||||
}
|
||||
if self.store.observe_asset(record):
|
||||
arp_count += 1; seen.add(ip)
|
||||
self._routeros_inventory_syncs += 1
|
||||
self._routeros_inventory_assets = len(seen)
|
||||
self._routeros_inventory_last_at = now
|
||||
return {"arp": arp_count, "dhcp": dhcp_count, "assets": len(seen)}
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set() or not self._queue.empty():
|
||||
try:
|
||||
record, alert_id = self._queue.get(timeout=0.25)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
self._process(record, alert_id)
|
||||
except Exception as exc:
|
||||
print(f"[ndr] analysis error: {exc}", flush=True)
|
||||
finally:
|
||||
self._processed += 1
|
||||
self._queue.task_done()
|
||||
|
||||
def _process(self, record: dict[str, Any], alert_id: int | None) -> None:
|
||||
subject = self._subject(record)
|
||||
if subject:
|
||||
asset = self.store.observe_asset(record)
|
||||
if asset and asset.get("mac_changed"):
|
||||
now = _dt(record.get("timestamp"))
|
||||
changes = self._identity_changes[subject]
|
||||
changes.append(now)
|
||||
while changes and changes[0] < now - 300:
|
||||
changes.popleft()
|
||||
if len(changes) >= 3 and self._ready(subject, "identity-flap", now, 900):
|
||||
self._behavior_hits += 1
|
||||
self._emit(
|
||||
record, alert_id, subject, "behavior", "network-spoofing", 78,
|
||||
f"Repeated IP/MAC identity changes: {len(changes)} changes in 5m (possible ARP spoofing/IP conflict)",
|
||||
details={"previous_mac": asset.get("previous_mac"), "mac": asset.get("mac"), "changes_5m": len(changes)},
|
||||
)
|
||||
elif self._ready(subject, "identity-change", now, 120):
|
||||
self._emit(record, alert_id, subject, "behavior", "identity-change", 45, f"IP/MAC identity changed: {asset.get('previous_mac')} → {asset.get('mac')}")
|
||||
self._baseline(record, alert_id, subject, int(asset.get("observations") or 0) if asset else 0)
|
||||
self._behavior(record, alert_id, subject)
|
||||
|
||||
for ioc in self.threat_intel.match(record):
|
||||
self.store.mark_ioc_hit(int(ioc["id"]), str(record.get("timestamp") or ""))
|
||||
self._ioc_hits += 1
|
||||
subject = subject or self._subject(record) or str(record.get("src_ip") or record.get("dest_ip") or "")
|
||||
if not subject: continue
|
||||
risk = min(98, 55 + int(ioc.get("confidence") or 0) // 3 + (12 if int(ioc.get("severity") or 4) == 1 else 0))
|
||||
self._emit(record, alert_id, subject, "ioc", "threat-intel", risk, f"IOC match: {ioc['indicator_type']} {ioc['indicator']} ({ioc['source']})", details={"ioc_id": ioc["id"], "confidence": ioc["confidence"]})
|
||||
|
||||
if record.get("type") == "alert" and not record.get("deduplicated") and not record.get("filtered"):
|
||||
subject = subject or self._subject(record)
|
||||
if subject:
|
||||
severity = int(record.get("severity") or 4)
|
||||
risk = {1: 78, 2: 58, 3: 38, 4: 22}.get(severity, 30)
|
||||
sid = int(record.get("signature_id") or 0)
|
||||
stage = LOCAL_STAGE_BY_SID.get(sid) or self._stage_from_alert(record)
|
||||
self._emit(record, alert_id, subject, "alert", stage, risk, str(record.get("signature") or "Suricata alert"))
|
||||
elif record.get("type") == "anomaly" and subject:
|
||||
anomaly = str(record.get("anomaly_event") or "Suricata protocol anomaly")
|
||||
now = _dt(record.get("timestamp"))
|
||||
if self._ready(subject, f"protocol-anomaly:{anomaly[:96]}", now, 300):
|
||||
self._emit(record, alert_id, subject, "behavior", "protocol-anomaly", 32, anomaly)
|
||||
|
||||
def _baseline(self, record: dict[str, Any], alert_id: int | None, subject: str, observations: int) -> None:
|
||||
values = []
|
||||
app = str(record.get("app_proto") or "").lower()
|
||||
if app: values.append(("app", app, 20))
|
||||
if record.get("direction") == "outbound" and record.get("dest_port"):
|
||||
port = int(record["dest_port"]); values.append(("outbound-port", str(port), 38 if port in SENSITIVE_PORTS else 18))
|
||||
domain = str(record.get("dns_query") or record.get("tls_sni") or record.get("quic_sni") or record.get("http_host") or "").lower().rstrip(".")
|
||||
if domain and len(domain) <= 255:
|
||||
values.append(("remote-domain", domain, 16))
|
||||
fingerprint = str(record.get("tls_ja4") or record.get("quic_ja4") or record.get("ssh_hassh_client") or "")
|
||||
if fingerprint:
|
||||
values.append(("client-fingerprint", fingerprint[:160], 30))
|
||||
for kind, value, risk in values:
|
||||
is_new, _ = self.store.baseline_touch(subject, kind, value, str(record.get("timestamp") or ""))
|
||||
if is_new and observations >= self.behavior_min_observations:
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "behavior-change", risk, f"New {kind} for established asset: {value}")
|
||||
|
||||
def _behavior(self, record: dict[str, Any], alert_id: int | None, subject: str) -> None:
|
||||
now = _dt(record.get("timestamp"))
|
||||
if record.get("type") == "flow":
|
||||
dest = str(record.get("dest_ip") or "")
|
||||
port = int(record.get("dest_port") or 0)
|
||||
if record.get("direction") == "outbound" and dest:
|
||||
key = (subject, dest)
|
||||
dq = self._beacon[key]; dq.append(now)
|
||||
if port in SENSITIVE_PORTS:
|
||||
scan = self._out_scan[subject]; scan.append((now, dest, port))
|
||||
while scan and scan[0][0] < now - 60: scan.popleft()
|
||||
unique_targets = {d for _, d, _ in scan}
|
||||
if len(unique_targets) >= 12 and self._ready(subject, "outbound-sensitive-scan", now, 900):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "recon", 64, f"Outbound scan-like fan-out to sensitive services: {len(unique_targets)} hosts in 60s")
|
||||
flow_bytes = max(0, int(record.get("bytes_out") or record.get("bytes") or 0))
|
||||
if flow_bytes:
|
||||
egress = self._egress[subject]; egress.append((now, flow_bytes, dest))
|
||||
while egress and egress[0][0] < now - 300: egress.popleft()
|
||||
total = sum(size for _, size, _ in egress)
|
||||
if (flow_bytes >= 256 * 1024 * 1024 or total >= 512 * 1024 * 1024) and self._ready(subject, "large-egress", now, 1800):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "exfiltration", 50, f"Large outbound transfer volume: ~{round(total / (1024*1024))} MiB in 5m")
|
||||
if len(dq) >= 6:
|
||||
intervals = [b-a for a,b in zip(dq, list(dq)[1:]) if b>a]
|
||||
if len(intervals) >= 5:
|
||||
mean = sum(intervals)/len(intervals)
|
||||
if 10 <= mean <= 900:
|
||||
variance = sum((x-mean)**2 for x in intervals)/len(intervals)
|
||||
cv = math.sqrt(variance)/mean if mean else 1
|
||||
if cv <= 0.16 and self._ready(subject, "beacon", now, 900):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "command-and-control", 52, f"Periodic outbound beaconing to {dest} every ~{round(mean)}s")
|
||||
if record.get("direction") == "internal" and dest:
|
||||
dq2 = self._scan[subject]; dq2.append((now, dest, port))
|
||||
while dq2 and dq2[0][0] < now - 60: dq2.popleft()
|
||||
unique = {(d,p) for _,d,p in dq2}
|
||||
if len(unique) >= 15 and self._ready(subject, "internal-scan", now, 600):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "lateral-movement", 62, f"Internal fan-out: {len(unique)} destination/port pairs in 60s")
|
||||
if record.get("type") == "dns":
|
||||
query = str(record.get("dns_query") or "").lower().rstrip(".")
|
||||
if not query:
|
||||
return
|
||||
first = query.split(".",1)[0]
|
||||
if len(first) >= 18 and _entropy(first) >= 3.5:
|
||||
dq = self._dga[subject]; dq.append((now, query))
|
||||
while dq and dq[0][0] < now - 120: dq.popleft()
|
||||
if len({q for _,q in dq}) >= 5 and self._ready(subject, "dga", now, 900):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "dns-anomaly", 56, "High-entropy burst of unique DNS names (possible DGA)", details={"queries_2m": len({q for _,q in dq})})
|
||||
|
||||
rcode = str(record.get("dns_rcode") or "").upper()
|
||||
if rcode in {"NXDOMAIN", "3"}:
|
||||
nx = self._nxdomain[subject]; nx.append((now, query))
|
||||
while nx and nx[0][0] < now - 60: nx.popleft()
|
||||
unique_nx = {q for _, q in nx}
|
||||
if len(unique_nx) >= 18 and self._ready(subject, "nxdomain-burst", now, 600):
|
||||
self._behavior_hits += 1
|
||||
self._emit(record, alert_id, subject, "behavior", "dns-anomaly", 58, f"NXDOMAIN burst: {len(unique_nx)} unique failed names in 60s", details={"unique_nxdomain_60s": len(unique_nx)})
|
||||
|
||||
labels = [label for label in query.split(".") if label]
|
||||
longest = max((len(label) for label in labels), default=0)
|
||||
entropy = max((_entropy(label) for label in labels), default=0.0)
|
||||
rrtype = str(record.get("dns_type") or "").upper()
|
||||
if len(query) >= 70 and longest >= 35 and entropy >= 3.8:
|
||||
tunnel = self._dns_tunnel[subject]; tunnel.append((now, query, len(query)))
|
||||
while tunnel and tunnel[0][0] < now - 120: tunnel.popleft()
|
||||
unique_tunnel = {q for _, q, _ in tunnel}
|
||||
if len(unique_tunnel) >= 4 and self._ready(subject, "dns-tunnel", now, 900):
|
||||
self._behavior_hits += 1
|
||||
risk = 74 if rrtype in {"TXT", "NULL", "CNAME"} else 66
|
||||
self._emit(record, alert_id, subject, "behavior", "dns-anomaly", risk, "Repeated long high-entropy DNS queries (possible DNS tunneling)", details={"queries_2m": len(unique_tunnel), "rrtype": rrtype, "max_label": longest, "entropy": round(entropy, 2)})
|
||||
|
||||
def _ready(self, subject: str, name: str, now: float, cooldown: int) -> bool:
|
||||
key = (subject, name)
|
||||
if self._cooldown.get(key, 0) > now: return False
|
||||
self._cooldown[key] = now + cooldown
|
||||
return True
|
||||
|
||||
def _emit(self, record: dict[str, Any], alert_id: int | None, subject: str, kind: str, stage: str, risk: int, summary: str, details: dict[str, Any] | None = None) -> None:
|
||||
mitre = classify_mitre(stage, summary, record)
|
||||
signal = {
|
||||
"subject_ip": subject, "timestamp": record.get("timestamp"), "kind": kind, "stage": stage,
|
||||
"risk": risk, "summary": summary, "title": summary, "src_ip": record.get("src_ip"),
|
||||
"dest_ip": record.get("dest_ip"), "signature_id": record.get("signature_id"), "flow_id": record.get("flow_id"),
|
||||
"community_id": record.get("community_id"), "details": details or {}, "mitre": mitre,
|
||||
}
|
||||
incident_id = self.store.correlate_signal(signal, self.correlation_window_seconds)
|
||||
incident = self.store.ndr_incident(incident_id) or {}
|
||||
combined_risk = max(risk, int(incident.get("risk_score") or 0))
|
||||
if self.notifier is not None:
|
||||
try:
|
||||
self.notifier.notify(incident, signal)
|
||||
except Exception as exc:
|
||||
print(f"[ndr] notifier error: {exc}", flush=True)
|
||||
self.store.raise_asset_risk(subject, combined_risk)
|
||||
if alert_id is not None:
|
||||
self.store.link_alert_incident(alert_id, incident_id, combined_risk)
|
||||
self._signals += 1
|
||||
if self.auto_block and combined_risk >= self.auto_block_risk and incident_id not in self._block_attempted:
|
||||
target = self._remote_target(record, subject)
|
||||
if target and self.routeros.configured:
|
||||
self._block_attempted.add(incident_id)
|
||||
result = self.routeros.block_ip(target, self.block_timeout, f"MikroSuricata NDR risk {combined_risk}: {summary}"[:220])
|
||||
if result.success:
|
||||
self.store.mark_incident_blocked(incident_id, target)
|
||||
|
||||
def _subject(self, record: dict[str, Any]) -> str:
|
||||
src = str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")
|
||||
dst = str(record.get("dest_ip") or "")
|
||||
if self._local(src): return src
|
||||
if self._local(dst): return dst
|
||||
return ""
|
||||
|
||||
def _remote_target(self, record: dict[str, Any], subject: str) -> str:
|
||||
for value in (record.get("src_ip"), record.get("dest_ip")):
|
||||
text = str(value or "")
|
||||
if not text or text == subject or self._local(text): continue
|
||||
try: ip = ipaddress.ip_address(text)
|
||||
except ValueError: continue
|
||||
if not ip.is_global or any(ip.version == net.version and ip in net for net in self.never_block): continue
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _local(self, value: str) -> bool:
|
||||
try: ip = ipaddress.ip_address(value)
|
||||
except ValueError: return False
|
||||
return any(ip.version == net.version and ip in net for net in self.networks)
|
||||
|
||||
@staticmethod
|
||||
def _stage_from_alert(record: dict[str, Any]) -> str:
|
||||
text = f"{record.get('category','')} {record.get('signature','')}".lower()
|
||||
if any(x in text for x in ("command and control", "c2", "trojan", "malware", "botnet")): return "command-and-control"
|
||||
if any(x in text for x in ("scan", "recon", "information leak")): return "recon"
|
||||
if any(x in text for x in ("credential", "brute", "login", "authentication")): return "credential-access"
|
||||
if any(x in text for x in ("lateral", "smb", "rdp")): return "lateral-movement"
|
||||
if any(x in text for x in ("exfil", "tunnel", "data theft")): return "exfiltration"
|
||||
return "detection"
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
class WebhookNotifier:
|
||||
"""Bounded asynchronous JSON webhook delivery for high-risk NDR incidents."""
|
||||
|
||||
def __init__(self, url: str, min_risk: int = 80, timeout: int = 5) -> None:
|
||||
self.url = str(url or "").strip()
|
||||
self.min_risk = max(1, min(100, int(min_risk)))
|
||||
self.timeout = max(1, min(30, int(timeout)))
|
||||
self.enabled = self.url.startswith(("http://", "https://"))
|
||||
self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=256)
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="ndr-webhook", daemon=True)
|
||||
self._sent_state: dict[int, tuple[int, float]] = {}
|
||||
self._sent = 0
|
||||
self._failed = 0
|
||||
self._dropped = 0
|
||||
self._last_error = ""
|
||||
self._last_sent_at = ""
|
||||
|
||||
def start(self) -> None:
|
||||
if self.enabled and not self._thread.is_alive():
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 3.0) -> None:
|
||||
self._stop.set()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=timeout)
|
||||
|
||||
def notify(self, incident: dict[str, Any], evidence: dict[str, Any]) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
risk = int(incident.get("risk_score") or 0)
|
||||
incident_id = int(incident.get("id") or 0)
|
||||
if risk < self.min_risk or incident_id <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
previous_risk, previous_at = self._sent_state.get(incident_id, (0, 0.0))
|
||||
# Re-notify only if risk meaningfully escalated or 15 minutes passed.
|
||||
if previous_at and risk < previous_risk + 10 and now - previous_at < 900:
|
||||
return
|
||||
payload = {
|
||||
"event": "mikrosuricata.ndr.incident",
|
||||
"incident": {
|
||||
"id": incident_id,
|
||||
"subject_ip": incident.get("subject_ip"),
|
||||
"risk_score": risk,
|
||||
"severity": incident.get("severity"),
|
||||
"status": incident.get("status"),
|
||||
"title": incident.get("title"),
|
||||
"summary": incident.get("summary"),
|
||||
"stages": incident.get("stages") or [],
|
||||
"destinations": incident.get("destinations") or [],
|
||||
"blocked": bool(incident.get("blocked")),
|
||||
"block_target": incident.get("block_target"),
|
||||
"last_seen": incident.get("last_seen"),
|
||||
},
|
||||
"evidence": {
|
||||
"kind": evidence.get("kind"),
|
||||
"stage": evidence.get("stage"),
|
||||
"risk": evidence.get("risk"),
|
||||
"summary": evidence.get("summary"),
|
||||
"src_ip": evidence.get("src_ip"),
|
||||
"dest_ip": evidence.get("dest_ip"),
|
||||
"signature_id": evidence.get("signature_id"),
|
||||
},
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(payload)
|
||||
self._sent_state[incident_id] = (risk, now)
|
||||
except queue.Full:
|
||||
self._dropped += 1
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"running": self._thread.is_alive(),
|
||||
"min_risk": self.min_risk,
|
||||
"queue": self._queue.qsize(),
|
||||
"sent": self._sent,
|
||||
"failed": self._failed,
|
||||
"dropped": self._dropped,
|
||||
"last_sent_at": self._last_sent_at,
|
||||
"last_error": self._last_error,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set() or not self._queue.empty():
|
||||
try:
|
||||
payload = self._queue.get(timeout=0.25)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "MikroSuricata-NDR/0.8"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
if int(getattr(response, "status", 200)) >= 400:
|
||||
raise urllib.error.HTTPError(self.url, response.status, "webhook error", response.headers, None)
|
||||
self._sent += 1
|
||||
self._last_sent_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
self._last_error = ""
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
|
||||
self._failed += 1
|
||||
self._last_error = str(exc)[:300]
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RedisSupervisor:
|
||||
"""Run the persistent Redis history service inside the IDS container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool,
|
||||
data_dir: str,
|
||||
port: int = 6379,
|
||||
maxmemory_mb: int = 0,
|
||||
snapshot_seconds: int = 1800,
|
||||
aof: bool = True,
|
||||
) -> None:
|
||||
self.enabled = bool(enabled)
|
||||
self.data_dir = data_dir
|
||||
self.port = int(port)
|
||||
# 0 means unlimited. Traffic retention is time-based; Redis must not evict
|
||||
# arbitrary history just because an old deployment exported a memory cap.
|
||||
self.maxmemory_mb = max(0, int(maxmemory_mb))
|
||||
self.snapshot_seconds = max(300, int(snapshot_seconds))
|
||||
self.aof = bool(aof)
|
||||
self.executable = shutil.which("redis-server")
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._thread = threading.Thread(target=self._run, name="redis-supervisor", daemon=True)
|
||||
self._restarts = 0
|
||||
self._last_error = ""
|
||||
|
||||
def start(self, *, wait_ready_seconds: float = 12.0) -> bool:
|
||||
if not self.enabled:
|
||||
self._last_error = "managed Redis disabled"
|
||||
return False
|
||||
if not self.executable:
|
||||
self._last_error = "redis-server executable not found"
|
||||
return False
|
||||
try:
|
||||
self._prepare_data_dir()
|
||||
except OSError as exc:
|
||||
self._last_error = f"cannot prepare Redis data directory: {exc}"
|
||||
return False
|
||||
self._spawn()
|
||||
if not self.wait_ready(wait_ready_seconds):
|
||||
return False
|
||||
if not self._thread.is_alive():
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def wait_ready(self, timeout: float = 12.0) -> bool:
|
||||
deadline = time.monotonic() + max(0.2, float(timeout))
|
||||
while time.monotonic() < deadline and not self._stop.is_set():
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
self._last_error = self._last_error or "redis-server did not start"
|
||||
return False
|
||||
code = proc.poll()
|
||||
if code is not None:
|
||||
self._last_error = f"redis-server exited with code {code}"
|
||||
return False
|
||||
if self._ping():
|
||||
self._last_error = ""
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
self._last_error = self._last_error or f"Redis did not become ready on 127.0.0.1:{self.port}"
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=2)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
running = bool(proc is not None and proc.poll() is None)
|
||||
pid = proc.pid if running else None
|
||||
return {
|
||||
"managed": self.enabled,
|
||||
"available": bool(self.executable),
|
||||
"running": running,
|
||||
"ready": running and self._ping(),
|
||||
"pid": pid,
|
||||
"restarts": self._restarts,
|
||||
"data_dir": self.data_dir,
|
||||
"maxmemory_mb": self.maxmemory_mb,
|
||||
"snapshot_seconds": self.snapshot_seconds,
|
||||
"aof": self.aof,
|
||||
"persistence": "AOF everysec + RDB" if self.aof else "RDB",
|
||||
"last_error": self._last_error,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.wait(2):
|
||||
with self._lock:
|
||||
proc = self._proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
continue
|
||||
if self._stop.is_set():
|
||||
return
|
||||
self._restarts += 1
|
||||
self._spawn()
|
||||
# A restart is only considered successful once Redis accepts PING.
|
||||
self.wait_ready(8.0)
|
||||
|
||||
def _prepare_data_dir(self) -> None:
|
||||
Path(self.data_dir).mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
user = pwd.getpwnam("redis")
|
||||
except KeyError:
|
||||
return
|
||||
for root, dirs, files in os.walk(self.data_dir):
|
||||
os.chown(root, user.pw_uid, user.pw_gid)
|
||||
for name in dirs:
|
||||
os.chown(os.path.join(root, name), user.pw_uid, user.pw_gid)
|
||||
for name in files:
|
||||
os.chown(os.path.join(root, name), user.pw_uid, user.pw_gid)
|
||||
|
||||
def _spawn(self) -> None:
|
||||
if not self.executable:
|
||||
return
|
||||
cmd = [
|
||||
self.executable,
|
||||
"--bind", "127.0.0.1",
|
||||
"--protected-mode", "yes",
|
||||
"--port", str(self.port),
|
||||
"--save", str(self.snapshot_seconds), "100",
|
||||
"--appendonly", "yes" if self.aof else "no",
|
||||
"--appendfsync", "everysec",
|
||||
"--aof-use-rdb-preamble", "yes",
|
||||
"--dir", self.data_dir,
|
||||
"--dbfilename", "traffic.rdb",
|
||||
"--maxmemory-policy", "noeviction",
|
||||
"--loglevel", "warning",
|
||||
]
|
||||
if self.maxmemory_mb > 0:
|
||||
cmd.extend(["--maxmemory", f"{self.maxmemory_mb}mb"])
|
||||
else:
|
||||
cmd.extend(["--maxmemory", "0"])
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"start_new_session": True,
|
||||
}
|
||||
try:
|
||||
user = pwd.getpwnam("redis")
|
||||
if os.geteuid() == 0:
|
||||
kwargs["user"] = user.pw_uid
|
||||
kwargs["group"] = user.pw_gid
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, **kwargs)
|
||||
with self._lock:
|
||||
self._proc = proc
|
||||
self._last_error = ""
|
||||
except OSError as exc:
|
||||
self._last_error = str(exc)
|
||||
with self._lock:
|
||||
self._proc = None
|
||||
|
||||
def _ping(self) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.3) as sock:
|
||||
sock.settimeout(0.3)
|
||||
sock.sendall(b"*1\r\n$4\r\nPING\r\n")
|
||||
return sock.recv(64).startswith(b"+PONG")
|
||||
except OSError:
|
||||
return False
|
||||
+111
@@ -64,6 +64,117 @@ class RouterOSClient:
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
||||
return BlockResult(False, f"RouterOS REST error: {exc}")
|
||||
|
||||
|
||||
def list_blocks(self) -> list[dict]:
|
||||
if not self.configured:
|
||||
return []
|
||||
try:
|
||||
result = self._request(
|
||||
"GET",
|
||||
"/rest/ip/firewall/address-list",
|
||||
query={"list": self.address_list},
|
||||
)
|
||||
if not isinstance(result, list):
|
||||
return []
|
||||
rows = []
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rows.append({
|
||||
"id": item.get(".id") or item.get("id"),
|
||||
"address": item.get("address"),
|
||||
"list": item.get("list"),
|
||||
"timeout": item.get("timeout"),
|
||||
"creation_time": item.get("creation-time") or item.get("creation_time"),
|
||||
"comment": item.get("comment", ""),
|
||||
"dynamic": str(item.get("dynamic", "false")).lower() == "true",
|
||||
})
|
||||
return rows
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def list_arp(self) -> list[dict]:
|
||||
"""Return RouterOS ARP observations for passive asset enrichment."""
|
||||
if not self.configured:
|
||||
return []
|
||||
try:
|
||||
result = self._request("GET", "/rest/ip/arp")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
||||
return []
|
||||
if not isinstance(result, list):
|
||||
return []
|
||||
rows = []
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
address = str(item.get("address") or "").strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
"address": address,
|
||||
"mac": str(item.get("mac-address") or item.get("mac_address") or "").strip(),
|
||||
"interface": str(item.get("interface") or "").strip(),
|
||||
"dynamic": str(item.get("dynamic", "false")).lower() == "true",
|
||||
"complete": str(item.get("complete", "true")).lower() != "false",
|
||||
})
|
||||
return rows
|
||||
|
||||
def list_dhcp_leases(self) -> list[dict]:
|
||||
"""Return DHCP lease identity data when the router exposes a DHCP server table."""
|
||||
if not self.configured:
|
||||
return []
|
||||
try:
|
||||
result = self._request("GET", "/rest/ip/dhcp-server/lease")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
||||
return []
|
||||
if not isinstance(result, list):
|
||||
return []
|
||||
rows = []
|
||||
for item in result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
address = str(item.get("active-address") or item.get("address") or "").strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
"address": address,
|
||||
"mac": str(item.get("active-mac-address") or item.get("mac-address") or "").strip(),
|
||||
"hostname": str(item.get("host-name") or "").strip(),
|
||||
"status": str(item.get("status") or "").strip(),
|
||||
"server": str(item.get("server") or "").strip(),
|
||||
"expires_after": str(item.get("expires-after") or "").strip(),
|
||||
"last_seen": str(item.get("last-seen") or "").strip(),
|
||||
})
|
||||
return rows
|
||||
|
||||
def unblock_ip(self, address: str) -> BlockResult:
|
||||
if not self.configured:
|
||||
return BlockResult(False, "RouterOS credentials are not configured")
|
||||
try:
|
||||
existing = self._request(
|
||||
"GET",
|
||||
"/rest/ip/firewall/address-list",
|
||||
query={"list": self.address_list, "address": address},
|
||||
)
|
||||
if not isinstance(existing, list) or not existing:
|
||||
return BlockResult(True, "address is not present in RouterOS address-list")
|
||||
removed = 0
|
||||
for item in existing:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_id = item.get(".id") or item.get("id")
|
||||
if not item_id:
|
||||
continue
|
||||
self._request(
|
||||
"DELETE",
|
||||
"/rest/ip/firewall/address-list/" + urllib.parse.quote(str(item_id), safe="*"),
|
||||
)
|
||||
removed += 1
|
||||
return BlockResult(True, f"removed {removed} RouterOS address-list entr{'y' if removed == 1 else 'ies'}")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
||||
return BlockResult(False, f"RouterOS REST error: {exc}")
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
|
||||
+334
-10
@@ -8,7 +8,10 @@ import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tarfile
|
||||
import threading
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -28,7 +31,7 @@ class RuleManager:
|
||||
MAX_THRESHOLD_BYTES = 256 * 1024
|
||||
SOURCE_INDEX_URL = "https://www.openinfosecfoundation.org/rules/index.yaml"
|
||||
DEFAULT_SOURCE = "et/open"
|
||||
SOURCE_NAME_RE = re.compile(r"^[A-Za-z0-9_.+-]+/[A-Za-z0-9_.+-]+$")
|
||||
SOURCE_NAME_RE = re.compile(r"^[A-Za-z0-9_.+-]+(?:/[A-Za-z0-9_.+-]+)?$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,7 +45,23 @@ class RuleManager:
|
||||
self._lock = threading.RLock()
|
||||
self._operation_lock = threading.RLock()
|
||||
self._update_lock = threading.Lock()
|
||||
self._source_queue_lock = threading.RLock()
|
||||
self._source_queue = {
|
||||
"id": "",
|
||||
"status": "idle",
|
||||
"phase": "idle",
|
||||
"created_at": None,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"message": "No queued source operation",
|
||||
"items": [],
|
||||
}
|
||||
self._last_result = "not changed"
|
||||
self._snapshot_dir = Path(self.config.suricata_custom_rules).parent / "rule-snapshots"
|
||||
self._snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_files()
|
||||
|
||||
def _ensure_files(self) -> None:
|
||||
@@ -56,10 +75,12 @@ class RuleManager:
|
||||
threshold = self._read(self.config.suricata_threshold_config)
|
||||
with self._lock:
|
||||
last_result = self._last_result
|
||||
vendor_rules = "/var/lib/suricata/rules/suricata.rules"
|
||||
vendor_root = self.config.suricata_persist_lib_dir
|
||||
vendor_rules = os.path.join(vendor_root, "rules", "suricata.rules")
|
||||
source_index = _first_existing_path(
|
||||
"/var/lib/suricata/update/cache/index.yaml",
|
||||
"/var/lib/suricata/rules/cache/index.yaml",
|
||||
os.path.join(vendor_root, "rules", ".cache", "index.yaml"),
|
||||
os.path.join(vendor_root, "update", "cache", "index.yaml"),
|
||||
os.path.join(vendor_root, "rules", "cache", "index.yaml"),
|
||||
)
|
||||
return {
|
||||
"available": self.suricata_available,
|
||||
@@ -77,6 +98,7 @@ class RuleManager:
|
||||
"source_index_updated_at": _file_mtime_iso(source_index) if source_index else None,
|
||||
"source_index_url": self.SOURCE_INDEX_URL,
|
||||
"last_result": last_result,
|
||||
"snapshots": len(self.list_snapshots()),
|
||||
}
|
||||
|
||||
def content(self) -> dict:
|
||||
@@ -139,6 +161,134 @@ class RuleManager:
|
||||
current += line + "\n"
|
||||
return self.replace_threshold_config(current)
|
||||
|
||||
def add_threshold(
|
||||
self,
|
||||
sid: int,
|
||||
*,
|
||||
threshold_type: str = "limit",
|
||||
track: str = "by_src",
|
||||
count: int = 5,
|
||||
seconds: int = 60,
|
||||
) -> RuleActionResult:
|
||||
sid = int(sid)
|
||||
threshold_type = str(threshold_type or "limit").strip().lower()
|
||||
track = str(track or "by_src").strip().lower()
|
||||
count = max(1, min(100000, int(count)))
|
||||
seconds = max(1, min(86400, int(seconds)))
|
||||
if sid <= 0:
|
||||
return RuleActionResult(False, "SID must be a positive integer")
|
||||
if threshold_type not in {"limit", "threshold", "both"}:
|
||||
return RuleActionResult(False, "threshold type must be limit, threshold or both")
|
||||
if track not in {"by_src", "by_dst", "by_rule", "by_both", "by_flow"}:
|
||||
return RuleActionResult(False, "unsupported threshold tracker")
|
||||
line = f"threshold gen_id 1, sig_id {sid}, type {threshold_type}, track {track}, count {count}, seconds {seconds}"
|
||||
with self._operation_lock:
|
||||
current = self._read(self.config.suricata_threshold_config)
|
||||
if line.casefold() in {x.strip().casefold() for x in current.splitlines() if x.strip()}:
|
||||
return RuleActionResult(True, f"SID {sid} already has that threshold")
|
||||
if current and not current.endswith("\n"):
|
||||
current += "\n"
|
||||
current += line + "\n"
|
||||
return self.replace_threshold_config(current)
|
||||
|
||||
def create_snapshot(self, reason: str = "manual") -> RuleActionResult:
|
||||
try:
|
||||
with self._operation_lock:
|
||||
path = self._create_snapshot(reason)
|
||||
return RuleActionResult(True, f"rule snapshot created: {path.name}")
|
||||
except Exception as exc:
|
||||
return RuleActionResult(False, f"could not create rule snapshot: {exc}")
|
||||
|
||||
def list_snapshots(self) -> list[dict]:
|
||||
out = []
|
||||
try:
|
||||
paths = sorted(self._snapshot_dir.glob("rules-*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
except OSError:
|
||||
return []
|
||||
for path in paths[:20]:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"id": path.name,
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
||||
"size_bytes": int(stat.st_size),
|
||||
})
|
||||
return out
|
||||
|
||||
def rollback_snapshot(self, snapshot_id: str) -> RuleActionResult:
|
||||
name = os.path.basename(str(snapshot_id or ""))
|
||||
if not re.fullmatch(r"rules-[A-Za-z0-9_.-]+\.tar\.gz", name):
|
||||
return RuleActionResult(False, "invalid rule snapshot")
|
||||
path = self._snapshot_dir / name
|
||||
if not path.is_file():
|
||||
return RuleActionResult(False, "rule snapshot not found")
|
||||
if not self.suricata_available:
|
||||
return RuleActionResult(False, "Suricata is not available in this mode")
|
||||
with self._operation_lock:
|
||||
backup = self._create_snapshot("pre-rollback")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="rules-rollback-") as td:
|
||||
root = Path(td)
|
||||
with tarfile.open(path, "r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
dest = (root / member.name).resolve()
|
||||
if root.resolve() not in dest.parents and dest != root.resolve():
|
||||
raise ValueError("unsafe snapshot path")
|
||||
tar.extractall(root)
|
||||
custom = (root / "custom.rules").read_text(encoding="utf-8") if (root / "custom.rules").exists() else ""
|
||||
threshold = (root / "threshold.config").read_text(encoding="utf-8") if (root / "threshold.config").exists() else ""
|
||||
validation = self.validate(custom, threshold)
|
||||
if not validation.ok:
|
||||
return RuleActionResult(False, f"snapshot validation failed: {validation.message}")
|
||||
self._atomic_write(self.config.suricata_custom_rules, custom)
|
||||
self._atomic_write(self.config.suricata_threshold_config, threshold)
|
||||
vendor = root / "vendor.rules"
|
||||
if vendor.exists():
|
||||
vendor_dest = Path(self._suricata_update_data_dir()) / "rules" / "suricata.rules"
|
||||
vendor_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(vendor, vendor_dest)
|
||||
sources = root / "sources"
|
||||
if sources.exists():
|
||||
source_dest = Path(self._suricata_update_data_dir()) / "update" / "sources"
|
||||
if source_dest.exists():
|
||||
shutil.rmtree(source_dest)
|
||||
shutil.copytree(sources, source_dest)
|
||||
result = self.reload()
|
||||
if result.ok:
|
||||
return RuleActionResult(True, f"restored {name}; {result.message}; safety snapshot {backup.name}")
|
||||
return RuleActionResult(False, f"restored files but {result.message}; safety snapshot {backup.name}")
|
||||
except Exception as exc:
|
||||
return RuleActionResult(False, f"rollback failed: {exc}; safety snapshot {backup.name}")
|
||||
|
||||
def _create_snapshot(self, reason: str) -> Path:
|
||||
self._snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
safe_reason = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(reason or "snapshot"))[:40].strip("-") or "snapshot"
|
||||
target = self._snapshot_dir / f"rules-{stamp}-{safe_reason}-{uuid.uuid4().hex[:6]}.tar.gz"
|
||||
with tarfile.open(target, "w:gz") as tar:
|
||||
for source, arcname in (
|
||||
(Path(self.config.suricata_custom_rules), "custom.rules"),
|
||||
(Path(self.config.suricata_threshold_config), "threshold.config"),
|
||||
(Path(self._suricata_update_data_dir()) / "rules" / "suricata.rules", "vendor.rules"),
|
||||
):
|
||||
if source.is_file():
|
||||
tar.add(source, arcname=arcname, recursive=False)
|
||||
sources = Path(self._suricata_update_data_dir()) / "update" / "sources"
|
||||
if sources.is_dir():
|
||||
tar.add(sources, arcname="sources", recursive=True)
|
||||
self._prune_snapshots(12)
|
||||
return target
|
||||
|
||||
def _prune_snapshots(self, keep: int) -> None:
|
||||
paths = sorted(self._snapshot_dir.glob("rules-*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for path in paths[max(1, int(keep)):]:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def update_vendor_rules(self) -> RuleActionResult:
|
||||
if not self.suricata_available:
|
||||
return RuleActionResult(False, "Suricata rule updates are unavailable in this mode")
|
||||
@@ -167,9 +317,13 @@ class RuleManager:
|
||||
enabled_proc = self._run_suricata_update(["list-sources", "--enabled"], timeout=30)
|
||||
enabled = _parse_enabled_sources(enabled_proc.stdout or "") if enabled_proc.returncode == 0 else set()
|
||||
sources = _parse_source_catalog(catalog.stdout or "")
|
||||
default_replaced = any(
|
||||
source.get("name") in enabled and self.DEFAULT_SOURCE in source.get("replaces", [])
|
||||
for source in sources
|
||||
)
|
||||
for source in sources:
|
||||
source["default"] = source["name"] == self.DEFAULT_SOURCE
|
||||
source["enabled"] = source["default"] or source["name"] in enabled
|
||||
source["enabled"] = source["name"] in enabled or (source["default"] and not default_replaced)
|
||||
source["can_toggle"] = not source["default"] and not bool(source.get("parameters"))
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -180,6 +334,8 @@ class RuleManager:
|
||||
"enabled_sources": sorted(
|
||||
{source["name"] for source in sources if source.get("enabled")}
|
||||
),
|
||||
"data_dir": self._suricata_update_data_dir(),
|
||||
"queue": self.source_queue_status(),
|
||||
"status": self.status(),
|
||||
}
|
||||
|
||||
@@ -247,7 +403,155 @@ class RuleManager:
|
||||
finally:
|
||||
self._update_lock.release()
|
||||
|
||||
def queue_sources(self, source_names: list[str]) -> RuleActionResult:
|
||||
if not self.suricata_available:
|
||||
return RuleActionResult(False, "Suricata rule sources are unavailable in this mode")
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in source_names or []:
|
||||
name = str(raw or "").strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
if not self.SOURCE_NAME_RE.fullmatch(name):
|
||||
return RuleActionResult(False, f"invalid rule source name: {name}")
|
||||
seen.add(name)
|
||||
normalized.append(name)
|
||||
if not normalized:
|
||||
return RuleActionResult(False, "select at least one rule source")
|
||||
if len(normalized) > 128:
|
||||
return RuleActionResult(False, "too many rule sources in one queue (maximum 128)")
|
||||
|
||||
with self._source_queue_lock:
|
||||
if self._source_queue.get("status") in {"queued", "running"}:
|
||||
return RuleActionResult(False, "a rule-source download queue is already running")
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self._source_queue = {
|
||||
"id": job_id,
|
||||
"status": "queued",
|
||||
"phase": "waiting",
|
||||
"created_at": now,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"total": len(normalized),
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"message": f"Queued {len(normalized)} source(s)",
|
||||
"items": [
|
||||
{"source": name, "status": "pending", "message": "Waiting"}
|
||||
for name in normalized
|
||||
],
|
||||
}
|
||||
|
||||
worker = threading.Thread(
|
||||
target=self._source_queue_worker,
|
||||
args=(job_id, normalized),
|
||||
name=f"rule-source-queue-{job_id}",
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
return RuleActionResult(True, f"Queued {len(normalized)} rule source(s) for sequential download")
|
||||
|
||||
def source_queue_status(self) -> dict:
|
||||
with self._source_queue_lock:
|
||||
return deepcopy(self._source_queue)
|
||||
|
||||
def _source_queue_worker(self, job_id: str, source_names: list[str]) -> None:
|
||||
self._queue_job_update(job_id, status="running", phase="catalog", started_at=datetime.now(timezone.utc).isoformat(), message="Loading persistent source catalog")
|
||||
self._update_lock.acquire()
|
||||
try:
|
||||
catalog = self.source_catalog()
|
||||
if not catalog.get("ok"):
|
||||
self._queue_job_finish(job_id, "failed", str(catalog.get("error") or "could not read source catalog"))
|
||||
return
|
||||
by_name = {str(item.get("name")): item for item in catalog.get("sources", [])}
|
||||
changed = 0
|
||||
failed = 0
|
||||
completed = 0
|
||||
|
||||
for index, name in enumerate(source_names):
|
||||
self._queue_item_update(job_id, index, "running", "Enabling source")
|
||||
source = by_name.get(name)
|
||||
if source is None:
|
||||
failed += 1
|
||||
self._queue_item_update(job_id, index, "failed", "Source is not present in the free OISF catalog")
|
||||
self._queue_job_update(job_id, failed=failed)
|
||||
continue
|
||||
if source.get("parameters"):
|
||||
failed += 1
|
||||
params = ", ".join(source.get("parameters") or [])
|
||||
self._queue_item_update(job_id, index, "failed", f"Requires parameters: {params}")
|
||||
self._queue_job_update(job_id, failed=failed)
|
||||
continue
|
||||
if source.get("enabled"):
|
||||
completed += 1
|
||||
self._queue_item_update(job_id, index, "done", "Already enabled; will refresh with active feeds")
|
||||
self._queue_job_update(job_id, completed=completed)
|
||||
continue
|
||||
|
||||
proc = self._run_suricata_update(["enable-source", name], timeout=90)
|
||||
if proc.returncode != 0:
|
||||
failed += 1
|
||||
self._queue_item_update(job_id, index, "failed", _command_tail(proc.stdout, "enable-source failed"))
|
||||
self._queue_job_update(job_id, failed=failed)
|
||||
continue
|
||||
changed += 1
|
||||
completed += 1
|
||||
self._queue_item_update(job_id, index, "done", "Enabled in persistent /data source state")
|
||||
self._queue_job_update(job_id, completed=completed)
|
||||
|
||||
self._queue_job_update(
|
||||
job_id,
|
||||
phase="download",
|
||||
message=f"Downloading and merging all active feeds ({completed} selected source(s) ready)",
|
||||
)
|
||||
update_result = self._run_vendor_update_unlocked()
|
||||
if not update_result.ok:
|
||||
self._queue_job_finish(job_id, "failed", update_result.message)
|
||||
return
|
||||
|
||||
final_status = "partial" if failed else "completed"
|
||||
summary = f"{completed} source(s) ready, {failed} failed; {update_result.message}"
|
||||
if changed == 0 and failed == 0:
|
||||
summary = f"Selected sources were already enabled; {update_result.message}"
|
||||
self._queue_job_finish(job_id, final_status, summary)
|
||||
except Exception as exc:
|
||||
self._queue_job_finish(job_id, "failed", f"rule-source queue failed: {exc}")
|
||||
finally:
|
||||
self._update_lock.release()
|
||||
|
||||
def _queue_job_update(self, job_id: str, **fields) -> None:
|
||||
with self._source_queue_lock:
|
||||
if self._source_queue.get("id") != job_id:
|
||||
return
|
||||
self._source_queue.update(fields)
|
||||
|
||||
def _queue_item_update(self, job_id: str, index: int, status: str, message: str) -> None:
|
||||
with self._source_queue_lock:
|
||||
if self._source_queue.get("id") != job_id:
|
||||
return
|
||||
items = self._source_queue.get("items") or []
|
||||
if 0 <= index < len(items):
|
||||
items[index]["status"] = status
|
||||
items[index]["message"] = str(message)[:1000]
|
||||
|
||||
def _queue_job_finish(self, job_id: str, status: str, message: str) -> None:
|
||||
self._queue_job_update(
|
||||
job_id,
|
||||
status=status,
|
||||
phase="done",
|
||||
finished_at=datetime.now(timezone.utc).isoformat(),
|
||||
message=str(message)[:1600],
|
||||
)
|
||||
with self._lock:
|
||||
self._last_result = str(message)[:1600]
|
||||
|
||||
def _run_vendor_update_unlocked(self) -> RuleActionResult:
|
||||
try:
|
||||
self._create_snapshot("pre-update")
|
||||
except Exception as exc:
|
||||
print(f"[rules] snapshot before update failed: {exc}", flush=True)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/opt/ids/scripts/update-rules.sh"],
|
||||
@@ -269,11 +573,14 @@ class RuleManager:
|
||||
self._last_result = result.message
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _run_suricata_update(args: list[str], timeout: int) -> subprocess.CompletedProcess:
|
||||
def _suricata_update_data_dir(self) -> str:
|
||||
return str(getattr(self.config, "suricata_persist_lib_dir", "/data/lib/suricata"))
|
||||
|
||||
def _run_suricata_update(self, args: list[str], timeout: int) -> subprocess.CompletedProcess:
|
||||
command = ["suricata-update", *args, "-D", self._suricata_update_data_dir()]
|
||||
try:
|
||||
return subprocess.run(
|
||||
["suricata-update", *args],
|
||||
command,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@@ -282,7 +589,7 @@ class RuleManager:
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return subprocess.CompletedProcess(
|
||||
["suricata-update", *args],
|
||||
command,
|
||||
127,
|
||||
stdout=f"suricata-update could not run: {exc}",
|
||||
)
|
||||
@@ -321,6 +628,11 @@ class RuleManager:
|
||||
name = os.path.basename(source)
|
||||
shutil.copyfile(source, os.path.join(rules_dir, name))
|
||||
copied.add(name)
|
||||
state_dir = os.path.dirname(self.config.suricata_custom_rules)
|
||||
for source in glob.glob(os.path.join(state_dir, "*.lst")):
|
||||
if not os.path.isfile(source):
|
||||
continue
|
||||
shutil.copyfile(source, os.path.join(rules_dir, os.path.basename(source)))
|
||||
local_name = os.path.basename(self.config.suricata_local_rules) or "local.rules"
|
||||
if local_name not in copied and os.path.isfile(self.config.suricata_local_rules):
|
||||
shutil.copyfile(self.config.suricata_local_rules, os.path.join(rules_dir, local_name))
|
||||
@@ -331,6 +643,8 @@ class RuleManager:
|
||||
"-T",
|
||||
"-c",
|
||||
self.config.suricata_config,
|
||||
"--include",
|
||||
self.config.suricata_output_config,
|
||||
"-l",
|
||||
log_dir,
|
||||
"-s",
|
||||
@@ -339,6 +653,12 @@ class RuleManager:
|
||||
f"vars.address-groups.HOME_NET={self.config.suricata_home_net}",
|
||||
"--set",
|
||||
f"threshold-file={threshold_path}",
|
||||
"--set",
|
||||
"app-layer.protocols.tls.ja3-fingerprints=yes",
|
||||
"--set",
|
||||
"app-layer.protocols.tls.ja4-fingerprints=yes",
|
||||
"--set",
|
||||
"app-layer.protocols.ssh.hassh=yes",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
@@ -380,6 +700,10 @@ class RuleManager:
|
||||
self._last_result = validation.message
|
||||
return validation
|
||||
|
||||
try:
|
||||
self._create_snapshot(f"pre-{label.replace(' ', '-')}")
|
||||
except Exception as exc:
|
||||
print(f"[rules] snapshot before {label} change failed: {exc}", flush=True)
|
||||
self._atomic_write(path, content)
|
||||
reload_result = self.reload()
|
||||
if reload_result.ok:
|
||||
@@ -467,7 +791,7 @@ def _parse_source_catalog(output: str) -> list[dict]:
|
||||
def _parse_enabled_sources(output: str) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for raw in _strip_ansi(output).splitlines():
|
||||
match = re.match(r"^\s*-\s+([A-Za-z0-9_.+-]+/[A-Za-z0-9_.+-]+)\s*$", raw)
|
||||
match = re.match(r"^\s*-\s+([A-Za-z0-9_.+-]+(?:/[A-Za-z0-9_.+-]+)?)\s*$", raw)
|
||||
if match:
|
||||
result.add(match.group(1))
|
||||
return result
|
||||
|
||||
@@ -31,6 +31,7 @@ class RuntimeStats:
|
||||
"block_attempts": 0,
|
||||
"block_success": 0,
|
||||
"block_errors": 0,
|
||||
"log_auto_truncations": 0,
|
||||
"last_packet_at": None,
|
||||
"last_alert_at": None,
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,838 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const MAX_BUFFERED_EVENTS = 1000;
|
||||
const LIVE_RENDER_INTERVAL_MS = 350;
|
||||
const VIEW_PATHS = {
|
||||
overview:'/', live:'/live', security:'/security', intelligence:'/intelligence', blocks:'/blocks',
|
||||
reports:'/reports', feeds:'/feeds', rules:'/rules', system:'/system'
|
||||
};
|
||||
const PATH_VIEWS = Object.fromEntries(Object.entries(VIEW_PATHS).map(([view,path])=>[path,view]));
|
||||
const WINDOW_LABELS = {900:'Last 15 minutes',3600:'Last 1 hour',21600:'Last 6 hours',86400:'Last 24 hours'};
|
||||
const state = {
|
||||
view: 'overview', ws: null, reconnectTimer: null, reconnectDelay: 1000,
|
||||
liveEnabled: false, paused: false, live: [], liveById: new Map(), liveSequence: 0,
|
||||
liveRenderTimer: null, liveFilterTimer: null, historyLoaded: false, snapshot: [],
|
||||
batchTimes: [], uiDropped: 0, serverDropped: 0,
|
||||
incidents: [], analytics: null, analyticsWindow: 0, throughput: null, throughputWindow: 0, status: null, config: null, ruleSources: [], ruleSourcesLoaded: false,
|
||||
selectedRuleSources: new Set(), sourceQueue: null, sourceQueueTimer: null,
|
||||
ndrIncidents: [], assets: [], iocs: [], pcaps: [], ndrSummary: {},
|
||||
ruleIntelligence: [], ruleSnapshots: [], backups: [], audit: [],
|
||||
authEnabled: false, authenticated: false, username: '', csrfToken: '', appStarted: false,
|
||||
refreshTimer: null, chartRenderTimer: null, analyticsPollTimer: null, analyticsRequest: 0,
|
||||
};
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const headers = {'Accept':'application/json'};
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET','HEAD','OPTIONS'].includes(method) && state.csrfToken && url !== '/api/auth/login') headers['X-CSRF-Token'] = state.csrfToken;
|
||||
const res = await fetch(url, {...options, credentials:'same-origin', headers:{...headers, ...(options.headers || {})}});
|
||||
let data = {};
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (res.status === 401 && url !== '/api/auth/login') {
|
||||
state.authenticated = false; state.csrfToken = ''; updateSessionUI(); showAuthModal('Your session expired. Sign in again.');
|
||||
if (state.ws) { try { state.ws.close(); } catch (_) {} state.ws = null; }
|
||||
}
|
||||
if (!res.ok) throw new Error(data.error || data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function notice(message, kind='ok') {
|
||||
const box = $('notice'); box.textContent = message; box.className = `notice ${kind}`;
|
||||
clearTimeout(notice.timer); notice.timer = setTimeout(() => box.classList.add('hidden'), 4500);
|
||||
}
|
||||
|
||||
function updateSessionUI() {
|
||||
const label = state.authenticated ? state.username || 'Signed in' : 'Sign in';
|
||||
const account = $('accountButton'); if (account) { account.textContent = label; account.classList.toggle('signed-in', state.authenticated); }
|
||||
const status = $('sessionStatus'); if (status) { status.textContent = state.authenticated ? `Signed in as ${state.username}` : (state.authEnabled ? 'Authentication required' : 'Login not configured'); status.classList.toggle('ok', state.authenticated); }
|
||||
for (const id of ['systemLoginButton','feedLoginButton']) {
|
||||
const button = $(id); if (button) button.textContent = state.authenticated ? 'Sign out' : 'Sign in';
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthModal(message='') {
|
||||
if (!state.authEnabled) return;
|
||||
const modal = $('authModal'); if (!modal) return;
|
||||
$('loginError').textContent = message; $('loginError').classList.toggle('hidden', !message);
|
||||
if (!$('loginUsername').value) $('loginUsername').value = state.username || 'admin';
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => (state.username ? $('loginPassword') : $('loginUsername')).focus(), 0);
|
||||
}
|
||||
|
||||
function hideAuthModal() { $('authModal')?.classList.add('hidden'); $('loginError')?.classList.add('hidden'); }
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const session = await api('/api/auth/session');
|
||||
state.authEnabled = Boolean(session.auth_enabled); state.authenticated = Boolean(session.authenticated);
|
||||
state.username = session.username || ''; state.csrfToken = session.csrf_token || '';
|
||||
updateSessionUI();
|
||||
if (state.authEnabled && !state.authenticated) showAuthModal();
|
||||
return session;
|
||||
} catch (e) {
|
||||
state.authEnabled = true; state.authenticated = false; updateSessionUI(); showAuthModal(e.message); return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event?.preventDefault();
|
||||
const username = $('loginUsername').value.trim(), password = $('loginPassword').value;
|
||||
const submit = $('loginSubmit'); submit.disabled = true; $('loginError').classList.add('hidden');
|
||||
try {
|
||||
const session = await api('/api/auth/login', {method:'POST', body:JSON.stringify({username,password})});
|
||||
state.authEnabled = true; state.authenticated = true; state.username = session.username || username; state.csrfToken = session.csrf_token || '';
|
||||
$('loginPassword').value = ''; updateSessionUI(); hideAuthModal();
|
||||
if (!state.appStarted) await startApplication(); else { await initialLoad(); restartWebSocket(0); }
|
||||
} catch (e) {
|
||||
$('loginError').textContent = e.message; $('loginError').classList.remove('hidden');
|
||||
} finally { submit.disabled = false; }
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (!state.authenticated) { showAuthModal(); return; }
|
||||
try { await api('/api/auth/logout', {method:'POST', body:'{}'}); } catch (_) {}
|
||||
state.authenticated = false; state.csrfToken = ''; updateSessionUI();
|
||||
if (state.ws) { state.ws.onclose = null; try { state.ws.close(); } catch (_) {} state.ws = null; }
|
||||
showAuthModal('Signed out.');
|
||||
}
|
||||
|
||||
function accountAction() { if (state.authenticated) logout(); else showAuthModal(); }
|
||||
|
||||
function openMobileNav() { document.body.classList.add('mobile-nav-open'); $('mobileMenu')?.setAttribute('aria-expanded','true'); }
|
||||
function closeMobileNav() { document.body.classList.remove('mobile-nav-open'); $('mobileMenu')?.setAttribute('aria-expanded','false'); }
|
||||
|
||||
function viewFromLocation() {
|
||||
const path=(location.pathname||'/').replace(/\/+$/,'')||'/';
|
||||
return PATH_VIEWS[path] || 'overview';
|
||||
}
|
||||
|
||||
function selectedWindow() { return Number($('windowSelect')?.value || 3600); }
|
||||
|
||||
function syncUrl(view=state.view, mode='replace') {
|
||||
const path=VIEW_PATHS[view]||'/';
|
||||
const url=new URL(location.href); url.pathname=path;
|
||||
const windowSec=selectedWindow();
|
||||
if(windowSec!==3600)url.searchParams.set('window',String(windowSec)); else url.searchParams.delete('window');
|
||||
const target=`${url.pathname}${url.search}${url.hash}`;
|
||||
if(mode==='push')history.pushState({view,window:windowSec},'',target); else history.replaceState({view,window:windowSec},'',target);
|
||||
}
|
||||
|
||||
function setView(name, historyMode='push') {
|
||||
if(!VIEW_PATHS[name])name='overview';
|
||||
const leavingLive = state.view === 'live' && name !== 'live' && state.liveEnabled;
|
||||
state.view = name;
|
||||
if (leavingLive) {
|
||||
state.liveEnabled = false;
|
||||
state.paused = false;
|
||||
state.batchTimes = [];
|
||||
updateLiveModeControls();
|
||||
restartWebSocket(0);
|
||||
}
|
||||
document.querySelectorAll('.view').forEach(el => el.classList.toggle('active', el.id === `view-${name}`));
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.view === name));
|
||||
const labels = {overview:'Overview',live:'Live Sessions',security:'Security',intelligence:'Intelligence',blocks:'Blocks',reports:'Reports',feeds:'Signature Feeds',rules:'Rules',system:'System'};
|
||||
$('pageTitle').textContent = labels[name] || name;
|
||||
if(historyMode!=='none')syncUrl(name,historyMode);
|
||||
closeMobileNav();
|
||||
if (name === 'blocks') loadBlocks();
|
||||
if (name === 'intelligence') loadIntelligence(true);
|
||||
if (name === 'feeds' && !state.ruleSourcesLoaded) loadRuleSources();
|
||||
if (name === 'rules') loadRuleOperations(true);
|
||||
if (name === 'system') loadSystemState(true);
|
||||
if (['overview','reports','security'].includes(name) && state.analytics) scheduleChartRender();
|
||||
if (name === 'reports') updateReportWindowState(state.analytics);
|
||||
if (name === 'live') {
|
||||
if (!state.historyLoaded) loadHistory(true);
|
||||
else scheduleLiveRender(0);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return '—'; const d = new Date(value); if (Number.isNaN(d.getTime())) return String(value);
|
||||
return d.toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false});
|
||||
}
|
||||
function fmtShortTime(ms) { const d = new Date(Number(ms || 0)); return Number.isNaN(d.getTime()) ? '—' : d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); }
|
||||
function fmtBytes(value) { let n=Number(value||0); const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++;} return `${n<10&&i? n.toFixed(1):Math.round(n)} ${u[i]}`; }
|
||||
function fmtBits(value) { let n=Math.max(0,Number(value||0)); const u=['bps','Kbps','Mbps','Gbps','Tbps']; let i=0; while(n>=1000&&i<u.length-1){n/=1000;i++;} return `${n<10&&i? n.toFixed(1):Math.round(n)} ${u[i]}`; }
|
||||
function fmtDuration(sec) { sec=Math.max(0,Number(sec||0)); const d=Math.floor(sec/86400),h=Math.floor(sec%86400/3600),m=Math.floor(sec%3600/60); return d?`${d}d ${h}h`:h?`${h}h ${m}m`:`${m}m`; }
|
||||
function endpoint(ip, port) { return `<span class="mono">${esc(ip || '—')}${port ? ':'+esc(port) : ''}</span>`; }
|
||||
|
||||
function saveBlob(blob, filename) {
|
||||
const url=URL.createObjectURL(blob), link=document.createElement('a');
|
||||
link.href=url; link.download=filename||'download'; document.body.appendChild(link); link.click(); link.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
|
||||
async function downloadUrl(url) {
|
||||
try {
|
||||
const res=await fetch(url,{credentials:'same-origin'});
|
||||
if(!res.ok){let message=`HTTP ${res.status}`;try{const data=await res.json();message=data.error||data.message||message;}catch(_){}throw new Error(message);}
|
||||
const blob=await res.blob(), disposition=res.headers.get('Content-Disposition')||'';
|
||||
const match=disposition.match(/filename="?([^";]+)"?/i), fallback=new URL(url,location.href).searchParams.get('name')||'download';
|
||||
saveBlob(blob,match?.[1]||fallback);
|
||||
} catch(e) { notice(`Download failed: ${e.message}`,'bad'); }
|
||||
}
|
||||
|
||||
const csvCell=value=>`"${String(value??'').replace(/"/g,'""')}"`;
|
||||
|
||||
function reportCsv(a) {
|
||||
const rows=[['section','name','count','timestamp','events','bytes','alerts']];
|
||||
for(const [name,value] of [['window_seconds',a.window_seconds],['events',a.events],['bytes',a.bytes],['alerts',a.alerts],['blocked',a.blocked],['local_clients',a.unique_local_clients],['remote_peers',a.unique_remote_peers]])rows.push(['summary',name,value,'','','','']);
|
||||
for(const row of a.timeline||[])rows.push(['timeline','', '',new Date(Number(row.ts_ms||0)).toISOString(),row.events||0,row.bytes||0,row.alerts||0]);
|
||||
for(const [section,items] of [['applications',a.top_apps],['protocols',a.protocols],['directions',a.directions],['event_types',a.event_types],['local_clients',a.top_local_clients],['remote_peers',a.top_remote_peers],['signatures',a.top_signatures]])for(const row of items||[])rows.push([section,row.name,row.count,'','','','']);
|
||||
return rows.map(row=>row.map(csvCell).join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
async function downloadCurrentReport() {
|
||||
let data=state.analytics;
|
||||
if(!data || state.analyticsWindow!==selectedWindow()){
|
||||
try{data=await api(`/api/traffic/analytics?window=${selectedWindow()}`);}catch(e){notice(`Report download: ${e.message}`,'bad');return;}
|
||||
}
|
||||
if(data?.snapshot_loading){notice('Report is still being generated in the background. Try again in a moment.','bad');scheduleAnalyticsPoll(selectedWindow());return;}
|
||||
const stamp=new Date().toISOString().slice(0,16).replace(/[:T]/g,'-');
|
||||
saveBlob(new Blob([reportCsv(data)],{type:'text/csv;charset=utf-8'}),`mikrosuricata-report-${selectedWindow()}s-${stamp}.csv`);
|
||||
}
|
||||
|
||||
function eventDetails(ev) {
|
||||
if (ev.type === 'alert') return ev.signature || ev.category || 'Suricata alert';
|
||||
if (ev.type === 'dns') return ev.dns_query ? `${ev.dns_query}${ev.dns_type ? ' · '+ev.dns_type : ''}` : 'DNS';
|
||||
if (ev.type === 'http') return `${ev.http_method || ''} ${ev.http_host || ''}${ev.http_url || ''}`.trim() || 'HTTP';
|
||||
if (ev.type === 'tls') return ev.tls_sni || ev.tls_subject || ev.tls_version || 'TLS';
|
||||
if (ev.type === 'ssh') return [ev.ssh_client,ev.ssh_server,ev.ssh_proto,ev.ssh_hassh_client && 'HASSH '+ev.ssh_hassh_client].filter(Boolean).join(' · ') || 'SSH session';
|
||||
if (ev.type === 'rdp') return [ev.rdp_event_type,ev.rdp_client_name,ev.rdp_client_build,ev.rdp_protocol,ev.rdp_cookie].filter(Boolean).join(' · ') || 'RDP session';
|
||||
if (ev.type === 'smb') return [ev.smb_command,ev.smb_share,ev.smb_filename,ev.smb_user,ev.smb_status].filter(Boolean).join(' · ') || 'SMB activity';
|
||||
if (ev.type === 'quic') return [ev.quic_sni,ev.quic_version,ev.quic_ja4 && 'JA4 '+ev.quic_ja4].filter(Boolean).join(' · ') || 'QUIC session';
|
||||
if (ev.type === 'dhcp') return [ev.dhcp_event_type,ev.dhcp_type,ev.dhcp_hostname,ev.dhcp_assigned_ip,ev.dhcp_client_mac].filter(Boolean).join(' · ') || 'DHCP';
|
||||
if (ev.type === 'arp') return [ev.arp_opcode,ev.arp_src_ip,ev.arp_src_mac,ev.arp_dest_ip].filter(Boolean).join(' · ') || 'ARP';
|
||||
if (ev.type === 'fileinfo') return [ev.filename,ev.file_sha256||ev.file_sha1||ev.file_md5].filter(Boolean).join(' · ') || ev.file_state || 'File';
|
||||
if (ev.type === 'anomaly') return ev.anomaly_event || 'Protocol anomaly';
|
||||
if (ev.app_summary) return ev.app_summary;
|
||||
return ev.flow_state ? `Flow ${ev.flow_state}${ev.flow_reason ? ' · '+ev.flow_reason : ''}` : 'Flow event';
|
||||
}
|
||||
|
||||
function eventRow(ev, compact=false) {
|
||||
const detail = esc(eventDetails(ev));
|
||||
if (compact) return `<tr><td>${fmtShortTime(ev.ts_ms)}</td><td><span class="event-type ${esc(ev.type)}">${esc(ev.type)}</span></td><td>${endpoint(ev.src_ip,ev.src_port)}</td><td>${endpoint(ev.dest_ip,ev.dest_port)}</td><td>${esc(ev.app_proto || '—')}</td><td class="details-cell" title="${detail}">${detail}</td><td class="right">${fmtBytes(ev.bytes)}</td></tr>`;
|
||||
const blockIp = candidateBlockIp(ev);
|
||||
const blockBtn = blockIp ? `<button class="link-btn" data-block-ip="${esc(blockIp)}">block</button>` : '';
|
||||
return `<tr><td>${fmtShortTime(ev.ts_ms)}</td><td><span class="event-type ${esc(ev.type)}">${esc(ev.type)}</span></td><td>${esc(ev.direction || '—')}</td><td>${endpoint(ev.src_ip,ev.src_port)}</td><td>${endpoint(ev.dest_ip,ev.dest_port)}</td><td>${esc(ev.proto || '—')}</td><td>${esc(ev.app_proto || '—')}</td><td class="details-cell" title="${detail}">${detail}</td><td class="right">${fmtBytes(ev.bytes)}</td><td>${blockBtn}</td></tr>`;
|
||||
}
|
||||
|
||||
function candidateBlockIp(ev) {
|
||||
if (ev.direction === 'outbound') return ev.dest_ip || '';
|
||||
if (ev.direction === 'inbound') return ev.src_ip || '';
|
||||
if (ev.direction === 'external') return ev.src_ip || ev.dest_ip || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function currentLiveFilters() {
|
||||
return {
|
||||
q: ($('liveSearch')?.value || '').trim().toLowerCase(),
|
||||
type: $('liveType')?.value || '', proto: $('liveProto')?.value || '', direction: $('liveDirection')?.value || ''
|
||||
};
|
||||
}
|
||||
|
||||
function eventMatchesLive(ev, filters=currentLiveFilters()) {
|
||||
if (filters.type && ev.type !== filters.type) return false;
|
||||
if (filters.proto && ev.proto !== filters.proto) return false;
|
||||
if (filters.direction && ev.direction !== filters.direction) return false;
|
||||
if (!filters.q) return true;
|
||||
return [ev.id,ev.flow_id,ev.community_id,ev.tx_id,ev.src_ip,ev.src_port,ev.dest_ip,ev.dest_port,ev.ether_src,ev.ether_dest,ev.app_proto,ev.signature,ev.signature_id,ev.category,ev.dns_query,ev.http_host,ev.http_url,ev.tls_sni,ev.tls_ja3,ev.tls_ja4,ev.ssh_client,ev.ssh_server,ev.ssh_hassh_client,ev.ssh_hassh_server,ev.rdp_client_name,ev.rdp_client_build,ev.smb_share,ev.smb_filename,ev.smb_user,ev.quic_sni,ev.quic_ja3,ev.quic_ja4,ev.dhcp_hostname,ev.dhcp_client_mac,ev.arp_src_mac,ev.filename,ev.app_summary]
|
||||
.some(v => String(v||'').toLowerCase().includes(filters.q));
|
||||
}
|
||||
|
||||
function filteredLive() {
|
||||
const filters = currentLiveFilters();
|
||||
return state.live.filter(ev => eventMatchesLive(ev, filters)).sort((a,b) => Number(b._uiSeq || b.ts_ms || 0) - Number(a._uiSeq || a.ts_ms || 0));
|
||||
}
|
||||
|
||||
function setLiveEvents(events) {
|
||||
state.live = [];
|
||||
state.liveById = new Map();
|
||||
const rows = Array.isArray(events) ? events.slice(0, MAX_BUFFERED_EVENTS) : [];
|
||||
for (const raw of rows.reverse()) mergeLiveEvent(raw);
|
||||
}
|
||||
|
||||
function mergeLiveEvent(raw) {
|
||||
if (!raw || !raw.id) return;
|
||||
const existing = state.liveById.get(raw.id);
|
||||
const seq = ++state.liveSequence;
|
||||
if (existing) {
|
||||
Object.assign(existing, raw, {_uiSeq:seq});
|
||||
return;
|
||||
}
|
||||
const row = {...raw, _uiSeq:seq};
|
||||
state.live.push(row);
|
||||
state.liveById.set(row.id, row);
|
||||
}
|
||||
|
||||
function trimLiveBuffer() {
|
||||
if (state.live.length <= MAX_BUFFERED_EVENTS) return;
|
||||
state.live.sort((a,b) => Number(b._uiSeq||0) - Number(a._uiSeq||0));
|
||||
const removed = state.live.splice(MAX_BUFFERED_EVENTS);
|
||||
for (const item of removed) state.liveById.delete(item.id);
|
||||
state.uiDropped += removed.length;
|
||||
}
|
||||
|
||||
function handleLiveBatch(events) {
|
||||
if (!Array.isArray(events) || !events.length) return;
|
||||
for (const ev of events) mergeLiveEvent(ev);
|
||||
trimLiveBuffer();
|
||||
const now = performance.now();
|
||||
state.batchTimes.push(now);
|
||||
while (state.batchTimes.length && state.batchTimes[0] < now - 5000) state.batchTimes.shift();
|
||||
if (state.view === 'live' && !state.paused) scheduleLiveRender();
|
||||
}
|
||||
|
||||
function scheduleLiveRender(delay=LIVE_RENDER_INTERVAL_MS) {
|
||||
if (state.liveRenderTimer !== null) return;
|
||||
state.liveRenderTimer = setTimeout(() => {
|
||||
state.liveRenderTimer = null;
|
||||
if (state.view === 'live') renderLive();
|
||||
}, Math.max(0, delay));
|
||||
}
|
||||
|
||||
function renderLive() {
|
||||
const limit = Math.min(500, Math.max(50, Number($('liveLimit')?.value || 200)));
|
||||
const matches = filteredLive();
|
||||
const rows = matches.slice(0, limit);
|
||||
$('liveRows').innerHTML = rows.length ? rows.map(ev => eventRow(ev)).join('') : '<tr><td colspan="10" class="empty">No matching sessions/events. Use Search history or Start live.</td></tr>';
|
||||
const rate = state.batchTimes.length ? state.batchTimes.length / 5 : 0;
|
||||
$('liveVisibleCount').textContent = `${rows.length.toLocaleString()} visible`;
|
||||
$('liveBufferedCount').textContent = `${state.live.length.toLocaleString()} buffered`;
|
||||
$('liveRate').textContent = `${rate.toFixed(rate < 10 ? 1 : 0)} batches/s`;
|
||||
$('liveDropped').textContent = `${(state.uiDropped + state.serverDropped).toLocaleString()} dropped/coalesced`;
|
||||
}
|
||||
|
||||
function renderOverviewSnapshot() {
|
||||
const recent = state.snapshot.slice(0, 12);
|
||||
$('overviewLiveRows').innerHTML = recent.length ? recent.map(ev => eventRow(ev,true)).join('') : '<tr><td colspan="7" class="empty">No recent history yet.</td></tr>';
|
||||
}
|
||||
|
||||
function renderRank(targetId, rows, label='name') {
|
||||
const el = $(targetId); if (!el) return; const items = rows || []; const max = Math.max(1, ...items.map(x => Number(x.count||0)));
|
||||
el.innerHTML = items.length ? items.map(row => `<div class="rank-row"><div class="rank-main"><div class="rank-label"><span title="${esc(row[label] || row.name || 'unknown')}">${esc(row[label] || row.name || 'unknown')}</span><span>${Number(row.count||0).toLocaleString()}</span></div><progress class="rank-bar" max="${Math.max(1,max)}" value="${Math.max(0,Number(row.count||0))}" aria-label="${esc(row[label] || row.name || 'unknown')}"></progress></div></div>`).join('') : '<div class="empty">No data in this window.</div>';
|
||||
}
|
||||
|
||||
function renderRankBytes(targetId, rows, label='name') {
|
||||
const el=$(targetId); if(!el)return; const items=rows||[]; const max=Math.max(1,...items.map(x=>Number(x.bytes||0)));
|
||||
el.innerHTML=items.length?items.map(row=>`<div class="rank-row"><div class="rank-main"><div class="rank-label"><span title="${esc(row[label]||row.name||'unknown')}">${esc(row[label]||row.name||'unknown')}</span><span>${fmtBytes(row.bytes||0)}</span></div><progress class="rank-bar" max="${Math.max(1,max)}" value="${Math.max(0,Number(row.bytes||0))}" aria-label="${esc(row[label]||row.name||'unknown')}"></progress></div></div>`).join(''):'<div class="empty">No data in this window.</div>';
|
||||
}
|
||||
|
||||
function windowLabel(windowSec=selectedWindow()) { return WINDOW_LABELS[Number(windowSec)] || `${Math.round(Number(windowSec||0)/60)} minutes`; }
|
||||
|
||||
function updateReportWindowState(a=null) {
|
||||
const badge=$('reportWindowBadge'), status=$('reportState');
|
||||
if(badge)badge.textContent=windowLabel(selectedWindow());
|
||||
if(!status)return;
|
||||
if(a?.snapshot_error){status.textContent='Redis unavailable';status.className='status-chip bad';return;}
|
||||
if(a?.snapshot_loading){status.textContent='building in background';status.className='status-chip warn';return;}
|
||||
if(a?.snapshot_refreshing){status.textContent='cached · refreshing';status.className='status-chip warn';return;}
|
||||
if(a){status.textContent=a.analytics_complete===false?'fallback history':'ready · full retained range';status.className=`status-chip ${a.analytics_complete===false?'warn':'ok'}`;return;}
|
||||
status.textContent='loading'; status.className='status-chip';
|
||||
}
|
||||
|
||||
function markAnalyticsLoading(windowSec=selectedWindow()) {
|
||||
if(state.analyticsWindow && state.analyticsWindow!==Number(windowSec))state.analytics=null;
|
||||
state.analyticsWindow=Number(windowSec);
|
||||
const meta=$('snapshotMeta'); if(meta){meta.textContent='building in background';meta.className='status-chip warn';}
|
||||
updateReportWindowState({snapshot_loading:true});
|
||||
const keepThroughput=state.throughput && state.throughputWindow===Number(windowSec);
|
||||
for(const id of ['metricEvents','metricAlerts','metricBlocked','metricAnomalies','metricNxdomain','metricEncrypted','metricCleartext','metricLocalClients','metricRemotePeers'])if($(id))$(id).textContent='…';
|
||||
if(!keepThroughput){for(const id of ['metricThroughput','metricBytes','metricPeakThroughput'])if($(id))$(id).textContent='…';if($('metricThroughputSplit'))$('metricThroughputSplit').textContent='IN … · OUT …';}
|
||||
for(const id of ['reportEvents','reportBytes','reportAlerts','reportClients'])if($(id))$(id).textContent='…';
|
||||
for(const id of ['topApps','topClients','topSources','reportSources','reportDestinations','eventTypes','securitySignatures','fingerprintRank','assetRank','fileRank'])if($(id))$(id).innerHTML='<div class="empty">Building the selected time range in the background…</div>';
|
||||
const charts=window.MikroSuricataCharts; if(charts?.drawLoading){if(!keepThroughput)charts.drawLoading($('throughputChart'));for(const id of ['trafficChart','eventsChart','directionDonut','eventTypeDonut','protocolDonut','reportDirectionDonut','appDonut','reportEventDonut','severityDonut'])charts.drawLoading($(id));}
|
||||
}
|
||||
|
||||
function scheduleAnalyticsPoll(windowSec, delay=1200) {
|
||||
clearTimeout(state.analyticsPollTimer);
|
||||
state.analyticsPollTimer=setTimeout(()=>{if(Number(windowSec)===selectedWindow())loadAnalytics(windowSec,true);},delay);
|
||||
}
|
||||
|
||||
function renderThroughput(t) {
|
||||
const windowSec=Number(t?.window_seconds||selectedWindow());
|
||||
if(windowSec!==selectedWindow())return;
|
||||
state.throughput=t; state.throughputWindow=windowSec;
|
||||
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(t.current_bps||0);
|
||||
if($('metricThroughputSplit')){
|
||||
const total=Math.max(0,Number(t.current_bps||0)), inbound=Math.max(0,Number(t.current_in_bps||0)), outbound=Math.max(0,Number(t.current_out_bps||0));
|
||||
const other=Math.max(0,Number(t.current_other_bps ?? (total-inbound-outbound)));
|
||||
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
|
||||
}
|
||||
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(t.peak_bps||0);
|
||||
if($('metricBytes'))$('metricBytes').textContent=fmtBytes(t.bytes||0);
|
||||
const charts=window.MikroSuricataCharts; if(charts?.drawThroughput)charts.drawThroughput($('throughputChart'),t.timeline||[]);
|
||||
}
|
||||
|
||||
async function loadThroughput(windowSec=selectedWindow(), silent=true) {
|
||||
const requested=Number(windowSec||3600);
|
||||
try {
|
||||
const data=await api(`/api/traffic/throughput?window=${requested}`);
|
||||
if(requested!==selectedWindow())return;
|
||||
renderThroughput(data);
|
||||
} catch(e) { if(!silent)notice(`Traffic throughput: ${e.message}`,'bad'); }
|
||||
}
|
||||
|
||||
async function loadAnalytics(windowSec=selectedWindow(), silent=false, forceLoading=false) {
|
||||
const requested=Number(windowSec||3600), requestId=++state.analyticsRequest;
|
||||
if(forceLoading || state.analyticsWindow!==requested)markAnalyticsLoading(requested);
|
||||
try {
|
||||
const data=await api(`/api/traffic/analytics?window=${requested}`);
|
||||
if(requestId!==state.analyticsRequest || requested!==selectedWindow())return;
|
||||
if(data.snapshot_loading){markAnalyticsLoading(requested);scheduleAnalyticsPoll(requested);return;}
|
||||
renderAnalytics(data);
|
||||
if(data.snapshot_refreshing)scheduleAnalyticsPoll(requested,1800);
|
||||
} catch(e) { if(!silent)notice(`Traffic analytics: ${e.message}`,'bad'); }
|
||||
}
|
||||
|
||||
function renderAnalytics(a) {
|
||||
const windowSec=Number(a?.window_seconds||selectedWindow());
|
||||
if(windowSec!==selectedWindow())return;
|
||||
if(a?.snapshot_loading){markAnalyticsLoading(windowSec);scheduleAnalyticsPoll(windowSec);return;}
|
||||
clearTimeout(state.analyticsPollTimer);
|
||||
state.analytics = a;
|
||||
state.analyticsWindow = windowSec;
|
||||
if(!state.throughput || state.throughputWindow!==windowSec){state.throughput=a;state.throughputWindow=windowSec;}
|
||||
$('metricEvents').textContent = Number(a.events||0).toLocaleString();
|
||||
const traffic=(state.throughput && state.throughputWindow===windowSec)?state.throughput:a;
|
||||
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(traffic.current_bps||0);
|
||||
if($('metricThroughputSplit')){
|
||||
const total=Math.max(0,Number(traffic.current_bps||0)), inbound=Math.max(0,Number(traffic.current_in_bps||0)), outbound=Math.max(0,Number(traffic.current_out_bps||0));
|
||||
const other=Math.max(0,Number(traffic.current_other_bps ?? (total-inbound-outbound)));
|
||||
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
|
||||
}
|
||||
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(traffic.peak_bps||0);
|
||||
$('metricBytes').textContent = fmtBytes(traffic.bytes||0); $('metricAlerts').textContent = Number(a.alerts||0).toLocaleString(); $('metricBlocked').textContent = Number(a.blocked||0).toLocaleString();
|
||||
$('metricEventRate').textContent = `${Math.round(Number(a.events||0)/(Number(a.window_seconds||3600)/60)).toLocaleString()} / min`;
|
||||
$('metricAnomalies').textContent = Number(a.anomalies||0).toLocaleString(); $('metricNxdomain').textContent = Number(a.dns_nxdomain||0).toLocaleString();
|
||||
$('metricEncrypted').textContent = Number(a.encrypted_sessions||0).toLocaleString(); $('metricCleartext').textContent = Number(a.cleartext_sessions||0).toLocaleString();
|
||||
$('metricLocalClients').textContent = Number(a.unique_local_clients||0).toLocaleString(); $('metricRemotePeers').textContent = Number(a.unique_remote_peers||0).toLocaleString();
|
||||
renderRank('topApps',a.top_apps);
|
||||
renderRankBytes('topClients',a.top_local_clients_by_bytes||[]);
|
||||
renderRankBytes('topSources',a.top_remote_peers_by_bytes||[]);
|
||||
renderRank('reportSources',a.top_local_clients || a.top_sources);
|
||||
renderRank('reportDestinations',a.top_remote_peers || a.top_destinations);
|
||||
renderRank('eventTypes',a.top_sources);
|
||||
renderRank('securitySignatures',a.top_signatures);
|
||||
renderRank('fingerprintRank',a.top_fingerprints);
|
||||
renderRank('assetRank',a.top_assets);
|
||||
renderRank('fileRank',a.top_files);
|
||||
$('reportEvents').textContent=Number(a.events||0).toLocaleString(); $('reportBytes').textContent=fmtBytes(traffic.bytes||0); $('reportAlerts').textContent=Number(a.alerts||0).toLocaleString(); $('reportClients').textContent=Number(a.unique_local_clients||0).toLocaleString();
|
||||
const coverage=[['Alerts',Number(a.alerts||0)],['Anomalies',Number(a.anomalies||0)],['DNS',Number((a.event_types||[]).find(x=>x.name==='dns')?.count||0)],['TLS / QUIC / SSH',Number(a.encrypted_sessions||0)],['Files',Number(a.files||0)]];
|
||||
$('coverageStatus').innerHTML=coverage.map(([k,v])=>`<div class="kv-row"><span>${esc(k)}</span><span>${Number(v).toLocaleString()}</span></div>`).join('');
|
||||
const age=Number(a.snapshot_age_seconds||0), source=a.snapshot_source||'live', stale=Boolean(a.snapshot_stale), refreshing=Boolean(a.snapshot_refreshing); const ageText=age<60?Math.round(age)+'s':age<3600?Math.round(age/60)+'m':Math.round(age/3600)+'h';
|
||||
const completeness=a.analytics_complete===false?'fallback':`all ${Number(a.retained_events_scanned??a.events??0).toLocaleString()} retained`;
|
||||
$('snapshotMeta').textContent=source==='redis-cache'?`${refreshing?'refreshing':'Redis cached'} · ${ageText} · ${completeness}`:`Redis · ${completeness}`;
|
||||
$('snapshotMeta').className=`status-chip ${a.analytics_complete===false||stale?'warn':'ok'}`;
|
||||
updateReportWindowState(a);
|
||||
scheduleChartRender();
|
||||
}
|
||||
|
||||
function scheduleChartRender() {
|
||||
if (!state.analytics) return;
|
||||
clearTimeout(state.chartRenderTimer);
|
||||
state.chartRenderTimer=setTimeout(()=>requestAnimationFrame(()=>requestAnimationFrame(drawVisibleCharts)),20);
|
||||
}
|
||||
|
||||
function drawVisibleCharts() {
|
||||
const a=state.analytics, charts=window.MikroSuricataCharts; if (!a || !charts) return;
|
||||
const t=(state.throughput && state.throughputWindow===selectedWindow())?state.throughput:a;
|
||||
charts.drawThroughput?.($('throughputChart'),t.timeline||[]); charts.drawEvents($('trafficChart'),a.timeline||[]); charts.drawEvents($('eventsChart'),a.timeline||[]);
|
||||
charts.drawDonut($('directionDonut'),a.directions||[]); charts.drawDonut($('eventTypeDonut'),a.event_types||[]);
|
||||
charts.drawDonut($('protocolDonut'),a.protocols||[]); charts.drawDonut($('reportDirectionDonut'),a.directions||[]);
|
||||
charts.drawDonut($('appDonut'),a.top_apps||[]); charts.drawDonut($('reportEventDonut'),a.event_types||[]); charts.drawDonut($('severityDonut'),a.severities||[]);
|
||||
}
|
||||
|
||||
function renderStatus(s) {
|
||||
state.status = s; const ok = s.status === 'ok'; $('sideHealth').textContent = ok ? 'Operational' : 'Degraded'; $('sideHealthDot').className=`status-dot ${ok?'ok':'bad'}`; $('sideUptime').textContent=`Uptime ${fmtDuration(s.uptime_seconds)}`;
|
||||
const rt=s.runtime||{}; $('filteredCount').textContent=Number(rt.alerts_filtered||0).toLocaleString();
|
||||
if (s.services) $('serviceRows').innerHTML = Object.values(s.services).map(x=>`<tr><td>${esc(x.name)}</td><td><span class="status-chip ${x.status==='up'||x.status==='configured'?'ok':x.status==='disabled'?'':'bad'}">${esc(x.status)}</span></td><td class="break">${esc(x.details)}</td></tr>`).join('');
|
||||
if (s.ports) $('portRows').innerHTML=s.ports.map(x=>`<tr><td>${esc(x.name)}</td><td>${esc(x.direction)}</td><td>${esc(x.protocol)}</td><td class="mono">${esc(x.address)}</td><td>${esc(x.port)}</td><td><span class="status-chip ${x.status==='up'||x.status==='configured'?'ok':''}">${esc(x.status)}</span></td></tr>`).join('');
|
||||
renderHistoryStatus(s.traffic_history || {}, s.analytics_snapshots || {});
|
||||
}
|
||||
|
||||
function renderHistoryStatus(h, snapshots={}) {
|
||||
state.serverDropped = Number(h.subscriber_dropped_events || 0);
|
||||
const rows=[['Backend',h.backend||'redis'],['Redis',h.redis_configured?(h.redis_ok?'connected':'degraded'):'disabled'],['Redis events',h.redis_events ?? '—'],['Throughput samples',h.throughput_samples ?? '—'],['RAM history','disabled'],['Retention',`${h.retention_hours||0} h`],['Event count cap','none'],['Chart snapshots',`${(snapshots.persisted||[]).length}/4 in Redis`],['Snapshot refresh',snapshots.interval_seconds?`${snapshots.interval_seconds}s`:'—'],['Writer queue',h.writer_queue??0],['Writer Redis errors',h.writer_redis_errors??0],['Writer dropped',h.writer_dropped??0],['WS dropped',h.subscriber_dropped_events??0]];
|
||||
$('historyStatus').innerHTML=rows.map(([k,v])=>`<div class="kv-row"><span>${esc(k)}</span><span>${esc(v)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderIncidents() {
|
||||
const q=($('incidentSearch')?.value||'').trim().toLowerCase(), sev=$('severityFilter')?.value||'';
|
||||
const rows=state.incidents.filter(x=>(!sev||String(x.severity)===sev)&&(!q||[x.signature,x.category,x.src_ip,x.dest_ip,x.block_target].some(v=>String(v||'').toLowerCase().includes(q))));
|
||||
$('incidentRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.last_seen||x.timestamp)}</td><td>${Number(x.hit_count||1).toLocaleString()}</td><td><span class="severity s${esc(x.severity)}">S${esc(x.severity||'—')}</span></td><td class="details-cell" title="${esc(x.signature)}">${esc(x.signature||'—')}</td><td>${endpoint(x.src_ip,x.src_port)}</td><td>${endpoint(x.dest_ip,x.dest_port)}</td><td>${x.blocked?'<span class="status-chip bad">blocked</span>':esc(x.action||'observe')}</td><td>${x.signature_id?`<button class="link-btn" data-suppress="${esc(x.signature_id)}">suppress</button>`:''}</td></tr>`).join(''):'<tr><td colspan="8" class="empty">No matching incidents.</td></tr>';
|
||||
}
|
||||
|
||||
function riskClass(value) {
|
||||
const risk=Number(value||0); return risk>=80?'risk-critical':risk>=55?'risk-high':risk>=30?'risk-medium':'risk-low';
|
||||
}
|
||||
|
||||
function renderAttack(mitre) {
|
||||
const items=Array.isArray(mitre)?mitre:[];
|
||||
if(!items.length)return '<span class="muted">—</span>';
|
||||
return `<div class="attack-list">${items.slice(0,4).map(x=>`<span class="attack-chip" title="${esc(`${x.tactic_id||''} ${x.tactic||''}`)}">${esc(x.technique_id||x.tactic_id||'ATT&CK')}<small>${esc(x.technique||x.tactic||'')}</small></span>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderIntelligence() {
|
||||
const summary=state.ndrSummary||{};
|
||||
$('ndrOpen').textContent=Number(summary.open_incidents||0).toLocaleString();
|
||||
$('ndrHighRisk').textContent=Number(summary.high_risk_incidents||0).toLocaleString();
|
||||
$('ndrAssets').textContent=Number(summary.assets||0).toLocaleString();
|
||||
$('ndrIocHits').textContent=Number(summary.ioc_hits||0).toLocaleString();
|
||||
$('ndrIncidentRows').innerHTML=state.ndrIncidents.length?state.ndrIncidents.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td>${fmtTime(x.last_seen)}</td><td class="mono">${esc(x.subject_ip||'—')}</td><td class="break stages-col">${esc((x.stages||[]).join(' → ')||'detection')}</td><td>${renderAttack(x.mitre)}</td><td class="details-cell" title="${esc(x.summary||x.title||'')}">${esc(x.summary||x.title||'—')}</td><td>${Number(x.event_count||0).toLocaleString()}${x.blocked?' · blocked':''}</td><td><span class="status-chip ${x.status==='open'?'bad':''}">${esc(x.status||'open')}</span></td><td><button class="link-btn" data-ndr-incident="${Number(x.id)}">evidence</button> · <button class="link-btn" data-ndr-status="${Number(x.id)}" data-status="${x.status==='closed'?'open':'closed'}">${x.status==='closed'?'reopen':'close'}</button></td></tr>`).join(''):'<tr><td colspan="9" class="empty">No correlated NDR incidents yet.</td></tr>';
|
||||
$('assetRows').innerHTML=state.assets.length?state.assets.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td class="mono">${esc(x.ip)}</td><td><strong>${esc(x.hostname||'—')}</strong><div class="muted mono">${esc(x.mac||x.identity_source||'—')}</div></td><td class="break">${esc((x.protocols||[]).slice(0,8).join(', ')||'—')}</td><td class="break">${esc((x.ports||[]).slice(0,12).join(', ')||'—')}</td><td>${Number(x.alert_count||0).toLocaleString()}</td><td>${fmtTime(x.last_seen)}</td></tr>`).join(''):'<tr><td colspan="7" class="empty">Assets appear after traffic or RouterOS inventory sync.</td></tr>';
|
||||
$('iocRows').innerHTML=state.iocs.length?state.iocs.map(x=>`<tr><td><span class="status-chip">${esc(x.indicator_type)}</span></td><td class="mono break">${esc(x.indicator)}</td><td>${Number(x.confidence||0)}%</td><td>S${esc(x.severity||'—')}</td><td>${esc(x.source||'—')}</td><td>${Number(x.hit_count||0).toLocaleString()}</td><td>${fmtTime(x.last_hit_at)}</td><td><button class="link-btn danger-link" data-delete-ioc="${Number(x.id)}">delete</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">No local IOCs configured.</td></tr>';
|
||||
$('pcapRows').innerHTML=state.pcaps.length?state.pcaps.map(x=>{const url=`/api/forensics/pcap?name=${encodeURIComponent(x.name)}`;return `<tr><td class="mono">${esc(x.name)}</td><td>${fmtBytes(x.size_bytes)}</td><td>${fmtTime(Number(x.modified_at||0)*1000)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No alert PCAP has rotated yet.</td></tr>';
|
||||
}
|
||||
|
||||
async function loadIntelligence(silent=false) {
|
||||
try {
|
||||
const [ndr,incidents,assets,iocs,pcaps]=await Promise.all([api('/api/ndr/summary'),api('/api/ndr/incidents?limit=150'),api('/api/assets?limit=300'),api('/api/threat-intel?limit=1000'),api('/api/forensics/pcaps')]);
|
||||
state.ndrSummary=ndr.summary||{}; state.ndrIncidents=incidents.incidents||[]; state.assets=assets.assets||[]; state.iocs=iocs.iocs||[]; state.pcaps=pcaps.files||[];
|
||||
renderIntelligence();
|
||||
if (!silent) notice('Intelligence data refreshed.');
|
||||
} catch(e) { if(!silent)notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function loadNdrIncident(id) {
|
||||
try {
|
||||
const data=await api(`/api/ndr/incidents/${Number(id)}`), incident=data.incident||{}, events=data.events||[];
|
||||
$('ndrEvidenceTitle').textContent=`#${incident.id||id} · ${incident.subject_ip||'asset'} · risk ${incident.risk_score||0}`;
|
||||
$('ndrEvidenceRows').innerHTML=events.length?events.map(x=>`<tr><td>${fmtTime(x.timestamp)}</td><td><span class="status-chip">${esc(x.stage||x.kind||'signal')}</span></td><td><span class="risk-score ${riskClass(x.risk)}">${Number(x.risk||0)}</span></td><td>${renderAttack(x.mitre)}</td><td class="break">${esc(x.summary||'—')}</td></tr>`).join(''):'<tr><td colspan="5" class="empty">No evidence rows.</td></tr>';
|
||||
} catch(e) { notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function addIoc() {
|
||||
const indicator=$('iocIndicator').value.trim(); if(!indicator)return notice('Enter an IOC indicator.','bad');
|
||||
try { const r=await adminPost('/api/admin/threat-intel/add',{type:$('iocType').value,indicator,confidence:Number($('iocConfidence').value||80),source:$('iocSource').value.trim()||'manual'}); $('iocIndicator').value=''; notice(r.message); await loadIntelligence(true); }
|
||||
catch(e){ notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function importIocs() {
|
||||
const text=$('iocBulk').value.trim(); if(!text)return notice('Paste IOC entries first.','bad');
|
||||
try { const r=await adminPost('/api/admin/threat-intel/import',{text}); notice(`${r.message}${r.errors?.length?` · ${r.errors.length} rejected`:''}`); if(r.added)$('iocBulk').value=''; await loadIntelligence(true); }
|
||||
catch(e){ notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function deleteIoc(id) {
|
||||
if(!confirm('Delete this IOC and rebuild Suricata datasets?'))return;
|
||||
try { const r=await adminPost('/api/admin/threat-intel/delete',{id:Number(id)}); notice(r.message); await loadIntelligence(true); }
|
||||
catch(e){ notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function setNdrStatus(id,status) {
|
||||
try { const r=await adminPost('/api/admin/ndr/incidents/status',{id:Number(id),status}); notice(r.message); await loadIntelligence(true); }
|
||||
catch(e){ notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
function recommendationChip(row) {
|
||||
const rec=String(row.recommendation||'keep');
|
||||
const cls=rec==='limit'?'bad':rec==='review'?'warn':'ok';
|
||||
return `<span class="status-chip ${cls}" title="${esc(row.recommendation_reason||'')}">${esc(rec)}</span>`;
|
||||
}
|
||||
|
||||
function renderRuleIntelligence() {
|
||||
const rows=state.ruleIntelligence||[];
|
||||
$('ruleIntelRows').innerHTML=rows.length?rows.map(x=>{
|
||||
const proposed=x.proposed_threshold||null;
|
||||
const action=proposed?`<button class="link-btn" data-rule-threshold="${Number(x.signature_id)}" data-count="${Number(proposed.count||5)}" data-seconds="${Number(proposed.seconds||60)}" data-track="${esc(proposed.track||'by_src')}">apply limit</button>`:'<span class="muted">—</span>';
|
||||
return `<tr><td><span class="noise-score ${Number(x.noise_score||0)>=70?'risk-critical':Number(x.noise_score||0)>=55?'risk-medium':'risk-low'}">${Number(x.noise_score||0)}</span></td><td class="mono">${esc(x.signature_id||'—')}</td><td>${Number(x.hits||0).toLocaleString()}</td><td>${Number(x.incidents||0).toLocaleString()}</td><td class="details-cell" title="${esc(x.signature||'')}">${esc(x.signature||'—')}</td><td>${recommendationChip(x)}<div class="muted text-xs">${esc(x.recommendation_reason||'')}</div></td><td>${action}</td></tr>`;
|
||||
}).join(''):'<tr><td colspan="7" class="empty">No signature observations in this window.</td></tr>';
|
||||
}
|
||||
|
||||
async function loadRuleIntelligence(silent=false) {
|
||||
try {
|
||||
const hours=Number($('ruleIntelHours')?.value||24), data=await api(`/api/rules/intelligence?hours=${hours}&limit=150`);
|
||||
state.ruleIntelligence=data.rules||[]; renderRuleIntelligence();
|
||||
if(!silent)notice(`Analyzed ${state.ruleIntelligence.length} signatures · ${Number(data.noisy||0)} limit candidates.`);
|
||||
} catch(e){if(!silent)notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
function renderRuleSnapshots() {
|
||||
const rows=state.ruleSnapshots||[];
|
||||
$('ruleSnapshotRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td class="break">${esc(String(x.id||'').replace(/^rules-[^-]+-|-[0-9a-f]{6}\.tar\.gz$/g,''))}</td><td>${fmtBytes(x.size_bytes||0)}</td><td><button class="link-btn" data-rule-rollback="${esc(x.id)}">rollback</button></td></tr>`).join(''):'<tr><td colspan="4" class="empty">No ruleset snapshots yet.</td></tr>';
|
||||
}
|
||||
|
||||
async function loadRuleSnapshots(silent=false) {
|
||||
if(state.authEnabled&&!state.authenticated){if(!silent)showAuthModal();return;}
|
||||
try { const data=await api('/api/admin/rules/snapshots'); state.ruleSnapshots=data.snapshots||[]; renderRuleSnapshots(); }
|
||||
catch(e){if(!silent)notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function loadRuleOperations(silent=false) {
|
||||
await Promise.all([loadRuleIntelligence(silent),loadRuleSnapshots(silent)]);
|
||||
}
|
||||
|
||||
async function applyRecommendedThreshold(target) {
|
||||
const sid=Number(target.dataset.ruleThreshold||0), count=Number(target.dataset.count||5), seconds=Number(target.dataset.seconds||60), track=target.dataset.track||'by_src';
|
||||
if(!sid)return;
|
||||
if(!confirm(`Apply Suricata limit to SID ${sid}: ${count} alert(s) / ${seconds}s, ${track}? Detection remains active; only alert frequency is limited.`))return;
|
||||
try { const r=await adminPost('/api/admin/rules/threshold',{sid,type:'limit',track,count,seconds}); notice(r.message); await Promise.all([loadRules(),loadRuleIntelligence(true),loadRuleSnapshots(true)]); }
|
||||
catch(e){notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function createRuleSnapshot() {
|
||||
try { const r=await adminPost('/api/admin/rules/snapshot',{reason:'manual'}); notice(r.message); await loadRuleSnapshots(true); }
|
||||
catch(e){notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function rollbackRuleSnapshot(id) {
|
||||
if(!confirm(`Rollback Suricata rules and source state to ${id}? A safety snapshot of the current state is created first.`))return;
|
||||
try { const r=await adminPost('/api/admin/rules/rollback',{id}); notice(r.message); await Promise.all([loadRuleSnapshots(true),loadRuleIntelligence(true)]); }
|
||||
catch(e){notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
function renderBackups() {
|
||||
const rows=state.backups||[];
|
||||
$('backupRows').innerHTML=rows.length?rows.map(x=>{const url=`/api/system/backup?name=${encodeURIComponent(x.id)}`;return `<tr><td>${fmtTime(x.created_at)}</td><td class="mono break">${esc(x.id)}</td><td>${fmtBytes(x.size_bytes||0)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a> · <button class="link-btn danger-link" data-backup-delete="${esc(x.id)}">delete</button></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No persistent backups yet.</td></tr>';
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const rows=state.audit||[];
|
||||
$('auditRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.timestamp)}</td><td>${esc(x.username||'system')}</td><td class="mono break">${esc(x.action||'—')}</td><td class="break">${esc(x.target||'—')}</td><td><span class="status-chip ${x.result==='ok'?'ok':x.result==='error'?'bad':'warn'}">${esc(x.result||'—')}</span></td></tr>`).join(''):'<tr><td colspan="5" class="empty">No administrative audit events yet.</td></tr>';
|
||||
}
|
||||
|
||||
async function loadSystemState(silent=false) {
|
||||
try { const [b,a]=await Promise.all([api('/api/system/backups'),api('/api/audit?limit=100')]); state.backups=b.backups||[]; state.audit=a.events||[]; renderBackups(); renderAudit(); if(!silent)notice('Backup and audit state refreshed.'); }
|
||||
catch(e){if(!silent)notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
try { const r=await adminPost('/api/admin/system/backups/create',{label:'manual'}); notice(r.message); await loadSystemState(true); }
|
||||
catch(e){notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function deleteBackup(id) {
|
||||
if(!confirm(`Delete backup ${id}?`))return;
|
||||
try { const r=await adminPost('/api/admin/system/backups/delete',{id}); notice(r.message); await loadSystemState(true); }
|
||||
catch(e){notice(e.message,'bad');}
|
||||
}
|
||||
|
||||
async function loadHistory(silent=false) {
|
||||
const limit=Math.min(500,Math.max(50,Number($('liveLimit').value||200)));
|
||||
const params=new URLSearchParams({limit:String(limit),window:String($('windowSelect').value||3600)});
|
||||
const f=currentLiveFilters(); if(f.q)params.set('q',f.q); if(f.type)params.set('type',f.type); if(f.proto)params.set('proto',f.proto); if(f.direction)params.set('direction',f.direction);
|
||||
try {
|
||||
const data=await api(`/api/traffic?${params}`); setLiveEvents(data.events||[]); state.historyLoaded=true; renderLive();
|
||||
if (!silent) notice(`Loaded ${state.live.length} matching historical events.`);
|
||||
} catch(e){ if (!silent) notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
function websocketUrl() {
|
||||
const scheme=location.protocol==='https:'?'wss':'ws';
|
||||
const streamActive = state.liveEnabled && state.view === 'live' && !document.hidden;
|
||||
const params=new URLSearchParams({window:String($('windowSelect').value||3600),stream:streamActive?'1':'0'});
|
||||
if (streamActive) {
|
||||
const f=currentLiveFilters(); if(f.q)params.set('q',f.q); if(f.type)params.set('type',f.type); if(f.proto)params.set('proto',f.proto); if(f.direction)params.set('direction',f.direction);
|
||||
}
|
||||
return `${scheme}://${location.host}/ws/live?${params}`;
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
if (state.authEnabled && !state.authenticated) return;
|
||||
const ws=new WebSocket(websocketUrl()); state.ws=ws;
|
||||
ws.onopen=()=>{
|
||||
state.reconnectDelay=1000;
|
||||
const liveActive=state.liveEnabled&&state.view==='live'&&!document.hidden; $('wsBadge').className='connection-badge online'; $('wsBadge').innerHTML=`<span class="status-dot"></span>${liveActive?'Live':'Connected'}`;
|
||||
updateLiveModeControls();
|
||||
};
|
||||
ws.onmessage=e=>{
|
||||
let msg; try{msg=JSON.parse(e.data)}catch(_){return}
|
||||
if(msg.type==='event') handleLiveBatch([msg.data]);
|
||||
else if(msg.type==='events') handleLiveBatch(msg.data||[]);
|
||||
else if(msg.type==='bootstrap') {
|
||||
if(state.liveEnabled && msg.data?.events?.length) handleLiveBatch(msg.data.events);
|
||||
if(msg.data?.status)renderStatus(msg.data.status);
|
||||
if(msg.data?.analytics)renderAnalytics(msg.data.analytics);
|
||||
} else if(msg.type==='status')renderStatus(msg.data||{});
|
||||
else if(msg.type==='analytics')renderAnalytics(msg.data||{});
|
||||
};
|
||||
ws.onclose=()=>{ if (!state.authEnabled || state.authenticated) scheduleReconnect(); };
|
||||
ws.onerror=()=>{try{ws.close();}catch(_){}};
|
||||
}
|
||||
|
||||
function restartWebSocket(delay=0) {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
if (state.ws) {
|
||||
state.ws.onclose = null;
|
||||
try { state.ws.close(); } catch (_) {}
|
||||
state.ws = null;
|
||||
}
|
||||
if (state.authEnabled && !state.authenticated) {
|
||||
$('wsBadge').className='connection-badge offline'; $('wsBadge').innerHTML='<span class="status-dot"></span>Sign in';
|
||||
return;
|
||||
}
|
||||
state.reconnectTimer=setTimeout(connectWebSocket,delay);
|
||||
}
|
||||
|
||||
function scheduleReconnect(){
|
||||
if (state.authEnabled && !state.authenticated) return;
|
||||
$('wsBadge').className='connection-badge offline'; $('wsBadge').innerHTML='<span class="status-dot"></span>Reconnecting';
|
||||
clearTimeout(state.reconnectTimer); state.reconnectTimer=setTimeout(connectWebSocket,state.reconnectDelay); state.reconnectDelay=Math.min(state.reconnectDelay*1.7,15000);
|
||||
}
|
||||
|
||||
function updateLiveModeControls() {
|
||||
const badge=$('liveModeBadge'), toggle=$('toggleLive'), pause=$('pauseLive');
|
||||
toggle.textContent=state.liveEnabled?'Stop live':'Start live';
|
||||
pause.disabled=!state.liveEnabled; pause.textContent=state.paused?'Resume display':'Pause display'; pause.classList.toggle('paused',state.paused);
|
||||
const suspended = state.liveEnabled && (document.hidden || state.view !== 'live');
|
||||
badge.className=`connection-badge ${state.liveEnabled&&!suspended?'online':'idle'}`;
|
||||
badge.innerHTML=`<span class="status-dot"></span>${state.liveEnabled?(suspended?'Live suspended':state.paused?'Live · display paused':'Live streaming'):'Live off'}`;
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
state.liveEnabled=!state.liveEnabled; state.paused=false; state.batchTimes=[]; updateLiveModeControls(); restartWebSocket(0);
|
||||
if(state.liveEnabled) notice('Live streaming enabled. Events are server-filtered, batched and coalesced.');
|
||||
else notice('Live streaming stopped. Capture and traffic history remain active.');
|
||||
}
|
||||
|
||||
function liveFilterChanged() {
|
||||
clearTimeout(state.liveFilterTimer);
|
||||
state.liveFilterTimer=setTimeout(()=>{
|
||||
scheduleLiveRender(0);
|
||||
if(state.liveEnabled) restartWebSocket(0);
|
||||
},250);
|
||||
}
|
||||
|
||||
async function loadOverviewSnapshot(windowSec=selectedWindow(), silent=true) {
|
||||
try {
|
||||
const traffic=await api(`/api/traffic?limit=12&window=${Number(windowSec)}`);
|
||||
if(Number(windowSec)!==selectedWindow())return;
|
||||
state.snapshot=traffic.events||[]; renderOverviewSnapshot();
|
||||
} catch(e) { if(!silent)notice(`Recent activity: ${e.message}`,'bad'); }
|
||||
}
|
||||
|
||||
async function initialLoad() {
|
||||
const windowSec=selectedWindow();
|
||||
markAnalyticsLoading(windowSec);
|
||||
const tasks=[
|
||||
api('/api/status').then(renderStatus).catch(e=>notice(`Status: ${e.message}`,'bad')),
|
||||
api('/api/stats').then(renderStats).catch(e=>notice(`Stats: ${e.message}`,'bad')),
|
||||
api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}).catch(e=>notice(`Incidents: ${e.message}`,'bad')),
|
||||
api('/api/config').then(config=>{state.config=config;}).catch(e=>notice(`Config: ${e.message}`,'bad')),
|
||||
loadOverviewSnapshot(windowSec,true),
|
||||
loadThroughput(windowSec,true),
|
||||
loadAnalytics(windowSec,true,true),
|
||||
];
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
function renderStats(data) {
|
||||
const summary=data.summary||{}, a=data.analytics||{}, ndr=data.ndr||{}; $('metricIncidents').textContent=`${Number(ndr.open_incidents ?? summary.incidents ?? 0).toLocaleString()} open NDR incidents`; $('alerts24h').textContent=Number(a.alerts_24h||0).toLocaleString(); $('uniqueSignatures').textContent=Number(a.signatures_24h||0).toLocaleString(); $('sources24h').textContent=Number(a.sources_24h||0).toLocaleString(); $('metricBlockRate').textContent=`${Number(summary.blocked_alerts||0).toLocaleString()} durable incidents`;
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
const windowSec=selectedWindow();
|
||||
await Promise.allSettled([
|
||||
api('/api/stats').then(renderStats),
|
||||
api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}),
|
||||
loadOverviewSnapshot(windowSec,true),
|
||||
loadAnalytics(windowSec,true),
|
||||
state.view==='intelligence'?loadIntelligence(true):Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadBlocks() {
|
||||
try { const data=await api('/api/blocks'); $('blocksMeta').textContent=data.configured?`${data.blocks.length} entries in ${data.address_list}`:'RouterOS REST is not configured.'; $('blockRows').innerHTML=data.blocks.length?data.blocks.map(x=>`<tr><td class="mono">${esc(x.address)}</td><td>${esc(x.timeout||'—')}</td><td>${esc(x.creation_time||'—')}</td><td class="details-cell">${esc(x.comment||'')}</td><td>${x.dynamic?'dynamic':'static'}</td><td><button class="link-btn" data-unblock="${esc(x.address)}">unblock</button></td></tr>`).join(''):'<tr><td colspan="6" class="empty">No active blocks or RouterOS unavailable.</td></tr>'; } catch(e){ notice(e.message,'bad'); }
|
||||
}
|
||||
|
||||
async function adminPost(url, body={}) { return api(url,{method:'POST',body:JSON.stringify(body)}); }
|
||||
async function addBlock() { const address=$('blockAddress').value.trim(); if(!address)return notice('Enter an IP address.','bad'); try{const r=await adminPost('/api/admin/blocks/add',{address,timeout:$('blockTimeout').value.trim(),comment:$('blockComment').value.trim()});notice(r.message);await loadBlocks();}catch(e){notice(e.message,'bad');} }
|
||||
async function unblock(address){if(!confirm(`Remove ${address} from the RouterOS block list?`))return;try{const r=await adminPost('/api/admin/blocks/remove',{address});notice(r.message);await loadBlocks();}catch(e){notice(e.message,'bad');}}
|
||||
async function suppress(sid){if(!confirm(`Globally suppress Suricata SID ${sid}?`))return;try{const r=await adminPost('/api/admin/rules/suppress',{sid:Number(sid)});notice(r.message);}catch(e){notice(e.message,'bad');}}
|
||||
|
||||
async function loadRules(){if(state.authEnabled&&!state.authenticated){showAuthModal();return;}try{const r=await api('/api/admin/rules');$('customRules').value=r.custom_rules||'';$('thresholdConfig').value=r.threshold_config||'';notice('Rule editors loaded.');}catch(e){notice(e.message,'bad');}}
|
||||
async function saveRuleFile(url,content){try{const r=await adminPost(url,{content});notice(r.message);}catch(e){notice(e.message,'bad');}}
|
||||
async function ruleAction(url,body={},confirmText=''){if(confirmText&&!confirm(confirmText))return;try{const r=await adminPost(url,body);notice(r.message);return r;}catch(e){notice(e.message,'bad');return null;}}
|
||||
async function loadRuleSources(){
|
||||
try{
|
||||
const r=await api('/api/rules/sources'); state.ruleSources=r.sources||[]; state.ruleSourcesLoaded=true; const st=r.status||{};
|
||||
state.sourceQueue=r.queue||state.sourceQueue; const known=new Set(state.ruleSources.map(x=>x.name)); state.selectedRuleSources=new Set([...state.selectedRuleSources].filter(name=>known.has(name)));
|
||||
$('sourceMeta').textContent=`${state.ruleSources.length} free sources · ${(r.enabled_sources||[]).length} active · persistent state ${r.data_dir||'/data/lib/suricata'} · vendor rules ${fmtBytes(st.vendor_rules_size_bytes||0)}`; renderRuleSources(); renderSourceQueue(state.sourceQueue);
|
||||
}catch(e){ $('sourceMeta').textContent='Could not load source catalog.'; notice(e.message,'bad'); }
|
||||
}
|
||||
function filteredRuleSources(){const q=($('sourceFilter')?.value||'').trim().toLowerCase();return state.ruleSources.filter(x=>!q||[x.name,x.vendor,x.license,(x.tags||[]).join(' ')].some(v=>String(v||'').toLowerCase().includes(q)));}
|
||||
function sourceQueueItemMap(){return new Map(((state.sourceQueue&&state.sourceQueue.items)||[]).map(item=>[item.source,item]));}
|
||||
function renderRuleSources(){
|
||||
const rows=filteredRuleSources(), queueItems=sourceQueueItemMap();
|
||||
$('ruleSourceRows').innerHTML=rows.length?rows.map(x=>{const item=queueItems.get(x.name),selectable=x.can_toggle&&!x.enabled,queued=item&&['pending','running'].includes(item.status);const status=item?`${x.enabled?'enabled · ':''}${item.status}`:(x.enabled?'enabled':'disabled');return `<tr><td class="select-col"><input type="checkbox" class="source-checkbox" data-source-select="${esc(x.name)}" ${state.selectedRuleSources.has(x.name)?'checked':''} ${selectable&&!queued?'':'disabled'} aria-label="Select ${esc(x.name)}"></td><td><strong>${esc(x.name)}</strong>${x.summary?`<div class="muted">${esc(x.summary)}</div>`:''}${item&&item.message?`<div class="muted queue-item-message">${esc(item.message)}</div>`:''}</td><td>${esc(x.vendor||'—')}</td><td>${esc(x.license||'—')}</td><td>${esc((x.tags||[]).join(', ')||'—')}</td><td><span class="status-chip ${x.enabled||item?.status==='done'?'ok':''} ${item?.status==='failed'?'bad':''}">${esc(status)}</span></td><td>${x.can_toggle?`<button class="link-btn" data-source="${esc(x.name)}" data-enable="${x.enabled?'0':'1'}" ${queued?'disabled':''}>${x.enabled?'disable':'enable & download'}</button>`:x.default?'default / active':'parameters required'}</td></tr>`;}).join(''):'<tr><td colspan="7" class="empty">No matching signature sources.</td></tr>';
|
||||
updateSourceSelectionButtons();
|
||||
}
|
||||
async function toggleSource(name,enable){const action=enable?'enable':'disable';if(!confirm(`${action} ${name}? Active feeds are rebuilt and validated before reload.`))return;const r=await ruleAction(`/api/admin/rules/sources/${action}`,{source:name});if(r)await loadRuleSources();}
|
||||
function updateSourceSelectionButtons(){const running=['queued','running'].includes(state.sourceQueue?.status);const count=state.selectedRuleSources.size;$('queueSelectedSources').textContent=count?`Queue selected (${count})`:'Queue selected';$('queueSelectedSources').disabled=running||count===0;$('selectVisibleSources').disabled=running;$('selectAllFreeSources').disabled=running;$('clearSourceSelection').disabled=running||count===0;}
|
||||
function renderSourceQueue(queue){state.sourceQueue=queue||{status:'idle'};const box=$('sourceQueueStatus');if(!box)return;const q=state.sourceQueue,running=['queued','running'].includes(q.status),total=Number(q.total||0),completed=Number(q.completed||0),failed=Number(q.failed||0);box.className=`source-queue-status ${running?'running':''} ${q.status==='failed'?'bad':''}`;box.textContent=running?`${q.phase==='download'?'Downloading feeds':'Source queue'}: ${completed}/${total}${failed?` · ${failed} failed`:''} · ${q.message||''}`:(q.status&&q.status!=='idle'?`${q.status}: ${q.message||''}`:'Queue idle');updateSourceSelectionButtons();if(running)pollSourceQueue();}
|
||||
function pollSourceQueue(){clearTimeout(state.sourceQueueTimer);state.sourceQueueTimer=setTimeout(async()=>{try{const q=await api('/api/admin/rules/sources/queue');const wasRunning=['queued','running'].includes(state.sourceQueue?.status);renderSourceQueue(q);renderRuleSources();if(wasRunning&&!['queued','running'].includes(q.status)){state.selectedRuleSources.clear();await loadRuleSources();notice(q.message,q.status==='failed'?'bad':'ok');}}catch(e){clearTimeout(state.sourceQueueTimer);notice(`Source queue: ${e.message}`,'bad');}},1000);}
|
||||
function selectVisibleSources(){for(const x of filteredRuleSources())if(x.can_toggle&&!x.enabled)state.selectedRuleSources.add(x.name);renderRuleSources();}
|
||||
function selectAllFreeSources(){for(const x of state.ruleSources)if(x.can_toggle&&!x.enabled)state.selectedRuleSources.add(x.name);renderRuleSources();}
|
||||
function clearSourceSelection(){state.selectedRuleSources.clear();renderRuleSources();}
|
||||
async function queueSelectedSources(){const sources=[...state.selectedRuleSources];if(!sources.length)return;if(!confirm(`Queue ${sources.length} selected source(s)? They will be enabled sequentially, then all active feeds will be downloaded, merged, validated and reloaded once.`))return;try{const r=await adminPost('/api/admin/rules/sources/queue',{sources});notice(r.message);state.sourceQueue={status:'queued',phase:'waiting',total:sources.length,completed:0,failed:0,message:r.message,items:sources.map(source=>({source,status:'pending',message:'Waiting'}))};renderSourceQueue(state.sourceQueue);renderRuleSources();}catch(e){notice(e.message,'bad');}}
|
||||
|
||||
function bind() {
|
||||
document.querySelectorAll('.nav-item').forEach(el=>el.addEventListener('click',()=>setView(el.dataset.view)));
|
||||
document.querySelectorAll('[data-nav]').forEach(el=>el.addEventListener('click',()=>setView(el.dataset.nav)));
|
||||
$('liveSearch').addEventListener('input',liveFilterChanged); ['liveType','liveProto','liveDirection'].forEach(id=>$(id).addEventListener('change',liveFilterChanged)); $('liveLimit').addEventListener('change',()=>scheduleLiveRender(0));
|
||||
$('incidentSearch').addEventListener('input',renderIncidents); $('severityFilter').addEventListener('change',renderIncidents);
|
||||
$('toggleLive').addEventListener('click',toggleLive);
|
||||
$('pauseLive').addEventListener('click',()=>{if(!state.liveEnabled)return;state.paused=!state.paused;updateLiveModeControls();if(!state.paused)scheduleLiveRender(0);});
|
||||
$('clearLiveView').addEventListener('click',()=>{setLiveEvents([]);renderLive();}); $('loadHistory').addEventListener('click',()=>loadHistory(false));
|
||||
$('windowSelect').addEventListener('change',async()=>{
|
||||
const w=selectedWindow(); syncUrl(state.view,'replace'); markAnalyticsLoading(w);
|
||||
state.throughput=null; state.throughputWindow=0;
|
||||
await Promise.allSettled([loadThroughput(w,false),loadAnalytics(w,false,true),loadOverviewSnapshot(w,false)]);
|
||||
restartWebSocket(0);
|
||||
});
|
||||
$('globalSearch').addEventListener('keydown',e=>{if(e.key==='Enter'){setView('live');$('liveSearch').value=e.currentTarget.value;loadHistory(false);}});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='/'&&!/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName||'')){e.preventDefault();$('globalSearch').focus();}});
|
||||
document.addEventListener('click',e=>{const link=e.target.closest('[data-download-url]');if(!link)return;e.preventDefault();downloadUrl(link.dataset.downloadUrl);});
|
||||
document.addEventListener('click',e=>{const t=e.target.closest('[data-block-ip],[data-unblock],[data-suppress],[data-source],[data-ndr-incident],[data-ndr-status],[data-delete-ioc],[data-rule-threshold],[data-rule-rollback],[data-backup-delete]');if(!t)return;if(t.dataset.blockIp){setView('blocks');$('blockAddress').value=t.dataset.blockIp;}else if(t.dataset.unblock)unblock(t.dataset.unblock);else if(t.dataset.suppress)suppress(t.dataset.suppress);else if(t.dataset.source)toggleSource(t.dataset.source,t.dataset.enable==='1');else if(t.dataset.ndrIncident)loadNdrIncident(t.dataset.ndrIncident);else if(t.dataset.ndrStatus)setNdrStatus(t.dataset.ndrStatus,t.dataset.status);else if(t.dataset.deleteIoc)deleteIoc(t.dataset.deleteIoc);else if(t.dataset.ruleThreshold)applyRecommendedThreshold(t);else if(t.dataset.ruleRollback)rollbackRuleSnapshot(t.dataset.ruleRollback);else if(t.dataset.backupDelete)deleteBackup(t.dataset.backupDelete);});
|
||||
$('refreshBlocks').addEventListener('click',loadBlocks); $('addBlock').addEventListener('click',addBlock);
|
||||
$('refreshIntelligence').addEventListener('click',()=>loadIntelligence(false)); $('addIoc').addEventListener('click',addIoc); $('importIocs').addEventListener('click',importIocs);
|
||||
$('refreshReports').addEventListener('click',()=>{loadThroughput(selectedWindow(),true);loadAnalytics(selectedWindow(),false,true);}); $('downloadReport').addEventListener('click',downloadCurrentReport);
|
||||
$('loginForm').addEventListener('submit',login); $('accountButton').addEventListener('click',accountAction); $('systemLoginButton').addEventListener('click',accountAction); $('feedLoginButton').addEventListener('click',accountAction);
|
||||
$('mobileMenu').addEventListener('click',()=>document.body.classList.contains('mobile-nav-open')?closeMobileNav():openMobileNav()); $('mobileBackdrop').addEventListener('click',closeMobileNav);
|
||||
$('loadRules').addEventListener('click',loadRules); $('reloadRules').addEventListener('click',()=>ruleAction('/api/admin/rules/reload')); $('saveCustomRules').addEventListener('click',()=>saveRuleFile('/api/admin/rules/custom',$('customRules').value)); $('saveThresholds').addEventListener('click',()=>saveRuleFile('/api/admin/rules/thresholds',$('thresholdConfig').value));
|
||||
$('loadRuleIntelligence').addEventListener('click',()=>loadRuleIntelligence(false)); $('ruleIntelHours').addEventListener('change',()=>loadRuleIntelligence(true)); $('createRuleSnapshot').addEventListener('click',createRuleSnapshot);
|
||||
$('loadRuleSources').addEventListener('click',loadRuleSources); $('refreshRuleSources').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/rules/sources/refresh',{},'Refresh the OISF provider catalog now?');if(r)await loadRuleSources();}); $('updateRules').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/rules/update',{},'Download all active feeds, validate the merged ruleset and reload Suricata?');if(r)await loadRuleSources();}); $('sourceFilter').addEventListener('input',renderRuleSources);
|
||||
$('selectVisibleSources').addEventListener('click',selectVisibleSources); $('selectAllFreeSources').addEventListener('click',selectAllFreeSources); $('clearSourceSelection').addEventListener('click',clearSourceSelection); $('queueSelectedSources').addEventListener('click',queueSelectedSources); $('ruleSourceRows').addEventListener('change',e=>{const box=e.target.closest('[data-source-select]');if(!box)return;box.checked?state.selectedRuleSources.add(box.dataset.sourceSelect):state.selectedRuleSources.delete(box.dataset.sourceSelect);updateSourceSelectionButtons();});
|
||||
$('resetCounters').addEventListener('click',()=>ruleAction('/api/admin/runtime/reset')); $('clearTraffic').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/traffic/clear',{},'Clear traffic history from RAM/Redis and remove persisted chart snapshots?');if(r){setLiveEvents([]);state.snapshot=[];renderLive();renderOverviewSnapshot();}}); $('vacuumDb').addEventListener('click',()=>ruleAction('/api/admin/database/vacuum')); $('clearAlerts').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/alerts/clear',{},'Delete all durable incident rows from SQLite?');if(r)await refreshStats();});
|
||||
$('refreshSystemState').addEventListener('click',()=>loadSystemState(false)); $('createBackup').addEventListener('click',createBackup);
|
||||
window.addEventListener('popstate',()=>{
|
||||
const value=new URL(location.href).searchParams.get('window'); if(WINDOW_LABELS[value])$('windowSelect').value=value;
|
||||
setView(viewFromLocation(),'none'); loadThroughput(selectedWindow(),true); loadAnalytics(selectedWindow(),true,true); loadOverviewSnapshot(selectedWindow(),true); restartWebSocket(0);
|
||||
});
|
||||
window.addEventListener('resize',()=>{if(state.analytics){clearTimeout(bind.resizeTimer);bind.resizeTimer=setTimeout(scheduleChartRender,150);}});
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if (!document.hidden && state.analytics) scheduleChartRender();
|
||||
if (!state.liveEnabled) return;
|
||||
updateLiveModeControls();
|
||||
restartWebSocket(document.hidden ? 0 : 100);
|
||||
});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeMobileNav(); if(state.authenticated)hideAuthModal();}});
|
||||
}
|
||||
|
||||
async function startApplication() {
|
||||
if (!state.appStarted) state.appStarted=true;
|
||||
await initialLoad();
|
||||
connectWebSocket();
|
||||
if (!state.refreshTimer) state.refreshTimer=setInterval(refreshStats,30000);
|
||||
if ('ResizeObserver' in window && !startApplication.observer) {
|
||||
startApplication.observer=new ResizeObserver(()=>scheduleChartRender());
|
||||
document.querySelectorAll('.view,.chart-panel,.donut-panel').forEach(el=>startApplication.observer.observe(el));
|
||||
}
|
||||
if (document.fonts?.ready) document.fonts.ready.then(scheduleChartRender).catch(()=>{});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const requestedWindow=new URL(location.href).searchParams.get('window'); if(WINDOW_LABELS[requestedWindow])$('windowSelect').value=requestedWindow;
|
||||
bind(); setView(viewFromLocation(),'replace'); updateLiveModeControls(); renderIncidents(); renderRuleSources();
|
||||
const session=await loadSession();
|
||||
if (session?.default_username && !state.authenticated) $('loginUsername').value=session.default_username;
|
||||
if (!state.authEnabled || state.authenticated) await startApplication();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,207 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const COLORS = {
|
||||
grid:'#202126', text:'#777780', strong:'#d4d4d8', green:'#3ecf8e', blue:'#60a5fa', red:'#f87171', amber:'#fbbf24',
|
||||
palette:['#3ecf8e','#60a5fa','#fbbf24','#a78bfa','#f87171','#22d3ee','#fb7185','#94a3b8']
|
||||
};
|
||||
|
||||
function setup(canvas) {
|
||||
if (!canvas || !canvas.isConnected || canvas.offsetParent === null) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width < 20) return null;
|
||||
const DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const cssHeight = Number.parseFloat(getComputedStyle(canvas).height) || 0;
|
||||
const height = Math.max(170, Math.floor(cssHeight || Number(canvas.getAttribute('height')) || 220));
|
||||
canvas.width = Math.floor(width * DPR);
|
||||
canvas.height = Math.floor(height * DPR);
|
||||
canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
return {ctx, width, height};
|
||||
}
|
||||
|
||||
function seriesMax(rows, key) {
|
||||
return Math.max(1, ...rows.map(row => Number(row?.[key] || 0)));
|
||||
}
|
||||
|
||||
function formatTick(ts) {
|
||||
const d = new Date(Number(ts || 0));
|
||||
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function compactNumber(value) {
|
||||
const n = Number(value || 0);
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
let n = Math.max(0, Number(value || 0));
|
||||
const units = ['bps','Kbps','Mbps','Gbps','Tbps'];
|
||||
let i = 0;
|
||||
while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; }
|
||||
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function drawGrid(ctx, width, height, pad, rows) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = COLORS.grid;
|
||||
ctx.fillStyle = COLORS.text;
|
||||
ctx.font = '10px ui-sans-serif, system-ui';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.t + ((height - pad.t - pad.b) / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(width - pad.r, y); ctx.stroke();
|
||||
}
|
||||
const labelIndexes = [0, Math.floor((rows.length - 1) / 2), rows.length - 1];
|
||||
ctx.textBaseline = 'bottom';
|
||||
labelIndexes.forEach((idx, i) => {
|
||||
if (!rows[idx]) return;
|
||||
const x = pad.l + ((width - pad.l - pad.r) * idx / Math.max(rows.length - 1, 1));
|
||||
ctx.textAlign = i === 0 ? 'left' : (i === labelIndexes.length - 1 ? 'right' : 'center');
|
||||
ctx.fillText(formatTick(rows[idx].ts_ms), x, height - 2);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLine(ctx, rows, key, max, width, height, pad, color, fillAlpha=0) {
|
||||
if (!rows.length || !(Number(max) > 0)) return;
|
||||
const iw = width - pad.l - pad.r;
|
||||
const ih = height - pad.t - pad.b;
|
||||
const points = rows.map((row, idx) => ({
|
||||
x: pad.l + iw * idx / Math.max(rows.length - 1, 1),
|
||||
y: pad.t + ih - (Number(row[key] || 0) / Number(max)) * ih,
|
||||
}));
|
||||
if (fillAlpha) {
|
||||
const grad = ctx.createLinearGradient(0, pad.t, 0, height - pad.b);
|
||||
grad.addColorStop(0, hexToRgba(color, fillAlpha)); grad.addColorStop(1, hexToRgba(color, 0));
|
||||
ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(points[0].x, height - pad.b);
|
||||
points.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(points.at(-1).x, height - pad.b); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); points.forEach((p, idx) => idx ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke();
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const value = hex.replace('#','');
|
||||
const n = parseInt(value, 16);
|
||||
return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${alpha})`;
|
||||
}
|
||||
|
||||
function normalizedDonutRows(rows) {
|
||||
const positive = (rows || []).map(row => ({
|
||||
name:String(row?.name || 'unknown'), count:Math.max(0, Number(row?.count || 0))
|
||||
})).filter(row => row.count > 0);
|
||||
if (positive.length <= 6) return positive;
|
||||
const head = positive.slice(0, 5);
|
||||
const other = positive.slice(5).reduce((sum, row) => sum + row.count, 0);
|
||||
if (other) head.push({name:'other', count:other});
|
||||
return head;
|
||||
}
|
||||
|
||||
function drawDonut(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
const prepared = setup(canvas); if (!prepared) return;
|
||||
const {ctx,width,height} = prepared;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const items = normalizedDonutRows(rows);
|
||||
const total = items.reduce((sum, row) => sum + row.count, 0);
|
||||
if (!total) {
|
||||
ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('No data in this window', width / 2, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const wide = width >= 360;
|
||||
const cx = wide ? Math.min(width * .34, 135) : width / 2;
|
||||
const cy = wide ? height / 2 : Math.min(88, height * .42);
|
||||
const radius = Math.min(70, Math.max(48, Math.min(width * .22, height * .32)));
|
||||
const inner = radius * .64;
|
||||
let angle = -Math.PI / 2;
|
||||
items.forEach((row, idx) => {
|
||||
const portion = row.count / total;
|
||||
const end = angle + portion * Math.PI * 2;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, radius, angle, end); ctx.arc(cx, cy, inner, end, angle, true); ctx.closePath();
|
||||
ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fill();
|
||||
angle = end;
|
||||
});
|
||||
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = COLORS.strong; ctx.font = '600 18px ui-sans-serif, system-ui'; ctx.fillText(compactNumber(total), cx, cy - 5);
|
||||
ctx.fillStyle = COLORS.text; ctx.font = '9px ui-sans-serif, system-ui'; ctx.fillText('events', cx, cy + 14);
|
||||
|
||||
const legendX = wide ? Math.min(width * .60, cx + radius + 35) : 14;
|
||||
const legendY = wide ? Math.max(18, cy - Math.min(items.length * 16, 84) / 2) : Math.min(height - 74, cy + radius + 16);
|
||||
const legendWidth = wide ? Math.max(90, width - legendX - 12) : width - 28;
|
||||
items.forEach((row, idx) => {
|
||||
const y = legendY + idx * 18;
|
||||
ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fillRect(legendX, y + 3, 7, 7);
|
||||
ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||
const label = row.name.length > 18 ? `${row.name.slice(0,17)}…` : row.name;
|
||||
ctx.fillText(label, legendX + 13, y, Math.max(40, legendWidth - 45));
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${Math.round(row.count / total * 100)}%`, legendX + legendWidth, y);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLoading(canvas, label='Building selected range…') {
|
||||
if (!canvas) return;
|
||||
const prepared = setup(canvas); if (!prepared) return;
|
||||
const {ctx,width,height} = prepared;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const y = height * i / 4;
|
||||
ctx.beginPath(); ctx.moveTo(12, y); ctx.lineTo(width - 12, y); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, width / 2, height / 2);
|
||||
}
|
||||
|
||||
function drawTraffic(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
const prepared = setup(canvas); if (!prepared) return;
|
||||
const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22};
|
||||
drawGrid(ctx,width,height,pad,rows);
|
||||
drawLine(ctx, rows, 'bytes', seriesMax(rows,'bytes'), width,height,pad,COLORS.blue,.10);
|
||||
drawLine(ctx, rows, 'events', seriesMax(rows,'events'), width,height,pad,COLORS.green,.12);
|
||||
}
|
||||
|
||||
function drawThroughput(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
const prepared = setup(canvas); if (!prepared) return;
|
||||
const {ctx,width,height} = prepared; const pad={l:56,r:10,t:12,b:22};
|
||||
drawGrid(ctx,width,height,pad,rows);
|
||||
const max = Math.max(seriesMax(rows,'bps'), seriesMax(rows,'in_bps'), seriesMax(rows,'out_bps'));
|
||||
ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||||
if (!(max > 0)) {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('No TZSP throughput samples in this window', width / 2, height / 2);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const value = max * (4 - i) / 4;
|
||||
const y = pad.t + ((height - pad.t - pad.b) / 4) * i;
|
||||
ctx.fillText(formatRate(value), pad.l - 7, y);
|
||||
}
|
||||
// Total is always available from raw TZSP byte counters, even when LAN
|
||||
// direction classification is not configured correctly.
|
||||
drawLine(ctx, rows, 'bps', max, width,height,pad,COLORS.amber,.04);
|
||||
drawLine(ctx, rows, 'in_bps', max, width,height,pad,COLORS.blue,.05);
|
||||
drawLine(ctx, rows, 'out_bps', max, width,height,pad,COLORS.green,.04);
|
||||
}
|
||||
|
||||
function drawEvents(canvas, rows) {
|
||||
if (!canvas) return;
|
||||
const prepared = setup(canvas); if (!prepared) return;
|
||||
const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22};
|
||||
drawGrid(ctx,width,height,pad,rows);
|
||||
const max = Math.max(seriesMax(rows,'events'), seriesMax(rows,'alerts'));
|
||||
drawLine(ctx, rows, 'events', max, width,height,pad,COLORS.green,.10);
|
||||
drawLine(ctx, rows, 'alerts', max, width,height,pad,COLORS.red,0);
|
||||
}
|
||||
|
||||
window.MikroSuricataCharts = {drawTraffic, drawThroughput, drawEvents, drawDonut, drawLoading};
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Tailwind Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
4.1.10
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--color-zinc-100: oklch(96.7% 0.001 286.375);
|
||||
--color-zinc-400: oklch(70.5% 0.015 286.067);
|
||||
--color-zinc-500: oklch(55.2% 0.016 285.938);
|
||||
--color-zinc-950: oklch(14.1% 0.005 285.823);
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.gap-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.gap-3 {
|
||||
gap: calc(var(--spacing) * 3);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.bg-zinc-950 {
|
||||
background-color: var(--color-zinc-950);
|
||||
}
|
||||
.text-sm {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
}
|
||||
.text-xs {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
}
|
||||
.text-zinc-100 {
|
||||
color: var(--color-zinc-100);
|
||||
}
|
||||
.text-zinc-400 {
|
||||
color: var(--color-zinc-400);
|
||||
}
|
||||
.text-zinc-500 {
|
||||
color: var(--color-zinc-500);
|
||||
}
|
||||
.antialiased {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
+753
-11
@@ -7,9 +7,12 @@ import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from .adaptive import score_rule
|
||||
from .mitre import classify as classify_mitre, merge as merge_mitre
|
||||
|
||||
|
||||
class AlertStore:
|
||||
SCHEMA_VERSION = 4
|
||||
SCHEMA_VERSION = 11
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
@@ -45,10 +48,117 @@ class AlertStore:
|
||||
severity INTEGER,
|
||||
action TEXT,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
incident_id INTEGER,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
block_target TEXT,
|
||||
block_reason TEXT,
|
||||
raw_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS traffic_snapshots (
|
||||
window_seconds INTEGER PRIMARY KEY,
|
||||
generated_at TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
ip TEXT PRIMARY KEY,
|
||||
mac TEXT,
|
||||
hostname TEXT,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
observations INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||
alert_count INTEGER NOT NULL DEFAULT 0,
|
||||
incident_count INTEGER NOT NULL DEFAULT 0,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_event_type TEXT,
|
||||
last_app_proto TEXT,
|
||||
identity_source TEXT,
|
||||
protocols_json TEXT NOT NULL DEFAULT '[]',
|
||||
ports_json TEXT NOT NULL DEFAULT '[]',
|
||||
domains_json TEXT NOT NULL DEFAULT '[]',
|
||||
fingerprints_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS asset_baseline (
|
||||
asset_ip TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
seen_count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(asset_ip, kind, value)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS threat_iocs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
indicator TEXT NOT NULL,
|
||||
indicator_type TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
confidence INTEGER NOT NULL DEFAULT 80,
|
||||
severity INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_hit_at TEXT,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(indicator_type, indicator)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ndr_incidents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_ip TEXT NOT NULL,
|
||||
opened_at TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
severity INTEGER NOT NULL DEFAULT 3,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
alert_count INTEGER NOT NULL DEFAULT 0,
|
||||
ioc_hits INTEGER NOT NULL DEFAULT 0,
|
||||
behavior_hits INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
block_target TEXT,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
stages_json TEXT NOT NULL DEFAULT '[]',
|
||||
signals_json TEXT NOT NULL DEFAULT '[]',
|
||||
flow_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
community_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
destinations_json TEXT NOT NULL DEFAULT '[]',
|
||||
mitre_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ndr_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
incident_id INTEGER NOT NULL REFERENCES ndr_incidents(id) ON DELETE CASCADE,
|
||||
timestamp TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
stage TEXT NOT NULL DEFAULT '',
|
||||
risk INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT NOT NULL,
|
||||
src_ip TEXT,
|
||||
dest_ip TEXT,
|
||||
signature_id INTEGER,
|
||||
flow_id TEXT,
|
||||
community_id TEXT,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
mitre_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL DEFAULT 'ok',
|
||||
remote_ip TEXT NOT NULL DEFAULT '',
|
||||
details_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Existing 0.3.x databases do not have last_seen/hit_count. Add
|
||||
@@ -62,6 +172,17 @@ class AlertStore:
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_blocked ON alerts(blocked);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_src_ip ON alerts(src_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_dest_ip ON alerts(dest_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_last_seen ON assets(last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_risk ON assets(risk_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_baseline_asset ON asset_baseline(asset_ip, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_iocs_enabled ON threat_iocs(enabled, indicator_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_iocs_expires ON threat_iocs(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ndr_incidents_last_seen ON ndr_incidents(last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ndr_incidents_subject ON ndr_incidents(subject_ip, status, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ndr_events_incident ON ndr_events(incident_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, timestamp DESC);
|
||||
"""
|
||||
)
|
||||
self._conn.execute(
|
||||
@@ -69,24 +190,201 @@ class AlertStore:
|
||||
"last_seen=COALESCE(last_seen,timestamp), hit_count=COALESCE(hit_count,1)"
|
||||
)
|
||||
self._normalise_existing_timestamps()
|
||||
self._purge_expired_sessions_locked()
|
||||
if previous_version < self.SCHEMA_VERSION:
|
||||
self._compact_existing_incidents(300)
|
||||
if previous_version < 11:
|
||||
self._backfill_mitre_locked()
|
||||
self._conn.execute(f"PRAGMA user_version={self.SCHEMA_VERSION}")
|
||||
self._conn.commit()
|
||||
|
||||
def _migrate_columns(self) -> None:
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in self._conn.execute("PRAGMA table_info(alerts)").fetchall()
|
||||
def save_traffic_snapshot(self, window_seconds: int, payload: dict[str, Any]) -> None:
|
||||
window_seconds = int(window_seconds)
|
||||
if window_seconds <= 0:
|
||||
raise ValueError("window_seconds must be positive")
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
stored = dict(payload)
|
||||
stored["window_seconds"] = window_seconds
|
||||
stored["generated_at"] = generated_at
|
||||
raw = json.dumps(stored, ensure_ascii=False, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO traffic_snapshots(window_seconds, generated_at, payload_json)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(window_seconds) DO UPDATE SET
|
||||
generated_at=excluded.generated_at,
|
||||
payload_json=excluded.payload_json
|
||||
""",
|
||||
(window_seconds, generated_at, raw),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def traffic_snapshot(self, window_seconds: int) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT generated_at, payload_json FROM traffic_snapshots WHERE window_seconds=?",
|
||||
(int(window_seconds),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(str(row["payload_json"]))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
payload.setdefault("generated_at", row["generated_at"])
|
||||
payload["persisted_snapshot"] = True
|
||||
return payload
|
||||
|
||||
def traffic_snapshot_status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT window_seconds, generated_at FROM traffic_snapshots ORDER BY window_seconds"
|
||||
).fetchall()
|
||||
return {
|
||||
"windows": [
|
||||
{"window_seconds": int(row["window_seconds"]), "generated_at": row["generated_at"]}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
additions = {
|
||||
|
||||
def clear_traffic_snapshots(self) -> int:
|
||||
with self._lock:
|
||||
count = int(self._conn.execute("SELECT COUNT(*) FROM traffic_snapshots").fetchone()[0])
|
||||
self._conn.execute("DELETE FROM traffic_snapshots")
|
||||
self._conn.commit()
|
||||
return count
|
||||
|
||||
def create_web_session(
|
||||
self,
|
||||
token_hash: str,
|
||||
username: str,
|
||||
csrf_token: str,
|
||||
expires_at: datetime,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
expiry = expires_at.astimezone(timezone.utc).isoformat()
|
||||
with self._lock:
|
||||
self._purge_expired_sessions_locked()
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO web_sessions(
|
||||
token_hash, username, csrf_token, created_at, expires_at, last_seen_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(token_hash, username, csrf_token, now, expiry, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_web_session(self, token_hash: str, *, touch: bool = True) -> dict[str, Any] | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT token_hash, username, csrf_token, created_at, expires_at, last_seen_at
|
||||
FROM web_sessions WHERE token_hash=?
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
expires_at = _parse_timestamp(row["expires_at"])
|
||||
if expires_at <= now:
|
||||
self._conn.execute("DELETE FROM web_sessions WHERE token_hash=?", (token_hash,))
|
||||
self._conn.commit()
|
||||
return None
|
||||
if touch:
|
||||
last_seen_at = now.isoformat()
|
||||
self._conn.execute(
|
||||
"UPDATE web_sessions SET last_seen_at=? WHERE token_hash=?",
|
||||
(last_seen_at, token_hash),
|
||||
)
|
||||
self._conn.commit()
|
||||
else:
|
||||
last_seen_at = str(row["last_seen_at"])
|
||||
return {
|
||||
"username": str(row["username"]),
|
||||
"csrf_token": str(row["csrf_token"]),
|
||||
"created_at": str(row["created_at"]),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"last_seen_at": last_seen_at,
|
||||
}
|
||||
|
||||
def delete_web_session(self, token_hash: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("DELETE FROM web_sessions WHERE token_hash=?", (token_hash,))
|
||||
self._conn.commit()
|
||||
|
||||
def purge_expired_sessions(self) -> int:
|
||||
with self._lock:
|
||||
count = self._purge_expired_sessions_locked()
|
||||
self._conn.commit()
|
||||
return count
|
||||
|
||||
def _purge_expired_sessions_locked(self) -> int:
|
||||
cutoff = datetime.now(timezone.utc).isoformat()
|
||||
cursor = self._conn.execute("DELETE FROM web_sessions WHERE expires_at<=?", (cutoff,))
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def _migrate_columns(self) -> None:
|
||||
def add_missing(table: str, additions: dict[str, str]) -> None:
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
}
|
||||
for name, definition in additions.items():
|
||||
if name not in columns:
|
||||
self._conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
||||
|
||||
add_missing("alerts", {
|
||||
"first_seen": "TEXT",
|
||||
"last_seen": "TEXT",
|
||||
"hit_count": "INTEGER NOT NULL DEFAULT 1",
|
||||
}
|
||||
for name, definition in additions.items():
|
||||
if name not in columns:
|
||||
self._conn.execute(f"ALTER TABLE alerts ADD COLUMN {name} {definition}")
|
||||
"incident_id": "INTEGER",
|
||||
"risk_score": "INTEGER NOT NULL DEFAULT 0",
|
||||
})
|
||||
add_missing("ndr_incidents", {
|
||||
"mitre_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
})
|
||||
add_missing("ndr_events", {
|
||||
"mitre_json": "TEXT NOT NULL DEFAULT '[]'",
|
||||
})
|
||||
|
||||
def _backfill_mitre_locked(self) -> None:
|
||||
rows = self._conn.execute(
|
||||
"SELECT id,incident_id,stage,summary,src_ip,dest_ip,details_json,mitre_json FROM ndr_events"
|
||||
).fetchall()
|
||||
incident_map: dict[int, list[dict[str, str]]] = {}
|
||||
for row in rows:
|
||||
current = _json_objects(row["mitre_json"] or "[]")
|
||||
try:
|
||||
details = json.loads(row["details_json"] or "{}")
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
details = {}
|
||||
if not isinstance(details, dict):
|
||||
details = {}
|
||||
record = dict(details)
|
||||
record.setdefault("src_ip", row["src_ip"])
|
||||
record.setdefault("dest_ip", row["dest_ip"])
|
||||
mapped = merge_mitre(current, classify_mitre(str(row["stage"] or ""), str(row["summary"] or ""), record))
|
||||
if mapped != current:
|
||||
self._conn.execute(
|
||||
"UPDATE ndr_events SET mitre_json=? WHERE id=?",
|
||||
(json.dumps(mapped, ensure_ascii=False, separators=(",", ":")), int(row["id"])),
|
||||
)
|
||||
incident_id = int(row["incident_id"])
|
||||
incident_map[incident_id] = merge_mitre(incident_map.get(incident_id, []), mapped)
|
||||
for incident_id, mapped in incident_map.items():
|
||||
row = self._conn.execute("SELECT mitre_json FROM ndr_incidents WHERE id=?", (incident_id,)).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
merged = merge_mitre(_json_objects(row["mitre_json"] or "[]"), mapped)
|
||||
self._conn.execute(
|
||||
"UPDATE ndr_incidents SET mitre_json=? WHERE id=?",
|
||||
(json.dumps(merged, ensure_ascii=False, separators=(",", ":")), incident_id),
|
||||
)
|
||||
|
||||
def _normalise_existing_timestamps(self) -> None:
|
||||
rows = self._conn.execute(
|
||||
@@ -298,7 +596,7 @@ class AlertStore:
|
||||
SELECT id, timestamp, first_seen, last_seen, hit_count,
|
||||
src_ip, src_port, dest_ip, dest_port, proto,
|
||||
signature_id, signature, category, severity, action,
|
||||
blocked, block_target, block_reason
|
||||
blocked, block_target, block_reason, incident_id, risk_score
|
||||
FROM alerts ORDER BY COALESCE(last_seen,timestamp) DESC, id DESC LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
@@ -431,6 +729,350 @@ class AlertStore:
|
||||
self._conn.commit()
|
||||
return count
|
||||
|
||||
def observe_asset(self, record: dict[str, Any], *, risk_score: int = 0, incident: bool = False) -> dict[str, Any] | None:
|
||||
ip = _local_subject(record)
|
||||
if not ip:
|
||||
return None
|
||||
now = _normalise_timestamp(record.get("timestamp"))
|
||||
mac = _asset_mac(record, ip)
|
||||
hostname = str(record.get("dhcp_hostname") or "")[:255]
|
||||
app_proto = str(record.get("app_proto") or "")[:48].lower()
|
||||
event_type = str(record.get("type") or "")[:32]
|
||||
bytes_count = max(_as_int(record.get("bytes")) or 0, 0)
|
||||
port = _asset_dest_port(record, ip)
|
||||
domain = str(record.get("dns_query") or record.get("tls_sni") or record.get("quic_sni") or record.get("http_host") or "")[:255].lower().rstrip(".")
|
||||
fingerprints = [str(record.get(k) or "")[:160] for k in ("tls_ja4", "tls_ja3", "quic_ja4", "quic_ja3", "ssh_hassh_client")]
|
||||
fingerprints = [x for x in fingerprints if x]
|
||||
is_alert = 1 if event_type == "alert" else 0
|
||||
with self._lock:
|
||||
old = self._conn.execute("SELECT * FROM assets WHERE ip=?", (ip,)).fetchone()
|
||||
protocols = _json_set(old["protocols_json"] if old else "[]")
|
||||
ports = _json_set(old["ports_json"] if old else "[]")
|
||||
domains = _json_set(old["domains_json"] if old else "[]")
|
||||
fps = _json_set(old["fingerprints_json"] if old else "[]")
|
||||
if app_proto:
|
||||
protocols.add(app_proto)
|
||||
if port:
|
||||
ports.add(str(port))
|
||||
if domain:
|
||||
domains.add(domain)
|
||||
fps.update(fingerprints)
|
||||
# Keep bounded identity metadata. Baseline details live in asset_baseline.
|
||||
protocols = set(sorted(protocols)[:64])
|
||||
ports = set(sorted(ports, key=lambda x: int(x) if x.isdigit() else 65536)[:128])
|
||||
domains = set(sorted(domains)[-128:])
|
||||
fps = set(sorted(fps)[-128:])
|
||||
previous_mac = str(old["mac"] or "") if old else ""
|
||||
new_mac = mac or previous_mac
|
||||
new_hostname = hostname or (str(old["hostname"] or "") if old else "")
|
||||
new_risk = max(int(old["risk_score"] or 0) if old else 0, max(0, min(100, int(risk_score))))
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO assets(ip,mac,hostname,first_seen,last_seen,observations,bytes_total,alert_count,incident_count,risk_score,last_event_type,last_app_proto,identity_source,protocols_json,ports_json,domains_json,fingerprints_json)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
mac=excluded.mac, hostname=excluded.hostname, last_seen=excluded.last_seen,
|
||||
observations=assets.observations+1, bytes_total=assets.bytes_total+excluded.bytes_total,
|
||||
alert_count=assets.alert_count+excluded.alert_count,
|
||||
incident_count=assets.incident_count+excluded.incident_count,
|
||||
risk_score=MAX(assets.risk_score, excluded.risk_score),
|
||||
last_event_type=excluded.last_event_type, last_app_proto=excluded.last_app_proto,
|
||||
identity_source=CASE WHEN excluded.identity_source<>'' THEN excluded.identity_source ELSE assets.identity_source END,
|
||||
protocols_json=excluded.protocols_json, ports_json=excluded.ports_json,
|
||||
domains_json=excluded.domains_json, fingerprints_json=excluded.fingerprints_json
|
||||
""",
|
||||
(
|
||||
ip,new_mac,new_hostname,now,now,1,bytes_count,is_alert,1 if incident else 0,new_risk,event_type,app_proto,
|
||||
"dhcp" if hostname or record.get("dhcp_client_mac") else "arp" if record.get("arp_src_mac") else "ethernet" if mac else "eve",
|
||||
_json_dump_set(protocols),_json_dump_set(ports),_json_dump_set(domains),_json_dump_set(fps),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
row = self._conn.execute("SELECT * FROM assets WHERE ip=?", (ip,)).fetchone()
|
||||
result = dict(row) if row else None
|
||||
if result:
|
||||
result["mac_changed"] = bool(previous_mac and mac and previous_mac.lower() != mac.lower())
|
||||
result["previous_mac"] = previous_mac
|
||||
return result
|
||||
|
||||
def baseline_touch(self, asset_ip: str, kind: str, value: str, timestamp: str) -> tuple[bool, int]:
|
||||
if not asset_ip or not kind or not value:
|
||||
return False, 0
|
||||
timestamp = _normalise_timestamp(timestamp)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT seen_count FROM asset_baseline WHERE asset_ip=? AND kind=? AND value=?",
|
||||
(asset_ip, kind, value),
|
||||
).fetchone()
|
||||
is_new = row is None
|
||||
if row is None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO asset_baseline(asset_ip,kind,value,first_seen,last_seen,seen_count) VALUES(?,?,?,?,?,1)",
|
||||
(asset_ip, kind, value, timestamp, timestamp),
|
||||
)
|
||||
count = 1
|
||||
else:
|
||||
count = int(row["seen_count"] or 0) + 1
|
||||
self._conn.execute(
|
||||
"UPDATE asset_baseline SET last_seen=?, seen_count=? WHERE asset_ip=? AND kind=? AND value=?",
|
||||
(timestamp, count, asset_ip, kind, value),
|
||||
)
|
||||
self._conn.commit()
|
||||
return is_new, count
|
||||
|
||||
def asset_observation_count(self, asset_ip: str) -> int:
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT observations FROM assets WHERE ip=?", (asset_ip,)).fetchone()
|
||||
return int(row["observations"] or 0) if row else 0
|
||||
|
||||
def assets(self, limit: int = 250) -> list[dict[str, Any]]:
|
||||
limit = min(max(int(limit), 1), 1000)
|
||||
with self._lock:
|
||||
rows = self._conn.execute("SELECT * FROM assets ORDER BY risk_score DESC,last_seen DESC LIMIT ?", (limit,)).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
for key in ("protocols_json","ports_json","domains_json","fingerprints_json"):
|
||||
item[key.removesuffix("_json")] = sorted(_json_set(item.pop(key, "[]")))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def raise_asset_risk(self, asset_ip: str, risk_score: int) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE assets SET risk_score=MAX(risk_score,?) WHERE ip=?",
|
||||
(max(0, min(100, int(risk_score))), str(asset_ip)[:64]),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def add_ioc(self, indicator: str, indicator_type: str, *, source: str = "manual", confidence: int = 80, severity: int = 1, note: str = "", expires_at: str | None = None) -> int:
|
||||
indicator_type = str(indicator_type).strip().lower()
|
||||
indicator = _normalise_ioc(indicator, indicator_type)
|
||||
if indicator_type not in {"ip","domain","sha256","ja3","ja4","hassh"}:
|
||||
raise ValueError("unsupported IOC type")
|
||||
if not indicator:
|
||||
raise ValueError("indicator is required")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO threat_iocs(indicator,indicator_type,source,confidence,severity,enabled,note,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,1,?,?,?)
|
||||
ON CONFLICT(indicator_type,indicator) DO UPDATE SET source=excluded.source,confidence=excluded.confidence,severity=excluded.severity,enabled=1,note=excluded.note,expires_at=excluded.expires_at
|
||||
""",
|
||||
(indicator,indicator_type,str(source)[:120],max(0,min(100,int(confidence))),max(1,min(4,int(severity))),str(note)[:500],now,expires_at),
|
||||
)
|
||||
self._conn.commit()
|
||||
row = self._conn.execute("SELECT id FROM threat_iocs WHERE indicator_type=? AND indicator=?", (indicator_type,indicator)).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
def list_iocs(self, limit: int = 1000, *, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
limit = min(max(int(limit), 1), 5000)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
where = "WHERE enabled=1 AND (expires_at IS NULL OR expires_at>?)" if enabled_only else ""
|
||||
params: tuple[Any, ...] = (now, limit) if enabled_only else (limit,)
|
||||
sql = f"SELECT * FROM threat_iocs {where} ORDER BY enabled DESC,severity ASC,confidence DESC,id DESC LIMIT ?"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
result=[]
|
||||
for row in rows:
|
||||
item=dict(row); item["enabled"]=bool(item["enabled"]); result.append(item)
|
||||
return result
|
||||
|
||||
def remove_ioc(self, ioc_id: int) -> bool:
|
||||
with self._lock:
|
||||
cur=self._conn.execute("DELETE FROM threat_iocs WHERE id=?", (int(ioc_id),)); self._conn.commit()
|
||||
return bool(cur.rowcount)
|
||||
|
||||
def mark_ioc_hit(self, ioc_id: int, timestamp: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("UPDATE threat_iocs SET hit_count=hit_count+1,last_hit_at=? WHERE id=?", (_normalise_timestamp(timestamp),int(ioc_id)))
|
||||
self._conn.commit()
|
||||
|
||||
def correlate_signal(self, signal: dict[str, Any], window_seconds: int = 1800) -> int:
|
||||
subject_ip = str(signal.get("subject_ip") or "")[:64]
|
||||
if not subject_ip:
|
||||
raise ValueError("subject_ip is required")
|
||||
ts = _normalise_timestamp(signal.get("timestamp"))
|
||||
cutoff = (_parse_timestamp(ts) - timedelta(seconds=max(60,int(window_seconds)))).isoformat()
|
||||
risk = max(0,min(100,int(signal.get("risk") or 0)))
|
||||
stage = str(signal.get("stage") or "")[:64]
|
||||
kind = str(signal.get("kind") or "signal")[:64]
|
||||
summary = str(signal.get("summary") or kind)[:500]
|
||||
flow_id = str(signal.get("flow_id") or "")[:64]
|
||||
community_id = str(signal.get("community_id") or "")[:128]
|
||||
dest_ip = str(signal.get("dest_ip") or "")[:64]
|
||||
mitre = [dict(item) for item in (signal.get("mitre") or []) if isinstance(item, dict)]
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM ndr_incidents WHERE subject_ip=? AND status='open' AND last_seen>=? ORDER BY last_seen DESC,id DESC LIMIT 1",
|
||||
(subject_ip, cutoff),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
stages=set(); signals=[]; flows=set(); communities=set(); destinations=set();
|
||||
if stage: stages.add(stage)
|
||||
signals.append(summary)
|
||||
if flow_id: flows.add(flow_id)
|
||||
if community_id: communities.add(community_id)
|
||||
if dest_ip and dest_ip != subject_ip: destinations.add(dest_ip)
|
||||
cursor=self._conn.execute(
|
||||
"""INSERT INTO ndr_incidents(subject_ip,opened_at,last_seen,title,risk_score,severity,event_count,alert_count,ioc_hits,behavior_hits,summary,stages_json,signals_json,flow_ids_json,community_ids_json,destinations_json,mitre_json)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(subject_ip,ts,ts,str(signal.get("title") or summary)[:220],risk,_risk_severity(risk),1,1 if kind=="alert" else 0,1 if kind=="ioc" else 0,1 if kind=="behavior" else 0,summary,_json_dump_set(stages),json.dumps(signals,ensure_ascii=False),_json_dump_set(flows),_json_dump_set(communities),_json_dump_set(destinations),json.dumps(mitre,ensure_ascii=False,separators=(",",":"))),
|
||||
)
|
||||
incident_id=int(cursor.lastrowid)
|
||||
else:
|
||||
incident_id=int(row["id"])
|
||||
stages=_json_set(row["stages_json"]); signals=_json_list(row["signals_json"]); flows=_json_set(row["flow_ids_json"]); communities=_json_set(row["community_ids_json"]); destinations=_json_set(row["destinations_json"]); mitre=merge_mitre(_json_objects(row["mitre_json"]), mitre)
|
||||
if stage: stages.add(stage)
|
||||
if summary and summary not in signals: signals=(signals+[summary])[-20:]
|
||||
if flow_id: flows.add(flow_id)
|
||||
if community_id: communities.add(community_id)
|
||||
if dest_ip and dest_ip != subject_ip: destinations.add(dest_ip)
|
||||
stage_bonus=10 if len(stages)>=2 else 0
|
||||
stage_bonus+=10 if len(stages)>=3 else 0
|
||||
combined=max(int(row["risk_score"] or 0), min(100,risk+stage_bonus))
|
||||
title=str(row["title"] or signal.get("title") or summary)[:220]
|
||||
if risk >= int(row["risk_score"] or 0): title=str(signal.get("title") or summary)[:220]
|
||||
self._conn.execute(
|
||||
"""UPDATE ndr_incidents SET last_seen=?,title=?,risk_score=?,severity=?,event_count=event_count+1,alert_count=alert_count+?,ioc_hits=ioc_hits+?,behavior_hits=behavior_hits+?,summary=?,stages_json=?,signals_json=?,flow_ids_json=?,community_ids_json=?,destinations_json=?,mitre_json=? WHERE id=?""",
|
||||
(ts,title,combined,_risk_severity(combined),1 if kind=="alert" else 0,1 if kind=="ioc" else 0,1 if kind=="behavior" else 0,summary,_json_dump_set(stages),json.dumps(signals,ensure_ascii=False),_json_dump_set(flows),_json_dump_set(communities),_json_dump_set(destinations),json.dumps(mitre,ensure_ascii=False,separators=(",",":")),incident_id),
|
||||
)
|
||||
self._conn.execute(
|
||||
"""INSERT INTO ndr_events(incident_id,timestamp,kind,stage,risk,summary,src_ip,dest_ip,signature_id,flow_id,community_id,details_json,mitre_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(incident_id,ts,kind,stage,risk,summary,str(signal.get("src_ip") or "")[:64],dest_ip,_as_int(signal.get("signature_id")),flow_id,community_id,json.dumps(signal.get("details") or {},ensure_ascii=False,separators=(",",":")),json.dumps(mitre,ensure_ascii=False,separators=(",",":"))),
|
||||
)
|
||||
# Bound evidence rows per incident without losing the incident summary.
|
||||
self._conn.execute("DELETE FROM ndr_events WHERE incident_id=? AND id NOT IN (SELECT id FROM ndr_events WHERE incident_id=? ORDER BY id DESC LIMIT 200)", (incident_id,incident_id))
|
||||
self._conn.commit()
|
||||
return incident_id
|
||||
|
||||
def link_alert_incident(self, alert_id: int, incident_id: int, risk_score: int) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("UPDATE alerts SET incident_id=?,risk_score=MAX(COALESCE(risk_score,0),?) WHERE id=?", (int(incident_id),max(0,min(100,int(risk_score))),int(alert_id)))
|
||||
self._conn.commit()
|
||||
|
||||
def mark_incident_blocked(self, incident_id: int, target: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("UPDATE ndr_incidents SET blocked=1,block_target=? WHERE id=?", (str(target)[:64],int(incident_id))); self._conn.commit()
|
||||
|
||||
def set_ndr_incident_status(self, incident_id: int, status: str) -> bool:
|
||||
status = str(status or "").strip().lower()
|
||||
if status not in {"open", "acknowledged", "closed"}:
|
||||
raise ValueError("status must be open, acknowledged or closed")
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"UPDATE ndr_incidents SET status=? WHERE id=?",
|
||||
(status, int(incident_id)),
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cursor.rowcount) > 0
|
||||
|
||||
def ndr_incident(self, incident_id: int) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT * FROM ndr_incidents WHERE id=?", (int(incident_id),)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
item = dict(row); item["blocked"] = bool(item["blocked"])
|
||||
for key in ("stages_json", "signals_json", "flow_ids_json", "community_ids_json", "destinations_json"):
|
||||
out = key.removesuffix("_json"); item[out] = _json_list(item.pop(key, "[]"))
|
||||
item["mitre"] = _json_objects(item.pop("mitre_json", "[]"))
|
||||
return item
|
||||
|
||||
def recent_ndr_incidents(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
limit=min(max(int(limit),1),500)
|
||||
with self._lock:
|
||||
rows=self._conn.execute("SELECT * FROM ndr_incidents ORDER BY last_seen DESC,id DESC LIMIT ?", (limit,)).fetchall()
|
||||
result=[]
|
||||
for row in rows:
|
||||
item=dict(row); item["blocked"]=bool(item["blocked"])
|
||||
for key in ("stages_json","signals_json","flow_ids_json","community_ids_json","destinations_json"):
|
||||
out=key.removesuffix("_json"); item[out]=_json_list(item.pop(key,"[]"))
|
||||
item["mitre"]=_json_objects(item.pop("mitre_json","[]"))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def ndr_incident_events(self, incident_id: int, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows=self._conn.execute("SELECT * FROM ndr_events WHERE incident_id=? ORDER BY timestamp DESC,id DESC LIMIT ?", (int(incident_id),min(max(int(limit),1),200))).fetchall()
|
||||
result=[]
|
||||
for row in rows:
|
||||
item=dict(row)
|
||||
try: item["details"]=json.loads(item.pop("details_json") or "{}")
|
||||
except (ValueError,TypeError,json.JSONDecodeError): item["details"]={}
|
||||
item["mitre"]=_json_objects(item.pop("mitre_json","[]"))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def ndr_summary(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
row=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN status='open' THEN 1 ELSE 0 END) open_count,SUM(CASE WHEN risk_score>=80 THEN 1 ELSE 0 END) criticalish,MAX(risk_score) max_risk FROM ndr_incidents").fetchone()
|
||||
assets=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN risk_score>=60 THEN 1 ELSE 0 END) risky FROM assets").fetchone()
|
||||
iocs=self._conn.execute("SELECT COUNT(*) total,SUM(CASE WHEN enabled=1 THEN 1 ELSE 0 END) enabled,SUM(hit_count) hits FROM threat_iocs").fetchone()
|
||||
return {"incidents":int(row["total"] or 0),"open_incidents":int(row["open_count"] or 0),"high_risk_incidents":int(row["criticalish"] or 0),"max_risk":int(row["max_risk"] or 0),"assets":int(assets["total"] or 0),"risky_assets":int(assets["risky"] or 0),"iocs":int(iocs["total"] or 0),"enabled_iocs":int(iocs["enabled"] or 0),"ioc_hits":int(iocs["hits"] or 0)}
|
||||
|
||||
def rule_intelligence(self, hours: int = 24, limit: int = 100) -> dict[str, Any]:
|
||||
hours = min(max(int(hours), 1), 24 * 30)
|
||||
limit = min(max(int(limit), 1), 500)
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT signature_id, MAX(signature) signature, MAX(category) category,
|
||||
MIN(COALESCE(severity,4)) severity,
|
||||
COUNT(*) rows, SUM(COALESCE(hit_count,1)) hits,
|
||||
COUNT(DISTINCT NULLIF(src_ip,'')) unique_src,
|
||||
COUNT(DISTINCT NULLIF(dest_ip,'')) unique_dst,
|
||||
COUNT(DISTINCT incident_id) incidents,
|
||||
SUM(CASE WHEN blocked=1 THEN 1 ELSE 0 END) blocked,
|
||||
MIN(COALESCE(first_seen,timestamp)) first_seen,
|
||||
MAX(COALESCE(last_seen,timestamp)) last_seen
|
||||
FROM alerts
|
||||
WHERE signature_id IS NOT NULL AND COALESCE(last_seen,timestamp)>=?
|
||||
GROUP BY signature_id
|
||||
ORDER BY hits DESC, last_seen DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(cutoff, limit),
|
||||
).fetchall()
|
||||
scored = [score_rule(dict(row)) for row in rows]
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"rules": scored,
|
||||
"noisy": sum(1 for row in scored if row["recommendation"] == "limit"),
|
||||
"review": sum(1 for row in scored if row["recommendation"] == "review"),
|
||||
}
|
||||
|
||||
def audit(self, username: str, action: str, *, target: str = "", result: str = "ok", remote_ip: str = "", details: dict[str, Any] | None = None) -> int:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
payload = json.dumps(details or {}, ensure_ascii=False, separators=(",", ":"))
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO audit_log(timestamp,username,action,target,result,remote_ip,details_json) VALUES(?,?,?,?,?,?,?)",
|
||||
(now, str(username or "")[:120], str(action or "")[:160], str(target or "")[:300], str(result or "")[:32], str(remote_ip or "")[:64], payload[:12000]),
|
||||
)
|
||||
self._conn.execute(
|
||||
"DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 10000)"
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def audit_events(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
limit = min(max(int(limit), 1), 1000)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM audit_log ORDER BY timestamp DESC,id DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
try:
|
||||
item["details"] = json.loads(item.pop("details_json") or "{}")
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
item["details"] = {}
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def vacuum(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("VACUUM")
|
||||
@@ -458,6 +1100,106 @@ def _normalise_timestamp(value: Any) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _json_set(raw: Any) -> set[str]:
|
||||
try:
|
||||
value = json.loads(str(raw or "[]"))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return set()
|
||||
return {str(x) for x in value if str(x)} if isinstance(value, list) else set()
|
||||
|
||||
|
||||
def _json_list(raw: Any) -> list[str]:
|
||||
try:
|
||||
value = json.loads(str(raw or "[]"))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
return [str(x) for x in value if str(x)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _json_dump_set(values: set[str]) -> str:
|
||||
return json.dumps(sorted(values), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _json_objects(raw: Any) -> list[dict[str, str]]:
|
||||
try:
|
||||
value = json.loads(str(raw or "[]"))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
out.append({str(k): str(v) for k, v in item.items() if v not in (None, "")})
|
||||
return out
|
||||
|
||||
|
||||
def _local_subject(record: dict[str, Any]) -> str:
|
||||
direction = str(record.get("direction") or "")
|
||||
if direction in {"outbound", "internal"}:
|
||||
return str(record.get("src_ip") or record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")[:64]
|
||||
if direction == "inbound":
|
||||
return str(record.get("dest_ip") or "")[:64]
|
||||
return str(record.get("dhcp_assigned_ip") or record.get("arp_src_ip") or "")[:64]
|
||||
|
||||
|
||||
def _asset_mac(record: dict[str, Any], ip: str) -> str:
|
||||
if str(record.get("dhcp_assigned_ip") or "") == ip:
|
||||
return str(record.get("dhcp_client_mac") or "")[:32]
|
||||
if str(record.get("arp_src_ip") or "") == ip:
|
||||
return str(record.get("arp_src_mac") or "")[:32]
|
||||
if str(record.get("src_ip") or "") == ip:
|
||||
return str(record.get("ether_src") or "")[:32]
|
||||
if str(record.get("dest_ip") or "") == ip:
|
||||
return str(record.get("ether_dest") or "")[:32]
|
||||
return ""
|
||||
|
||||
|
||||
def _asset_dest_port(record: dict[str, Any], ip: str) -> int | None:
|
||||
if str(record.get("src_ip") or "") == ip:
|
||||
return _as_int(record.get("dest_port"))
|
||||
return None
|
||||
|
||||
|
||||
def _risk_severity(risk: int) -> int:
|
||||
if risk >= 80: return 1
|
||||
if risk >= 55: return 2
|
||||
if risk >= 30: return 3
|
||||
return 4
|
||||
|
||||
|
||||
def _normalise_ioc(indicator: str, indicator_type: str) -> str:
|
||||
value = str(indicator or "").strip()
|
||||
if indicator_type == "ip":
|
||||
try:
|
||||
return str(__import__("ipaddress").ip_address(value))
|
||||
except ValueError:
|
||||
raise ValueError("invalid IP IOC")
|
||||
if indicator_type == "domain":
|
||||
value = value.lower().rstrip(".")
|
||||
if value.startswith("*."):
|
||||
value = value[2:]
|
||||
if not value or "." not in value or any(ch.isspace() for ch in value):
|
||||
raise ValueError("invalid domain IOC")
|
||||
return value
|
||||
if indicator_type == "sha256":
|
||||
value=value.lower()
|
||||
if len(value)!=64 or any(c not in "0123456789abcdef" for c in value):
|
||||
raise ValueError("invalid SHA256 IOC")
|
||||
return value
|
||||
if indicator_type in {"ja3", "hassh"}:
|
||||
value = value.lower()
|
||||
if len(value) != 32 or any(c not in "0123456789abcdef" for c in value):
|
||||
raise ValueError(f"invalid {indicator_type.upper()} IOC")
|
||||
return value
|
||||
if indicator_type == "ja4":
|
||||
value = value.lower()
|
||||
if len(value) < 20 or len(value) > 96 or any(c.isspace() for c in value):
|
||||
raise ValueError("invalid JA4 IOC")
|
||||
return value
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _file_size(path: str) -> int:
|
||||
try:
|
||||
return int(os.path.getsize(path))
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>MikroSuricata · Network Security</title>
|
||||
<link rel="stylesheet" href="/static/libs/tailwindcss/tailwind.min.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><div><strong>MikroSuricata</strong><span>Suricata / RouterOS</span></div></div>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌁</span>Overview</button>
|
||||
<button class="nav-item" data-view="live"><span class="nav-icon">↯</span>Live Sessions</button>
|
||||
<button class="nav-item" data-view="security"><span class="nav-icon">◇</span>Security</button>
|
||||
<button class="nav-item" data-view="intelligence"><span class="nav-icon">◎</span>Intelligence</button>
|
||||
<button class="nav-item" data-view="blocks"><span class="nav-icon">⊘</span>Blocks</button>
|
||||
<button class="nav-item" data-view="reports"><span class="nav-icon">⌗</span>Reports</button>
|
||||
<button class="nav-item" data-view="feeds"><span class="nav-icon">↓</span>Signature Feeds</button>
|
||||
<button class="nav-item" data-view="rules"><span class="nav-icon">≡</span>Rules</button>
|
||||
<button class="nav-item" data-view="system"><span class="nav-icon" aria-hidden="true"><svg class="nav-svg" viewBox="0 0 20 20"><path d="M3 5h8M15 5h2M3 10h2M9 10h8M3 15h7M14 15h3"/><circle cx="13" cy="5" r="2"/><circle cx="7" cy="10" r="2"/><circle cx="12" cy="15" r="2"/></svg></span>System</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="health-line"><span id="sideHealthDot" class="status-dot"></span><span id="sideHealth">Loading status</span></div>
|
||||
<div id="sideUptime" class="text-xs text-zinc-500">—</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<header class="topbar">
|
||||
<button id="mobileMenu" class="mobile-menu-btn" type="button" aria-label="Open navigation" aria-expanded="false">☰</button>
|
||||
<div>
|
||||
<div class="eyebrow">NETWORK INTELLIGENCE</div>
|
||||
<h1 id="pageTitle">Overview</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<label class="global-search"><span>⌕</span><input id="globalSearch" type="search" placeholder="Search IP, domain, signature…"><kbd>/</kbd></label>
|
||||
<select id="windowSelect" class="control compact" aria-label="Time window">
|
||||
<option value="900">15 min</option><option value="3600" selected>1 hour</option><option value="21600">6 hours</option><option value="86400">24 hours</option>
|
||||
</select>
|
||||
<span id="wsBadge" class="connection-badge offline"><span class="status-dot"></span>Offline</span>
|
||||
<button id="accountButton" class="account-button" type="button">Sign in</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="notice" class="notice hidden"></div>
|
||||
|
||||
<section id="view-overview" class="view active">
|
||||
<div class="metric-grid overview-metrics">
|
||||
<article class="metric-card"><div class="metric-label">Events</div><div id="metricEvents" class="metric-value">0</div><div id="metricEventRate" class="metric-sub">0 / min</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Throughput now</div><div id="metricThroughput" class="metric-value">0 bps</div><div id="metricThroughputSplit" class="metric-sub">IN 0 bps · OUT 0 bps</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Observed traffic</div><div id="metricBytes" class="metric-value">0 B</div><div class="metric-sub">TZSP bytes in selected range</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Peak throughput</div><div id="metricPeakThroughput" class="metric-value">0 bps</div><div class="metric-sub">Selected time range</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Threats</div><div id="metricAlerts" class="metric-value">0</div><div id="metricIncidents" class="metric-sub">0 incidents</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Blocked</div><div id="metricBlocked" class="metric-value">0</div><div id="metricBlockRate" class="metric-sub">Policy actions</div></article>
|
||||
</div>
|
||||
<div class="grid-main">
|
||||
<article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Traffic throughput</h2><p>Total, inbound and outbound network speed sampled from TZSP traffic and retained in Redis.</p></div><div class="chart-head-meta"><span id="snapshotMeta" class="status-chip">loading</span><div class="legend"><span><i class="legend-amber"></i>Total</span><span><i class="legend-blue"></i>Inbound</span><span><i class="legend-green"></i>Outbound</span></div></div></div><canvas id="throughputChart" height="230"></canvas></article>
|
||||
<article class="panel donut-panel"><div class="panel-head"><div><h2>Traffic direction</h2><p>Inbound / outbound / internal</p></div></div><canvas id="directionDonut" height="230"></canvas></article>
|
||||
<article class="panel chart-panel span-2"><div class="panel-head"><div><h2>Events & alerts</h2><p>Complete retained event history for the selected time range.</p></div><div class="legend"><span><i class="legend-green"></i>Events</span><span><i class="legend-red"></i>Alerts</span></div></div><canvas id="trafficChart" height="220"></canvas></article>
|
||||
<article class="panel donut-panel"><div class="panel-head"><div><h2>Event mix</h2><p>Flow, DNS, TLS, HTTP and alerts</p></div></div><canvas id="eventTypeDonut" height="220"></canvas></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Top applications</h2><p>Unique detected flows; failed/unknown classifications are excluded.</p></div></div><div id="topApps" class="rank-list"></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Top local clients</h2><p>Traffic volume by monitored endpoint</p></div></div><div id="topClients" class="rank-list"></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Top remote peers</h2><p>Traffic volume by external endpoint</p></div></div><div id="topSources" class="rank-list"></div></article>
|
||||
</div>
|
||||
<div class="insight-grid mt-4">
|
||||
<article class="metric-card insight-card"><div class="metric-label">Protocol anomalies</div><div id="metricAnomalies" class="metric-value small-value">0</div><div class="metric-sub">Parser / stream anomalies</div></article>
|
||||
<article class="metric-card insight-card"><div class="metric-label">DNS NXDOMAIN</div><div id="metricNxdomain" class="metric-value small-value">0</div><div class="metric-sub">Failed DNS resolutions</div></article>
|
||||
<article class="metric-card insight-card"><div class="metric-label">Encrypted sessions</div><div id="metricEncrypted" class="metric-value small-value">0</div><div class="metric-sub">TLS / QUIC / SSH</div></article>
|
||||
<article class="metric-card insight-card"><div class="metric-label">Cleartext sessions</div><div id="metricCleartext" class="metric-value small-value">0</div><div class="metric-sub">HTTP / FTP / SMTP / Telnet</div></article>
|
||||
<article class="metric-card insight-card"><div class="metric-label">Local clients</div><div id="metricLocalClients" class="metric-value small-value">0</div><div class="metric-sub">Unique monitored endpoints</div></article>
|
||||
<article class="metric-card insight-card"><div class="metric-label">Remote peers</div><div id="metricRemotePeers" class="metric-value small-value">0</div><div class="metric-sub">Unique external endpoints</div></article>
|
||||
</div>
|
||||
<article class="panel mt-4"><div class="panel-head"><div><h2>Recent activity snapshot</h2><p>Small bounded snapshot. Continuous live streaming is disabled until you start it.</p></div><button class="btn ghost small" data-nav="live">Open Live Sessions</button></div><div class="table-wrap overview-snapshot"><table><thead><tr><th>Time</th><th>Type</th><th>Source</th><th>Destination</th><th>Application</th><th>Details</th><th>Bytes</th></tr></thead><tbody id="overviewLiveRows"></tbody></table></div></article>
|
||||
</section>
|
||||
|
||||
<section id="view-live" class="view">
|
||||
<div class="section-bar"><div><h2>Live Sessions</h2><p>Continuous streaming is off by default. Capture and Redis history continue independently.</p></div><div class="inline-actions"><span id="liveModeBadge" class="connection-badge idle"><span class="status-dot"></span>Live off</span><button id="toggleLive" class="btn">Start live</button><button id="pauseLive" class="btn ghost" disabled>Pause display</button><button id="clearLiveView" class="btn ghost">Clear view</button></div></div>
|
||||
<div class="live-hint">The browser receives coalesced batches instead of every packet/update. Filters are applied server-side while live mode is active.</div>
|
||||
<div class="filter-bar">
|
||||
<input id="liveSearch" class="control grow" type="search" placeholder="IP, host, domain, signature, flow ID…">
|
||||
<select id="liveType" class="control"><option value="flow" selected>Sessions / flow</option><option value="">All event types</option><option>dns</option><option>mdns</option><option>http</option><option>http2</option><option>doh2</option><option>tls</option><option>ssh</option><option>rdp</option><option>smb</option><option>quic</option><option>dhcp</option><option>arp</option><option>krb5</option><option>dcerpc</option><option>ldap</option><option>nfs</option><option>snmp</option><option>rfb</option><option>sip</option><option>ike</option><option>mqtt</option><option>ftp</option><option>ftp_data</option><option>smtp</option><option>pop3</option><option>tftp</option><option>websocket</option><option>alert</option><option>fileinfo</option><option>anomaly</option></select>
|
||||
<select id="liveProto" class="control"><option value="">All protocols</option><option>TCP</option><option>UDP</option><option>ICMP</option><option>ICMPV6</option></select>
|
||||
<select id="liveDirection" class="control"><option value="">Any direction</option><option>inbound</option><option>outbound</option><option>internal</option><option>external</option></select>
|
||||
<select id="liveLimit" class="control" aria-label="Visible rows"><option value="100">100 rows</option><option value="200" selected>200 rows</option><option value="300">300 rows</option><option value="500">500 rows</option></select>
|
||||
<button id="loadHistory" class="btn ghost">Search history</button>
|
||||
</div>
|
||||
<div class="live-stats"><span id="liveVisibleCount">0 visible</span><span id="liveBufferedCount">0 buffered</span><span id="liveRate">0 batches/s</span><span id="liveDropped">0 UI drops</span></div>
|
||||
<div class="table-wrap panel flat live-table-wrap"><table class="dense"><thead><tr><th>Time</th><th>Type</th><th>Direction</th><th>Source</th><th>Destination</th><th>Protocol</th><th>App</th><th>Details</th><th class="right">Bytes</th><th></th></tr></thead><tbody id="liveRows"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section id="view-security" class="view">
|
||||
<div class="section-bar"><div><h2>Security incidents</h2><p>Durable, deduplicated Suricata alerts stored in SQLite.</p></div><div class="pill" id="securityWindow">Last 24 hours</div></div>
|
||||
<div class="metric-grid compact-grid"><article class="metric-card"><div class="metric-label">Alerts / 24h</div><div id="alerts24h" class="metric-value small-value">0</div></article><article class="metric-card"><div class="metric-label">Unique signatures</div><div id="uniqueSignatures" class="metric-value small-value">0</div></article><article class="metric-card"><div class="metric-label">Sources / 24h</div><div id="sources24h" class="metric-value small-value">0</div></article><article class="metric-card"><div class="metric-label">Filtered noise</div><div id="filteredCount" class="metric-value small-value">0</div></article></div>
|
||||
<div class="filter-bar"><input id="incidentSearch" class="control grow" type="search" placeholder="Filter incidents in table…"><select id="severityFilter" class="control"><option value="">All severities</option><option value="1">Severity 1</option><option value="2">Severity 2</option><option value="3">Severity 3</option></select></div>
|
||||
<div class="table-wrap panel flat"><table><thead><tr><th>Last seen</th><th>Hits</th><th>Severity</th><th>Signature</th><th>Source</th><th>Destination</th><th>Action</th><th></th></tr></thead><tbody id="incidentRows"></tbody></table></div>
|
||||
<div class="grid-main mt-4"><article class="panel"><div class="panel-head"><div><h2>Top signatures</h2><p>Most frequent detections in the selected traffic window</p></div></div><div id="securitySignatures" class="rank-list"></div></article><article class="panel donut-panel"><div class="panel-head"><div><h2>Alert severity mix</h2><p>Suricata priority distribution</p></div></div><canvas id="severityDonut" height="220"></canvas></article><article class="panel"><div class="panel-head"><div><h2>Detection coverage</h2><p>Core IDS telemetry visible in the selected window</p></div></div><div id="coverageStatus" class="kv-list"></div></article></div>
|
||||
<div class="grid-main mt-4"><article class="panel span-2"><div class="panel-head"><div><h2>Encrypted client fingerprints</h2><p>JA4 / JA3 / HASSH fingerprints observed in TLS, QUIC and SSH telemetry</p></div></div><div id="fingerprintRank" class="rank-list"></div></article><article class="panel"><div class="panel-head"><div><h2>Correlation identifiers</h2><p>Flow/community IDs stay searchable in Live Sessions for cross-tool investigation.</p></div></div><div class="kv-list"><div class="kv-row"><span>Community ID</span><span>indexed in history</span></div><div class="kv-row"><span>Flow ID</span><span>indexed in history</span></div><div class="kv-row"><span>Transaction ID</span><span>indexed in history</span></div></div></article></div>
|
||||
<div class="grid-main mt-4"><article class="panel"><div class="panel-head"><div><h2>Observed asset identities</h2><p>DHCP, ARP and passive Ethernet IP/MAC observations</p></div></div><div id="assetRank" class="rank-list"></div></article><article class="panel span-2"><div class="panel-head"><div><h2>File activity</h2><p>Suricata fileinfo names and hashes when available</p></div></div><div id="fileRank" class="rank-list"></div></article></div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="view-intelligence" class="view">
|
||||
<div class="section-bar"><div><h2>MikroSuricata NDR</h2><p>Correlated incidents, asset behavior and local threat intelligence. Select an incident to inspect its evidence chain.</p></div><button id="refreshIntelligence" class="btn ghost">Refresh</button></div>
|
||||
<div class="metric-grid compact-grid">
|
||||
<article class="metric-card"><div class="metric-label">Open incidents</div><div id="ndrOpen" class="metric-value small-value">0</div></article>
|
||||
<article class="metric-card"><div class="metric-label">High risk ≥80</div><div id="ndrHighRisk" class="metric-value small-value">0</div></article>
|
||||
<article class="metric-card"><div class="metric-label">Known assets</div><div id="ndrAssets" class="metric-value small-value">0</div></article>
|
||||
<article class="metric-card"><div class="metric-label">IOC hits</div><div id="ndrIocHits" class="metric-value small-value">0</div></article>
|
||||
</div>
|
||||
<div class="grid-main">
|
||||
<article class="panel span-2"><div class="panel-head"><div><h2>Correlated incidents</h2><p>Multi-stage evidence grouped around the affected local asset.</p></div></div><div class="table-wrap"><table><thead><tr><th>Risk</th><th>Last seen</th><th>Asset</th><th class="stages-col">Stages</th><th>ATT&CK</th><th>Summary</th><th>Signals</th><th>Status</th><th></th></tr></thead><tbody id="ndrIncidentRows"></tbody></table></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Incident evidence</h2><p id="ndrEvidenceTitle">Select an incident.</p></div></div><div class="table-wrap evidence-table"><table><thead><tr><th>Time</th><th>Stage</th><th>Risk</th><th>ATT&CK</th><th>Evidence</th></tr></thead><tbody id="ndrEvidenceRows"><tr><td colspan="5" class="empty">No incident selected.</td></tr></tbody></table></div></article>
|
||||
</div>
|
||||
<article class="panel mt-4"><div class="panel-head"><div><h2>Asset intelligence</h2><p>Passive Suricata identity enriched with RouterOS ARP/DHCP data.</p></div></div><div class="table-wrap"><table><thead><tr><th>Risk</th><th>IP</th><th>Identity</th><th>Protocols</th><th>Outbound ports</th><th>Alerts</th><th>Last seen</th></tr></thead><tbody id="assetRows"></tbody></table></div></article>
|
||||
<div class="grid-main mt-4 intelligence-grid">
|
||||
<article class="panel"><div class="panel-head"><div><h2>Add IOC</h2><p>Saved persistently and synchronized into Suricata datasets.</p></div></div><div class="form-stack">
|
||||
<label>Type<select id="iocType" class="control"><option value="ip">IP</option><option value="domain">Domain</option><option value="sha256">SHA-256</option><option value="ja3">JA3</option><option value="ja4">JA4</option><option value="hassh">HASSH</option></select></label>
|
||||
<label>Indicator<input id="iocIndicator" class="control" placeholder="203.0.113.10 or example.test"></label>
|
||||
<label>Confidence<input id="iocConfidence" class="control" type="number" min="0" max="100" value="80"></label>
|
||||
<label>Source<input id="iocSource" class="control" value="manual" placeholder="manual / feed name"></label>
|
||||
<button id="addIoc" class="btn">Add & reload</button>
|
||||
</div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Bulk IOC import</h2><p>One indicator per line, or type,indicator,confidence,source,note.</p></div></div><textarea id="iocBulk" class="code-editor compact-editor" spellcheck="false" placeholder="domain,bad.example,90,internal-feed
|
||||
198.51.100.50"></textarea><div class="panel-actions"><button id="importIocs" class="btn">Import & reload</button></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Detection engines</h2><p>Signals combined into NDR risk.</p></div></div><div class="kv-list"><div class="kv-row"><span>Suricata signatures</span><span>enabled</span></div><div class="kv-row"><span>Threat intelligence</span><span>datasets + app matching</span></div><div class="kv-row"><span>Behavior baseline</span><span>apps / ports / identity</span></div><div class="kv-row"><span>Beaconing</span><span>periodicity detector</span></div><div class="kv-row"><span>DNS anomaly</span><span>entropy / NXDOMAIN / tunnel</span></div><div class="kv-row"><span>Lateral movement</span><span>fan-out + xbits</span></div><div class="kv-row"><span>MITRE ATT&CK</span><span>network-evidence mapping</span></div><div class="kv-row"><span>Egress analytics</span><span>large outbound transfers</span></div></div></article>
|
||||
</div>
|
||||
<article class="panel mt-4"><div class="panel-head"><div><h2>Threat intelligence repository</h2><p>IOC hits increase incident risk and remain persistent in SQLite.</p></div></div><div class="table-wrap"><table><thead><tr><th>Type</th><th>Indicator</th><th>Confidence</th><th>Severity</th><th>Source</th><th>Hits</th><th>Last hit</th><th></th></tr></thead><tbody id="iocRows"></tbody></table></div></article>
|
||||
<article class="panel mt-4"><div class="panel-head"><div><h2>Forensic PCAP ring</h2><p>Only flows associated with alerts are captured; files rotate inside the persistent /data volume.</p></div></div><div class="table-wrap"><table><thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead><tbody id="pcapRows"></tbody></table></div></article>
|
||||
</section>
|
||||
|
||||
<section id="view-blocks" class="view">
|
||||
<div class="section-bar"><div><h2>RouterOS blocks</h2><p>Manual and automatic entries in the configured address-list.</p></div><button id="refreshBlocks" class="btn ghost">Refresh</button></div>
|
||||
<div class="grid-main blocks-grid">
|
||||
<article class="panel"><div class="panel-head"><div><h2>Add block</h2><p>Requires an authenticated session and RouterOS REST credentials.</p></div></div><div class="form-stack"><label>IP address<input id="blockAddress" class="control" placeholder="203.0.113.10"></label><label>Timeout<input id="blockTimeout" class="control" value="1h" placeholder="1h"></label><label>Comment<input id="blockComment" class="control" placeholder="Manual block from dashboard"></label><button id="addBlock" class="btn danger-soft">Block address</button></div></article>
|
||||
<article class="panel span-2"><div class="panel-head"><div><h2>Active address-list</h2><p id="blocksMeta">RouterOS status not loaded.</p></div></div><div class="table-wrap"><table><thead><tr><th>Address</th><th>Timeout</th><th>Created</th><th>Comment</th><th>Type</th><th></th></tr></thead><tbody id="blockRows"></tbody></table></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-reports" class="view">
|
||||
<div class="section-bar"><div><h2>Reports</h2><p>All report widgets use the global time range and are generated from the same backend snapshot.</p></div><div class="inline-actions"><span id="reportWindowBadge" class="pill">Last 1 hour</span><span id="reportState" class="status-chip">loading</span><button id="refreshReports" class="btn ghost">Refresh</button><button id="downloadReport" class="btn">Download CSV</button></div></div>
|
||||
<div class="metric-grid compact-grid"><article class="metric-card"><div class="metric-label">Events</div><div id="reportEvents" class="metric-value small-value">—</div><div class="metric-sub">selected range</div></article><article class="metric-card"><div class="metric-label">Traffic</div><div id="reportBytes" class="metric-value small-value">—</div><div class="metric-sub">observed bytes</div></article><article class="metric-card"><div class="metric-label">Alerts</div><div id="reportAlerts" class="metric-value small-value">—</div><div class="metric-sub">Suricata detections</div></article><article class="metric-card"><div class="metric-label">Local clients</div><div id="reportClients" class="metric-value small-value">—</div><div class="metric-sub">unique endpoints</div></article></div>
|
||||
<div class="grid-main"><article class="panel span-2 chart-panel"><div class="panel-head"><div><h2>Events over time</h2><p>Alerts overlaid on total events</p></div></div><canvas id="eventsChart" height="240"></canvas></article><article class="panel donut-panel"><div class="panel-head"><div><h2>Protocols</h2><p>Transport protocol distribution</p></div></div><canvas id="protocolDonut" height="240"></canvas></article></div>
|
||||
<div class="grid-main mt-4"><article class="panel donut-panel"><div class="panel-head"><div><h2>Directions</h2><p>Relative to monitored networks</p></div></div><canvas id="reportDirectionDonut" height="220"></canvas></article><article class="panel donut-panel"><div class="panel-head"><div><h2>Applications</h2><p>Unique detected flows; failed/unknown classifications excluded.</p></div></div><canvas id="appDonut" height="220"></canvas></article><article class="panel donut-panel"><div class="panel-head"><div><h2>Event types</h2><p>EVE event distribution</p></div></div><canvas id="reportEventDonut" height="220"></canvas></article></div>
|
||||
<div class="grid-main mt-4"><article class="panel"><div class="panel-head"><div><h2>Local clients</h2><p>Endpoints inside monitored networks</p></div></div><div id="reportSources" class="rank-list"></div></article><article class="panel"><div class="panel-head"><div><h2>Remote peers</h2><p>External endpoints seen by local clients</p></div></div><div id="reportDestinations" class="rank-list"></div></article><article class="panel"><div class="panel-head"><div><h2>Raw event sources</h2><p>Unclassified source addresses for diagnostics</p></div></div><div id="eventTypes" class="rank-list"></div></article></div>
|
||||
</section>
|
||||
|
||||
<section id="view-feeds" class="view">
|
||||
<div class="section-bar"><div><h2>Signature Feeds</h2><p>Download and manage signatures from ET/Open and other providers exposed by the OISF suricata-update catalog.</p></div><div class="inline-actions"><button id="loadRuleSources" class="btn ghost">Reload list</button><button id="refreshRuleSources" class="btn ghost">Refresh provider catalog</button><button id="updateRules" class="btn">Download active feeds</button></div></div>
|
||||
<div class="feed-summary"><div><span>Catalog</span><strong>OISF suricata-update</strong></div><div><span>Mode</span><strong>Free sources</strong></div><div><span>Activation</span><strong>Validated before reload</strong></div></div>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Providers and rulesets</h2><p id="sourceMeta">Loading available signature sources…</p></div><button class="btn ghost small" id="feedLoginButton">Admin login</button></div><div class="feed-queue-toolbar"><div class="inline-actions"><button id="selectVisibleSources" class="btn ghost small">Select visible</button><button id="selectAllFreeSources" class="btn ghost small">Select all free</button><button id="clearSourceSelection" class="btn ghost small">Clear</button><button id="queueSelectedSources" class="btn small">Queue selected</button></div><div id="sourceQueueStatus" class="source-queue-status">Queue idle</div></div><div class="panel-filter"><input id="sourceFilter" class="control" type="search" placeholder="Search provider, source, license or tag…"></div><div class="table-wrap"><table><thead><tr><th class="select-col">Select</th><th>Source</th><th>Vendor</th><th>License</th><th>Tags</th><th>Status</th><th>Action</th></tr></thead><tbody id="ruleSourceRows"></tbody></table></div></article>
|
||||
</section>
|
||||
|
||||
<section id="view-rules" class="view">
|
||||
<div class="section-bar"><div><h2>Rules</h2><p>Custom signatures, thresholds and suppressions.</p></div><div class="inline-actions"><button class="btn ghost" data-nav="feeds">Signature feeds</button><button id="loadRules" class="btn ghost">Load editors</button><button id="reloadRules" class="btn">Reload</button></div></div>
|
||||
<div class="grid-main"><article class="panel"><div class="panel-head"><div><h2>Custom Suricata signatures</h2><p>Validated before replacing the active ruleset.</p></div><button id="saveCustomRules" class="btn small">Save & reload</button></div><textarea id="customRules" class="code-editor" spellcheck="false" placeholder="Load editor first…"></textarea></article><article class="panel"><div class="panel-head"><div><h2>Threshold / suppress</h2><p>Noise controls and scoped suppression entries.</p></div><button id="saveThresholds" class="btn small">Save & reload</button></div><textarea id="thresholdConfig" class="code-editor" spellcheck="false" placeholder="Load editor first…"></textarea></article></div>
|
||||
<div class="grid-main mt-4">
|
||||
<article class="panel span-2"><div class="panel-head"><div><h2>Adaptive rule intelligence</h2><p>Observed alert noise and concentration. Recommendations never disable signatures automatically.</p></div><div class="inline-actions"><select id="ruleIntelHours" class="control compact"><option value="24">24h</option><option value="72">3d</option><option value="168">7d</option><option value="720">30d</option></select><button id="loadRuleIntelligence" class="btn ghost small">Analyze</button></div></div><div class="table-wrap"><table><thead><tr><th>Noise</th><th>SID</th><th>Hits</th><th>Incidents</th><th>Signature</th><th>Recommendation</th><th></th></tr></thead><tbody id="ruleIntelRows"><tr><td colspan="7" class="empty">Open Rules to analyze recent signatures.</td></tr></tbody></table></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Ruleset snapshots</h2><p>Local rules, thresholds, merged vendor rules and enabled source state.</p></div><button id="createRuleSnapshot" class="btn ghost small">Create snapshot</button></div><div class="table-wrap"><table><thead><tr><th>Created</th><th>Reason</th><th>Size</th><th></th></tr></thead><tbody id="ruleSnapshotRows"><tr><td colspan="4" class="empty">No snapshots loaded.</td></tr></tbody></table></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-system" class="view">
|
||||
<div class="section-bar"><div><h2>System</h2><p>Pipeline, storage and maintenance state.</p></div></div>
|
||||
<div class="grid-main"><article class="panel span-2"><div class="panel-head"><div><h2>Services</h2></div></div><div class="table-wrap"><table><thead><tr><th>Component</th><th>Status</th><th>Details</th></tr></thead><tbody id="serviceRows"></tbody></table></div></article><article class="panel"><div class="panel-head"><div><h2>Traffic history</h2></div></div><div id="historyStatus" class="kv-list"></div></article></div>
|
||||
<div class="grid-main mt-4"><article class="panel span-2"><div class="panel-head"><div><h2>Ports</h2></div></div><div class="table-wrap"><table><thead><tr><th>Service</th><th>Direction</th><th>Protocol</th><th>Address</th><th>Port</th><th>Status</th></tr></thead><tbody id="portRows"></tbody></table></div></article><article class="panel"><div class="panel-head"><div><h2>Maintenance</h2><p>Destructive actions require an authenticated admin session.</p></div></div><div class="form-stack"><div id="sessionStatus" class="session-status">Not signed in</div><button id="systemLoginButton" class="btn ghost">Sign in</button><button id="resetCounters" class="btn ghost">Reset runtime counters</button><button id="clearTraffic" class="btn ghost">Clear traffic history</button><button id="vacuumDb" class="btn ghost">Compact incident DB</button><button id="clearAlerts" class="btn danger-soft">Delete all incidents</button></div></article></div>
|
||||
<div class="grid-main mt-4">
|
||||
<article class="panel span-2"><div class="panel-head"><div><h2>Persistent backups</h2><p>SQLite and IDS configuration only; Redis runtime data, logs and forensic PCAP are excluded.</p></div><div class="inline-actions"><button id="refreshSystemState" class="btn ghost small">Refresh</button><button id="createBackup" class="btn small">Create backup</button></div></div><div class="table-wrap"><table><thead><tr><th>Created</th><th>File</th><th>Size</th><th></th></tr></thead><tbody id="backupRows"><tr><td colspan="4" class="empty">No backups loaded.</td></tr></tbody></table></div></article>
|
||||
<article class="panel"><div class="panel-head"><div><h2>Audit log</h2><p>Administrative actions recorded in SQLite.</p></div></div><div class="table-wrap audit-table"><table><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Target</th><th>Result</th></tr></thead><tbody id="auditRows"><tr><td colspan="5" class="empty">No audit events loaded.</td></tr></tbody></table></div></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<button id="mobileBackdrop" class="mobile-backdrop" type="button" aria-label="Close navigation"></button>
|
||||
<div id="authModal" class="auth-modal hidden" role="dialog" aria-modal="true" aria-labelledby="authTitle">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand"><div><strong id="authTitle">MikroSuricata login</strong><span>Protected IDS console</span></div></div>
|
||||
<form id="loginForm" class="auth-form">
|
||||
<label>Username<input id="loginUsername" class="control" autocomplete="username" required></label>
|
||||
<label>Password<input id="loginPassword" class="control" type="password" autocomplete="current-password" required></label>
|
||||
<div id="loginError" class="auth-error hidden"></div>
|
||||
<button id="loginSubmit" class="btn" type="submit">Sign in</button>
|
||||
</form>
|
||||
<p id="authHint" class="auth-hint">Session is stored in an HttpOnly browser cookie and survives page reloads.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/charts.js" defer></script>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -73,6 +73,7 @@ class TZSPReceiver(threading.Thread):
|
||||
frame_writer: Callable[[bytes], int],
|
||||
stats: RuntimeStats,
|
||||
stop_event: threading.Event,
|
||||
frame_observer: Callable[[bytes], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="tzsp-receiver", daemon=True)
|
||||
self.bind_host = bind_host
|
||||
@@ -80,6 +81,7 @@ class TZSPReceiver(threading.Thread):
|
||||
self.frame_writer = frame_writer
|
||||
self.stats = stats
|
||||
self.stop_event = stop_event
|
||||
self.frame_observer = frame_observer
|
||||
self.sock: socket.socket | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -113,6 +115,12 @@ class TZSPReceiver(threading.Thread):
|
||||
self.stats.inc("tzsp_unsupported")
|
||||
continue
|
||||
|
||||
if self.frame_observer is not None:
|
||||
try:
|
||||
self.frame_observer(packet.frame)
|
||||
except Exception:
|
||||
# Live tracking is best-effort and must never break packet injection.
|
||||
self.stats.inc("flow_tracker_errors")
|
||||
try:
|
||||
self.frame_writer(packet.frame)
|
||||
self.stats.inc("frames_injected")
|
||||
|
||||
+849
-191
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user