ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
588 lines
22 KiB
Python
588 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-user activity timeline — aggregates login events, service access,
|
|
file operations, and web activity into chronological user profiles.
|
|
|
|
Ingests data from dns_logger, auth_flow_tracker, smb_monitor, and
|
|
credential_sniffer via the event bus and periodic state queries.
|
|
Identifies work hours, admin activity windows, and service account behavior.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Optional
|
|
|
|
from modules.base import BaseModule
|
|
from utils.credential_encryption import emit_credential_found
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS user_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp REAL NOT NULL,
|
|
username TEXT NOT NULL,
|
|
domain TEXT DEFAULT '',
|
|
event_type TEXT NOT NULL,
|
|
source_ip TEXT,
|
|
target_ip TEXT,
|
|
service TEXT,
|
|
detail TEXT DEFAULT '',
|
|
source_module TEXT DEFAULT ''
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_ue_user ON user_events(username);
|
|
CREATE INDEX IF NOT EXISTS idx_ue_ts ON user_events(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_ue_type ON user_events(event_type);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
|
username TEXT PRIMARY KEY,
|
|
domain TEXT DEFAULT '',
|
|
first_seen REAL,
|
|
last_seen REAL,
|
|
event_count INTEGER DEFAULT 0,
|
|
is_admin INTEGER DEFAULT 0,
|
|
is_service_acct INTEGER DEFAULT 0,
|
|
typical_hours TEXT DEFAULT '',
|
|
workstations TEXT DEFAULT '',
|
|
services_used TEXT DEFAULT '',
|
|
notes TEXT DEFAULT ''
|
|
);
|
|
"""
|
|
|
|
# Event types
|
|
EVENT_LOGIN = "login"
|
|
EVENT_LOGOUT = "logout"
|
|
EVENT_AUTH_FAIL = "auth_fail"
|
|
EVENT_SERVICE_ACCESS = "service_access"
|
|
EVENT_FILE_ACCESS = "file_access"
|
|
EVENT_WEB_BROWSE = "web_browse"
|
|
EVENT_ADMIN_ACTION = "admin_action"
|
|
EVENT_CRED_CAPTURED = "cred_captured"
|
|
|
|
# Admin indicators: services or patterns that suggest admin activity
|
|
_ADMIN_SERVICES = {
|
|
"rdp", "ssh", "winrm", "wmi", "psremoting", "dcom",
|
|
"ldap", "kerberos", "smb_admin", "rpc",
|
|
}
|
|
|
|
_ADMIN_USERNAMES = {
|
|
"administrator", "admin", "root", "domain admin",
|
|
}
|
|
|
|
# Service account patterns
|
|
_SVC_PATTERNS = (
|
|
"svc_", "svc-", "service_", "sa_", "task_", "app_",
|
|
"sql_", "iis_", "backup_", "scan_", "nessus", "splunk",
|
|
"crowdstrike", "sccm", "wsus",
|
|
)
|
|
|
|
|
|
class UserTimeline(BaseModule):
|
|
"""Build per-user activity timelines from network observation data."""
|
|
|
|
name = "user_timeline"
|
|
module_type = "intel"
|
|
priority = 200
|
|
requires_root = False
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._db_path = ""
|
|
self._conn: Optional[sqlite3.Connection] = None
|
|
self._lock = threading.Lock()
|
|
self._ingest_thread: Optional[threading.Thread] = None
|
|
self._event_count = 0
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
|
self._db_path = os.path.join(base_dir, "user_timeline.db")
|
|
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
|
|
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.executescript(_SCHEMA)
|
|
|
|
# Subscribe to credential and host events for user correlation
|
|
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
|
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
|
|
|
self._running = True
|
|
self._pid = os.getpid()
|
|
self._start_time = time.time()
|
|
|
|
# Periodic ingestion from other module state
|
|
self._ingest_thread = threading.Thread(
|
|
target=self._periodic_ingest, daemon=True,
|
|
name="sensor-user-timeline-ingest",
|
|
)
|
|
self._ingest_thread.start()
|
|
|
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
|
logger.info("UserTimeline started — db=%s", self._db_path)
|
|
|
|
def stop(self) -> None:
|
|
if not self._running:
|
|
return
|
|
self._running = False
|
|
|
|
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
|
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
|
|
|
if self._conn:
|
|
self._conn.close()
|
|
self._conn = None
|
|
|
|
self.state.set_module_status(self.name, "stopped")
|
|
logger.info("UserTimeline stopped — %d events recorded", self._event_count)
|
|
|
|
def status(self) -> dict:
|
|
user_count = 0
|
|
if self._conn:
|
|
try:
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(DISTINCT username) FROM user_events"
|
|
).fetchone()
|
|
user_count = row[0] if row else 0
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"total_events": self._event_count,
|
|
"tracked_users": user_count,
|
|
}
|
|
|
|
def configure(self, config: dict) -> None:
|
|
self.config.update(config)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _on_credential_found(self, event) -> None:
|
|
"""Record credential capture as a user event."""
|
|
p = event.payload
|
|
from utils.credential_encryption import decrypt_credential_payload
|
|
p = decrypt_credential_payload(p)
|
|
username = p.get("username", "")
|
|
if not username:
|
|
return
|
|
|
|
self._record_event(
|
|
username=username,
|
|
domain=p.get("domain", ""),
|
|
event_type=EVENT_CRED_CAPTURED,
|
|
source_ip=p.get("source_ip", ""),
|
|
target_ip=p.get("target_ip", ""),
|
|
service=p.get("service", ""),
|
|
detail=f"Type: {p.get('cred_type', 'unknown')}",
|
|
source_module=event.source_module,
|
|
)
|
|
|
|
def _on_host_discovered(self, event) -> None:
|
|
"""Check for user information in host discovery data."""
|
|
p = event.payload
|
|
# Some host discovery may include logged-in user information
|
|
username = p.get("logged_in_user", "")
|
|
if username:
|
|
self._record_event(
|
|
username=username,
|
|
domain=p.get("domain", ""),
|
|
event_type=EVENT_LOGIN,
|
|
source_ip=p.get("ip", ""),
|
|
target_ip="",
|
|
service="workstation",
|
|
detail=f"Active on {p.get('hostname', p.get('ip', ''))}",
|
|
source_module=event.source_module,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event recording
|
|
# ------------------------------------------------------------------
|
|
|
|
def _record_event(self, username: str, domain: str, event_type: str,
|
|
source_ip: str = "", target_ip: str = "",
|
|
service: str = "", detail: str = "",
|
|
source_module: str = "",
|
|
timestamp: float = None) -> None:
|
|
"""Record a user activity event."""
|
|
if not username:
|
|
return
|
|
|
|
ts = timestamp or time.time()
|
|
self._event_count += 1
|
|
|
|
with self._lock:
|
|
try:
|
|
self._conn.execute(
|
|
"""INSERT INTO user_events
|
|
(timestamp, username, domain, event_type, source_ip,
|
|
target_ip, service, detail, source_module)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(ts, username, domain, event_type, source_ip,
|
|
target_ip, service, detail, source_module),
|
|
)
|
|
|
|
# Update user profile
|
|
is_admin = 1 if (
|
|
service.lower() in _ADMIN_SERVICES
|
|
or username.lower() in _ADMIN_USERNAMES
|
|
or event_type == EVENT_ADMIN_ACTION
|
|
) else 0
|
|
|
|
is_svc = 1 if any(
|
|
username.lower().startswith(p) for p in _SVC_PATTERNS
|
|
) else 0
|
|
|
|
self._conn.execute(
|
|
"""INSERT INTO user_profiles
|
|
(username, domain, first_seen, last_seen, event_count,
|
|
is_admin, is_service_acct)
|
|
VALUES (?, ?, ?, ?, 1, ?, ?)
|
|
ON CONFLICT(username) DO UPDATE SET
|
|
last_seen = MAX(excluded.last_seen, user_profiles.last_seen),
|
|
event_count = user_profiles.event_count + 1,
|
|
is_admin = MAX(excluded.is_admin, user_profiles.is_admin),
|
|
is_service_acct = MAX(excluded.is_service_acct, user_profiles.is_service_acct)
|
|
""",
|
|
(username, domain, ts, ts, is_admin, is_svc),
|
|
)
|
|
|
|
self._conn.commit()
|
|
except Exception:
|
|
logger.exception("Failed to record user event for %s", username)
|
|
|
|
def ingest_auth_event(self, username: str, domain: str, source_ip: str,
|
|
target_ip: str, service: str, success: bool,
|
|
timestamp: float = None) -> None:
|
|
"""Ingest an authentication event from external modules."""
|
|
event_type = EVENT_LOGIN if success else EVENT_AUTH_FAIL
|
|
detail = "success" if success else "failure"
|
|
self._record_event(
|
|
username=username, domain=domain, event_type=event_type,
|
|
source_ip=source_ip, target_ip=target_ip, service=service,
|
|
detail=detail, source_module="auth_flow_tracker",
|
|
timestamp=timestamp,
|
|
)
|
|
|
|
def ingest_file_access(self, username: str, source_ip: str,
|
|
file_path: str, action: str = "read",
|
|
timestamp: float = None) -> None:
|
|
"""Ingest a file access event (from smb_monitor)."""
|
|
self._record_event(
|
|
username=username, domain="", event_type=EVENT_FILE_ACCESS,
|
|
source_ip=source_ip, target_ip="", service="smb",
|
|
detail=f"{action}: {file_path}",
|
|
source_module="smb_monitor", timestamp=timestamp,
|
|
)
|
|
|
|
def ingest_web_activity(self, source_ip: str, domain: str,
|
|
url: str = "", timestamp: float = None) -> None:
|
|
"""Ingest web browsing activity (correlated to user by IP)."""
|
|
# Look up username by source_ip from recent events
|
|
username = self._resolve_user_by_ip(source_ip)
|
|
if not username:
|
|
username = f"host:{source_ip}"
|
|
|
|
self._record_event(
|
|
username=username, domain="", event_type=EVENT_WEB_BROWSE,
|
|
source_ip=source_ip, target_ip="", service="web",
|
|
detail=url or domain,
|
|
source_module="dns_logger", timestamp=timestamp,
|
|
)
|
|
|
|
def _resolve_user_by_ip(self, ip: str) -> str:
|
|
"""Try to resolve a username from a source IP using recent login events."""
|
|
with self._lock:
|
|
try:
|
|
row = self._conn.execute(
|
|
"""SELECT username FROM user_events
|
|
WHERE source_ip = ? AND event_type = ?
|
|
ORDER BY timestamp DESC LIMIT 1""",
|
|
(ip, EVENT_LOGIN),
|
|
).fetchone()
|
|
return row["username"] if row else ""
|
|
except Exception:
|
|
return ""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Periodic state ingestion
|
|
# ------------------------------------------------------------------
|
|
|
|
def _periodic_ingest(self) -> None:
|
|
"""Periodically pull data from other module state stores."""
|
|
interval = self.config.get("timeline_ingest_interval", 120)
|
|
while self._running:
|
|
time.sleep(interval)
|
|
try:
|
|
self._ingest_from_auth_tracker()
|
|
self._ingest_from_smb_monitor()
|
|
self._ingest_from_dns_logger()
|
|
self._update_user_profiles()
|
|
except Exception:
|
|
logger.exception("Periodic user timeline ingest failed")
|
|
|
|
def _ingest_from_auth_tracker(self) -> None:
|
|
"""Pull authentication events from auth_flow_tracker state."""
|
|
last_ts = self.state.get(self.name, "auth_tracker_last_ts")
|
|
last_ts = float(last_ts) if last_ts else 0
|
|
|
|
auth_json = self.state.get("auth_flow_tracker", "recent_events")
|
|
if not auth_json:
|
|
return
|
|
|
|
try:
|
|
events = json.loads(auth_json)
|
|
for evt in events:
|
|
ts = evt.get("timestamp", 0)
|
|
if ts <= last_ts:
|
|
continue
|
|
self.ingest_auth_event(
|
|
username=evt.get("username", ""),
|
|
domain=evt.get("domain", ""),
|
|
source_ip=evt.get("source_ip", ""),
|
|
target_ip=evt.get("target_ip", ""),
|
|
service=evt.get("service", ""),
|
|
success=evt.get("success", True),
|
|
timestamp=ts,
|
|
)
|
|
last_ts = max(last_ts, ts)
|
|
|
|
self.state.set(self.name, "auth_tracker_last_ts", str(last_ts))
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
def _ingest_from_smb_monitor(self) -> None:
|
|
"""Pull SMB file access events from smb_monitor state."""
|
|
last_ts = self.state.get(self.name, "smb_monitor_last_ts")
|
|
last_ts = float(last_ts) if last_ts else 0
|
|
|
|
smb_json = self.state.get("smb_monitor", "recent_file_ops")
|
|
if not smb_json:
|
|
return
|
|
|
|
try:
|
|
events = json.loads(smb_json)
|
|
for evt in events:
|
|
ts = evt.get("timestamp", 0)
|
|
if ts <= last_ts:
|
|
continue
|
|
self.ingest_file_access(
|
|
username=evt.get("username", ""),
|
|
source_ip=evt.get("source_ip", ""),
|
|
file_path=evt.get("path", ""),
|
|
action=evt.get("action", "read"),
|
|
timestamp=ts,
|
|
)
|
|
last_ts = max(last_ts, ts)
|
|
|
|
self.state.set(self.name, "smb_monitor_last_ts", str(last_ts))
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
def _ingest_from_dns_logger(self) -> None:
|
|
"""Pull DNS query data for web activity correlation."""
|
|
last_ts = self.state.get(self.name, "dns_logger_last_ts")
|
|
last_ts = float(last_ts) if last_ts else 0
|
|
|
|
dns_json = self.state.get("dns_logger", "recent_queries")
|
|
if not dns_json:
|
|
return
|
|
|
|
try:
|
|
queries = json.loads(dns_json)
|
|
for q in queries:
|
|
ts = q.get("timestamp", 0)
|
|
if ts <= last_ts:
|
|
continue
|
|
self.ingest_web_activity(
|
|
source_ip=q.get("client_ip", ""),
|
|
domain=q.get("query_name", ""),
|
|
timestamp=ts,
|
|
)
|
|
last_ts = max(last_ts, ts)
|
|
|
|
self.state.set(self.name, "dns_logger_last_ts", str(last_ts))
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
def _update_user_profiles(self) -> None:
|
|
"""Refresh computed fields on user profiles (work hours, workstations, etc)."""
|
|
with self._lock:
|
|
try:
|
|
users = self._conn.execute(
|
|
"SELECT DISTINCT username FROM user_events"
|
|
).fetchall()
|
|
|
|
for (username,) in users:
|
|
# Compute typical hours
|
|
rows = self._conn.execute(
|
|
"""SELECT timestamp FROM user_events
|
|
WHERE username = ?""",
|
|
(username,),
|
|
).fetchall()
|
|
|
|
hours = defaultdict(int)
|
|
for (ts,) in rows:
|
|
hour = time.localtime(ts).tm_hour
|
|
hours[hour] += 1
|
|
|
|
# Top active hours (>10% of activity)
|
|
total = sum(hours.values())
|
|
if total > 0:
|
|
typical = sorted(
|
|
[h for h, c in hours.items() if c / total > 0.1]
|
|
)
|
|
else:
|
|
typical = []
|
|
|
|
# Workstations (source IPs used for login)
|
|
ws_rows = self._conn.execute(
|
|
"""SELECT DISTINCT source_ip FROM user_events
|
|
WHERE username = ? AND event_type = ? AND source_ip != ''""",
|
|
(username, EVENT_LOGIN),
|
|
).fetchall()
|
|
workstations = [r[0] for r in ws_rows]
|
|
|
|
# Services used
|
|
svc_rows = self._conn.execute(
|
|
"""SELECT DISTINCT service FROM user_events
|
|
WHERE username = ? AND service != ''""",
|
|
(username,),
|
|
).fetchall()
|
|
services = [r[0] for r in svc_rows]
|
|
|
|
self._conn.execute(
|
|
"""UPDATE user_profiles
|
|
SET typical_hours = ?, workstations = ?, services_used = ?
|
|
WHERE username = ?""",
|
|
(json.dumps(typical), json.dumps(workstations),
|
|
json.dumps(services), username),
|
|
)
|
|
|
|
self._conn.commit()
|
|
except Exception:
|
|
logger.exception("Failed to update user profiles")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Query interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_timeline(self, username: str, limit: int = 200,
|
|
since: float = 0) -> list:
|
|
"""Get chronological activity timeline for a user."""
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT * FROM user_events
|
|
WHERE username = ? AND timestamp > ?
|
|
ORDER BY timestamp DESC LIMIT ?""",
|
|
(username, since, limit),
|
|
).fetchall()
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_active_users(self, hours: float = 1.0) -> list:
|
|
"""Get users active within the given time window."""
|
|
since = time.time() - (hours * 3600)
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT username, domain, COUNT(*) as event_count,
|
|
MAX(timestamp) as last_active
|
|
FROM user_events
|
|
WHERE timestamp > ?
|
|
GROUP BY username
|
|
ORDER BY last_active DESC""",
|
|
(since,),
|
|
).fetchall()
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_admin_users(self) -> list:
|
|
"""Get all users identified as having admin privileges."""
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT * FROM user_profiles
|
|
WHERE is_admin = 1
|
|
ORDER BY last_seen DESC"""
|
|
).fetchall()
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_service_accounts(self) -> list:
|
|
"""Get all detected service accounts."""
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT * FROM user_profiles
|
|
WHERE is_service_acct = 1
|
|
ORDER BY event_count DESC"""
|
|
).fetchall()
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_user_profile(self, username: str) -> Optional[dict]:
|
|
"""Get computed profile for a user."""
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT * FROM user_profiles WHERE username = ?",
|
|
(username,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
profile = dict(row)
|
|
# Parse JSON fields
|
|
for field in ("typical_hours", "workstations", "services_used"):
|
|
try:
|
|
profile[field] = json.loads(profile.get(field, "[]"))
|
|
except (json.JSONDecodeError, TypeError):
|
|
profile[field] = []
|
|
|
|
return profile
|
|
|
|
def get_user_work_hours(self, username: str) -> dict:
|
|
"""Analyze and return a user's typical work hours."""
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT timestamp FROM user_events
|
|
WHERE username = ?""",
|
|
(username,),
|
|
).fetchall()
|
|
|
|
if not rows:
|
|
return {"username": username, "hours": {}, "typical_start": None, "typical_end": None}
|
|
|
|
hours = defaultdict(int)
|
|
for (ts,) in rows:
|
|
hour = time.localtime(ts).tm_hour
|
|
hours[hour] += 1
|
|
|
|
total = sum(hours.values())
|
|
active_hours = sorted([h for h, c in hours.items() if c / total > 0.05])
|
|
|
|
return {
|
|
"username": username,
|
|
"hours": dict(hours),
|
|
"active_hours": active_hours,
|
|
"typical_start": active_hours[0] if active_hours else None,
|
|
"typical_end": active_hours[-1] if active_hours else None,
|
|
"total_events": total,
|
|
}
|