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
This commit is contained in:
n0mad1k
2026-04-01 22:11:01 -04:00
parent 2611221981
commit 2762b0dfb8
12 changed files with 4034 additions and 84 deletions
+495
View File
@@ -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