Initial public release

Full BigBrother network implant - passive SOC + active exploitation.
Personal identifiers removed; all capabilities intact.
See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
n0mad1k
2026-06-26 09:52:50 -04:00
commit ccc6b729de
175 changed files with 47941 additions and 0 deletions
View File
+117
View File
@@ -0,0 +1,117 @@
"""Shared pytest fixtures for SystemMonitor 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 SystemMonitor 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
+414
View File
@@ -0,0 +1,414 @@
"""Tests for ALL module categories — import, BaseModule subclass, and attribute validation.
Covers: passive (17), active (9), intel (9), connectivity (8).
Stealth modules are already covered by test_stealth_modules.py.
"""
import importlib
import pytest
from modules.base import BaseModule
# ---------------------------------------------------------------------------
# Passive modules (17)
# ---------------------------------------------------------------------------
PASSIVE_MODULE_DOTTED = [
"modules.passive.packet_capture",
"modules.passive.dns_logger",
"modules.passive.tls_sni_extractor",
"modules.passive.credential_sniffer",
"modules.passive.kerberos_harvester",
"modules.passive.host_discovery",
"modules.passive.os_fingerprint",
"modules.passive.traffic_analyzer",
"modules.passive.vlan_discovery",
"modules.passive.network_mapper",
"modules.passive.auth_flow_tracker",
"modules.passive.smb_monitor",
"modules.passive.cloud_token_harvester",
"modules.passive.ldap_harvester",
"modules.passive.rdp_monitor",
"modules.passive.quic_analyzer",
"modules.passive.db_interceptor",
]
PASSIVE_CLASS_NAMES = [
"PacketCapture",
"DNSLogger",
"TLSSNIExtractor",
"CredentialSniffer",
"KerberosHarvester",
"HostDiscovery",
"OSFingerprint",
"TrafficAnalyzer",
"VLANDiscovery",
"NetworkMapper",
"AuthFlowTracker",
"SMBMonitor",
"CloudTokenHarvester",
"LDAPHarvester",
"RDPMonitor",
"QUICAnalyzer",
"DBInterceptor",
]
class TestPassiveModulesImportable:
@pytest.mark.parametrize("dotted_path", PASSIVE_MODULE_DOTTED)
def test_passive_module_importable(self, dotted_path):
"""Each passive module file can be imported without errors."""
mod = importlib.import_module(dotted_path)
assert mod is not None
class TestPassiveModulesAreBaseModule:
@pytest.mark.parametrize("class_name", PASSIVE_CLASS_NAMES)
def test_passive_modules_are_base_module(self, class_name):
"""Each passive module class is a subclass of BaseModule."""
import modules.passive as passive_pkg
cls = getattr(passive_pkg, class_name)
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
class TestPassiveModuleAttributes:
@pytest.mark.parametrize("class_name", PASSIVE_CLASS_NAMES)
def test_passive_module_attributes(self, class_name):
"""Each passive module has required class attributes."""
import modules.passive as passive_pkg
cls = getattr(passive_pkg, class_name)
assert hasattr(cls, "name") and cls.name != "unnamed"
assert hasattr(cls, "module_type") and cls.module_type == "passive"
assert hasattr(cls, "priority") and isinstance(cls.priority, int)
assert hasattr(cls, "dependencies") and isinstance(cls.dependencies, list)
assert hasattr(cls, "requires_root")
for method_name in ("start", "stop", "status", "configure"):
assert callable(getattr(cls, method_name, None))
class TestPassiveModuleInstantiate:
@pytest.mark.parametrize("class_name", PASSIVE_CLASS_NAMES)
def test_passive_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
"""Each passive module can be instantiated with mock bus/state/config."""
import modules.passive as passive_pkg
cls = getattr(passive_pkg, class_name)
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
assert instance is not None
assert instance._running is False
# ---------------------------------------------------------------------------
# Active modules (9)
# ---------------------------------------------------------------------------
ACTIVE_MODULE_DOTTED = [
"modules.active.bettercap_mgr",
"modules.active.arp_spoof",
"modules.active.dns_poison",
"modules.active.dhcp_spoof",
"modules.active.evil_twin",
"modules.active.ipv6_slaac",
"modules.active.responder_mgr",
"modules.active.mitmproxy_mgr",
"modules.active.ntlm_relay",
]
ACTIVE_CLASS_NAMES = [
"BettercapManager",
"ARPSpoof",
"DNSPoison",
"DHCPSpoof",
"EvilTwin",
"IPv6SLAAC",
"ResponderManager",
"MitmproxyManager",
"NTLMRelay",
]
class TestActiveModulesImportable:
@pytest.mark.parametrize("dotted_path", ACTIVE_MODULE_DOTTED)
def test_active_module_importable(self, dotted_path):
"""Each active module file can be imported without errors."""
mod = importlib.import_module(dotted_path)
assert mod is not None
class TestActiveModulesAreBaseModule:
@pytest.mark.parametrize("class_name", ACTIVE_CLASS_NAMES)
def test_active_modules_are_base_module(self, class_name):
"""Each active module class is a subclass of BaseModule."""
import modules.active as active_pkg
cls = getattr(active_pkg, class_name)
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
class TestActiveModuleAttributes:
@pytest.mark.parametrize("class_name", ACTIVE_CLASS_NAMES)
def test_active_module_attributes(self, class_name):
"""Each active module has required class attributes."""
import modules.active as active_pkg
cls = getattr(active_pkg, class_name)
assert hasattr(cls, "name") and cls.name != "unnamed"
assert hasattr(cls, "module_type") and cls.module_type == "active"
assert hasattr(cls, "priority") and isinstance(cls.priority, int)
assert hasattr(cls, "dependencies") and isinstance(cls.dependencies, list)
assert hasattr(cls, "requires_root")
for method_name in ("start", "stop", "status", "configure"):
assert callable(getattr(cls, method_name, None))
class TestActiveModuleInstantiate:
@pytest.mark.parametrize("class_name", ACTIVE_CLASS_NAMES)
def test_active_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
"""Each active module can be instantiated with mock bus/state/config."""
import modules.active as active_pkg
cls = getattr(active_pkg, class_name)
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
assert instance is not None
assert instance._running is False
# ---------------------------------------------------------------------------
# Intel modules (9)
# ---------------------------------------------------------------------------
INTEL_MODULE_DOTTED = [
"modules.intel.credential_db",
"modules.intel.topology_mapper",
"modules.intel.net_intel",
"modules.intel.user_timeline",
"modules.intel.supply_chain_detect",
"modules.intel.change_detector",
"modules.intel.security_posture",
"modules.intel.operator_audit",
"modules.intel.tool_output_parser",
]
INTEL_CLASS_NAMES = [
"CredentialDB",
"TopologyMapper",
"NetIntel",
"UserTimeline",
"SupplyChainDetect",
"ChangeDetector",
"SecurityPosture",
"OperatorAudit",
"ToolOutputParser",
]
class TestIntelModulesImportable:
@pytest.mark.parametrize("dotted_path", INTEL_MODULE_DOTTED)
def test_intel_module_importable(self, dotted_path):
"""Each intel module file can be imported without errors."""
mod = importlib.import_module(dotted_path)
assert mod is not None
class TestIntelModulesAreBaseModule:
@pytest.mark.parametrize("class_name", INTEL_CLASS_NAMES)
def test_intel_modules_are_base_module(self, class_name):
"""Each intel module class is a subclass of BaseModule."""
import modules.intel as intel_pkg
cls = getattr(intel_pkg, class_name)
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
class TestIntelModuleAttributes:
@pytest.mark.parametrize("class_name", INTEL_CLASS_NAMES)
def test_intel_module_attributes(self, class_name):
"""Each intel module has required class attributes."""
import modules.intel as intel_pkg
cls = getattr(intel_pkg, class_name)
assert hasattr(cls, "name") and cls.name != "unnamed"
assert hasattr(cls, "module_type") and cls.module_type == "intel"
assert hasattr(cls, "priority") and isinstance(cls.priority, int)
assert hasattr(cls, "dependencies") and isinstance(cls.dependencies, list)
assert hasattr(cls, "requires_root")
for method_name in ("start", "stop", "status", "configure"):
assert callable(getattr(cls, method_name, None))
class TestIntelModuleInstantiate:
@pytest.mark.parametrize("class_name", INTEL_CLASS_NAMES)
def test_intel_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
"""Each intel module can be instantiated with mock bus/state/config."""
import modules.intel as intel_pkg
cls = getattr(intel_pkg, class_name)
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
assert instance is not None
assert instance._running is False
# ---------------------------------------------------------------------------
# Connectivity modules (8)
# ---------------------------------------------------------------------------
CONNECTIVITY_MODULE_DOTTED = [
"modules.connectivity.wireguard",
"modules.connectivity.tailscale",
"modules.connectivity.bridge",
"modules.connectivity.wifi_client",
"modules.connectivity.cellular_backup",
"modules.connectivity.ble_emergency",
"modules.connectivity.data_exfil",
]
CONNECTIVITY_CLASS_NAMES = [
"WireGuard",
"Tailscale",
"Bridge",
"WiFiClient",
"CellularBackup",
"BLEEmergency",
"DataExfil",
]
class TestConnectivityModulesImportable:
@pytest.mark.parametrize("dotted_path", CONNECTIVITY_MODULE_DOTTED)
def test_connectivity_module_importable(self, dotted_path):
"""Each connectivity module file can be imported without errors."""
mod = importlib.import_module(dotted_path)
assert mod is not None
class TestConnectivityModulesAreBaseModule:
@pytest.mark.parametrize("class_name", CONNECTIVITY_CLASS_NAMES)
def test_connectivity_modules_are_base_module(self, class_name):
"""Each connectivity module class is a subclass of BaseModule."""
import modules.connectivity as conn_pkg
cls = getattr(conn_pkg, class_name)
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
class TestConnectivityModuleAttributes:
@pytest.mark.parametrize("class_name", CONNECTIVITY_CLASS_NAMES)
def test_connectivity_module_attributes(self, class_name):
"""Each connectivity module has required class attributes."""
import modules.connectivity as conn_pkg
cls = getattr(conn_pkg, class_name)
assert hasattr(cls, "name") and cls.name != "unnamed"
assert hasattr(cls, "module_type") and cls.module_type == "connectivity"
assert hasattr(cls, "priority") and isinstance(cls.priority, int)
assert hasattr(cls, "dependencies") and isinstance(cls.dependencies, list)
assert hasattr(cls, "requires_root")
for method_name in ("start", "stop", "status", "configure"):
assert callable(getattr(cls, method_name, None))
class TestConnectivityModuleInstantiate:
@pytest.mark.parametrize("class_name", CONNECTIVITY_CLASS_NAMES)
def test_connectivity_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
"""Each connectivity module can be instantiated with mock bus/state/config."""
import modules.connectivity as conn_pkg
cls = getattr(conn_pkg, class_name)
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
assert instance is not None
assert instance._running is False
# ---------------------------------------------------------------------------
# Cross-category: package __init__.py exports
# ---------------------------------------------------------------------------
class TestPackageExports:
def test_passive_init_exports_all(self):
"""modules.passive.__init__.py exports all 17 classes."""
import modules.passive as pkg
assert len(pkg.__all__) == 17
for name in pkg.__all__:
cls = getattr(pkg, name)
assert issubclass(cls, BaseModule)
def test_active_init_exports_all(self):
"""modules.active.__init__.py exports all 9 classes."""
import modules.active as pkg
assert len(pkg.__all__) == 9
for name in pkg.__all__:
cls = getattr(pkg, name)
assert issubclass(cls, BaseModule)
def test_intel_init_exports_all(self):
"""modules.intel.__init__.py exports all 9 classes."""
import modules.intel as pkg
assert len(pkg.__all__) == 9
for name in pkg.__all__:
cls = getattr(pkg, name)
assert issubclass(cls, BaseModule)
def test_connectivity_init_exports_all(self):
"""modules.connectivity.__init__.py exports all 7 classes."""
import modules.connectivity as pkg
assert len(pkg.__all__) == 7
for name in pkg.__all__:
cls = getattr(pkg, name)
assert issubclass(cls, BaseModule)
def test_stealth_init_exports_all(self):
"""modules.stealth.__init__.py exports all 12 classes."""
import modules.stealth as pkg
assert len(pkg.__all__) == 12
for name in pkg.__all__:
cls = getattr(pkg, name)
assert issubclass(cls, BaseModule)
# ---------------------------------------------------------------------------
# Discovery function test
# ---------------------------------------------------------------------------
class TestModuleDiscovery:
def test_discover_all_modules(self):
"""discover_all_modules() finds all 55 modules across 5 categories."""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bigbrother import discover_all_modules, discover_modules_in_category
all_mods = discover_all_modules()
# Stealth: 12, Passive: 17, Active: 9, Intel: 9, Connectivity: 7 = 54
assert len(all_mods) == 54, f"Expected 54 modules, found {len(all_mods)}"
# Verify per-category counts
assert len(discover_modules_in_category("stealth")) == 12
assert len(discover_modules_in_category("passive")) == 17
assert len(discover_modules_in_category("active")) == 9
assert len(discover_modules_in_category("intel")) == 9
assert len(discover_modules_in_category("connectivity")) == 7
def test_all_discovered_are_base_module(self):
"""Every discovered module is a BaseModule subclass."""
from bigbrother import discover_all_modules
for name, cls in discover_all_modules().items():
assert issubclass(cls, BaseModule), f"{name} ({cls}) is not a BaseModule subclass"
+309
View File
@@ -0,0 +1,309 @@
"""Tests for modules/connectivity/bridge — transparent inline bridge.
Validates bridge setup/teardown logic, ebtables rule application,
watchdog failure counting, status reporting, and health checks
without requiring root, ip/bridge commands, or physical interfaces.
"""
import os
import subprocess
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import pytest
from modules.base import BaseModule
from modules.connectivity.bridge import (
Bridge, BRIDGE_NAME, WATCHDOG_INTERVAL, WATCHDOG_MAX_FAILURES,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def bridge_config(tmp_path):
"""Return a valid bridge config dict."""
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir()
return {
"connectivity": {
"bridge": {
"interface1": "eth0",
"interface2": "usb0",
}
},
"device": {
"install_path": str(tmp_path),
},
}
@pytest.fixture
def bridge(mock_bus, mock_state, bridge_config):
"""Return a Bridge instance (not started)."""
return Bridge(mock_bus, mock_state, bridge_config)
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestBridgeStructure:
def test_is_base_module(self):
assert issubclass(Bridge, BaseModule)
def test_module_attributes(self):
assert Bridge.name == "bridge"
assert Bridge.module_type == "connectivity"
assert Bridge.requires_root is True
assert Bridge.priority == -300
def test_bridge_name_constant(self):
assert BRIDGE_NAME == "br0"
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
class TestConfigParsing:
def test_no_bridge_config_sets_error(self, mock_bus, mock_state):
mod = Bridge(mock_bus, mock_state, {})
mod.start()
assert mod._running is not True
def test_missing_interface2_sets_error(self, mock_bus, mock_state):
config = {"connectivity": {"bridge": {"interface1": "eth0"}}}
mod = Bridge(mock_bus, mock_state, config)
mod.start()
assert mod._running is not True
def test_interfaces_parsed_from_config(self, bridge, bridge_config):
br_cfg = bridge_config["connectivity"]["bridge"]
assert br_cfg["interface1"] == "eth0"
assert br_cfg["interface2"] == "usb0"
# ---------------------------------------------------------------------------
# Setup bridge (inline)
# ---------------------------------------------------------------------------
class TestInlineSetup:
@patch("subprocess.run")
def test_setup_bridge_inline_success(self, mock_run, bridge):
mock_run.return_value = MagicMock(returncode=0)
bridge._scripts_dir = "/nonexistent"
result = bridge._setup_bridge_inline("eth0", "usb0")
assert result is True
cmds = [c[0][0] for c in mock_run.call_args_list]
cmd_strs = [" ".join(c) for c in cmds]
assert any("add name br0 type bridge" in s for s in cmd_strs)
assert any("eth0 master br0" in s for s in cmd_strs)
assert any("usb0 master br0" in s for s in cmd_strs)
@patch("subprocess.run")
def test_setup_bridge_inline_failure_teardown(self, mock_run, bridge):
mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"error")
bridge._scripts_dir = "/nonexistent"
bridge._if1 = "eth0"
bridge._if2 = "usb0"
result = bridge._setup_bridge_inline("eth0", "usb0")
assert result is False
# ---------------------------------------------------------------------------
# Teardown bridge (inline)
# ---------------------------------------------------------------------------
class TestInlineTeardown:
@patch("subprocess.run")
def test_teardown_runs_commands(self, mock_run, bridge):
mock_run.return_value = MagicMock(returncode=0)
bridge._if1 = "eth0"
bridge._if2 = "usb0"
result = bridge._teardown_bridge_inline()
assert result is True
cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
assert any("del br0" in s for s in cmd_strs)
@patch("subprocess.run")
def test_teardown_is_best_effort(self, mock_run, bridge):
mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"")
bridge._if1 = "eth0"
bridge._if2 = "usb0"
result = bridge._teardown_bridge_inline()
assert result is True
# ---------------------------------------------------------------------------
# Script-based setup/teardown
# ---------------------------------------------------------------------------
class TestScriptBasedOps:
@patch("subprocess.run")
def test_setup_uses_script_if_available(self, mock_run, bridge, tmp_path):
script = tmp_path / "scripts" / "setup_bridge.sh"
script.write_text("#!/bin/bash\necho ok")
bridge._scripts_dir = str(tmp_path / "scripts")
mock_run.return_value = MagicMock(returncode=0)
result = bridge.setup_bridge("eth0", "usb0")
assert result is True
@patch("subprocess.run")
def test_setup_falls_back_to_inline(self, mock_run, bridge):
bridge._scripts_dir = "/nonexistent"
mock_run.return_value = MagicMock(returncode=0)
result = bridge.setup_bridge("eth0", "usb0")
assert result is True
# ---------------------------------------------------------------------------
# Ebtables rules
# ---------------------------------------------------------------------------
class TestEbtablesRules:
@patch("subprocess.run")
def test_apply_ebtables_rules(self, mock_run, bridge):
mock_run.return_value = MagicMock(returncode=0)
bridge._apply_ebtables_rules("eth0", "usb0")
cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
assert any("01:80:C2:00:00:00" in s for s in cmd_strs) # STP
assert any("01:00:0C:CC:CC:CC" in s for s in cmd_strs) # CDP
assert any("01:80:C2:00:00:0E" in s for s in cmd_strs) # LLDP
@patch("subprocess.run", side_effect=FileNotFoundError("ebtables not found"))
def test_apply_ebtables_graceful_on_missing(self, mock_run, bridge):
bridge._apply_ebtables_rules("eth0", "usb0")
@patch("subprocess.run")
def test_remove_ebtables_flushes(self, mock_run, bridge):
mock_run.return_value = MagicMock(returncode=0)
bridge._remove_ebtables_rules()
cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
assert any("ebtables -F" in s for s in cmd_strs)
assert any("ebtables -X" in s for s in cmd_strs)
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
class TestHealthCheck:
@patch("subprocess.run")
def test_healthy_bridge(self, mock_run, bridge):
bridge._if1 = "eth0"
bridge._if2 = "usb0"
def fake_run(cmd, **kwargs):
return MagicMock(returncode=0, stdout="br0 stuff")
mock_run.side_effect = fake_run
assert bridge.is_healthy() is True
@patch("subprocess.run")
def test_unhealthy_bridge_missing(self, mock_run, bridge):
bridge._if1 = "eth0"
bridge._if2 = "usb0"
mock_run.return_value = MagicMock(returncode=1, stdout="")
assert bridge.is_healthy() is False
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ip", 5))
def test_unhealthy_on_timeout(self, mock_run, bridge):
bridge._if1 = "eth0"
bridge._if2 = "usb0"
assert bridge.is_healthy() is False
# ---------------------------------------------------------------------------
# Watchdog logic
# ---------------------------------------------------------------------------
class TestWatchdog:
def test_consecutive_failures_tracked(self, bridge):
bridge._consecutive_failures = 0
bridge._consecutive_failures += 1
assert bridge._consecutive_failures == 1
def test_watchdog_max_failures_constant(self):
assert WATCHDOG_MAX_FAILURES == 3
def test_watchdog_interval_constant(self):
assert WATCHDOG_INTERVAL == 5.0
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_not_running(self, bridge):
s = bridge.status()
assert s["running"] is False
assert s["bridge_up"] is False
assert s["bridge_name"] == "br0"
def test_status_tracks_interfaces(self, bridge):
bridge._if1 = "eth0"
bridge._if2 = "usb0"
s = bridge.status()
assert s["interface1"] == "eth0"
assert s["interface2"] == "usb0"
def test_status_tracks_failures(self, bridge):
bridge._consecutive_failures = 2
s = bridge.status()
assert s["consecutive_failures"] == 2
# ---------------------------------------------------------------------------
# Start/stop lifecycle (mocked)
# ---------------------------------------------------------------------------
class TestLifecycle:
@patch.object(Bridge, "setup_bridge", return_value=True)
@patch.object(Bridge, "is_healthy", return_value=True)
def test_start_sets_running(self, mock_health, mock_setup, bridge):
bridge.start()
assert bridge._running is True
assert bridge._bridge_up is True
@patch.object(Bridge, "setup_bridge", return_value=True)
@patch.object(Bridge, "teardown_bridge", return_value=True)
@patch.object(Bridge, "is_healthy", return_value=True)
def test_stop_tears_down(self, mock_health, mock_teardown, mock_setup, bridge):
bridge.start()
bridge.stop()
assert bridge._running is False
assert bridge._bridge_up is False
mock_teardown.assert_called_once()
@patch.object(Bridge, "setup_bridge", return_value=False)
def test_start_fails_on_setup_error(self, mock_setup, bridge):
bridge.start()
assert bridge._running is not True
+134
View File
@@ -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
+346
View File
@@ -0,0 +1,346 @@
"""Tests for modules/intel/credential_db — deduplication, bulk load, hashcat export.
Validates credential ingestion, deduplication logic, export formats,
and bulk operations without requiring network or bus events.
"""
import json
import os
import sqlite3
import time
from unittest.mock import MagicMock, patch
import pytest
from modules.base import BaseModule
from modules.intel.credential_db import CredentialDB, _TABLE_DDL, _INDEXES, _HASHCAT_MODES
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def cred_db(mock_bus, mock_state, tmp_path):
"""Return a started CredentialDB instance backed by a temp SQLite file."""
config = {"data_dir": str(tmp_path)}
mod = CredentialDB(mock_bus, mock_state, config)
mod.start()
yield mod
mod.stop()
def _ingest(db, username="admin", service="smb", cred_type="ntlmv2",
cred_value="hash123", domain="CORP", source_module="test",
source_ip="10.0.0.5", target_ip="10.0.0.1", target_port=445,
notes=""):
"""Helper to call _ingest_credential with defaults."""
return db._ingest_credential(
source_module=source_module,
source_ip=source_ip,
target_ip=target_ip,
target_port=target_port,
service=service,
username=username,
domain=domain,
cred_type=cred_type,
cred_value=cred_value,
notes=notes,
)
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestCredentialDBStructure:
def test_is_base_module(self):
assert issubclass(CredentialDB, BaseModule)
def test_module_attributes(self):
assert CredentialDB.name == "credential_db"
assert CredentialDB.module_type == "intel"
assert CredentialDB.requires_root is False
# ---------------------------------------------------------------------------
# Ingestion + Deduplication
# ---------------------------------------------------------------------------
class TestIngestion:
def test_ingest_new_credential(self, cred_db):
"""A new credential returns True and increments count."""
result = _ingest(cred_db)
assert result is True
assert cred_db._ingest_count == 1
def test_ingest_empty_value_rejected(self, cred_db):
"""Empty cred_value is rejected."""
result = _ingest(cred_db, cred_value="")
assert result is False
def test_duplicate_returns_false(self, cred_db):
"""Inserting the same (service, username, cred_type, cred_value) deduplicates."""
_ingest(cred_db, username="admin", service="smb", cred_type="ntlmv2", cred_value="AAA")
result = _ingest(cred_db, username="admin", service="smb", cred_type="ntlmv2", cred_value="AAA")
assert result is False
assert cred_db._dedup_count == 1
def test_duplicate_appends_notes(self, cred_db):
"""Duplicate credential appends new notes to existing record."""
_ingest(cred_db, username="user1", cred_value="hash1", notes="first seen")
_ingest(cred_db, username="user1", cred_value="hash1", notes="seen again")
creds = cred_db.get_credentials(username="user1")
assert len(creds) == 1
assert "first seen" in creds[0]["notes"]
assert "seen again" in creds[0]["notes"]
def test_duplicate_appends_source_module(self, cred_db):
"""Duplicate from a different module appends source_module."""
_ingest(cred_db, username="user1", cred_value="hash1", source_module="sniffer")
_ingest(cred_db, username="user1", cred_value="hash1", source_module="responder")
creds = cred_db.get_credentials(username="user1")
assert "sniffer" in creds[0]["source_module"]
assert "responder" in creds[0]["source_module"]
def test_different_cred_types_not_deduplicated(self, cred_db):
"""Same username+service but different cred_type are distinct entries."""
_ingest(cred_db, cred_type="ntlmv2", cred_value="hash_ntlm")
_ingest(cred_db, cred_type="kerberos_tgs", cred_value="hash_kerb")
creds = cred_db.get_credentials()
assert len(creds) == 2
def test_auto_hashcat_mode_resolution(self, cred_db):
"""hashcat_mode is auto-resolved from cred_type when not provided."""
_ingest(cred_db, cred_type="ntlmv2", cred_value="somehash")
creds = cred_db.get_credentials(cred_type="ntlmv2")
assert len(creds) == 1
assert creds[0]["hashcat_mode"] == 5600
# ---------------------------------------------------------------------------
# Bulk load (1000+ credentials)
# ---------------------------------------------------------------------------
class TestBulkLoad:
def test_ingest_1000_unique_credentials(self, cred_db):
"""Ingest 1000 unique credentials without errors."""
for i in range(1000):
result = _ingest(
cred_db,
username=f"user_{i}",
cred_value=f"hash_{i:04d}",
service="smb",
cred_type="ntlmv2",
)
assert result is True
assert cred_db._ingest_count == 1000
s = cred_db.summary()
assert s["total"] == 1000
assert s["unique_users"] == 1000
def test_bulk_deduplication_at_scale(self, cred_db):
"""Ingest 500 unique + 500 duplicates — exactly 500 stored."""
for i in range(500):
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
for i in range(500):
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
assert cred_db._dedup_count == 500
s = cred_db.summary()
assert s["total"] == 500
def test_mixed_services_bulk(self, cred_db):
"""1200 credentials across multiple services."""
services = ["smb", "ldap", "http", "rdp", "ssh", "ftp"]
for i in range(1200):
svc = services[i % len(services)]
_ingest(cred_db, username=f"user_{i}", cred_value=f"val_{i}", service=svc)
s = cred_db.summary()
assert s["total"] == 1200
assert len(s["by_service"]) == len(services)
# ---------------------------------------------------------------------------
# Hashcat export
# ---------------------------------------------------------------------------
class TestHashcatExport:
def test_to_hashcat_filters_by_mode(self, cred_db):
"""to_hashcat(mode) only returns credentials with matching hashcat_mode."""
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="ntlm_hash_1")
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="kerb_hash_1")
ntlm_output = cred_db.to_hashcat(mode=5600)
assert "ntlm_hash_1" in ntlm_output
assert "kerb_hash_1" not in ntlm_output
kerb_output = cred_db.to_hashcat(mode=13100)
assert "kerb_hash_1" in kerb_output
assert "ntlm_hash_1" not in kerb_output
def test_to_hashcat_all_hashable(self, cred_db):
"""to_hashcat(None) returns all credentials with a hashcat_mode."""
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="h1")
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="h2")
_ingest(cred_db, username="u3", cred_type="ftp", cred_value="plaintext") # no hashcat mode
output = cred_db.to_hashcat()
assert "h1" in output
assert "h2" in output
assert "plaintext" not in output
def test_to_hashcat_excludes_cracked(self, cred_db):
"""Already cracked credentials are excluded from hashcat export."""
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="hash_uncracked")
_ingest(cred_db, username="u2", cred_type="ntlmv2", cred_value="hash_cracked")
creds = cred_db.get_credentials(username="u2")
cred_db.update_crack_status(creds[0]["id"], "cracked", "password123")
output = cred_db.to_hashcat(mode=5600)
assert "hash_uncracked" in output
assert "hash_cracked" not in output
# ---------------------------------------------------------------------------
# Crack status management
# ---------------------------------------------------------------------------
class TestCrackStatus:
def test_update_crack_status(self, cred_db):
"""update_crack_status changes status and stores cracked value."""
_ingest(cred_db, username="target", cred_value="ntlm_hash")
creds = cred_db.get_credentials(username="target")
cred_id = creds[0]["id"]
cred_db.update_crack_status(cred_id, "cracked", "P@ssw0rd!")
updated = cred_db.get_credentials(username="target")
assert updated[0]["crack_status"] == "cracked"
assert updated[0]["cracked_value"] == "P@ssw0rd!"
def test_invalid_status_ignored(self, cred_db):
"""Invalid status values are silently ignored."""
_ingest(cred_db, username="u1", cred_value="h1")
creds = cred_db.get_credentials(username="u1")
cred_db.update_crack_status(creds[0]["id"], "invalid_status")
updated = cred_db.get_credentials(username="u1")
assert updated[0]["crack_status"] == "uncracked"
def test_bulk_update_cracked(self, cred_db):
"""bulk_update_cracked updates multiple credentials from hashcat results."""
for i in range(10):
_ingest(cred_db, username=f"u{i}", cred_type="ntlmv2", cred_value=f"hash_{i}")
results = {f"hash_{i}": f"pass_{i}" for i in range(5)}
updated = cred_db.bulk_update_cracked(results)
assert updated == 5
cracked = cred_db.get_cracked()
assert len(cracked) == 5
def test_bulk_update_idempotent(self, cred_db):
"""Running bulk_update_cracked twice doesn't double-count."""
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="hash_x")
results = {"hash_x": "cracked_pass"}
first = cred_db.bulk_update_cracked(results)
second = cred_db.bulk_update_cracked(results)
assert first == 1
assert second == 0
# ---------------------------------------------------------------------------
# Export formats
# ---------------------------------------------------------------------------
class TestExports:
def test_to_csv(self, cred_db):
"""CSV export includes header and all rows."""
for i in range(5):
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
csv_output = cred_db.to_csv()
lines = csv_output.strip().split("\n")
assert len(lines) == 6 # header + 5 data rows
assert "username" in lines[0]
def test_to_json(self, cred_db):
"""JSON export is valid and contains all records."""
for i in range(3):
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
json_output = cred_db.to_json()
data = json.loads(json_output)
assert len(data) == 3
assert all("username" in entry for entry in data)
def test_summary(self, cred_db):
"""Summary returns correct counts by type and service."""
_ingest(cred_db, username="u1", service="smb", cred_type="ntlmv2", cred_value="h1")
_ingest(cred_db, username="u2", service="ldap", cred_type="ntlmv2", cred_value="h2")
_ingest(cred_db, username="u3", service="smb", cred_type="kerberos_tgs", cred_value="h3")
s = cred_db.summary()
assert s["total"] == 3
assert s["unique_users"] == 3
assert s["by_type"]["ntlmv2"] == 2
assert s["by_service"]["smb"] == 2
assert s["by_service"]["ldap"] == 1
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
class TestQueries:
def test_get_credentials_filter_by_service(self, cred_db):
_ingest(cred_db, username="u1", service="smb", cred_value="h1")
_ingest(cred_db, username="u2", service="ldap", cred_value="h2")
smb_creds = cred_db.get_credentials(service="smb")
assert len(smb_creds) == 1
assert smb_creds[0]["service"] == "smb"
def test_get_credentials_filter_by_cred_type(self, cred_db):
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="h1")
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="h2")
kerb = cred_db.get_credentials(cred_type="kerberos_tgs")
assert len(kerb) == 1
def test_get_credentials_limit(self, cred_db):
for i in range(20):
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
limited = cred_db.get_credentials(limit=5)
assert len(limited) == 5
def test_get_cracked_returns_only_cracked(self, cred_db):
_ingest(cred_db, username="u1", cred_value="h1")
_ingest(cred_db, username="u2", cred_value="h2")
creds = cred_db.get_credentials(username="u2")
cred_db.update_crack_status(creds[0]["id"], "cracked", "pw")
cracked = cred_db.get_cracked()
assert len(cracked) == 1
assert cracked[0]["username"] == "u2"
+105
View File
@@ -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
+225
View File
@@ -0,0 +1,225 @@
"""Tests for core.engine — Module lifecycle manager."""
import os
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")
def test_capture_bus_instantiated_in_start_all(self, mock_bus, mock_state, mock_config):
"""Engine.start_all() creates and injects CaptureBus into passive modules."""
# Create a passive module that requires CaptureBus
class MockPassiveWithCaptureBus(BaseModule):
name = "mock_passive_capture"
module_type = "passive"
priority = 100
dependencies = []
requires_root = False
requires_capture_bus = True
def start(self):
# This module should receive capture_bus in config
self._capture_bus = self.config.get("capture_bus")
if not self._capture_bus:
raise RuntimeError("capture_bus not provided in config")
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
def stop(self):
self._running = False
def status(self):
return {"running": self._running, "pid": self._pid}
def configure(self, config):
pass
engine = Engine(bus=mock_bus, state=mock_state, config=mock_config)
engine.register(MockPassiveWithCaptureBus, config=mock_config)
# Mock CaptureBus.start() to avoid permission errors during test
with patch("core.capture_bus.CaptureBus.start"):
# start_all() should create CaptureBus and inject it
results = engine.start_all()
# Verify CaptureBus was created
assert engine.capture_bus is not None
assert results.get("mock_passive_capture") is True
# Verify the module config has capture_bus injected
entry = engine._modules["mock_passive_capture"]
assert "capture_bus" in entry.config
assert entry.config["capture_bus"] is not None
# Cleanup
engine.stop_all()
+354
View File
@@ -0,0 +1,354 @@
"""Tests for modules/stealth/ids_tester — risk assessment, rule loading, preflight checks.
Validates action risk assessment, IDS rule parsing and matching,
caplet assessment, preflight go/nogo decisions, and risk escalation
without requiring Snort/Suricata or network access.
"""
import os
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from modules.base import BaseModule
from modules.stealth.ids_tester import (
IDSTester, RiskAssessment,
ACTION_RISK_BASELINE, CAPLET_ACTION_RISK,
RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def ids(mock_bus, mock_state, tmp_path):
"""Return a started IDSTester with no rules loaded."""
config = {"rules_dir": str(tmp_path / "rules")}
mod = IDSTester(mock_bus, mock_state, config)
mod.start()
return mod
@pytest.fixture
def ids_with_rules(mock_bus, mock_state, tmp_path):
"""Return a started IDSTester with sample Snort rules."""
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "local.rules").write_text(
'alert tcp any any -> any any (msg:"LLMNR Spoof Detected"; sid:1000001; classtype:bad-unknown;)\n'
'alert udp any any -> any any (msg:"NBT-NS Poisoning"; sid:1000002; classtype:bad-unknown;)\n'
'alert tcp any any -> any 445 (msg:"SMB Relay Detected"; sid:1000003; classtype:attempted-admin;)\n'
'alert tcp any any -> any 80 (msg:"HTTP MITM Detected"; sid:1000004; classtype:web-application-attack;)\n'
'# This is a comment line\n'
'\n'
)
config = {"rules_dir": str(rules_dir)}
mod = IDSTester(mock_bus, mock_state, config)
mod.start()
return mod
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestIDSTesterStructure:
def test_is_base_module(self):
assert issubclass(IDSTester, BaseModule)
def test_module_attributes(self):
assert IDSTester.name == "ids_tester"
assert IDSTester.module_type == "stealth"
assert IDSTester.requires_root is False
# ---------------------------------------------------------------------------
# Risk constants
# ---------------------------------------------------------------------------
class TestRiskConstants:
def test_action_baselines_exist(self):
assert len(ACTION_RISK_BASELINE) > 10
def test_passive_actions_are_low(self):
for action in ["passive_sniff", "dns_logging", "pcap_capture"]:
assert ACTION_RISK_BASELINE[action] == RISK_LOW
def test_active_actions_are_high_or_critical(self):
for action in ["responder", "ntlm_relay", "mitmproxy_ssl_intercept"]:
assert ACTION_RISK_BASELINE[action] in (RISK_HIGH, RISK_CRITICAL)
def test_caplet_actions_defined(self):
assert "net.sniff" in CAPLET_ACTION_RISK
assert "arp.spoof" in CAPLET_ACTION_RISK
assert "https.proxy" in CAPLET_ACTION_RISK
# ---------------------------------------------------------------------------
# Rule loading
# ---------------------------------------------------------------------------
class TestRuleLoading:
def test_no_rules_dir(self, ids):
assert ids._rules == []
def test_rules_loaded_from_file(self, ids_with_rules):
assert len(ids_with_rules._rules) == 4
def test_comments_skipped(self, ids_with_rules):
sids = [r["sid"] for r in ids_with_rules._rules]
assert all(s.isdigit() for s in sids)
def test_parsed_rule_structure(self, ids_with_rules):
rule = ids_with_rules._rules[0]
assert "sid" in rule
assert "msg" in rule
assert "raw" in rule
assert rule["sid"] == "1000001"
# ---------------------------------------------------------------------------
# Rule parsing
# ---------------------------------------------------------------------------
class TestRuleParsing:
def test_parse_valid_rule(self):
line = 'alert tcp any any -> any any (msg:"Test Rule"; sid:12345; classtype:trojan-activity;)'
result = IDSTester._parse_rule(line)
assert result is not None
assert result["sid"] == "12345"
assert result["msg"] == "Test Rule"
assert result["classtype"] == "trojan-activity"
def test_parse_rule_without_sid_returns_none(self):
result = IDSTester._parse_rule("alert tcp any any -> any any (msg:\"No SID\";)")
assert result is None
def test_parse_rule_without_msg(self):
result = IDSTester._parse_rule("alert tcp any any -> any any (sid:99999;)")
assert result is not None
assert result["msg"] == ""
# ---------------------------------------------------------------------------
# Risk assessment (baseline only, no rules)
# ---------------------------------------------------------------------------
class TestBaselineRiskAssessment:
def test_passive_action_low_risk(self, ids):
result = ids.assess_risk("passive_sniff")
assert result["risk_level"] == RISK_LOW
def test_active_action_high_risk(self, ids):
result = ids.assess_risk("responder")
assert result["risk_level"] == RISK_HIGH
def test_critical_action(self, ids):
result = ids.assess_risk("mitmproxy_ssl_intercept")
assert result["risk_level"] == RISK_CRITICAL
def test_unknown_action_medium_risk(self, ids):
result = ids.assess_risk("completely_unknown_action")
assert result["risk_level"] == RISK_MEDIUM
def test_assessment_stored(self, ids):
ids.assess_risk("arp_spoof")
assert len(ids._assessments) == 1
assert ids._last_assessment.action == "arp_spoof"
# ---------------------------------------------------------------------------
# Risk escalation with rules
# ---------------------------------------------------------------------------
class TestRiskEscalation:
def test_responder_escalates_with_matching_rules(self, ids_with_rules):
result = ids_with_rules.assess_risk("responder")
assert result["risk_level"] in (RISK_HIGH, RISK_CRITICAL)
assert len(result["matching_sids"]) > 0
def test_matching_sids_capped_at_20(self, mock_bus, mock_state, tmp_path):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
lines = "\n".join(
f'alert tcp any any -> any any (msg:"ARP rule {i}"; sid:{2000+i};)'
for i in range(30)
)
(rules_dir / "arp.rules").write_text(lines)
config = {"rules_dir": str(rules_dir)}
mod = IDSTester(mock_bus, mock_state, config)
mod.start()
result = mod.assess_risk("arp_spoof")
assert len(result["matching_sids"]) <= 20
# ---------------------------------------------------------------------------
# Recommendation text
# ---------------------------------------------------------------------------
class TestRecommendation:
def test_critical_recommendation(self, ids):
result = ids.assess_risk("mitmproxy_ssl_intercept")
assert "CRITICAL" in result["recommendation"] or result["risk_level"] == RISK_CRITICAL
def test_low_recommendation(self, ids):
result = ids.assess_risk("passive_sniff")
assert "LOW" in result["recommendation"]
def test_recommendation_includes_action_name(self, ids):
result = ids.assess_risk("arp_spoof")
assert "arp_spoof" in result["recommendation"]
# ---------------------------------------------------------------------------
# Keyword mapping
# ---------------------------------------------------------------------------
class TestKeywordMapping:
def test_responder_keywords(self):
keywords = IDSTester._action_to_keywords("responder")
assert "llmnr" in keywords
assert "nbns" in keywords
def test_arp_spoof_keywords(self):
keywords = IDSTester._action_to_keywords("arp_spoof")
assert "arp" in keywords
def test_unknown_action_uses_name(self):
keywords = IDSTester._action_to_keywords("custom_action")
assert "custom action" in keywords
# ---------------------------------------------------------------------------
# Caplet assessment
# ---------------------------------------------------------------------------
class TestCapletAssessment:
def test_assess_caplet_detects_actions(self, ids):
caplet = (
"# Bettercap caplet\n"
"net.sniff on\n"
"arp.spoof on\n"
)
results = ids.assess_caplet(caplet)
assert len(results) == 2
def test_assess_caplet_skips_comments(self, ids):
caplet = "# net.sniff on\n"
results = ids.assess_caplet(caplet)
assert len(results) == 0
def test_assess_caplet_includes_line(self, ids):
caplet = "arp.spoof on\n"
results = ids.assess_caplet(caplet)
assert results[0]["caplet_line"] == "arp.spoof on"
# ---------------------------------------------------------------------------
# Preflight check
# ---------------------------------------------------------------------------
class TestPreflightCheck:
def test_low_risk_go(self, ids):
result = ids.preflight_check("packet_capture", ["passive_sniff", "pcap_capture"])
assert result["go_nogo"] == "GO"
assert result["overall_risk"] == RISK_LOW
def test_critical_risk_nogo(self, ids):
result = ids.preflight_check("mitmproxy", ["mitmproxy_ssl_intercept"])
assert result["go_nogo"] == "NO-GO"
assert result["overall_risk"] == RISK_CRITICAL
def test_high_risk_caution(self, ids):
result = ids.preflight_check("responder_mgr", ["responder"])
assert result["go_nogo"] == "CAUTION"
assert result["overall_risk"] == RISK_HIGH
def test_preflight_returns_per_action_risks(self, ids):
result = ids.preflight_check("test_module", ["passive_sniff", "responder"])
assert len(result["action_risks"]) == 2
def test_preflight_overall_is_max(self, ids):
result = ids.preflight_check("mixed", ["passive_sniff", "mitmproxy_ssl_intercept"])
assert result["overall_risk"] == RISK_CRITICAL
# ---------------------------------------------------------------------------
# RiskAssessment dataclass
# ---------------------------------------------------------------------------
class TestRiskAssessmentDataclass:
def test_to_dict(self):
ra = RiskAssessment(
action="test",
risk_level=RISK_LOW,
matching_sids=["100"],
matching_rules=["Test rule"],
recommendation="Safe",
)
d = ra.to_dict()
assert d["action"] == "test"
assert d["risk_level"] == RISK_LOW
assert d["matching_sids"] == ["100"]
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_running(self, ids):
s = ids.status()
assert s["running"] is True
assert s["rules_loaded"] == 0
assert s["total_assessments"] == 0
def test_status_after_assessment(self, ids):
ids.assess_risk("arp_spoof")
s = ids.status()
assert s["total_assessments"] == 1
assert s["last_assessment_action"] == "arp_spoof"
def test_status_with_rules(self, ids_with_rules):
s = ids_with_rules.status()
assert s["rules_loaded"] == 4
def test_configure_reloads_rules(self, ids, tmp_path):
new_dir = tmp_path / "new_rules"
new_dir.mkdir()
(new_dir / "test.rules").write_text(
'alert tcp any any -> any any (msg:"New"; sid:99999;)\n'
)
ids.configure({"rules_dir": str(new_dir)})
assert len(ids._rules) == 1
# ---------------------------------------------------------------------------
# Assessment history
# ---------------------------------------------------------------------------
class TestAssessmentHistory:
def test_history_capped_at_100(self, ids):
for i in range(110):
ids.assess_risk("arp_spoof")
assert len(ids._assessments) == 100
+119
View File
@@ -0,0 +1,119 @@
"""Tests for interface detection with retry fallback chain."""
import time
from pathlib import Path
from unittest.mock import patch, MagicMock
from tempfile import TemporaryDirectory
import pytest
from utils.networking import get_primary_interface, detect_interface_with_retry
class TestInterfaceDetection:
"""Test robust interface detection with retry fallback."""
def test_get_primary_interface_from_route_table(self):
"""get_primary_interface reads default route from /proc/net/route."""
route_content = """Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
eth0 00000000 0101010A 0003 0 0 100 00000000 0 0 0
eth0 0101010A 00000000 0001 0 0 100 FFFFFFFF 0 0 0
"""
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__.return_value.readlines.return_value = route_content.split("\n")
result = get_primary_interface()
assert result == "eth0"
def test_get_primary_interface_fallback_eth(self):
"""get_primary_interface falls back to eth* interfaces if /proc/net/route is empty."""
with patch("builtins.open", side_effect=IOError):
with patch("utils.networking.get_all_interfaces", return_value=["eth0", "eth1"]):
result = get_primary_interface()
assert result == "eth0"
def test_get_primary_interface_fallback_wlan(self):
"""get_primary_interface falls back to first interface if no eth/en."""
with patch("builtins.open", side_effect=IOError):
with patch("utils.networking.get_all_interfaces", return_value=["wlan0"]):
result = get_primary_interface()
assert result == "wlan0"
def test_get_primary_interface_no_interfaces(self):
"""get_primary_interface returns None if no interfaces available."""
with patch("builtins.open", side_effect=IOError):
with patch("utils.networking.get_all_interfaces", return_value=[]):
result = get_primary_interface()
assert result is None
def test_detect_interface_with_retry_immediate_success(self):
"""detect_interface_with_retry returns interface immediately if found."""
with patch("utils.networking.get_primary_interface", return_value="eth0"):
result = detect_interface_with_retry(max_retries=3, retry_delay=1)
assert result == "eth0"
def test_detect_interface_with_retry_eventual_success(self):
"""detect_interface_with_retry retries and succeeds after N attempts."""
call_count = [0]
def get_iface_side_effect():
call_count[0] += 1
if call_count[0] <= 2:
return None # Fail first 2 times
return "wlan0" # Succeed on 3rd call
with patch("utils.networking.get_primary_interface", side_effect=get_iface_side_effect):
with patch("utils.networking._get_up_interface", return_value=None):
result = detect_interface_with_retry(max_retries=3, retry_delay=0.01)
assert result == "wlan0"
assert call_count[0] == 3
def test_detect_interface_with_retry_exhausted(self):
"""detect_interface_with_retry returns None if all retries exhausted."""
with patch("utils.networking.get_primary_interface", return_value=None):
with patch("utils.networking._get_up_interface", return_value=None):
result = detect_interface_with_retry(max_retries=2, retry_delay=0.01)
assert result is None
def test_detect_interface_with_retry_uses_config_fallback(self):
"""detect_interface_with_retry accepts config-set interface as ultimate fallback."""
with patch("utils.networking.get_primary_interface", return_value=None):
with patch("utils.networking._get_up_interface", return_value=None):
result = detect_interface_with_retry(
max_retries=1,
retry_delay=0.01,
config_interface="configured_iface"
)
# Should use config fallback when detection fails
assert result == "configured_iface"
def test_detect_interface_exponential_backoff(self):
"""detect_interface_with_retry uses exponential backoff (5s, 10s, 20s)."""
call_times = []
def get_iface_with_timing():
call_times.append(time.time())
return None
with patch("utils.networking.get_primary_interface", side_effect=get_iface_with_timing):
with patch("utils.networking._get_up_interface", return_value=None):
with patch("time.sleep") as mock_sleep:
result = detect_interface_with_retry(
max_retries=3,
retry_delay=5, # Base delay
exponential=True
)
# Should have called sleep with 5, 10, 20
assert mock_sleep.call_count == 3
# Check exponential backoff values (5 * 2^0, 5 * 2^1, 5 * 2^2)
sleep_calls = [call[0][0] for call in mock_sleep.call_args_list]
assert sleep_calls == [5, 10, 20]
def test_detect_interface_filters_excluded(self):
"""detect_interface_with_retry excludes lo, tailscale*, wg*, tun*, docker*, veth*, br-*."""
excluded_ifaces = ["lo", "tailscale0", "wg0", "tun0", "docker0", "veth123", "br-abc"]
with patch("utils.networking.get_primary_interface", return_value=None):
# Mock get_all_interfaces to return only excluded ones
with patch("utils.networking.get_all_interfaces", return_value=excluded_ifaces):
result = detect_interface_with_retry(max_retries=1, retry_delay=0.01)
assert result is None
+349
View File
@@ -0,0 +1,349 @@
"""Tests for modules/stealth/ja3_spoofer — profile loading, cipher mapping, randomization validation.
Validates JA3 fingerprint profile selection, cipher suite mapping,
SSL context configuration, and database loading without requiring
root, iptables, or NFQUEUE.
"""
import json
import os
import sqlite3
import ssl
from unittest.mock import MagicMock, patch
import pytest
from modules.base import BaseModule
from modules.stealth.ja3_spoofer import JA3Spoofer, BUILTIN_PROFILES
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def spoofer(mock_bus, mock_state):
"""Return a JA3Spoofer instance with NFQUEUE disabled."""
config = {"use_nfqueue": False}
return JA3Spoofer(mock_bus, mock_state, config)
@pytest.fixture
def ja3_db(tmp_path):
"""Create a test ja3_fingerprints.db with sample profiles."""
db_path = tmp_path / "ja3_fingerprints.db"
conn = sqlite3.connect(str(db_path))
conn.execute("""
CREATE TABLE ja3_profiles (
name TEXT PRIMARY KEY,
ja3_hash TEXT,
description TEXT,
cipher_suites TEXT,
extensions TEXT,
elliptic_curves TEXT,
ec_point_formats TEXT
)
""")
# Insert test profiles
profiles = [
("safari_17_macos", "aabbccdd11223344", "Safari 17 on macOS",
json.dumps([0x1301, 0x1302, 0xc02b, 0xc02f]),
json.dumps([0, 23, 65281, 10, 11]),
json.dumps([0x001d, 0x0017]),
json.dumps([0])),
("curl_8_linux", "eeff00112233aabb", "curl 8.x on Linux",
json.dumps([0x1301, 0x1303, 0xc02f, 0x009c]),
json.dumps([0, 10, 11, 13]),
json.dumps([0x0017, 0x0018]),
json.dumps([0])),
]
conn.executemany(
"INSERT INTO ja3_profiles VALUES (?, ?, ?, ?, ?, ?, ?)", profiles
)
conn.commit()
conn.close()
return str(db_path)
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestJA3SpooferStructure:
def test_is_base_module(self):
assert issubclass(JA3Spoofer, BaseModule)
def test_module_attributes(self):
assert JA3Spoofer.name == "ja3_spoofer"
assert JA3Spoofer.module_type == "stealth"
assert JA3Spoofer.requires_root is True
# ---------------------------------------------------------------------------
# Built-in profiles
# ---------------------------------------------------------------------------
class TestBuiltinProfiles:
def test_builtin_profiles_exist(self):
"""At least 4 built-in profiles should be defined."""
assert len(BUILTIN_PROFILES) >= 4
def test_all_profiles_have_required_fields(self):
"""Every profile has ja3_hash, cipher_suites, extensions, elliptic_curves."""
required = {"ja3_hash", "cipher_suites", "extensions", "elliptic_curves"}
for name, profile in BUILTIN_PROFILES.items():
missing = required - set(profile.keys())
assert not missing, f"Profile {name} missing fields: {missing}"
def test_all_ja3_hashes_are_32_hex(self):
"""JA3 hashes should be 32-char hex strings (MD5)."""
for name, profile in BUILTIN_PROFILES.items():
h = profile["ja3_hash"]
assert len(h) == 32, f"{name}: hash length {len(h)} != 32"
assert all(c in "0123456789abcdef" for c in h), f"{name}: non-hex chars in hash"
def test_cipher_suites_are_valid_ints(self):
"""Cipher suite IDs should be positive integers."""
for name, profile in BUILTIN_PROFILES.items():
for cs in profile["cipher_suites"]:
assert isinstance(cs, int) and cs > 0, f"{name}: invalid cipher {cs}"
def test_profiles_have_tls13_ciphers(self):
"""Modern profiles should include TLS 1.3 cipher suites (0x1301-0x1303)."""
tls13 = {0x1301, 0x1302, 0x1303}
for name, profile in BUILTIN_PROFILES.items():
suites = set(profile["cipher_suites"])
assert suites & tls13, f"{name}: no TLS 1.3 ciphers"
def test_chrome_and_firefox_profiles_differ(self):
"""Chrome and Firefox profiles should have different JA3 hashes."""
chrome = BUILTIN_PROFILES["chrome_120_win"]["ja3_hash"]
firefox = BUILTIN_PROFILES["firefox_121_win"]["ja3_hash"]
assert chrome != firefox
# ---------------------------------------------------------------------------
# Cipher ID to OpenSSL name mapping
# ---------------------------------------------------------------------------
class TestCipherMapping:
def test_known_cipher_ids_resolve(self):
"""All TLS 1.3 and common TLS 1.2 ciphers map to OpenSSL names."""
known_ids = [0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0x009c, 0x009d]
names = JA3Spoofer._cipher_ids_to_openssl_names(known_ids)
assert len(names) == len(known_ids)
assert "TLS_AES_128_GCM_SHA256" in names
assert "TLS_AES_256_GCM_SHA384" in names
def test_unknown_cipher_ids_skipped(self):
"""Unknown cipher IDs are silently skipped."""
ids = [0x1301, 0xFFFF, 0xDEAD]
names = JA3Spoofer._cipher_ids_to_openssl_names(ids)
assert len(names) == 1
assert names[0] == "TLS_AES_128_GCM_SHA256"
def test_empty_input(self):
"""Empty cipher list returns empty names."""
assert JA3Spoofer._cipher_ids_to_openssl_names([]) == []
def test_all_builtin_profiles_resolve_ciphers(self):
"""Every builtin profile's cipher_suites should resolve to at least some names."""
for name, profile in BUILTIN_PROFILES.items():
names = JA3Spoofer._cipher_ids_to_openssl_names(profile["cipher_suites"])
assert len(names) > 0, f"{name}: no ciphers resolved"
def test_cipher_ordering_preserved(self):
"""Output cipher name ordering matches input ID ordering."""
ids = [0xc02f, 0x1301, 0x009c]
names = JA3Spoofer._cipher_ids_to_openssl_names(ids)
assert names[0] == "ECDHE-RSA-AES128-GCM-SHA256"
assert names[1] == "TLS_AES_128_GCM_SHA256"
assert names[2] == "AES128-GCM-SHA256"
# ---------------------------------------------------------------------------
# External fingerprint database loading
# ---------------------------------------------------------------------------
class TestFingerprintDatabase:
def test_load_from_sqlite(self, spoofer, ja3_db):
"""Profiles from SQLite database are loaded alongside builtins."""
initial_count = len(spoofer._profiles)
with patch("os.path.isfile", return_value=True):
with patch("modules.stealth.ja3_spoofer.os.path.isfile", return_value=True):
# Temporarily point to our test DB
original_load = spoofer._load_fingerprint_db
def patched_load():
import sqlite3 as sq3
conn = sq3.connect(ja3_db)
conn.row_factory = sq3.Row
rows = conn.execute(
"SELECT name, ja3_hash, description, cipher_suites, extensions, "
"elliptic_curves, ec_point_formats FROM ja3_profiles"
).fetchall()
for row in rows:
spoofer._profiles[row["name"]] = {
"ja3_hash": row["ja3_hash"],
"description": row["description"],
"cipher_suites": json.loads(row["cipher_suites"]),
"extensions": json.loads(row["extensions"]),
"elliptic_curves": json.loads(row["elliptic_curves"]),
"ec_point_formats": json.loads(row["ec_point_formats"]),
}
conn.close()
patched_load()
assert len(spoofer._profiles) == initial_count + 2
assert "safari_17_macos" in spoofer._profiles
assert "curl_8_linux" in spoofer._profiles
def test_db_profiles_have_correct_structure(self, spoofer, ja3_db):
"""Loaded DB profiles have the same structure as builtins."""
# Load profiles from DB
import sqlite3 as sq3
conn = sq3.connect(ja3_db)
conn.row_factory = sq3.Row
rows = conn.execute(
"SELECT name, ja3_hash, description, cipher_suites, extensions, "
"elliptic_curves, ec_point_formats FROM ja3_profiles"
).fetchall()
for row in rows:
spoofer._profiles[row["name"]] = {
"ja3_hash": row["ja3_hash"],
"description": row["description"],
"cipher_suites": json.loads(row["cipher_suites"]),
"extensions": json.loads(row["extensions"]),
"elliptic_curves": json.loads(row["elliptic_curves"]),
"ec_point_formats": json.loads(row["ec_point_formats"]),
}
conn.close()
safari = spoofer._profiles["safari_17_macos"]
assert isinstance(safari["cipher_suites"], list)
assert isinstance(safari["extensions"], list)
assert isinstance(safari["elliptic_curves"], list)
assert safari["ja3_hash"] == "aabbccdd11223344"
def test_missing_db_uses_builtins_only(self, spoofer):
"""When no external DB exists, only builtins are loaded."""
spoofer._profiles = dict(BUILTIN_PROFILES)
spoofer._load_fingerprint_db()
assert len(spoofer._profiles) == len(BUILTIN_PROFILES)
# ---------------------------------------------------------------------------
# Profile auto-selection
# ---------------------------------------------------------------------------
class TestAutoSelection:
def test_default_is_chrome_windows(self, spoofer):
"""Default auto-selection returns chrome_120_win."""
profile = spoofer._auto_select_profile()
assert profile == "chrome_120_win"
def test_windows_heavy_network_selects_chrome_win(self, spoofer):
"""Windows-heavy network environment selects Windows Chrome."""
spoofer.state.get = MagicMock(
return_value=json.dumps({"windows": 15, "linux": 3})
)
profile = spoofer._auto_select_profile()
assert profile == "chrome_120_win"
def test_linux_heavy_network_selects_chrome_linux(self, spoofer):
"""Linux-heavy network environment selects Linux Chrome."""
spoofer.state.get = MagicMock(
return_value=json.dumps({"windows": 2, "linux": 10})
)
profile = spoofer._auto_select_profile()
assert profile == "chrome_120_linux"
# ---------------------------------------------------------------------------
# SSL context configuration
# ---------------------------------------------------------------------------
class TestSSLContext:
def test_get_ssl_context_returns_context(self, spoofer):
"""get_ssl_context returns a valid SSLContext."""
spoofer._active_profile = "chrome_120_win"
ctx = spoofer.get_ssl_context()
assert isinstance(ctx, ssl.SSLContext)
assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2
def test_get_ssl_context_with_invalid_profile(self, spoofer):
"""Invalid profile still returns a usable SSLContext (default ciphers)."""
spoofer._active_profile = "nonexistent_profile"
ctx = spoofer.get_ssl_context()
assert isinstance(ctx, ssl.SSLContext)
# ---------------------------------------------------------------------------
# Status reporting
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_not_running(self, spoofer):
s = spoofer.status()
assert s["running"] is False
assert s["packets_modified"] == 0
assert s["profiles_loaded"] == len(BUILTIN_PROFILES)
def test_status_after_start(self, spoofer):
"""After start (with nfqueue disabled), status shows running."""
spoofer.start()
s = spoofer.status()
assert s["running"] is True
assert s["active_profile"] is not None
assert s["active_ja3_hash"] != "none"
spoofer.stop()
def test_configure_switches_profile(self, spoofer):
"""configure() with target_profile switches the active profile."""
spoofer._active_profile = "chrome_120_win"
spoofer.configure({"target_profile": "firefox_121_win"})
assert spoofer._active_profile == "firefox_121_win"
# ---------------------------------------------------------------------------
# Randomization validation — ensure profiles produce distinct fingerprints
# ---------------------------------------------------------------------------
class TestRandomizationValidation:
def test_all_profiles_produce_unique_ja3_hashes(self):
"""Every profile should have a unique JA3 hash."""
hashes = [p["ja3_hash"] for p in BUILTIN_PROFILES.values()]
assert len(hashes) == len(set(hashes)), "Duplicate JA3 hashes found"
def test_all_profiles_have_distinct_cipher_orderings(self):
"""Profiles should have at least 2 distinct cipher suite orderings
(Chromium-based browsers share orderings, Firefox differs)."""
orderings = []
for p in BUILTIN_PROFILES.values():
orderings.append(tuple(p["cipher_suites"]))
unique = len(set(orderings))
assert unique >= 2, f"Only {unique} unique cipher orderings across {len(BUILTIN_PROFILES)} profiles"
def test_profile_ciphers_produce_valid_ssl_context(self):
"""Each profile's cipher string should be accepted by OpenSSL."""
for name, profile in BUILTIN_PROFILES.items():
names = JA3Spoofer._cipher_ids_to_openssl_names(profile["cipher_suites"])
if not names:
continue
ctx = ssl.create_default_context()
# Some ciphers might not be available on all OpenSSL builds
try:
ctx.set_ciphers(":".join(names))
except ssl.SSLError:
# Acceptable — some cipher combos not supported on all platforms
pass
+271
View File
@@ -0,0 +1,271 @@
"""Tests for modules/active/ntlm_relay — target management, protocol inference, command building.
Validates relay target file writing, protocol inference from URLs,
ntlmrelayx command construction, Responder coordination, output
parsing, and status reporting without requiring ntlmrelayx binary,
root, or network access.
"""
import os
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from modules.base import BaseModule
from modules.active.ntlm_relay import NTLMRelay, RELAY_PROTOCOLS
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def relay(mock_bus, mock_state, tmp_path):
"""Return a started NTLMRelay instance with temp dirs."""
config = {
"interface": "eth0",
"ntlmrelayx_binary": str(tmp_path / "ntlmrelayx.py"),
}
mod = NTLMRelay(mock_bus, mock_state, config)
mod._target_file = str(tmp_path / "relay_targets.txt")
mod._loot_dir = str(tmp_path / "loot")
os.makedirs(mod._loot_dir, exist_ok=True)
os.makedirs(os.path.dirname(mod._target_file), exist_ok=True)
mod.start()
return mod
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestNTLMRelayStructure:
def test_is_base_module(self):
assert issubclass(NTLMRelay, BaseModule)
def test_module_attributes(self):
assert NTLMRelay.name == "ntlm_relay"
assert NTLMRelay.module_type == "active"
assert NTLMRelay.requires_root is True
def test_relay_protocols_frozen(self):
assert isinstance(RELAY_PROTOCOLS, frozenset)
assert "smb" in RELAY_PROTOCOLS
assert "ldap" in RELAY_PROTOCOLS
assert "adcs" in RELAY_PROTOCOLS
# ---------------------------------------------------------------------------
# Protocol inference
# ---------------------------------------------------------------------------
class TestProtocolInference:
def test_infer_smb_from_url(self, relay):
protos = relay._infer_protocols(["smb://192.168.1.10"])
assert "smb" in protos
def test_infer_ldap_from_url(self, relay):
protos = relay._infer_protocols(["ldap://192.168.1.10"])
assert "ldap" in protos
def test_infer_multiple_protocols(self, relay):
protos = relay._infer_protocols([
"smb://192.168.1.10",
"ldap://192.168.1.20",
"adcs://192.168.1.30",
])
assert protos == {"smb", "ldap", "adcs"}
def test_infer_defaults_to_smb(self, relay):
protos = relay._infer_protocols(["192.168.1.10"])
assert "smb" in protos
def test_unknown_protocol_ignored(self, relay):
protos = relay._infer_protocols(["fake://192.168.1.10", "smb://10.0.0.1"])
assert "smb" in protos
assert "fake" not in protos
def test_empty_targets_defaults_smb(self, relay):
protos = relay._infer_protocols([])
assert protos == {"smb"}
# ---------------------------------------------------------------------------
# Target file
# ---------------------------------------------------------------------------
class TestTargetFile:
def test_write_target_file(self, relay):
relay._targets = ["smb://192.168.1.10", "ldap://192.168.1.20"]
relay._write_target_file()
content = Path(relay._target_file).read_text()
assert "smb://192.168.1.10" in content
assert "ldap://192.168.1.20" in content
def test_write_target_file_one_per_line(self, relay):
relay._targets = ["smb://10.0.0.1", "smb://10.0.0.2", "smb://10.0.0.3"]
relay._write_target_file()
lines = Path(relay._target_file).read_text().strip().split("\n")
assert len(lines) == 3
# ---------------------------------------------------------------------------
# Command building
# ---------------------------------------------------------------------------
class TestCommandBuilding:
def test_basic_command(self, relay):
relay._targets = ["smb://10.0.0.1"]
relay._protocols = {"smb"}
cmd = relay._build_command()
assert "python3" in cmd
assert "-tf" in cmd
assert "-smb2support" in cmd
assert "-socks" in cmd
def test_adcs_flags(self, relay):
relay._targets = ["adcs://10.0.0.1"]
relay._protocols = {"adcs"}
relay._adcs_template = "ESC1"
cmd = relay._build_command()
assert "--adcs" in cmd
assert "--template" in cmd
assert "ESC1" in cmd
def test_ldap_delegate_access(self, relay):
relay._targets = ["ldap://10.0.0.1"]
relay._protocols = {"ldap"}
cmd = relay._build_command()
assert "--delegate-access" in cmd
def test_no_adcs_without_template(self, relay):
relay._targets = ["smb://10.0.0.1"]
relay._protocols = {"smb", "adcs"}
relay._adcs_template = None
cmd = relay._build_command()
assert "--adcs" not in cmd
# ---------------------------------------------------------------------------
# Responder coordination
# ---------------------------------------------------------------------------
class TestResponderCoordination:
def test_update_exclusions_extracts_ips(self, relay):
mock_responder = MagicMock()
relay._responder_mgr = mock_responder
relay._targets = ["smb://192.168.1.10", "ldap://192.168.1.20:389"]
relay._update_responder_exclusions()
mock_responder.set_relay_targets.assert_called_once()
ips = mock_responder.set_relay_targets.call_args[0][0]
assert "192.168.1.10" in ips
assert "192.168.1.20" in ips
def test_no_responder_no_error(self, relay):
relay._responder_mgr = None
relay._targets = ["smb://10.0.0.1"]
relay._update_responder_exclusions()
def test_extract_ip_from_plain_target(self, relay):
mock_responder = MagicMock()
relay._responder_mgr = mock_responder
relay._targets = ["10.0.0.5"]
relay._update_responder_exclusions()
ips = mock_responder.set_relay_targets.call_args[0][0]
assert "10.0.0.5" in ips
# ---------------------------------------------------------------------------
# Add target
# ---------------------------------------------------------------------------
class TestAddTarget:
def test_add_new_target(self, relay):
result = relay.add_target("smb://192.168.1.50")
assert result is True
assert "smb://192.168.1.50" in relay._targets
def test_add_duplicate_target(self, relay):
relay.add_target("smb://192.168.1.50")
result = relay.add_target("smb://192.168.1.50")
assert result is False
assert relay._targets.count("smb://192.168.1.50") == 1
# ---------------------------------------------------------------------------
# Output parsing
# ---------------------------------------------------------------------------
class TestOutputParsing:
def test_parse_auth_success(self, relay):
relay._parse_relay_output("[*] Authenticated successfully against smb://10.0.0.1")
assert len(relay._successful_relays) == 1
assert relay._successful_relays[0]["type"] == "auth_success"
def test_parse_adcs_certificate(self, relay):
relay._parse_relay_output("[*] Certificate saved to /tmp/cert.pem")
def test_parse_unrelated_line(self, relay):
relay._parse_relay_output("[*] Trying to connect to target smb://10.0.0.1")
assert len(relay._successful_relays) == 0
# ---------------------------------------------------------------------------
# Configure
# ---------------------------------------------------------------------------
class TestConfigure:
def test_configure_binary_path(self, relay):
relay.configure({"ntlmrelayx_binary": "/opt/new/ntlmrelayx.py"})
assert relay._ntlmrelayx_binary == "/opt/new/ntlmrelayx.py"
def test_configure_adcs_template(self, relay):
relay.configure({"adcs_template": "ESC8"})
assert relay._adcs_template == "ESC8"
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_when_idle(self, relay):
s = relay.status()
assert s["running"] is True
assert s["relay_active"] is False
assert s["relay_pid"] is None
assert s["targets"] == []
assert s["successful_relays"] == 0
def test_status_reflects_targets(self, relay):
relay._targets = ["smb://10.0.0.1"]
s = relay.status()
assert s["targets"] == ["smb://10.0.0.1"]
def test_status_reflects_protocols(self, relay):
relay._protocols = {"smb", "ldap"}
s = relay.status()
assert set(s["protocols"]) == {"smb", "ldap"}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Test that OPSEC violations are fixed.
Tests for:
1. sensor.* logger namespaces (should be __name__)
2. Hardcoded "bb" API user (should be generic)
3. Hardcoded "bb" relay_user (should be generic)
"""
import os
import re
import subprocess
from pathlib import Path
def test_no_sensor_logger_fingerprints():
"""Verify that no logging.getLogger(__name__) calls exist."""
bb_root = Path(__file__).parent.parent
# Search for sensor.* logger calls in Python files
result = subprocess.run(
["grep", "-r", r'getLogger("sensor\.', str(bb_root), "--include=*.py"],
capture_output=True,
text=True,
)
# Should find no matches (empty stdout)
assert result.stdout == "", (
f"Found sensor.* logger fingerprints:\n{result.stdout}"
)
def test_no_bb_api_user_hardcoded():
"""Verify that 'bb' is not hardcoded as bettercap API user."""
bb_root = Path(__file__).parent.parent
# Check config file
config_file = bb_root / "config" / "bigbrother.yaml"
with open(config_file) as f:
config_content = f.read()
# Look for api_user: "bb" (should be something else)
match = re.search(r'api_user:\s*"([^"]+)"', config_content)
assert match and match.group(1) != "bb", (
f"Found hardcoded api_user as 'bb' in config. Should be generic."
)
# Check Python files for hardcoded "bb" as default user parameter
result = subprocess.run(
["grep", "-r", 'user:\s*str\s*=\s*"bb"', str(bb_root), "--include=*.py"],
capture_output=True,
text=True,
)
assert result.stdout == "", (
f"Found hardcoded 'bb' as default user in code:\n{result.stdout}"
)
def test_no_bb_relay_user_hardcoded():
"""Verify that 'bb' is not hardcoded as relay_user."""
bb_root = Path(__file__).parent.parent
# Check config file
config_file = bb_root / "config" / "bigbrother.yaml"
with open(config_file) as f:
config_content = f.read()
# Look for relay_user: "bb" (should be something else)
match = re.search(r'relay_user:\s*"([^"]+)"', config_content)
assert match and match.group(1) != "bb", (
f"Found hardcoded relay_user as 'bb' in config. Should be generic."
)
if __name__ == "__main__":
test_no_sensor_logger_fingerprints()
test_no_bb_api_user_hardcoded()
test_no_bb_relay_user_hardcoded()
print("All OPSEC tests passed!")
+329
View File
@@ -0,0 +1,329 @@
"""Tests for modules/passive/packet_capture — rotation, zstd compression, AES-256-GCM encryption.
Validates the post-rotation pipeline without requiring tcpdump or root.
"""
import os
import struct
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
from modules.base import BaseModule
from modules.passive.packet_capture import PacketCapture
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def pcap_module(mock_bus, mock_state, tmp_path):
"""Return a PacketCapture instance with temp dirs and mocked externals."""
config = {
"data_dir": str(tmp_path),
"interface": "eth0",
"snap_length": 65535,
"rotation_minutes": 60,
"compression_level": 3,
"disk_threshold": 85,
"encryption_key": os.urandom(32),
}
mod = PacketCapture(mock_bus, mock_state, config)
# Pre-create pcap dir (start() would do this, but we bypass tcpdump)
pcap_dir = tmp_path / "pcaps"
pcap_dir.mkdir(exist_ok=True)
mod._pcap_dir = str(pcap_dir)
mod._encryption_key = config["encryption_key"]
mod._running = True
return mod
@pytest.fixture
def fake_pcap(tmp_path):
"""Create a minimal valid-looking pcap file in the pcaps subdir."""
pcap_dir = tmp_path / "pcaps"
pcap_dir.mkdir(exist_ok=True)
# Pcap global header (24 bytes) + a small amount of fake packet data
pcap_path = pcap_dir / "capture_20260410_010000.pcap"
header = struct.pack("<IHHiIII", 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1)
fake_pkt = os.urandom(200)
pcap_path.write_bytes(header + fake_pkt)
return str(pcap_path)
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestPacketCaptureStructure:
def test_is_base_module(self):
assert issubclass(PacketCapture, BaseModule)
def test_module_attributes(self):
assert PacketCapture.name == "packet_capture"
assert PacketCapture.module_type == "passive"
assert PacketCapture.requires_root is True
# ---------------------------------------------------------------------------
# Compression (zstd)
# ---------------------------------------------------------------------------
class TestZstdCompression:
def test_compress_zstd_python_library(self, tmp_path):
"""Test compression via the zstandard Python library."""
zstd = pytest.importorskip("zstandard")
src = tmp_path / "input.pcap"
dst = tmp_path / "output.pcap.zst"
data = os.urandom(4096)
src.write_bytes(data)
PacketCapture._compress_zstd(str(src), str(dst), level=3)
assert dst.exists()
assert dst.stat().st_size > 0
# Decompress and verify round-trip
dctx = zstd.ZstdDecompressor()
with open(str(dst), "rb") as f:
reader = dctx.stream_reader(f)
decompressed = reader.read()
assert decompressed == data
def test_compress_zstd_cli_fallback(self, tmp_path):
"""Test CLI fallback when zstandard library is not available."""
src = tmp_path / "input.pcap"
dst = tmp_path / "output.pcap.zst"
data = os.urandom(2048)
src.write_bytes(data)
with patch.dict("sys.modules", {"zstandard": None}):
try:
PacketCapture._compress_zstd(str(src), str(dst), level=3)
assert dst.exists()
except FileNotFoundError:
pytest.skip("zstd CLI not installed")
# ---------------------------------------------------------------------------
# Encryption (AES-256-GCM)
# ---------------------------------------------------------------------------
class TestAES256GCMEncryption:
def test_encrypt_file_produces_valid_output(self, pcap_module, tmp_path):
"""Encrypted file has BB01 magic header, 12-byte nonce, and ciphertext."""
src = tmp_path / "plain.bin"
dst = tmp_path / "encrypted.bin"
plaintext = os.urandom(1024)
src.write_bytes(plaintext)
pcap_module._encrypt_file(str(src), str(dst))
encrypted = dst.read_bytes()
# Magic header
assert encrypted[:4] == b"BB01"
# Nonce is 12 bytes
nonce = encrypted[4:16]
assert len(nonce) == 12
# Ciphertext is longer than plaintext (GCM tag adds 16 bytes)
ciphertext = encrypted[16:]
assert len(ciphertext) == len(plaintext) + 16
def test_encrypt_decrypt_roundtrip(self, pcap_module, tmp_path):
"""Encrypt then decrypt returns original data."""
cryptography = pytest.importorskip("cryptography")
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
src = tmp_path / "plain.bin"
dst = tmp_path / "encrypted.bin"
plaintext = os.urandom(2048)
src.write_bytes(plaintext)
pcap_module._encrypt_file(str(src), str(dst))
# Decrypt manually
encrypted = dst.read_bytes()
nonce = encrypted[4:16]
ciphertext = encrypted[16:]
key = pcap_module._encryption_key[:32].ljust(32, b"\x00")
aesgcm = AESGCM(key)
decrypted = aesgcm.decrypt(nonce, ciphertext, None)
assert decrypted == plaintext
def test_encrypt_with_wrong_key_fails(self, pcap_module, tmp_path):
"""Decrypting with wrong key raises an error."""
cryptography = pytest.importorskip("cryptography")
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
src = tmp_path / "plain.bin"
dst = tmp_path / "encrypted.bin"
src.write_bytes(b"secret pcap data")
pcap_module._encrypt_file(str(src), str(dst))
encrypted = dst.read_bytes()
nonce = encrypted[4:16]
ciphertext = encrypted[16:]
wrong_key = os.urandom(32)
aesgcm = AESGCM(wrong_key)
with pytest.raises(Exception):
aesgcm.decrypt(nonce, ciphertext, None)
def test_encrypt_skips_when_no_cryptography(self, pcap_module, tmp_path):
"""When cryptography lib is missing, file is just renamed."""
src = tmp_path / "plain.bin"
dst = tmp_path / "renamed.bin"
data = b"plain data"
src.write_bytes(data)
with patch.dict("sys.modules", {"cryptography": None, "cryptography.hazmat.primitives.ciphers.aead": None}):
# Force ImportError by patching at function level
original = pcap_module._encrypt_file
def patched_encrypt(s, d):
try:
raise ImportError("no cryptography")
except ImportError:
import logging
logging.getLogger().warning("cryptography not available")
os.rename(s, d)
pcap_module._encrypt_file = patched_encrypt
pcap_module._encrypt_file(str(src), str(dst))
assert dst.read_bytes() == data
assert not src.exists()
# ---------------------------------------------------------------------------
# Rotation pipeline (compress + encrypt + remove original)
# ---------------------------------------------------------------------------
class TestRotationPipeline:
def test_process_completed_pcaps_full_pipeline(self, pcap_module, fake_pcap):
"""A completed pcap is compressed, encrypted, and original removed."""
cryptography = pytest.importorskip("cryptography")
zstd = pytest.importorskip("zstandard")
# Ensure _is_file_locked returns False so the pcap is eligible
with patch.object(PacketCapture, "_is_file_locked", return_value=False):
pcap_module._process_completed_pcaps(compress_level=3)
# Original pcap should be deleted
assert not os.path.exists(fake_pcap)
# Encrypted+compressed file should exist
expected = fake_pcap + ".zst.enc"
assert os.path.exists(expected)
# Counter should be incremented
assert pcap_module._pcap_count == 1
assert pcap_module._bytes_written > 0
# Bus should have emitted PCAP_ROTATED
# (mock_bus captures events internally)
def test_process_skips_locked_files(self, pcap_module, fake_pcap):
"""Files still being written by tcpdump are skipped."""
with patch.object(PacketCapture, "_is_file_locked", return_value=True):
pcap_module._process_completed_pcaps(compress_level=3)
# Original should still exist
assert os.path.exists(fake_pcap)
assert pcap_module._pcap_count == 0
def test_process_skips_tiny_files(self, pcap_module, tmp_path):
"""Files smaller than pcap global header (24 bytes) are skipped."""
pcap_dir = tmp_path / "pcaps"
tiny = pcap_dir / "capture_20260410_020000.pcap"
tiny.write_bytes(b"tiny")
with patch.object(PacketCapture, "_is_file_locked", return_value=False):
pcap_module._process_completed_pcaps(compress_level=3)
assert tiny.exists()
assert pcap_module._pcap_count == 0
def test_compress_only_when_no_encryption_key(self, pcap_module, fake_pcap):
"""Without encryption key, only compression happens."""
zstd = pytest.importorskip("zstandard")
pcap_module._encryption_key = b""
with patch.object(PacketCapture, "_is_file_locked", return_value=False):
pcap_module._process_completed_pcaps(compress_level=3)
assert not os.path.exists(fake_pcap)
compressed = fake_pcap + ".zst"
assert os.path.exists(compressed)
# No .enc file should exist
assert not os.path.exists(fake_pcap + ".zst.enc")
# ---------------------------------------------------------------------------
# Disk usage purge
# ---------------------------------------------------------------------------
class TestDiskPurge:
def test_no_purge_below_threshold(self, pcap_module, tmp_path):
"""No files deleted when disk usage is below threshold."""
pcap_dir = tmp_path / "pcaps"
enc_file = pcap_dir / "capture_20260410_010000.pcap.zst.enc"
enc_file.write_bytes(os.urandom(100))
# Mock disk_usage to return 50% usage
mock_usage = MagicMock(used=50, total=100)
with patch("shutil.disk_usage", return_value=mock_usage):
pcap_module._check_disk_usage(threshold=85)
assert enc_file.exists()
def test_purge_above_threshold(self, pcap_module, tmp_path):
"""Oldest files are purged when disk exceeds threshold."""
pcap_dir = tmp_path / "pcaps"
# Create 3 encrypted pcap files with different mtimes
for i in range(3):
f = pcap_dir / f"capture_20260410_0{i}0000.pcap.zst.enc"
f.write_bytes(os.urandom(100))
os.utime(str(f), (1000 + i, 1000 + i))
# Mock: first call 90% (above threshold), after purge 75% (below threshold-5)
call_count = [0]
def mock_disk_usage(path):
call_count[0] += 1
if call_count[0] <= 1:
return MagicMock(used=90, total=100)
return MagicMock(used=75, total=100)
with patch("shutil.disk_usage", side_effect=mock_disk_usage):
pcap_module._check_disk_usage(threshold=85)
# At least one file should have been purged
remaining = list(pcap_dir.glob("*.enc"))
assert len(remaining) < 3
# ---------------------------------------------------------------------------
# Status reporting
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_when_not_running(self, mock_bus, mock_state):
mod = PacketCapture(mock_bus, mock_state, {})
s = mod.status()
assert s["running"] is False
assert s["pcap_count"] == 0
assert s["bytes_written"] == 0
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Test suite for presence_daemon parsers — netlink and DHCP bounds checking.
Tests minimal reproductions of bounds checking issues.
"""
import struct
import sys
from pathlib import Path
# We'll test by importing just the functions directly
# by reading the source and executing key portions
def parse_netlink_current(data: bytes) -> None:
"""Current implementation from presence_daemon.py."""
try:
offset = 0
while offset < len(data):
if offset + 16 > len(data):
break
nlmsg_len, nlmsg_type, _, _, _ = struct.unpack_from('=IHHII', data, offset)
if nlmsg_len < 16:
break
payload = data[offset + 16:offset + nlmsg_len]
# This is the issue: if nlmsg_len > len(data), we silently get a short payload
# No guard that offset + nlmsg_len <= len(data)
offset += (nlmsg_len + 3) & ~3
except Exception as e:
pass
def parse_ndmsg_current(data: bytes, msg_type: int) -> None:
"""Current implementation from presence_daemon.py."""
try:
if len(data) < 12:
return
state = struct.unpack_from('=H', data, 8)[0]
mac = None
ip = None
offset = 12
while offset + 4 <= len(data):
rta_len, rta_type = struct.unpack_from('=HH', data, offset)
if rta_len < 4:
break
val = data[offset + 4:offset + rta_len]
if rta_type == 1 and len(val) == 6: # NDA_LLADDR
mac = ':'.join(f'{b:02x}' for b in val)
elif rta_type == 2: # NDA_DST
if len(val) == 4:
pass # would parse IP
offset += (rta_len + 3) & ~3
# If we had extracted a MAC, we'd proceed
# The issue: rta_len might claim more bytes than available
except Exception as e:
pass
def parse_dhcp_current(data: bytes) -> None:
"""Current implementation from presence_daemon.py."""
try:
if len(data) < 14:
return
eth_type = struct.unpack('!H', data[12:14])[0]
if eth_type != 0x0800:
return
if len(data) < 23:
return
proto = data[23]
if proto != 17:
return
if len(data) < 38:
return
src_port = struct.unpack('!H', data[34:36])[0]
dst_port = struct.unpack('!H', data[36:38])[0]
if not (src_port in (67, 68) and dst_port in (67, 68)):
return
if len(data) < 42:
return
dhcp = data[42:]
if len(dhcp) < 236:
return
# MAC extraction — offset 28-34
mac_bytes = dhcp[28:34]
mac = ':'.join(f'{b:02x}' for b in mac_bytes)
if mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'):
return
if len(dhcp) < 240:
return
msg_type = None
i = 240
while i < len(dhcp):
opt = dhcp[i]
if opt == 255:
break
if opt == 0:
i += 1
continue
if i + 1 >= len(dhcp):
break
length = dhcp[i + 1]
if i + 2 + length > len(dhcp):
break
val = dhcp[i + 2:i + 2 + length]
if opt == 53 and length == 1:
msg_type = val[0]
i += 2 + length
except Exception as e:
pass
# Test cases
def test_parse_netlink_truncated_header():
"""Netlink parser should handle buffer shorter than header gracefully."""
truncated = b'\x01\x02\x03'
# Should not raise
parse_netlink_current(truncated)
print("✓ test_parse_netlink_truncated_header passed")
def test_parse_netlink_header_claims_more_than_available():
"""Netlink parser should handle nlmsg_len > buffer length."""
# Build a netlink header that claims 100 bytes but only provide 16
nlmsg_len = 100
nlmsg_type = 20
flags = 0
seq = 0
pid = 0
data = struct.pack('=IHHII', nlmsg_len, nlmsg_type, flags, seq, pid)
assert len(data) == 16
# Should not raise
parse_netlink_current(data)
print("✓ test_parse_netlink_header_claims_more_than_available passed")
def test_parse_netlink_offset_overflow():
"""Netlink parser should not overflow offset calculation."""
# Two messages: first claims huge length, second is valid
msg1 = struct.pack('=IHHII', 0xffffffff, 20, 0, 0, 0) # Claims ~4GB
msg2 = struct.pack('=IHHII', 16, 20, 0, 1, 0) # Valid header
data = msg1 + msg2
# Should not raise (might infinite loop with current code)
parse_netlink_current(data)
print("✓ test_parse_netlink_offset_overflow passed")
def test_parse_ndmsg_rta_claims_more_data():
"""ndmsg parser should handle RTA that claims more bytes than available."""
# ndmsg header: family, pad, type, flags, state, index (6 bytes with BBBBHH)
ndmsg_header = struct.pack('=BBBBHH', 0, 0, 0, 0, 64, 0)
# RTA header claiming 20 bytes total length
rta_len = 20
rta_type = 1 # NDA_LLADDR
rta_header = struct.pack('=HH', rta_len, rta_type)
# But only provide 2 bytes of actual RTA data (not 16 bytes as claimed)
data = ndmsg_header + rta_header + b'\x01\x02'
# Should not raise
parse_ndmsg_current(data, 20) # RTM_NEWNEIGH
print("✓ test_parse_ndmsg_rta_claims_more_data passed")
def test_parse_dhcp_minimal_valid():
"""DHCP parser should accept valid minimal DHCP frame."""
# Build minimal valid DHCP frame (Eth + IP + UDP + DHCP)
eth_dst = b'\xff\xff\xff\xff\xff\xff'
eth_src = b'\x00\x11\x22\x33\x44\x55'
eth_type = struct.pack('!H', 0x0800)
# IP header (20 bytes)
ip_version_ihl = 0x45
ip_dscp_ecn = 0
ip_length = struct.pack('!H', 20 + 8 + 260)
ip_id = struct.pack('!H', 0)
ip_flags_frag = struct.pack('!H', 0x4000)
ip_ttl = 64
ip_proto = 17 # UDP
ip_checksum = struct.pack('!H', 0)
ip_src = b'\x00\x00\x00\x00'
ip_dst = b'\xff\xff\xff\xff'
ip_header = (
bytes([ip_version_ihl, ip_dscp_ecn]) +
ip_length + ip_id + ip_flags_frag +
bytes([ip_ttl, ip_proto]) + ip_checksum +
ip_src + ip_dst
)
# UDP header (8 bytes)
udp_src = struct.pack('!H', 68)
udp_dst = struct.pack('!H', 67)
udp_length = struct.pack('!H', 8 + 260)
udp_checksum = struct.pack('!H', 0)
udp_header = udp_src + udp_dst + udp_length + udp_checksum
# DHCP payload (260 bytes)
dhcp_body = bytearray(260)
dhcp_body[0] = 1 # BOOTREQUEST
dhcp_body[4:10] = b'\x12\x34\x56\x78\x9a\xbc'
dhcp_body[28:34] = b'\xaa\xbb\xcc\xdd\xee\xff' # chaddr
# DHCP magic + option 53
dhcp_body[240:244] = b'\x63\x82\x53\x63'
dhcp_body[244:247] = b'\x35\x01\x01' # option 53, length 1, value 1 (DISCOVER)
dhcp_body[247] = 255 # End
frame = eth_dst + eth_src + eth_type + ip_header + udp_header + bytes(dhcp_body)
parse_dhcp_current(frame)
print("✓ test_parse_dhcp_minimal_valid passed")
if __name__ == '__main__':
test_parse_netlink_truncated_header()
test_parse_netlink_header_claims_more_than_available()
test_parse_netlink_offset_overflow()
test_parse_ndmsg_rta_claims_more_data()
test_parse_dhcp_minimal_valid()
print("\nAll tests passed!")
+85
View File
@@ -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
+247
View File
@@ -0,0 +1,247 @@
"""Tests for modules/active/responder_mgr — hash capture, dedup, config generation.
Validates hash parsing, deduplication, Responder.conf generation,
relay target exclusion, and status reporting without requiring
Responder binary, root, or network access.
"""
import os
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from modules.base import BaseModule
from modules.active.responder_mgr import (
ResponderManager, HASH_FILE_PATTERN, NTLMV2_REGEX, NTLMV1_REGEX,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def responder(mock_bus, mock_state, tmp_path):
"""Return a ResponderManager with temp dirs and mocked externals."""
config = {
"responder_path": str(tmp_path / "Responder"),
"interface": "eth0",
"template_dir": str(tmp_path / "templates"),
}
resp_dir = tmp_path / "Responder"
resp_dir.mkdir()
(resp_dir / "logs").mkdir()
(resp_dir / "Responder.py").touch()
mod = ResponderManager(mock_bus, mock_state, config)
mod.start()
return mod
@pytest.fixture
def hash_log_dir(responder, tmp_path):
"""Return the Responder logs directory."""
return tmp_path / "Responder" / "logs"
# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------
class TestResponderStructure:
def test_is_base_module(self):
assert issubclass(ResponderManager, BaseModule)
def test_module_attributes(self):
assert ResponderManager.name == "responder_mgr"
assert ResponderManager.module_type == "active"
assert ResponderManager.requires_root is True
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
class TestHashRegex:
def test_hash_file_pattern_matches_ntlmv2(self):
assert HASH_FILE_PATTERN.search("SMB-NTLMv2-10.0.0.5.txt")
def test_hash_file_pattern_matches_ntlmv1(self):
assert HASH_FILE_PATTERN.search("HTTP-NTLMv1-10.0.0.5.txt")
def test_hash_file_pattern_rejects_other(self):
assert not HASH_FILE_PATTERN.search("Responder-Session.log")
def test_ntlmv2_regex_parses_valid_hash(self):
line = "admin::CORP:aabbccdd11223344:eeff00112233445566778899aabbccdd:0011223344556677"
m = NTLMV2_REGEX.match(line)
assert m is not None
assert m.group("username") == "admin"
assert m.group("domain") == "CORP"
def test_ntlmv1_regex_parses_valid_hash(self):
line = "user1::DOMAIN:aabbccdd11223344:eeff00112233445566778899aabbccdd:0011223344556677"
m = NTLMV1_REGEX.match(line)
assert m is not None
assert m.group("username") == "user1"
assert m.group("domain") == "DOMAIN"
def test_ntlmv2_regex_rejects_garbage(self):
assert NTLMV2_REGEX.match("not a hash line") is None
# ---------------------------------------------------------------------------
# Hash line processing
# ---------------------------------------------------------------------------
class TestHashProcessing:
def test_process_ntlmv2_hash_line(self, responder):
line = "admin::CORP:aabbccdd11223344:eeff00112233445566778899aabbccdd:0011223344556677"
responder._process_hash_line(line, "SMB-NTLMv2-10.0.0.5.txt")
hashes = responder.get_captured_hashes()
assert len(hashes) == 1
assert hashes[0]["username"] == "admin"
assert hashes[0]["domain"] == "CORP"
assert hashes[0]["hash_type"] == "ntlmv2"
assert hashes[0]["hashcat_mode"] == 5600
def test_process_ntlmv1_hash_line(self, responder):
line = "user1::DOMAIN:aabb:ccdd:eeff"
responder._process_hash_line(line, "HTTP-NTLMv1-10.0.0.5.txt")
hashes = responder.get_captured_hashes()
assert len(hashes) == 1
assert hashes[0]["hash_type"] == "ntlmv1"
assert hashes[0]["hashcat_mode"] == 5500
def test_process_unparseable_line(self, responder):
"""Lines without :: still get processed with username='unknown'."""
responder._process_hash_line("garbage", "SMB-NTLMv2-test.txt")
hashes = responder.get_captured_hashes()
assert len(hashes) == 1
assert hashes[0]["username"] == "unknown"
# ---------------------------------------------------------------------------
# Hash file scanning
# ---------------------------------------------------------------------------
class TestHashFileScanning:
def test_scan_hash_files_discovers_new_hashes(self, responder, hash_log_dir):
hash_file = hash_log_dir / "SMB-NTLMv2-10.0.0.5.txt"
hash_file.write_text(
"admin::CORP:aa:bb:cc\n"
"user2::CORP:dd:ee:ff\n"
)
responder._scan_hash_files()
assert len(responder.get_captured_hashes()) == 2
def test_scan_deduplicates_seen_hashes(self, responder, hash_log_dir):
hash_file = hash_log_dir / "SMB-NTLMv2-10.0.0.5.txt"
hash_file.write_text("admin::CORP:aa:bb:cc\n")
responder._scan_hash_files()
responder._scan_hash_files()
assert len(responder.get_captured_hashes()) == 1
def test_scan_ignores_empty_lines(self, responder, hash_log_dir):
hash_file = hash_log_dir / "SMB-NTLMv2-10.0.0.5.txt"
hash_file.write_text("\n\nadmin::CORP:aa:bb:cc\n\n")
responder._scan_hash_files()
assert len(responder.get_captured_hashes()) == 1
# ---------------------------------------------------------------------------
# Responder.conf generation
# ---------------------------------------------------------------------------
class TestConfGeneration:
def test_fallback_conf_written(self, responder, tmp_path):
responder._write_responder_conf()
conf_path = tmp_path / "Responder" / "Responder.conf"
assert conf_path.exists()
content = conf_path.read_text()
assert "SMB = On" in content
assert "HTTP = On" in content
def test_protocol_toggles_reflected(self, responder, tmp_path):
responder._protocols["SMB"] = False
responder._protocols["HTTP"] = False
responder._write_responder_conf()
conf_path = tmp_path / "Responder" / "Responder.conf"
content = conf_path.read_text()
assert "SMB = Off" in content
assert "HTTP = Off" in content
# ---------------------------------------------------------------------------
# Relay target exclusion
# ---------------------------------------------------------------------------
class TestRelayExclusion:
def test_set_relay_targets(self, responder):
responder.set_relay_targets({"10.0.0.1", "10.0.0.2"})
assert "10.0.0.1" in responder._relay_targets
assert "10.0.0.2" in responder._relay_targets
def test_set_relay_targets_replaces_previous(self, responder):
responder.set_relay_targets({"10.0.0.1"})
responder.set_relay_targets({"10.0.0.2"})
assert "10.0.0.1" not in responder._relay_targets
assert "10.0.0.2" in responder._relay_targets
# ---------------------------------------------------------------------------
# Configure
# ---------------------------------------------------------------------------
class TestConfigure:
def test_configure_updates_interface(self, responder):
responder.configure({"interface": "wlan0"})
assert responder._iface == "wlan0"
def test_configure_updates_relay_targets(self, responder):
responder.configure({"relay_targets": ["10.0.0.5"]})
assert "10.0.0.5" in responder._relay_targets
def test_configure_updates_protocols(self, responder):
responder.configure({"protocols": {"FTP": True, "SMB": False}})
assert responder._protocols["FTP"] is True
assert responder._protocols["SMB"] is False
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_when_running(self, responder):
s = responder.status()
assert s["running"] is True
assert s["responder_running"] is False
assert s["captured_hash_count"] == 0
assert s["interface"] == "eth0"
def test_status_reflects_captured_hashes(self, responder):
responder._process_hash_line("admin::CORP:aa:bb:cc", "SMB-NTLMv2-test.txt")
s = responder.status()
assert s["captured_hash_count"] == 1
def test_status_shows_relay_targets(self, responder):
responder.set_relay_targets({"10.0.0.1"})
s = responder.status()
assert "10.0.0.1" in s["relay_targets"]
+113
View File
@@ -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
+162
View File
@@ -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