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:
@@ -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