Files
bigbrother/modules/intel/user_timeline.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
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
2026-04-08 22:18:35 -04:00

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,
}