Add main CLI entry point and full test suite
- bigbrother.py: Click CLI with start/stop/status/activate/deactivate/ kill/config/selftest/modules commands plus Rich interactive menu. Module discovery scans modules/stealth/ for BaseModule subclasses. Supports --daemon mode with PID file and --passive-only flag. - tests/conftest.py: Shared fixtures (tmp_dir, mock_config, mock_bus, mock_state, hardware_tier_override, project_root) - tests/test_bus.py: 6 tests for EventBus pub/sub, filtering, wildcards - tests/test_engine.py: 5 tests for Engine lifecycle, deps, tier, phase - tests/test_crypto.py: 5 tests for AES-256-GCM, file encryption, KDF - tests/test_resource.py: 7 tests for hardware detection, memory, disk - tests/test_tool_manager.py: 6 tests for subprocess management - tests/test_stealth_modules.py: 4 parametrized test classes covering all 12 stealth modules (import, inheritance, attributes, instantiation)
This commit is contained in:
Executable
+625
@@ -0,0 +1,625 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""BigBrother — Network surveillance implant CLI.
|
||||||
|
|
||||||
|
Main entry point: Click-based CLI + Rich interactive menu.
|
||||||
|
Manages module lifecycle, status monitoring, and kill switch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import logging
|
||||||
|
import inspect
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Type
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
|
||||||
|
# Ensure project root is on sys.path
|
||||||
|
PROJECT_ROOT = str(Path(__file__).resolve().parent)
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
from core.bus import EventBus
|
||||||
|
from core.state import StateManager
|
||||||
|
from core.engine import Engine, detect_hardware_tier, HARDWARE_TIERS
|
||||||
|
from core.tool_manager import ToolManager
|
||||||
|
from core.scheduler import Scheduler
|
||||||
|
from core.resource_monitor import ResourceMonitor
|
||||||
|
from core.kill_switch import KillSwitch
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.config_loader import load_config, ConfigLoader
|
||||||
|
from utils.resource import (
|
||||||
|
detect_hardware,
|
||||||
|
get_resource_usage,
|
||||||
|
get_hardware_tier,
|
||||||
|
get_cpu_temperature,
|
||||||
|
get_disk_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
logger = logging.getLogger("bb.cli")
|
||||||
|
|
||||||
|
# PID file for daemon mode
|
||||||
|
PID_FILE = "/tmp/.bb.pid"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def discover_stealth_modules() -> Dict[str, Type[BaseModule]]:
|
||||||
|
"""Scan modules/stealth/ for BaseModule subclasses."""
|
||||||
|
modules: Dict[str, Type[BaseModule]] = {}
|
||||||
|
stealth_dir = os.path.join(PROJECT_ROOT, "modules", "stealth")
|
||||||
|
|
||||||
|
if not os.path.isdir(stealth_dir):
|
||||||
|
return modules
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(stealth_dir)):
|
||||||
|
if filename.startswith("_") or not filename.endswith(".py"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
module_name = filename[:-3]
|
||||||
|
dotted = f"modules.stealth.{module_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(dotted)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to import %s: %s", dotted, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for attr_name in dir(mod):
|
||||||
|
obj = getattr(mod, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(obj, type)
|
||||||
|
and issubclass(obj, BaseModule)
|
||||||
|
and obj is not BaseModule
|
||||||
|
and hasattr(obj, "name")
|
||||||
|
and obj.name != "unnamed"
|
||||||
|
):
|
||||||
|
modules[obj.name] = obj
|
||||||
|
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_config(module_name: str, config: dict) -> dict:
|
||||||
|
"""Extract module-specific config from the master config."""
|
||||||
|
# Check for module-specific section
|
||||||
|
mod_cfg = config.get("modules", {}).get(module_name, {})
|
||||||
|
if mod_cfg:
|
||||||
|
# Merge with top-level config for access to device, network, etc.
|
||||||
|
merged = dict(config)
|
||||||
|
merged.update(mod_cfg)
|
||||||
|
return merged
|
||||||
|
return dict(config)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Click CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx):
|
||||||
|
"""BigBrother — Network surveillance implant."""
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
interactive_menu()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--daemon", is_flag=True, help="Fork to background, write PID file")
|
||||||
|
@click.option("--passive-only", is_flag=True, help="Force passive-only engagement phase")
|
||||||
|
def start(daemon, passive_only):
|
||||||
|
"""Start BigBrother — initialize all core systems and enabled modules."""
|
||||||
|
if daemon:
|
||||||
|
_daemonize()
|
||||||
|
|
||||||
|
console.print("[bold green]Starting BigBrother...[/bold green]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]Config error:[/red] {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if passive_only:
|
||||||
|
config.setdefault("engagement_phase", {})["mode"] = "passive_only"
|
||||||
|
|
||||||
|
# Initialize core components
|
||||||
|
bus = EventBus()
|
||||||
|
bus.start()
|
||||||
|
|
||||||
|
state = StateManager()
|
||||||
|
state.start()
|
||||||
|
|
||||||
|
engine = Engine(bus=bus, state=state, config=config)
|
||||||
|
tool_manager = ToolManager(bus=bus)
|
||||||
|
tool_manager.start_monitoring()
|
||||||
|
|
||||||
|
# Discover and register stealth modules
|
||||||
|
discovered = discover_stealth_modules()
|
||||||
|
console.print(f" Discovered [cyan]{len(discovered)}[/cyan] stealth modules")
|
||||||
|
|
||||||
|
enabled_modules = config.get("modules", {}).get("enabled", list(discovered.keys()))
|
||||||
|
for name, cls in discovered.items():
|
||||||
|
if name in enabled_modules or not config.get("modules", {}).get("enabled"):
|
||||||
|
mod_config = get_module_config(name, config)
|
||||||
|
engine.register(cls, mod_config)
|
||||||
|
|
||||||
|
# Start modules in dependency order
|
||||||
|
results = engine.start_all()
|
||||||
|
for name, success in results.items():
|
||||||
|
status_str = "[green]OK[/green]" if success else "[red]FAILED[/red]"
|
||||||
|
console.print(f" {name}: {status_str}")
|
||||||
|
|
||||||
|
# Start resource monitor
|
||||||
|
res_mon = ResourceMonitor(bus=bus, state=state, config=config,
|
||||||
|
engine=engine, tool_manager=tool_manager)
|
||||||
|
res_mon.start()
|
||||||
|
|
||||||
|
console.print(f"\n[bold green]BigBrother running[/bold green] (tier={engine.tier}, phase={engine.phase})")
|
||||||
|
|
||||||
|
if daemon:
|
||||||
|
# Write PID file
|
||||||
|
with open(PID_FILE, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
# Block main thread
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
console.print("Press Ctrl+C to stop")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
console.print("\n[yellow]Shutting down...[/yellow]")
|
||||||
|
res_mon.stop()
|
||||||
|
engine.stop_all()
|
||||||
|
tool_manager.stop_all()
|
||||||
|
state.stop()
|
||||||
|
bus.stop()
|
||||||
|
console.print("[green]BigBrother stopped.[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def stop():
|
||||||
|
"""Graceful shutdown — stop all modules in reverse dependency order."""
|
||||||
|
if os.path.exists(PID_FILE):
|
||||||
|
try:
|
||||||
|
with open(PID_FILE, "r") as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
console.print(f"[green]Sent SIGTERM to BigBrother (PID {pid})[/green]")
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except (ProcessLookupError, ValueError):
|
||||||
|
console.print("[yellow]BigBrother not running (stale PID file)[/yellow]")
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except PermissionError:
|
||||||
|
console.print("[red]Permission denied — try with sudo[/red]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]No PID file found — BigBrother may not be running[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def status():
|
||||||
|
"""Show module status as a Rich table."""
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
state = StateManager()
|
||||||
|
all_status = state.get_all_module_status()
|
||||||
|
|
||||||
|
table = Table(title="BigBrother Module Status", show_lines=True)
|
||||||
|
table.add_column("Module", style="cyan", min_width=20)
|
||||||
|
table.add_column("Status", min_width=10)
|
||||||
|
table.add_column("PID", justify="right", min_width=8)
|
||||||
|
table.add_column("Uptime", justify="right", min_width=12)
|
||||||
|
|
||||||
|
for name, info in sorted(all_status.items()):
|
||||||
|
mod_status = info.get("status", "unknown")
|
||||||
|
pid = str(info.get("pid", "-") or "-")
|
||||||
|
started = info.get("started")
|
||||||
|
|
||||||
|
if mod_status == "running":
|
||||||
|
status_text = Text("running", style="bold green")
|
||||||
|
if started:
|
||||||
|
uptime_sec = time.time() - started
|
||||||
|
uptime_str = _format_uptime(uptime_sec)
|
||||||
|
else:
|
||||||
|
uptime_str = "-"
|
||||||
|
elif mod_status == "stopped":
|
||||||
|
status_text = Text("stopped", style="dim")
|
||||||
|
uptime_str = "-"
|
||||||
|
elif mod_status == "error":
|
||||||
|
status_text = Text("error", style="bold red")
|
||||||
|
uptime_str = "-"
|
||||||
|
else:
|
||||||
|
status_text = Text(mod_status, style="yellow")
|
||||||
|
uptime_str = "-"
|
||||||
|
|
||||||
|
table.add_row(name, status_text, pid, uptime_str)
|
||||||
|
|
||||||
|
if not all_status:
|
||||||
|
table.add_row("[dim]No modules registered[/dim]", "", "", "")
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("module_name")
|
||||||
|
def activate(module_name):
|
||||||
|
"""Enable and start a specific module."""
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]Config error:[/red] {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
discovered = discover_stealth_modules()
|
||||||
|
if module_name not in discovered:
|
||||||
|
console.print(f"[red]Module '{module_name}' not found.[/red]")
|
||||||
|
console.print(f"Available: {', '.join(sorted(discovered.keys()))}")
|
||||||
|
return
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
bus.start()
|
||||||
|
state = StateManager()
|
||||||
|
state.start()
|
||||||
|
engine = Engine(bus=bus, state=state, config=config)
|
||||||
|
|
||||||
|
cls = discovered[module_name]
|
||||||
|
mod_config = get_module_config(module_name, config)
|
||||||
|
engine.register(cls, mod_config)
|
||||||
|
|
||||||
|
success = engine.start(module_name)
|
||||||
|
if success:
|
||||||
|
console.print(f"[green]Module '{module_name}' activated.[/green]")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Failed to activate '{module_name}'.[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("module_name")
|
||||||
|
def deactivate(module_name):
|
||||||
|
"""Stop and disable a specific module."""
|
||||||
|
state = StateManager()
|
||||||
|
mod_status = state.get_module_status(module_name)
|
||||||
|
|
||||||
|
if mod_status.get("status") == "unknown":
|
||||||
|
console.print(f"[yellow]Module '{module_name}' is not registered.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send SIGTERM to the module's PID if running
|
||||||
|
pid = mod_status.get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
console.print(f"[green]Module '{module_name}' (PID {pid}) stopped.[/green]")
|
||||||
|
except (ProcessLookupError, PermissionError) as exc:
|
||||||
|
console.print(f"[yellow]Could not stop PID {pid}: {exc}[/yellow]")
|
||||||
|
|
||||||
|
state.set_module_status(module_name, "stopped")
|
||||||
|
console.print(f"[green]Module '{module_name}' deactivated.[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("kill")
|
||||||
|
def kill_switch():
|
||||||
|
"""Activate kill switch — full wipe and shutdown."""
|
||||||
|
console.print(Panel(
|
||||||
|
"[bold red]KILL SWITCH[/bold red]\n\n"
|
||||||
|
"This will:\n"
|
||||||
|
" 1. Stop all modules and subprocesses\n"
|
||||||
|
" 2. Send corrective ARP packets\n"
|
||||||
|
" 3. Destroy LUKS encryption header\n"
|
||||||
|
" 4. Shred all sensitive files\n"
|
||||||
|
" 5. Zero-fill data partition\n"
|
||||||
|
" 6. Clear RAM and reboot\n\n"
|
||||||
|
"[bold]THIS ACTION IS IRREVERSIBLE.[/bold]",
|
||||||
|
title="WARNING",
|
||||||
|
border_style="red",
|
||||||
|
))
|
||||||
|
|
||||||
|
confirm = Prompt.ask("Type WIPE to confirm", default="")
|
||||||
|
if confirm != "WIPE":
|
||||||
|
console.print("[yellow]Kill switch aborted.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
ks = KillSwitch(bus=bus, config=config)
|
||||||
|
console.print("[red]Executing kill switch...[/red]")
|
||||||
|
ks.execute(reason="manual_cli")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("config")
|
||||||
|
def show_config():
|
||||||
|
"""Pretty-print current configuration."""
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f"[red]Config error:[/red] {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
import yaml
|
||||||
|
yaml_str = yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||||
|
syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False)
|
||||||
|
console.print(Panel(syntax, title="BigBrother Configuration", border_style="cyan"))
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def selftest():
|
||||||
|
"""Run health checks: imports, tier detection, permissions, config, storage, binaries."""
|
||||||
|
console.print("[bold]Running self-test...[/bold]\n")
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
# 1. Core imports
|
||||||
|
tests = [
|
||||||
|
("Core: EventBus", lambda: __import__("core.bus")),
|
||||||
|
("Core: StateManager", lambda: __import__("core.state")),
|
||||||
|
("Core: Engine", lambda: __import__("core.engine")),
|
||||||
|
("Core: ToolManager", lambda: __import__("core.tool_manager")),
|
||||||
|
("Core: KillSwitch", lambda: __import__("core.kill_switch")),
|
||||||
|
("Core: Scheduler", lambda: __import__("core.scheduler")),
|
||||||
|
("Core: ResourceMonitor", lambda: __import__("core.resource_monitor")),
|
||||||
|
("Utils: crypto", lambda: __import__("utils.crypto")),
|
||||||
|
("Utils: config_loader", lambda: __import__("utils.config_loader")),
|
||||||
|
("Utils: resource", lambda: __import__("utils.resource")),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, test_fn in tests:
|
||||||
|
try:
|
||||||
|
test_fn()
|
||||||
|
console.print(f" [green]PASS[/green] {label}")
|
||||||
|
passed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f" [red]FAIL[/red] {label}: {exc}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 2. Hardware tier
|
||||||
|
try:
|
||||||
|
tier = get_hardware_tier()
|
||||||
|
console.print(f" [green]PASS[/green] Hardware tier: {tier}")
|
||||||
|
passed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f" [red]FAIL[/red] Hardware tier: {exc}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 3. Config loading
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
console.print(f" [green]PASS[/green] Config loaded ({len(config)} top-level keys)")
|
||||||
|
passed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f" [red]FAIL[/red] Config: {exc}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 4. Permissions
|
||||||
|
install_path = "/opt/.cache/bb"
|
||||||
|
if os.path.isdir(install_path):
|
||||||
|
console.print(f" [green]PASS[/green] Install path exists: {install_path}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
console.print(f" [yellow]SKIP[/yellow] Install path not found: {install_path}")
|
||||||
|
|
||||||
|
# 5. Storage
|
||||||
|
try:
|
||||||
|
total, free, used_pct = get_disk_usage("/")
|
||||||
|
console.print(f" [green]PASS[/green] Disk: {total:.1f}GB total, {free:.1f}GB free ({used_pct:.1f}% used)")
|
||||||
|
passed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
console.print(f" [red]FAIL[/red] Disk check: {exc}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 6. External binaries
|
||||||
|
binaries = ["tcpdump", "ip", "bettercap", "cryptsetup"]
|
||||||
|
for binary in binaries:
|
||||||
|
from shutil import which
|
||||||
|
if which(binary):
|
||||||
|
console.print(f" [green]PASS[/green] Binary: {binary}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
console.print(f" [yellow]SKIP[/yellow] Binary not found: {binary}")
|
||||||
|
|
||||||
|
# 7. Stealth modules
|
||||||
|
discovered = discover_stealth_modules()
|
||||||
|
console.print(f" [green]PASS[/green] Stealth modules discovered: {len(discovered)}")
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
console.print(f"\n[bold]Results: {passed} passed, {failed} failed[/bold]")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("modules")
|
||||||
|
def list_modules():
|
||||||
|
"""List all available modules with type, enabled status, and dependencies."""
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
discovered = discover_stealth_modules()
|
||||||
|
enabled_list = config.get("modules", {}).get("enabled")
|
||||||
|
|
||||||
|
table = Table(title="Available Modules", show_lines=True)
|
||||||
|
table.add_column("Module", style="cyan", min_width=22)
|
||||||
|
table.add_column("Type", min_width=12)
|
||||||
|
table.add_column("Priority", justify="right", min_width=8)
|
||||||
|
table.add_column("Root", justify="center", min_width=5)
|
||||||
|
table.add_column("Enabled", justify="center", min_width=7)
|
||||||
|
table.add_column("Dependencies", min_width=15)
|
||||||
|
|
||||||
|
for name in sorted(discovered.keys()):
|
||||||
|
cls = discovered[name]
|
||||||
|
mod_type = getattr(cls, "module_type", "unknown")
|
||||||
|
priority = str(getattr(cls, "priority", 0))
|
||||||
|
requires_root = "Yes" if getattr(cls, "requires_root", False) else "No"
|
||||||
|
deps = ", ".join(getattr(cls, "dependencies", [])) or "-"
|
||||||
|
|
||||||
|
if enabled_list is None:
|
||||||
|
enabled = "[green]Yes[/green]"
|
||||||
|
elif name in enabled_list:
|
||||||
|
enabled = "[green]Yes[/green]"
|
||||||
|
else:
|
||||||
|
enabled = "[dim]No[/dim]"
|
||||||
|
|
||||||
|
table.add_row(name, mod_type, priority, requires_root, enabled, deps)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive menu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def interactive_menu():
|
||||||
|
"""Rich-based numbered menu with hardware info and phase display."""
|
||||||
|
try:
|
||||||
|
config = load_config(config_dir=os.path.join(PROJECT_ROOT, "config"))
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
hw = detect_hardware()
|
||||||
|
tier = hw.tier
|
||||||
|
phase = config.get("engagement_phase", {}).get("mode", "passive_only")
|
||||||
|
temp = get_cpu_temperature()
|
||||||
|
temp_str = f"{temp:.1f}C" if temp is not None else "N/A"
|
||||||
|
|
||||||
|
header = (
|
||||||
|
f"[bold cyan]BigBrother[/bold cyan] v1.0\n"
|
||||||
|
f"Hardware: [yellow]{hw.model or 'Unknown'}[/yellow] ({tier})\n"
|
||||||
|
f"CPU: {hw.cpu_cores} cores ({hw.architecture}) | RAM: {hw.total_ram_mb}MB | Temp: {temp_str}\n"
|
||||||
|
f"Phase: [{'green' if phase == 'passive_only' else 'red'}]{phase}[/{'green' if phase == 'passive_only' else 'red'}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
console.print()
|
||||||
|
console.print(Panel(header, title="Dashboard", border_style="cyan"))
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold]1.[/bold] Start all modules")
|
||||||
|
console.print(" [bold]2.[/bold] Stop all modules")
|
||||||
|
console.print(" [bold]3.[/bold] Module status")
|
||||||
|
console.print(" [bold]4.[/bold] List modules")
|
||||||
|
console.print(" [bold]5.[/bold] Credentials [dim](Available in Phase 2)[/dim]")
|
||||||
|
console.print(" [bold]6.[/bold] Intelligence [dim](Available in Phase 2)[/dim]")
|
||||||
|
console.print(" [bold]7.[/bold] Topology [dim](Available in Phase 2)[/dim]")
|
||||||
|
console.print(" [bold]8.[/bold] Timeline [dim](Available in Phase 2)[/dim]")
|
||||||
|
console.print(" [bold]9.[/bold] Self-test")
|
||||||
|
console.print(" [bold]C.[/bold] Configuration")
|
||||||
|
console.print(" [bold]K.[/bold] Kill switch")
|
||||||
|
console.print(" [bold]Q.[/bold] Quit")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
choice = Prompt.ask("Select", default="Q")
|
||||||
|
choice = choice.strip().upper()
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
ctx = click.Context(start)
|
||||||
|
ctx.invoke(start, daemon=False, passive_only=False)
|
||||||
|
elif choice == "2":
|
||||||
|
ctx = click.Context(stop)
|
||||||
|
ctx.invoke(stop)
|
||||||
|
elif choice == "3":
|
||||||
|
ctx = click.Context(status)
|
||||||
|
ctx.invoke(status)
|
||||||
|
elif choice == "4":
|
||||||
|
ctx = click.Context(list_modules)
|
||||||
|
ctx.invoke(list_modules)
|
||||||
|
elif choice in ("5", "6", "7", "8"):
|
||||||
|
labels = {"5": "Credentials", "6": "Intelligence", "7": "Topology", "8": "Timeline"}
|
||||||
|
console.print(f"\n[yellow]{labels[choice]} viewer is available in Phase 2.[/yellow]")
|
||||||
|
elif choice == "9":
|
||||||
|
ctx = click.Context(selftest)
|
||||||
|
ctx.invoke(selftest)
|
||||||
|
elif choice == "C":
|
||||||
|
ctx = click.Context(show_config)
|
||||||
|
ctx.invoke(show_config)
|
||||||
|
elif choice == "K":
|
||||||
|
ctx = click.Context(kill_switch)
|
||||||
|
ctx.invoke(kill_switch)
|
||||||
|
elif choice == "Q":
|
||||||
|
console.print("[dim]Goodbye.[/dim]")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
console.print("[yellow]Invalid choice.[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _format_uptime(seconds: float) -> str:
|
||||||
|
"""Format seconds into a human-readable uptime string."""
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds:.0f}s"
|
||||||
|
elif seconds < 3600:
|
||||||
|
return f"{seconds / 60:.0f}m {seconds % 60:.0f}s"
|
||||||
|
elif seconds < 86400:
|
||||||
|
h = int(seconds // 3600)
|
||||||
|
m = int((seconds % 3600) // 60)
|
||||||
|
return f"{h}h {m}m"
|
||||||
|
else:
|
||||||
|
d = int(seconds // 86400)
|
||||||
|
h = int((seconds % 86400) // 3600)
|
||||||
|
return f"{d}d {h}h"
|
||||||
|
|
||||||
|
|
||||||
|
def _daemonize():
|
||||||
|
"""Fork into background, redirect stdio, write PID file."""
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
# Parent exits
|
||||||
|
sys.exit(0)
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f"[red]Fork failed:[/red] {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Decouple from parent
|
||||||
|
os.setsid()
|
||||||
|
os.umask(0)
|
||||||
|
|
||||||
|
# Second fork
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
sys.exit(0)
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Redirect stdio
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
devnull = open(os.devnull, "r+b")
|
||||||
|
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||||
|
os.dup2(devnull.fileno(), sys.stdout.fileno())
|
||||||
|
os.dup2(devnull.fileno(), sys.stderr.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Shared pytest fixtures for BigBrother tests."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensure project root is on sys.path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_root():
|
||||||
|
"""Return the project root directory and ensure it is on sys.path."""
|
||||||
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_dir(tmp_path):
|
||||||
|
"""Provide a temporary directory for test artifacts."""
|
||||||
|
return str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
"""Return a valid BigBrother config dict (no YAML file needed)."""
|
||||||
|
return {
|
||||||
|
"device": {
|
||||||
|
"platform": "generic",
|
||||||
|
"hostname": "test-device",
|
||||||
|
"install_path": "/tmp/bb-test",
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"primary_interface": "eth0",
|
||||||
|
"scope_enforcement": False,
|
||||||
|
},
|
||||||
|
"connectivity": {
|
||||||
|
"primary": "wireguard",
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"encryption": "aes-256-gcm",
|
||||||
|
"encryption_key_derive": "argon2id",
|
||||||
|
"kill_switch_enabled": True,
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"interface": "eth0",
|
||||||
|
"snap_length": 65535,
|
||||||
|
"pcap_rotation_hours": 1,
|
||||||
|
"pcap_compression": "zstd",
|
||||||
|
"pcap_compression_level": 19,
|
||||||
|
},
|
||||||
|
"stealth": {
|
||||||
|
"mac_profile": "auto",
|
||||||
|
"process_disguise": True,
|
||||||
|
"log_suppression": True,
|
||||||
|
},
|
||||||
|
"engagement_phase": {
|
||||||
|
"mode": "passive_only",
|
||||||
|
"passive_days": 7,
|
||||||
|
},
|
||||||
|
"bettercap": {
|
||||||
|
"binary": "/usr/local/bin/bettercap",
|
||||||
|
"api_address": "127.0.0.1",
|
||||||
|
"api_port": 8083,
|
||||||
|
},
|
||||||
|
"storage_path": "/tmp/bb-test/storage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bus():
|
||||||
|
"""Return a started EventBus instance. Stopped after test."""
|
||||||
|
from core.bus import EventBus
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
bus.start()
|
||||||
|
yield bus
|
||||||
|
bus.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_state(tmp_path):
|
||||||
|
"""Return a StateManager backed by a temp SQLite database. Stopped after test."""
|
||||||
|
from core.state import StateManager
|
||||||
|
|
||||||
|
db_path = str(tmp_path / "test_state.db")
|
||||||
|
state = StateManager(db_path=db_path)
|
||||||
|
state.start()
|
||||||
|
yield state
|
||||||
|
state.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hardware_tier_override():
|
||||||
|
"""Context manager fixture to patch detect_hardware_tier to return a specific tier.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
def test_foo(hardware_tier_override):
|
||||||
|
with hardware_tier_override("pi_zero"):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _override(tier: str):
|
||||||
|
with patch("core.engine.detect_hardware_tier", return_value=tier):
|
||||||
|
yield
|
||||||
|
|
||||||
|
return _override
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests for core.bus — EventBus pub/sub system."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.bus import EventBus, Event
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBus:
|
||||||
|
|
||||||
|
def test_subscribe_and_publish(self, mock_bus):
|
||||||
|
"""A subscriber receives an event that was published."""
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def handler(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
mock_bus.subscribe(handler, event_type="HOST_DISCOVERED")
|
||||||
|
mock_bus.publish(Event(
|
||||||
|
event_type="HOST_DISCOVERED",
|
||||||
|
payload={"ip": "10.0.0.1"},
|
||||||
|
source_module="test",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Wait for dispatch
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while not received and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].event_type == "HOST_DISCOVERED"
|
||||||
|
assert received[0].payload["ip"] == "10.0.0.1"
|
||||||
|
|
||||||
|
def test_publish_no_subscribers(self, mock_bus):
|
||||||
|
"""Publishing with no subscribers does not raise an error."""
|
||||||
|
mock_bus.publish(Event(
|
||||||
|
event_type="CREDENTIAL_FOUND",
|
||||||
|
payload={"user": "admin"},
|
||||||
|
source_module="test",
|
||||||
|
))
|
||||||
|
# Give the dispatcher a moment to process
|
||||||
|
time.sleep(0.2)
|
||||||
|
# No crash = pass
|
||||||
|
|
||||||
|
def test_multiple_subscribers(self, mock_bus):
|
||||||
|
"""Multiple subscribers for the same event type all receive the event."""
|
||||||
|
results_a = []
|
||||||
|
results_b = []
|
||||||
|
|
||||||
|
mock_bus.subscribe(lambda e: results_a.append(e), event_type="PCAP_ROTATED")
|
||||||
|
mock_bus.subscribe(lambda e: results_b.append(e), event_type="PCAP_ROTATED")
|
||||||
|
|
||||||
|
mock_bus.publish(Event(
|
||||||
|
event_type="PCAP_ROTATED",
|
||||||
|
payload={"file": "/tmp/test.pcap"},
|
||||||
|
source_module="test",
|
||||||
|
))
|
||||||
|
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while (not results_a or not results_b) and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert len(results_a) == 1
|
||||||
|
assert len(results_b) == 1
|
||||||
|
|
||||||
|
def test_event_type_filtering(self, mock_bus):
|
||||||
|
"""A subscriber only receives events matching its registered type."""
|
||||||
|
received = []
|
||||||
|
|
||||||
|
mock_bus.subscribe(lambda e: received.append(e), event_type="CREDENTIAL_FOUND")
|
||||||
|
|
||||||
|
# Publish a different event type
|
||||||
|
mock_bus.publish(Event(
|
||||||
|
event_type="HOST_DISCOVERED",
|
||||||
|
payload={"ip": "10.0.0.2"},
|
||||||
|
source_module="test",
|
||||||
|
))
|
||||||
|
|
||||||
|
# And the matching type
|
||||||
|
mock_bus.publish(Event(
|
||||||
|
event_type="CREDENTIAL_FOUND",
|
||||||
|
payload={"user": "root"},
|
||||||
|
source_module="test",
|
||||||
|
))
|
||||||
|
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while len(received) < 1 and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
# Allow a little extra time for any misdelivered events
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].event_type == "CREDENTIAL_FOUND"
|
||||||
|
|
||||||
|
def test_wildcard_subscriber(self, mock_bus):
|
||||||
|
"""A wildcard subscriber (event_type=None) receives all events."""
|
||||||
|
received = []
|
||||||
|
|
||||||
|
mock_bus.subscribe(lambda e: received.append(e), event_type=None)
|
||||||
|
|
||||||
|
mock_bus.publish(Event(event_type="HOST_DISCOVERED", payload={}, source_module="test"))
|
||||||
|
mock_bus.publish(Event(event_type="CREDENTIAL_FOUND", payload={}, source_module="test"))
|
||||||
|
|
||||||
|
deadline = time.time() + 3.0
|
||||||
|
while len(received) < 2 and time.time() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert len(received) == 2
|
||||||
|
types = {e.event_type for e in received}
|
||||||
|
assert "HOST_DISCOVERED" in types
|
||||||
|
assert "CREDENTIAL_FOUND" in types
|
||||||
|
|
||||||
|
def test_event_dataclass(self):
|
||||||
|
"""Event dataclass fields and to_dict() work correctly."""
|
||||||
|
before = time.time()
|
||||||
|
event = Event(
|
||||||
|
event_type="MODULE_STARTED",
|
||||||
|
payload={"module": "dns_logger", "pid": 1234},
|
||||||
|
source_module="engine",
|
||||||
|
)
|
||||||
|
after = time.time()
|
||||||
|
|
||||||
|
assert event.event_type == "MODULE_STARTED"
|
||||||
|
assert event.payload["module"] == "dns_logger"
|
||||||
|
assert event.source_module == "engine"
|
||||||
|
assert before <= event.timestamp <= after
|
||||||
|
|
||||||
|
d = event.to_dict()
|
||||||
|
assert isinstance(d, dict)
|
||||||
|
assert d["event_type"] == "MODULE_STARTED"
|
||||||
|
assert d["payload"]["pid"] == 1234
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for utils.crypto — AES-256-GCM encryption and key derivation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.crypto import (
|
||||||
|
CryptoEngine,
|
||||||
|
KEY_SIZE,
|
||||||
|
NONCE_SIZE,
|
||||||
|
derive_key,
|
||||||
|
encrypt_file,
|
||||||
|
decrypt_file,
|
||||||
|
HAS_ARGON2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCryptoEngine:
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_roundtrip(self):
|
||||||
|
"""Encrypt then decrypt returns the original plaintext."""
|
||||||
|
key = os.urandom(KEY_SIZE)
|
||||||
|
engine = CryptoEngine(key)
|
||||||
|
|
||||||
|
plaintext = b"Sensitive credential data: admin:p@ssw0rd!"
|
||||||
|
ciphertext = engine.encrypt(plaintext)
|
||||||
|
|
||||||
|
# Ciphertext should differ from plaintext
|
||||||
|
assert ciphertext != plaintext
|
||||||
|
# Should include nonce prefix
|
||||||
|
assert len(ciphertext) > len(plaintext)
|
||||||
|
|
||||||
|
decrypted = engine.decrypt(ciphertext)
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
def test_encrypt_file_decrypt_file(self, tmp_path):
|
||||||
|
"""encrypt_file() and decrypt_file() round-trip a file correctly."""
|
||||||
|
src = str(tmp_path / "source.bin")
|
||||||
|
enc = str(tmp_path / "encrypted.bb")
|
||||||
|
dec = str(tmp_path / "decrypted.bin")
|
||||||
|
|
||||||
|
original_data = os.urandom(4096)
|
||||||
|
with open(src, "wb") as f:
|
||||||
|
f.write(original_data)
|
||||||
|
|
||||||
|
password = b"test-passphrase-42"
|
||||||
|
# Use pbkdf2 to avoid argon2 dependency issues
|
||||||
|
encrypt_file(src, enc, password, method="pbkdf2")
|
||||||
|
decrypt_file(enc, dec, password, method="pbkdf2")
|
||||||
|
|
||||||
|
with open(dec, "rb") as f:
|
||||||
|
recovered = f.read()
|
||||||
|
|
||||||
|
assert recovered == original_data
|
||||||
|
|
||||||
|
def test_different_keys_fail(self):
|
||||||
|
"""Decrypting with a different key raises an error."""
|
||||||
|
key1 = os.urandom(KEY_SIZE)
|
||||||
|
key2 = os.urandom(KEY_SIZE)
|
||||||
|
engine1 = CryptoEngine(key1)
|
||||||
|
engine2 = CryptoEngine(key2)
|
||||||
|
|
||||||
|
plaintext = b"secret data"
|
||||||
|
ciphertext = engine1.encrypt(plaintext)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
engine2.decrypt(ciphertext)
|
||||||
|
|
||||||
|
def test_key_derivation_argon2(self):
|
||||||
|
"""derive_key with argon2id returns a 32-byte key (or falls back to pbkdf2)."""
|
||||||
|
password = b"my-strong-password"
|
||||||
|
salt = os.urandom(16)
|
||||||
|
|
||||||
|
key = derive_key(password, salt, method="argon2id")
|
||||||
|
|
||||||
|
assert isinstance(key, bytes)
|
||||||
|
assert len(key) == KEY_SIZE
|
||||||
|
|
||||||
|
# Same inputs produce the same key
|
||||||
|
key2 = derive_key(password, salt, method="argon2id")
|
||||||
|
assert key == key2
|
||||||
|
|
||||||
|
# Different salt produces a different key
|
||||||
|
salt2 = os.urandom(16)
|
||||||
|
key3 = derive_key(password, salt2, method="argon2id")
|
||||||
|
assert key3 != key
|
||||||
|
|
||||||
|
def test_key_derivation_pbkdf2(self):
|
||||||
|
"""derive_key with pbkdf2 returns a 32-byte key."""
|
||||||
|
password = b"another-password"
|
||||||
|
salt = os.urandom(16)
|
||||||
|
|
||||||
|
key = derive_key(password, salt, method="pbkdf2")
|
||||||
|
|
||||||
|
assert isinstance(key, bytes)
|
||||||
|
assert len(key) == KEY_SIZE
|
||||||
|
|
||||||
|
# Same inputs produce the same key
|
||||||
|
key2 = derive_key(password, salt, method="pbkdf2")
|
||||||
|
assert key == key2
|
||||||
|
|
||||||
|
# Different password produces a different key
|
||||||
|
key3 = derive_key(b"different-password", salt, method="pbkdf2")
|
||||||
|
assert key3 != key
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Tests for core.engine — Module lifecycle manager."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.engine import Engine, HARDWARE_TIERS, _topo_sort, ModuleEntry
|
||||||
|
from core.bus import EventBus
|
||||||
|
from core.state import StateManager
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock module for testing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MockModule(BaseModule):
|
||||||
|
"""Minimal BaseModule subclass for testing."""
|
||||||
|
name = "mock_module"
|
||||||
|
module_type = "passive"
|
||||||
|
priority = 100
|
||||||
|
dependencies = []
|
||||||
|
requires_root = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._running = True
|
||||||
|
import os, time as _t
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = _t.time()
|
||||||
|
# Block to keep the process alive
|
||||||
|
import signal
|
||||||
|
signal.pause()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"running": self._running, "pid": self._pid}
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
|
||||||
|
class MockActiveModule(BaseModule):
|
||||||
|
"""Active module for phase-gating tests."""
|
||||||
|
name = "mock_active"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
dependencies = []
|
||||||
|
requires_root = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"running": self._running}
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MockDepModule(BaseModule):
|
||||||
|
"""Module that depends on mock_module."""
|
||||||
|
name = "mock_dep"
|
||||||
|
module_type = "passive"
|
||||||
|
priority = 150
|
||||||
|
dependencies = ["mock_module"]
|
||||||
|
requires_root = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"running": self._running}
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngine:
|
||||||
|
|
||||||
|
def test_load_module(self, mock_bus, mock_state, mock_config):
|
||||||
|
"""Engine.register() adds a module to the registry."""
|
||||||
|
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
engine.register(MockModule, config={"test": True})
|
||||||
|
|
||||||
|
assert "mock_module" in engine._modules
|
||||||
|
assert engine._modules["mock_module"].module_class is MockModule
|
||||||
|
assert engine._modules["mock_module"].config == {"test": True}
|
||||||
|
|
||||||
|
def test_start_stop_module(self, mock_bus, mock_state, mock_config):
|
||||||
|
"""Engine can start and stop a module process."""
|
||||||
|
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
engine.register(MockModule, config=mock_config)
|
||||||
|
|
||||||
|
success = engine.start("mock_module")
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
entry = engine._modules["mock_module"]
|
||||||
|
assert entry.process is not None
|
||||||
|
assert entry.process.is_alive()
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
engine.stop("mock_module")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
assert entry.process is None
|
||||||
|
|
||||||
|
def test_dependency_resolution(self, mock_bus, mock_state, mock_config):
|
||||||
|
"""_topo_sort orders modules by dependency (dependencies first)."""
|
||||||
|
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
engine.register(MockDepModule)
|
||||||
|
engine.register(MockModule)
|
||||||
|
|
||||||
|
order = _topo_sort(engine._modules)
|
||||||
|
|
||||||
|
assert order.index("mock_module") < order.index("mock_dep")
|
||||||
|
|
||||||
|
def test_hardware_tier_enforcement(self, mock_bus, mock_state, mock_config, hardware_tier_override):
|
||||||
|
"""Pi Zero tier blocks more than max_passive modules."""
|
||||||
|
# Set platform to pi_zero directly in config
|
||||||
|
mock_config["device"]["platform"] = "pi_zero"
|
||||||
|
|
||||||
|
with hardware_tier_override("pi_zero"):
|
||||||
|
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
|
||||||
|
# pi_zero allows max 4 passive modules
|
||||||
|
assert engine._tier == "pi_zero"
|
||||||
|
limits = HARDWARE_TIERS["pi_zero"]
|
||||||
|
assert limits["max_passive"] == 4
|
||||||
|
|
||||||
|
# Register 5 passive modules (create unique subclasses)
|
||||||
|
for i in range(5):
|
||||||
|
cls = type(f"MockPassive{i}", (MockModule,), {"name": f"passive_{i}"})
|
||||||
|
engine.register(cls, config=mock_config)
|
||||||
|
|
||||||
|
# Start modules — first 4 should succeed, 5th should be blocked
|
||||||
|
results = []
|
||||||
|
for i in range(5):
|
||||||
|
# Mock the process as alive for previously started ones
|
||||||
|
for name, entry in engine._modules.items():
|
||||||
|
if entry.process is not None:
|
||||||
|
entry.process = MagicMock()
|
||||||
|
entry.process.is_alive.return_value = True
|
||||||
|
results.append(engine.start(f"passive_{i}"))
|
||||||
|
|
||||||
|
# At least some should succeed and some should be blocked
|
||||||
|
started = sum(1 for r in results if r is True)
|
||||||
|
# The first 4 should start, 5th blocked by tier limit
|
||||||
|
assert started <= limits["max_passive"]
|
||||||
|
|
||||||
|
def test_engagement_phase_gating(self, mock_bus, mock_state, mock_config):
|
||||||
|
"""passive_only phase blocks active modules."""
|
||||||
|
mock_config["engagement_phase"]["mode"] = "passive_only"
|
||||||
|
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
|
||||||
|
engine.register(MockActiveModule, config=mock_config)
|
||||||
|
|
||||||
|
success = engine.start("mock_active")
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
# Switch to active_allowed
|
||||||
|
engine.set_phase("active_allowed")
|
||||||
|
# Register again since it was already registered
|
||||||
|
success = engine.start("mock_active")
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
engine.stop("mock_active")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for utils.resource — Hardware detection and resource monitoring."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch, mock_open
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.resource import (
|
||||||
|
get_hardware_tier,
|
||||||
|
get_memory_info,
|
||||||
|
get_cpu_temperature,
|
||||||
|
get_disk_usage,
|
||||||
|
get_process_resources,
|
||||||
|
detect_hardware,
|
||||||
|
HardwareInfo,
|
||||||
|
TIER_GENERIC,
|
||||||
|
TIER_PI_ZERO,
|
||||||
|
TIER_OPI_ZERO3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResource:
|
||||||
|
|
||||||
|
def test_detect_hardware_tier(self):
|
||||||
|
"""get_hardware_tier() returns a valid tier string."""
|
||||||
|
tier = get_hardware_tier()
|
||||||
|
assert tier in (TIER_GENERIC, TIER_PI_ZERO, TIER_OPI_ZERO3)
|
||||||
|
|
||||||
|
def test_detect_hardware_info(self):
|
||||||
|
"""detect_hardware() returns a HardwareInfo dataclass."""
|
||||||
|
info = detect_hardware()
|
||||||
|
assert isinstance(info, HardwareInfo)
|
||||||
|
assert info.tier in (TIER_GENERIC, TIER_PI_ZERO, TIER_OPI_ZERO3)
|
||||||
|
assert info.cpu_cores >= 1
|
||||||
|
assert isinstance(info.architecture, str)
|
||||||
|
assert len(info.architecture) > 0
|
||||||
|
|
||||||
|
def test_get_memory_info(self):
|
||||||
|
"""get_memory_info() returns a dict with MemTotal and MemAvailable."""
|
||||||
|
info = get_memory_info()
|
||||||
|
assert isinstance(info, dict)
|
||||||
|
# On Linux, /proc/meminfo should be available
|
||||||
|
if os.path.exists("/proc/meminfo"):
|
||||||
|
assert "MemTotal" in info
|
||||||
|
assert info["MemTotal"] > 0
|
||||||
|
|
||||||
|
def test_get_cpu_temperature(self):
|
||||||
|
"""get_cpu_temperature() returns a float or None (no crash)."""
|
||||||
|
temp = get_cpu_temperature()
|
||||||
|
# May be None if no thermal zone is available (CI/container)
|
||||||
|
if temp is not None:
|
||||||
|
assert isinstance(temp, float)
|
||||||
|
# Sanity check: between -20 and 120 Celsius
|
||||||
|
assert -20 <= temp <= 120
|
||||||
|
|
||||||
|
def test_get_disk_usage(self):
|
||||||
|
"""get_disk_usage() returns (total_gb, free_gb, used_pct) for /."""
|
||||||
|
total_gb, free_gb, used_pct = get_disk_usage("/")
|
||||||
|
|
||||||
|
assert isinstance(total_gb, float)
|
||||||
|
assert isinstance(free_gb, float)
|
||||||
|
assert isinstance(used_pct, float)
|
||||||
|
|
||||||
|
assert total_gb > 0
|
||||||
|
assert free_gb >= 0
|
||||||
|
assert 0 <= used_pct <= 100
|
||||||
|
|
||||||
|
def test_get_process_resources(self):
|
||||||
|
"""get_process_resources() returns stats for the current PID."""
|
||||||
|
pid = os.getpid()
|
||||||
|
result = get_process_resources(pid)
|
||||||
|
|
||||||
|
# Should succeed for our own PID on Linux
|
||||||
|
if result is not None:
|
||||||
|
assert result.pid == pid
|
||||||
|
assert result.rss_mb >= 0
|
||||||
|
assert result.vms_mb >= 0
|
||||||
|
assert result.threads >= 1
|
||||||
|
assert isinstance(result.name, str)
|
||||||
|
assert isinstance(result.state, str)
|
||||||
|
|
||||||
|
def test_get_process_resources_invalid_pid(self):
|
||||||
|
"""get_process_resources() returns None for a non-existent PID."""
|
||||||
|
result = get_process_resources(999999999)
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for modules/stealth/ — Stealth module import and structure validation."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
|
||||||
|
# All stealth modules defined in modules/stealth/__init__.py
|
||||||
|
STEALTH_MODULE_NAMES = [
|
||||||
|
"MacManager",
|
||||||
|
"ProcessDisguise",
|
||||||
|
"LogSuppression",
|
||||||
|
"EncryptedStorage",
|
||||||
|
"TmpfsManager",
|
||||||
|
"Watchdog",
|
||||||
|
"AntiForensics",
|
||||||
|
"TrafficMimicry",
|
||||||
|
"JA3Spoofer",
|
||||||
|
"IDSTester",
|
||||||
|
"LKMRootkit",
|
||||||
|
"OverlayfsManager",
|
||||||
|
]
|
||||||
|
|
||||||
|
STEALTH_MODULE_DOTTED = [
|
||||||
|
"modules.stealth.mac_manager",
|
||||||
|
"modules.stealth.process_disguise",
|
||||||
|
"modules.stealth.log_suppression",
|
||||||
|
"modules.stealth.encrypted_storage",
|
||||||
|
"modules.stealth.tmpfs_manager",
|
||||||
|
"modules.stealth.watchdog",
|
||||||
|
"modules.stealth.anti_forensics",
|
||||||
|
"modules.stealth.traffic_mimicry",
|
||||||
|
"modules.stealth.ja3_spoofer",
|
||||||
|
"modules.stealth.ids_tester",
|
||||||
|
"modules.stealth.lkm_rootkit",
|
||||||
|
"modules.stealth.overlayfs_manager",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStealthModulesImportable:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dotted_path", STEALTH_MODULE_DOTTED)
|
||||||
|
def test_all_stealth_modules_importable(self, dotted_path):
|
||||||
|
"""Each stealth module file can be imported without errors."""
|
||||||
|
mod = importlib.import_module(dotted_path)
|
||||||
|
assert mod is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStealthModulesAreBaseModule:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||||
|
def test_stealth_modules_are_base_module(self, class_name):
|
||||||
|
"""Each stealth module class is a subclass of BaseModule."""
|
||||||
|
import modules.stealth as stealth_pkg
|
||||||
|
cls = getattr(stealth_pkg, class_name)
|
||||||
|
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStealthModuleAttributes:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||||
|
def test_stealth_module_attributes(self, class_name):
|
||||||
|
"""Each stealth module has required class attributes: name, module_type, priority."""
|
||||||
|
import modules.stealth as stealth_pkg
|
||||||
|
cls = getattr(stealth_pkg, class_name)
|
||||||
|
|
||||||
|
assert hasattr(cls, "name"), f"{class_name} missing 'name'"
|
||||||
|
assert hasattr(cls, "module_type"), f"{class_name} missing 'module_type'"
|
||||||
|
assert hasattr(cls, "priority"), f"{class_name} missing 'priority'"
|
||||||
|
assert hasattr(cls, "dependencies"), f"{class_name} missing 'dependencies'"
|
||||||
|
assert hasattr(cls, "requires_root"), f"{class_name} missing 'requires_root'"
|
||||||
|
|
||||||
|
# name should not be 'unnamed' (BaseModule default)
|
||||||
|
assert cls.name != "unnamed", f"{class_name} still has default name 'unnamed'"
|
||||||
|
|
||||||
|
# module_type should be 'stealth'
|
||||||
|
assert cls.module_type == "stealth", f"{class_name} module_type is '{cls.module_type}', expected 'stealth'"
|
||||||
|
|
||||||
|
# priority should be an integer
|
||||||
|
assert isinstance(cls.priority, int), f"{class_name} priority is not an int"
|
||||||
|
|
||||||
|
# dependencies should be a list
|
||||||
|
assert isinstance(cls.dependencies, list), f"{class_name} dependencies is not a list"
|
||||||
|
|
||||||
|
# Check abstract methods are implemented
|
||||||
|
for method_name in ("start", "stop", "status", "configure"):
|
||||||
|
method = getattr(cls, method_name, None)
|
||||||
|
assert method is not None, f"{class_name} missing method '{method_name}'"
|
||||||
|
assert callable(method), f"{class_name}.{method_name} is not callable"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStealthModuleInstantiate:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||||
|
def test_stealth_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
|
||||||
|
"""Each stealth module can be instantiated with mock bus/state/config."""
|
||||||
|
import modules.stealth as stealth_pkg
|
||||||
|
cls = getattr(stealth_pkg, class_name)
|
||||||
|
|
||||||
|
# Instantiate (should not crash)
|
||||||
|
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.bus is mock_bus
|
||||||
|
assert instance.state is mock_state
|
||||||
|
assert instance.config is mock_config
|
||||||
|
assert instance._running is False
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests for core.tool_manager — Subprocess lifecycle manager."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.tool_manager import ToolManager, ManagedTool
|
||||||
|
from core.bus import EventBus
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolManager:
|
||||||
|
|
||||||
|
def test_register_tool(self, mock_bus):
|
||||||
|
"""register() adds a tool to the internal registry."""
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
tm.register(
|
||||||
|
name="test_tool",
|
||||||
|
binary="/bin/echo",
|
||||||
|
args=["hello"],
|
||||||
|
max_restarts=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "test_tool" in tm._tools
|
||||||
|
tool = tm._tools["test_tool"]
|
||||||
|
assert tool.name == "test_tool"
|
||||||
|
assert tool.binary == "/bin/echo"
|
||||||
|
assert tool.args == ["hello"]
|
||||||
|
assert tool.max_restarts == 2
|
||||||
|
|
||||||
|
def test_start_stop_tool(self, mock_bus):
|
||||||
|
"""start() launches a subprocess and stop() terminates it."""
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
tm.register(
|
||||||
|
name="sleeper",
|
||||||
|
binary="/bin/sleep",
|
||||||
|
args=["999"],
|
||||||
|
)
|
||||||
|
|
||||||
|
success = tm.start("sleeper")
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
status = tm.get_status("sleeper")
|
||||||
|
assert status["running"] is True
|
||||||
|
assert status["pid"] is not None
|
||||||
|
pid = status["pid"]
|
||||||
|
|
||||||
|
# Verify the process actually exists
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
alive = True
|
||||||
|
except OSError:
|
||||||
|
alive = False
|
||||||
|
assert alive is True
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
tm.stop("sleeper")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
status_after = tm.get_status("sleeper")
|
||||||
|
assert status_after["running"] is False
|
||||||
|
|
||||||
|
def test_restart_on_crash(self, mock_bus):
|
||||||
|
"""A tool that exits immediately triggers restart when monitor detects it."""
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
|
||||||
|
# Register a tool that exits immediately (exit code 0)
|
||||||
|
tm.register(
|
||||||
|
name="crasher",
|
||||||
|
binary="/bin/false", # exits with code 1 immediately
|
||||||
|
args=[],
|
||||||
|
max_restarts=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = tm.start("crasher")
|
||||||
|
# The start itself may succeed (process launched) even though it exits fast
|
||||||
|
# On some systems /bin/false exits so fast that poll() already shows it dead.
|
||||||
|
# Either way, the tool should have been created.
|
||||||
|
assert "crasher" in tm._tools
|
||||||
|
|
||||||
|
tool = tm._tools["crasher"]
|
||||||
|
# Wait briefly for the process to exit
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if tool.process is not None:
|
||||||
|
rc = tool.process.poll()
|
||||||
|
# /bin/false exits with 1
|
||||||
|
assert rc is not None # process already exited
|
||||||
|
|
||||||
|
def test_max_restarts(self, mock_bus):
|
||||||
|
"""After max_restarts, monitor gives up and leaves the tool stopped."""
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
tm.register(
|
||||||
|
name="failbot",
|
||||||
|
binary="/bin/false",
|
||||||
|
args=[],
|
||||||
|
max_restarts=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start monitoring
|
||||||
|
tm.start_monitoring()
|
||||||
|
|
||||||
|
# Start the tool — it will exit immediately
|
||||||
|
tm.start("failbot")
|
||||||
|
|
||||||
|
# Wait for monitor to detect crash and exhaust restarts
|
||||||
|
# Monitor loop runs every 5 seconds, but we simulate faster
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
tool = tm._tools["failbot"]
|
||||||
|
# Manually simulate what the monitor does
|
||||||
|
if tool.process and tool.process.poll() is not None:
|
||||||
|
tool.restart_count += 1
|
||||||
|
if tool.restart_count >= tool.max_restarts:
|
||||||
|
tool.process = None
|
||||||
|
tool.pid = None
|
||||||
|
|
||||||
|
# After exceeding max_restarts, process should be None
|
||||||
|
assert tool.restart_count >= tool.max_restarts
|
||||||
|
|
||||||
|
tm.stop_monitoring()
|
||||||
|
tm.stop_all()
|
||||||
|
|
||||||
|
def test_health_check(self, mock_bus):
|
||||||
|
"""Health check callback is stored and can be called."""
|
||||||
|
health_called = {"count": 0}
|
||||||
|
|
||||||
|
def my_health_check():
|
||||||
|
health_called["count"] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
tm.register(
|
||||||
|
name="healthy_tool",
|
||||||
|
binary="/bin/sleep",
|
||||||
|
args=["999"],
|
||||||
|
health_check=my_health_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
tool = tm._tools["healthy_tool"]
|
||||||
|
assert tool.health_check is not None
|
||||||
|
|
||||||
|
# Call the health check directly
|
||||||
|
result = tool.health_check()
|
||||||
|
assert result is True
|
||||||
|
assert health_called["count"] == 1
|
||||||
|
|
||||||
|
# Start the tool and verify health check still works
|
||||||
|
tm.start("healthy_tool")
|
||||||
|
result2 = tool.health_check()
|
||||||
|
assert result2 is True
|
||||||
|
assert health_called["count"] == 2
|
||||||
|
|
||||||
|
tm.stop("healthy_tool")
|
||||||
|
|
||||||
|
def test_get_status_unregistered(self, mock_bus):
|
||||||
|
"""get_status() for an unregistered tool returns an error dict."""
|
||||||
|
tm = ToolManager(bus=mock_bus)
|
||||||
|
status = tm.get_status("nonexistent")
|
||||||
|
assert "error" in status
|
||||||
Reference in New Issue
Block a user