Files
Bjorn/db_utils/agents.py

370 lines
14 KiB
Python

# db_utils/agents.py
# C2 (Command & Control) agent management operations
import json
import os
import sqlite3
from typing import List, Optional
import logging
from logger import Logger
logger = Logger(name="db_utils.agents", level=logging.DEBUG)
class AgentOps:
"""C2 agent tracking and command history operations"""
def __init__(self, base):
self.base = base
def create_tables(self):
"""Create C2 agent tables"""
# Agents table
self.base.execute("""
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
hostname TEXT,
platform TEXT,
os_version TEXT,
architecture TEXT,
ip_address TEXT,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
status TEXT,
notes TEXT
);
""")
# Indexes for performance
self.base.execute("CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen);")
self.base.execute("CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);")
# Commands table
self.base.execute("""
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT,
command TEXT,
timestamp TIMESTAMP,
response TEXT,
success BOOLEAN,
FOREIGN KEY (agent_id) REFERENCES agents (id)
);
""")
# Agent keys (versioned for rotation)
self.base.execute("""
CREATE TABLE IF NOT EXISTS agent_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
key_b64 TEXT NOT NULL,
version INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
rotated_at TIMESTAMP,
revoked_at TIMESTAMP,
active INTEGER DEFAULT 1,
UNIQUE(agent_id, version)
);
""")
self.base.execute("CREATE INDEX IF NOT EXISTS idx_agent_keys_active ON agent_keys(agent_id, active);")
# Loot table
self.base.execute("""
CREATE TABLE IF NOT EXISTS loot (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT,
filename TEXT,
filepath TEXT,
size INTEGER,
timestamp TIMESTAMP,
hash TEXT,
FOREIGN KEY (agent_id) REFERENCES agents (id)
);
""")
# Telemetry table
self.base.execute("""
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT,
cpu_percent REAL,
mem_percent REAL,
disk_percent REAL,
uptime INTEGER,
timestamp TIMESTAMP,
FOREIGN KEY (agent_id) REFERENCES agents (id)
);
""")
logger.debug("C2 agent tables created/verified")
# =========================================================================
# AGENT OPERATIONS
# =========================================================================
def save_agent(self, agent_data: dict) -> None:
"""
Upsert an agent preserving first_seen and updating last_seen.
Status field expected as str (e.g. 'online'/'offline').
"""
agent_id = agent_data.get('id')
hostname = agent_data.get('hostname')
platform_ = agent_data.get('platform')
os_version = agent_data.get('os_version')
arch = agent_data.get('architecture')
ip_address = agent_data.get('ip_address')
status = agent_data.get('status') or 'offline'
notes = agent_data.get('notes')
if not agent_id:
raise ValueError("save_agent: 'id' is required in agent_data")
# Upsert that preserves first_seen and updates last_seen to NOW
self.base.execute("""
INSERT INTO agents (id, hostname, platform, os_version, architecture, ip_address,
first_seen, last_seen, status, notes)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)
ON CONFLICT(id) DO UPDATE SET
hostname = COALESCE(excluded.hostname, agents.hostname),
platform = COALESCE(excluded.platform, agents.platform),
os_version = COALESCE(excluded.os_version, agents.os_version),
architecture = COALESCE(excluded.architecture, agents.architecture),
ip_address = COALESCE(excluded.ip_address, agents.ip_address),
first_seen = COALESCE(agents.first_seen, excluded.first_seen, CURRENT_TIMESTAMP),
last_seen = CURRENT_TIMESTAMP,
status = COALESCE(excluded.status, agents.status),
notes = COALESCE(excluded.notes, agents.notes)
""", (agent_id, hostname, platform_, os_version, arch, ip_address, status, notes))
# Optionally refresh zombie counter
try:
self._refresh_zombie_counter()
except Exception:
pass
def save_command(self, agent_id: str, command: str,
response: str | None = None, success: bool = False) -> None:
"""Record a command history entry"""
if not agent_id or not command:
raise ValueError("save_command: 'agent_id' and 'command' are required")
self.base.execute("""
INSERT INTO commands (agent_id, command, timestamp, response, success)
VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?)
""", (agent_id, command, response, 1 if success else 0))
def save_telemetry(self, agent_id: str, telemetry: dict) -> None:
"""Record a telemetry snapshot for an agent"""
if not agent_id:
raise ValueError("save_telemetry: 'agent_id' is required")
self.base.execute("""
INSERT INTO telemetry (agent_id, cpu_percent, mem_percent, disk_percent, uptime, timestamp)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (
agent_id,
telemetry.get('cpu_percent'),
telemetry.get('mem_percent'),
telemetry.get('disk_percent'),
telemetry.get('uptime')
))
def save_loot(self, loot: dict) -> None:
"""
Record a retrieved file (loot).
Expected: {'agent_id', 'filename', 'filepath', 'size', 'hash'}
Timestamp is added database-side.
"""
if not loot or not loot.get('agent_id') or not loot.get('filename'):
raise ValueError("save_loot: 'agent_id' and 'filename' are required")
self.base.execute("""
INSERT INTO loot (agent_id, filename, filepath, size, timestamp, hash)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
""", (
loot.get('agent_id'),
loot.get('filename'),
loot.get('filepath'),
int(loot.get('size') or 0),
loot.get('hash')
))
def get_agent_history(self, agent_id: str) -> List[dict]:
"""
Return the 100 most recent commands for an agent (most recent first).
"""
if not agent_id:
return []
rows = self.base.query("""
SELECT command, timestamp, response, success
FROM commands
WHERE agent_id = ?
ORDER BY datetime(timestamp) DESC
LIMIT 100
""", (agent_id,))
# Normalize success to bool
for r in rows:
r['success'] = bool(r.get('success'))
return rows
def purge_stale_agents(self, threshold_seconds: int) -> int:
"""
Delete agents whose last_seen is older than now - threshold_seconds.
Returns the number of deleted rows.
"""
if not threshold_seconds or threshold_seconds <= 0:
return 0
return self.base.execute("""
DELETE FROM agents
WHERE last_seen IS NOT NULL
AND datetime(last_seen) < datetime('now', ?)
""", (f'-{threshold_seconds} seconds',))
def get_stale_agents(self, threshold_seconds: int) -> list[dict]:
"""
Return the list of agents whose last_seen is older than now - threshold_seconds.
Useful for detecting/purging inactive agents.
"""
if not threshold_seconds or threshold_seconds <= 0:
return []
rows = self.base.query("""
SELECT *
FROM agents
WHERE last_seen IS NOT NULL
AND datetime(last_seen) < datetime('now', ?)
""", (f'-{threshold_seconds} seconds',))
return rows or []
# =========================================================================
# AGENT KEY MANAGEMENT
# =========================================================================
def get_active_key(self, agent_id: str) -> str | None:
"""Return the active key (base64) for an agent, or None"""
row = self.base.query_one("""
SELECT key_b64 FROM agent_keys
WHERE agent_id=? AND active=1
ORDER BY version DESC
LIMIT 1
""", (agent_id,))
return row["key_b64"] if row else None
def list_keys(self, agent_id: str) -> list[dict]:
"""List all keys for an agent (versions, states)"""
return self.base.query("""
SELECT id, agent_id, key_b64, version, created_at, rotated_at, revoked_at, active
FROM agent_keys
WHERE agent_id=?
ORDER BY version DESC
""", (agent_id,))
def _next_key_version(self, agent_id: str) -> int:
"""Get next key version number for an agent"""
row = self.base.query_one("SELECT COALESCE(MAX(version),0) AS v FROM agent_keys WHERE agent_id=?", (agent_id,))
return int(row["v"] or 0) + 1
def save_new_key(self, agent_id: str, key_b64: str) -> int:
"""
Record a first key for an agent (if no existing key).
Returns the version created.
"""
v = self._next_key_version(agent_id)
self.base.execute("""
INSERT INTO agent_keys(agent_id, key_b64, version, active)
VALUES(?,?,?,1)
""", (agent_id, key_b64, v))
return v
def rotate_key(self, agent_id: str, new_key_b64: str) -> int:
"""
Rotation: disable old active key (rotated_at), insert new one in version+1 active=1.
Returns the new version.
"""
with self.base.transaction():
# Disable existing active key
self.base.execute("""
UPDATE agent_keys
SET active=0, rotated_at=CURRENT_TIMESTAMP
WHERE agent_id=? AND active=1
""", (agent_id,))
# Insert new
v = self._next_key_version(agent_id)
self.base.execute("""
INSERT INTO agent_keys(agent_id, key_b64, version, active)
VALUES(?,?,?,1)
""", (agent_id, new_key_b64, v))
return v
def revoke_keys(self, agent_id: str) -> int:
"""
Total revocation: active=0 + revoked_at now for all agent keys.
Returns the number of affected rows.
"""
return self.base.execute("""
UPDATE agent_keys
SET active=0, revoked_at=CURRENT_TIMESTAMP
WHERE agent_id=? AND active=1
""", (agent_id,))
def verify_client_key(self, agent_id: str, key_b64: str) -> bool:
"""True if the provided key matches an active key for this agent"""
row = self.base.query_one("""
SELECT 1 FROM agent_keys
WHERE agent_id=? AND key_b64=? AND active=1
LIMIT 1
""", (agent_id, key_b64))
return bool(row)
def migrate_keys_from_file(self, json_path: str) -> int:
"""
One-shot migration from a keys.json in format {agent_id: key_b64}.
For each agent: if no active key, create it in version 1.
Returns the number of keys inserted.
"""
if not json_path or not os.path.exists(json_path):
return 0
inserted = 0
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return 0
with self.base.transaction():
for agent_id, key_b64 in data.items():
if not self.get_active_key(agent_id):
self.save_new_key(agent_id, key_b64)
inserted += 1
except Exception:
pass
return inserted
# =========================================================================
# HELPER METHODS
# =========================================================================
def _refresh_zombie_counter(self) -> None:
"""
Update stats.zombie_count with the number of online agents.
Won't fail if the column doesn't exist yet.
"""
try:
row = self.base.query_one("SELECT COUNT(*) AS c FROM agents WHERE LOWER(status)='online';")
count = int(row['c'] if row else 0)
updated = self.base.execute("UPDATE stats SET zombie_count=? WHERE id=1;", (count,))
if not updated:
# Ensure singleton row exists
self.base.execute("INSERT OR IGNORE INTO stats(id) VALUES(1);")
self.base.execute("UPDATE stats SET zombie_count=? WHERE id=1;", (count,))
except sqlite3.OperationalError:
# Column absent: add it properly and retry
try:
self.base.execute("ALTER TABLE stats ADD COLUMN zombie_count INTEGER DEFAULT 0;")
self.base.execute("UPDATE stats SET zombie_count=0 WHERE id=1;")
row = self.base.query_one("SELECT COUNT(*) AS c FROM agents WHERE LOWER(status)='online';")
count = int(row['c'] if row else 0)
self.base.execute("UPDATE stats SET zombie_count=? WHERE id=1;", (count,))
except Exception:
pass