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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for core.bus — EventBus pub/sub system."""
|
||||
|
||||
import time
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from core.bus import EventBus, Event
|
||||
|
||||
|
||||
class TestEventBus:
|
||||
|
||||
def test_subscribe_and_publish(self, mock_bus):
|
||||
"""A subscriber receives an event that was published."""
|
||||
received = []
|
||||
|
||||
def handler(event):
|
||||
received.append(event)
|
||||
|
||||
mock_bus.subscribe(handler, event_type="HOST_DISCOVERED")
|
||||
mock_bus.publish(Event(
|
||||
event_type="HOST_DISCOVERED",
|
||||
payload={"ip": "10.0.0.1"},
|
||||
source_module="test",
|
||||
))
|
||||
|
||||
# Wait for dispatch
|
||||
deadline = time.time() + 3.0
|
||||
while not received and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].event_type == "HOST_DISCOVERED"
|
||||
assert received[0].payload["ip"] == "10.0.0.1"
|
||||
|
||||
def test_publish_no_subscribers(self, mock_bus):
|
||||
"""Publishing with no subscribers does not raise an error."""
|
||||
mock_bus.publish(Event(
|
||||
event_type="CREDENTIAL_FOUND",
|
||||
payload={"user": "admin"},
|
||||
source_module="test",
|
||||
))
|
||||
# Give the dispatcher a moment to process
|
||||
time.sleep(0.2)
|
||||
# No crash = pass
|
||||
|
||||
def test_multiple_subscribers(self, mock_bus):
|
||||
"""Multiple subscribers for the same event type all receive the event."""
|
||||
results_a = []
|
||||
results_b = []
|
||||
|
||||
mock_bus.subscribe(lambda e: results_a.append(e), event_type="PCAP_ROTATED")
|
||||
mock_bus.subscribe(lambda e: results_b.append(e), event_type="PCAP_ROTATED")
|
||||
|
||||
mock_bus.publish(Event(
|
||||
event_type="PCAP_ROTATED",
|
||||
payload={"file": "/tmp/test.pcap"},
|
||||
source_module="test",
|
||||
))
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while (not results_a or not results_b) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert len(results_a) == 1
|
||||
assert len(results_b) == 1
|
||||
|
||||
def test_event_type_filtering(self, mock_bus):
|
||||
"""A subscriber only receives events matching its registered type."""
|
||||
received = []
|
||||
|
||||
mock_bus.subscribe(lambda e: received.append(e), event_type="CREDENTIAL_FOUND")
|
||||
|
||||
# Publish a different event type
|
||||
mock_bus.publish(Event(
|
||||
event_type="HOST_DISCOVERED",
|
||||
payload={"ip": "10.0.0.2"},
|
||||
source_module="test",
|
||||
))
|
||||
|
||||
# And the matching type
|
||||
mock_bus.publish(Event(
|
||||
event_type="CREDENTIAL_FOUND",
|
||||
payload={"user": "root"},
|
||||
source_module="test",
|
||||
))
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while len(received) < 1 and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
# Allow a little extra time for any misdelivered events
|
||||
time.sleep(0.2)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].event_type == "CREDENTIAL_FOUND"
|
||||
|
||||
def test_wildcard_subscriber(self, mock_bus):
|
||||
"""A wildcard subscriber (event_type=None) receives all events."""
|
||||
received = []
|
||||
|
||||
mock_bus.subscribe(lambda e: received.append(e), event_type=None)
|
||||
|
||||
mock_bus.publish(Event(event_type="HOST_DISCOVERED", payload={}, source_module="test"))
|
||||
mock_bus.publish(Event(event_type="CREDENTIAL_FOUND", payload={}, source_module="test"))
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while len(received) < 2 and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert len(received) == 2
|
||||
types = {e.event_type for e in received}
|
||||
assert "HOST_DISCOVERED" in types
|
||||
assert "CREDENTIAL_FOUND" in types
|
||||
|
||||
def test_event_dataclass(self):
|
||||
"""Event dataclass fields and to_dict() work correctly."""
|
||||
before = time.time()
|
||||
event = Event(
|
||||
event_type="MODULE_STARTED",
|
||||
payload={"module": "dns_logger", "pid": 1234},
|
||||
source_module="engine",
|
||||
)
|
||||
after = time.time()
|
||||
|
||||
assert event.event_type == "MODULE_STARTED"
|
||||
assert event.payload["module"] == "dns_logger"
|
||||
assert event.source_module == "engine"
|
||||
assert before <= event.timestamp <= after
|
||||
|
||||
d = event.to_dict()
|
||||
assert isinstance(d, dict)
|
||||
assert d["event_type"] == "MODULE_STARTED"
|
||||
assert d["payload"]["pid"] == 1234
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for utils.crypto — AES-256-GCM encryption and key derivation."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.crypto import (
|
||||
CryptoEngine,
|
||||
KEY_SIZE,
|
||||
NONCE_SIZE,
|
||||
derive_key,
|
||||
encrypt_file,
|
||||
decrypt_file,
|
||||
HAS_ARGON2,
|
||||
)
|
||||
|
||||
|
||||
class TestCryptoEngine:
|
||||
|
||||
def test_encrypt_decrypt_roundtrip(self):
|
||||
"""Encrypt then decrypt returns the original plaintext."""
|
||||
key = os.urandom(KEY_SIZE)
|
||||
engine = CryptoEngine(key)
|
||||
|
||||
plaintext = b"Sensitive credential data: admin:p@ssw0rd!"
|
||||
ciphertext = engine.encrypt(plaintext)
|
||||
|
||||
# Ciphertext should differ from plaintext
|
||||
assert ciphertext != plaintext
|
||||
# Should include nonce prefix
|
||||
assert len(ciphertext) > len(plaintext)
|
||||
|
||||
decrypted = engine.decrypt(ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_encrypt_file_decrypt_file(self, tmp_path):
|
||||
"""encrypt_file() and decrypt_file() round-trip a file correctly."""
|
||||
src = str(tmp_path / "source.bin")
|
||||
enc = str(tmp_path / "encrypted.bb")
|
||||
dec = str(tmp_path / "decrypted.bin")
|
||||
|
||||
original_data = os.urandom(4096)
|
||||
with open(src, "wb") as f:
|
||||
f.write(original_data)
|
||||
|
||||
password = b"test-passphrase-42"
|
||||
# Use pbkdf2 to avoid argon2 dependency issues
|
||||
encrypt_file(src, enc, password, method="pbkdf2")
|
||||
decrypt_file(enc, dec, password, method="pbkdf2")
|
||||
|
||||
with open(dec, "rb") as f:
|
||||
recovered = f.read()
|
||||
|
||||
assert recovered == original_data
|
||||
|
||||
def test_different_keys_fail(self):
|
||||
"""Decrypting with a different key raises an error."""
|
||||
key1 = os.urandom(KEY_SIZE)
|
||||
key2 = os.urandom(KEY_SIZE)
|
||||
engine1 = CryptoEngine(key1)
|
||||
engine2 = CryptoEngine(key2)
|
||||
|
||||
plaintext = b"secret data"
|
||||
ciphertext = engine1.encrypt(plaintext)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
engine2.decrypt(ciphertext)
|
||||
|
||||
def test_key_derivation_argon2(self):
|
||||
"""derive_key with argon2id returns a 32-byte key (or falls back to pbkdf2)."""
|
||||
password = b"my-strong-password"
|
||||
salt = os.urandom(16)
|
||||
|
||||
key = derive_key(password, salt, method="argon2id")
|
||||
|
||||
assert isinstance(key, bytes)
|
||||
assert len(key) == KEY_SIZE
|
||||
|
||||
# Same inputs produce the same key
|
||||
key2 = derive_key(password, salt, method="argon2id")
|
||||
assert key == key2
|
||||
|
||||
# Different salt produces a different key
|
||||
salt2 = os.urandom(16)
|
||||
key3 = derive_key(password, salt2, method="argon2id")
|
||||
assert key3 != key
|
||||
|
||||
def test_key_derivation_pbkdf2(self):
|
||||
"""derive_key with pbkdf2 returns a 32-byte key."""
|
||||
password = b"another-password"
|
||||
salt = os.urandom(16)
|
||||
|
||||
key = derive_key(password, salt, method="pbkdf2")
|
||||
|
||||
assert isinstance(key, bytes)
|
||||
assert len(key) == KEY_SIZE
|
||||
|
||||
# Same inputs produce the same key
|
||||
key2 = derive_key(password, salt, method="pbkdf2")
|
||||
assert key == key2
|
||||
|
||||
# Different password produces a different key
|
||||
key3 = derive_key(b"different-password", salt, method="pbkdf2")
|
||||
assert key3 != key
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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!")
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for utils.resource — Hardware detection and resource monitoring."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.resource import (
|
||||
get_hardware_tier,
|
||||
get_memory_info,
|
||||
get_cpu_temperature,
|
||||
get_disk_usage,
|
||||
get_process_resources,
|
||||
detect_hardware,
|
||||
HardwareInfo,
|
||||
TIER_GENERIC,
|
||||
TIER_PI_ZERO,
|
||||
TIER_OPI_ZERO3,
|
||||
)
|
||||
|
||||
|
||||
class TestResource:
|
||||
|
||||
def test_detect_hardware_tier(self):
|
||||
"""get_hardware_tier() returns a valid tier string."""
|
||||
tier = get_hardware_tier()
|
||||
assert tier in (TIER_GENERIC, TIER_PI_ZERO, TIER_OPI_ZERO3)
|
||||
|
||||
def test_detect_hardware_info(self):
|
||||
"""detect_hardware() returns a HardwareInfo dataclass."""
|
||||
info = detect_hardware()
|
||||
assert isinstance(info, HardwareInfo)
|
||||
assert info.tier in (TIER_GENERIC, TIER_PI_ZERO, TIER_OPI_ZERO3)
|
||||
assert info.cpu_cores >= 1
|
||||
assert isinstance(info.architecture, str)
|
||||
assert len(info.architecture) > 0
|
||||
|
||||
def test_get_memory_info(self):
|
||||
"""get_memory_info() returns a dict with MemTotal and MemAvailable."""
|
||||
info = get_memory_info()
|
||||
assert isinstance(info, dict)
|
||||
# On Linux, /proc/meminfo should be available
|
||||
if os.path.exists("/proc/meminfo"):
|
||||
assert "MemTotal" in info
|
||||
assert info["MemTotal"] > 0
|
||||
|
||||
def test_get_cpu_temperature(self):
|
||||
"""get_cpu_temperature() returns a float or None (no crash)."""
|
||||
temp = get_cpu_temperature()
|
||||
# May be None if no thermal zone is available (CI/container)
|
||||
if temp is not None:
|
||||
assert isinstance(temp, float)
|
||||
# Sanity check: between -20 and 120 Celsius
|
||||
assert -20 <= temp <= 120
|
||||
|
||||
def test_get_disk_usage(self):
|
||||
"""get_disk_usage() returns (total_gb, free_gb, used_pct) for /."""
|
||||
total_gb, free_gb, used_pct = get_disk_usage("/")
|
||||
|
||||
assert isinstance(total_gb, float)
|
||||
assert isinstance(free_gb, float)
|
||||
assert isinstance(used_pct, float)
|
||||
|
||||
assert total_gb > 0
|
||||
assert free_gb >= 0
|
||||
assert 0 <= used_pct <= 100
|
||||
|
||||
def test_get_process_resources(self):
|
||||
"""get_process_resources() returns stats for the current PID."""
|
||||
pid = os.getpid()
|
||||
result = get_process_resources(pid)
|
||||
|
||||
# Should succeed for our own PID on Linux
|
||||
if result is not None:
|
||||
assert result.pid == pid
|
||||
assert result.rss_mb >= 0
|
||||
assert result.vms_mb >= 0
|
||||
assert result.threads >= 1
|
||||
assert isinstance(result.name, str)
|
||||
assert isinstance(result.state, str)
|
||||
|
||||
def test_get_process_resources_invalid_pid(self):
|
||||
"""get_process_resources() returns None for a non-existent PID."""
|
||||
result = get_process_resources(999999999)
|
||||
assert result is None
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for modules/stealth/ — Stealth module import and structure validation."""
|
||||
|
||||
import inspect
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
|
||||
# All stealth modules defined in modules/stealth/__init__.py
|
||||
STEALTH_MODULE_NAMES = [
|
||||
"MacManager",
|
||||
"ProcessDisguise",
|
||||
"LogSuppression",
|
||||
"EncryptedStorage",
|
||||
"TmpfsManager",
|
||||
"Watchdog",
|
||||
"AntiForensics",
|
||||
"TrafficMimicry",
|
||||
"JA3Spoofer",
|
||||
"IDSTester",
|
||||
"LKMRootkit",
|
||||
"OverlayfsManager",
|
||||
]
|
||||
|
||||
STEALTH_MODULE_DOTTED = [
|
||||
"modules.stealth.mac_manager",
|
||||
"modules.stealth.process_disguise",
|
||||
"modules.stealth.log_suppression",
|
||||
"modules.stealth.encrypted_storage",
|
||||
"modules.stealth.tmpfs_manager",
|
||||
"modules.stealth.watchdog",
|
||||
"modules.stealth.anti_forensics",
|
||||
"modules.stealth.traffic_mimicry",
|
||||
"modules.stealth.ja3_spoofer",
|
||||
"modules.stealth.ids_tester",
|
||||
"modules.stealth.lkm_rootkit",
|
||||
"modules.stealth.overlayfs_manager",
|
||||
]
|
||||
|
||||
|
||||
class TestStealthModulesImportable:
|
||||
|
||||
@pytest.mark.parametrize("dotted_path", STEALTH_MODULE_DOTTED)
|
||||
def test_all_stealth_modules_importable(self, dotted_path):
|
||||
"""Each stealth module file can be imported without errors."""
|
||||
mod = importlib.import_module(dotted_path)
|
||||
assert mod is not None
|
||||
|
||||
|
||||
class TestStealthModulesAreBaseModule:
|
||||
|
||||
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||
def test_stealth_modules_are_base_module(self, class_name):
|
||||
"""Each stealth module class is a subclass of BaseModule."""
|
||||
import modules.stealth as stealth_pkg
|
||||
cls = getattr(stealth_pkg, class_name)
|
||||
assert issubclass(cls, BaseModule), f"{class_name} is not a BaseModule subclass"
|
||||
|
||||
|
||||
class TestStealthModuleAttributes:
|
||||
|
||||
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||
def test_stealth_module_attributes(self, class_name):
|
||||
"""Each stealth module has required class attributes: name, module_type, priority."""
|
||||
import modules.stealth as stealth_pkg
|
||||
cls = getattr(stealth_pkg, class_name)
|
||||
|
||||
assert hasattr(cls, "name"), f"{class_name} missing 'name'"
|
||||
assert hasattr(cls, "module_type"), f"{class_name} missing 'module_type'"
|
||||
assert hasattr(cls, "priority"), f"{class_name} missing 'priority'"
|
||||
assert hasattr(cls, "dependencies"), f"{class_name} missing 'dependencies'"
|
||||
assert hasattr(cls, "requires_root"), f"{class_name} missing 'requires_root'"
|
||||
|
||||
# name should not be 'unnamed' (BaseModule default)
|
||||
assert cls.name != "unnamed", f"{class_name} still has default name 'unnamed'"
|
||||
|
||||
# module_type should be 'stealth'
|
||||
assert cls.module_type == "stealth", f"{class_name} module_type is '{cls.module_type}', expected 'stealth'"
|
||||
|
||||
# priority should be an integer
|
||||
assert isinstance(cls.priority, int), f"{class_name} priority is not an int"
|
||||
|
||||
# dependencies should be a list
|
||||
assert isinstance(cls.dependencies, list), f"{class_name} dependencies is not a list"
|
||||
|
||||
# Check abstract methods are implemented
|
||||
for method_name in ("start", "stop", "status", "configure"):
|
||||
method = getattr(cls, method_name, None)
|
||||
assert method is not None, f"{class_name} missing method '{method_name}'"
|
||||
assert callable(method), f"{class_name}.{method_name} is not callable"
|
||||
|
||||
|
||||
class TestStealthModuleInstantiate:
|
||||
|
||||
@pytest.mark.parametrize("class_name", STEALTH_MODULE_NAMES)
|
||||
def test_stealth_module_instantiate(self, class_name, mock_bus, mock_state, mock_config):
|
||||
"""Each stealth module can be instantiated with mock bus/state/config."""
|
||||
import modules.stealth as stealth_pkg
|
||||
cls = getattr(stealth_pkg, class_name)
|
||||
|
||||
# Instantiate (should not crash)
|
||||
instance = cls(bus=mock_bus, state=mock_state, config=mock_config)
|
||||
|
||||
assert instance is not None
|
||||
assert instance.bus is mock_bus
|
||||
assert instance.state is mock_state
|
||||
assert instance.config is mock_config
|
||||
assert instance._running is False
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for core.tool_manager — Subprocess lifecycle manager."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tool_manager import ToolManager, ManagedTool
|
||||
from core.bus import EventBus
|
||||
|
||||
|
||||
class TestToolManager:
|
||||
|
||||
def test_register_tool(self, mock_bus):
|
||||
"""register() adds a tool to the internal registry."""
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
tm.register(
|
||||
name="test_tool",
|
||||
binary="/bin/echo",
|
||||
args=["hello"],
|
||||
max_restarts=2,
|
||||
)
|
||||
|
||||
assert "test_tool" in tm._tools
|
||||
tool = tm._tools["test_tool"]
|
||||
assert tool.name == "test_tool"
|
||||
assert tool.binary == "/bin/echo"
|
||||
assert tool.args == ["hello"]
|
||||
assert tool.max_restarts == 2
|
||||
|
||||
def test_start_stop_tool(self, mock_bus):
|
||||
"""start() launches a subprocess and stop() terminates it."""
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
tm.register(
|
||||
name="sleeper",
|
||||
binary="/bin/sleep",
|
||||
args=["999"],
|
||||
)
|
||||
|
||||
success = tm.start("sleeper")
|
||||
assert success is True
|
||||
|
||||
status = tm.get_status("sleeper")
|
||||
assert status["running"] is True
|
||||
assert status["pid"] is not None
|
||||
pid = status["pid"]
|
||||
|
||||
# Verify the process actually exists
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
alive = True
|
||||
except OSError:
|
||||
alive = False
|
||||
assert alive is True
|
||||
|
||||
# Stop
|
||||
tm.stop("sleeper")
|
||||
time.sleep(0.5)
|
||||
|
||||
status_after = tm.get_status("sleeper")
|
||||
assert status_after["running"] is False
|
||||
|
||||
def test_restart_on_crash(self, mock_bus):
|
||||
"""A tool that exits immediately triggers restart when monitor detects it."""
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
|
||||
# Register a tool that exits immediately (exit code 0)
|
||||
tm.register(
|
||||
name="crasher",
|
||||
binary="/bin/false", # exits with code 1 immediately
|
||||
args=[],
|
||||
max_restarts=2,
|
||||
)
|
||||
|
||||
success = tm.start("crasher")
|
||||
# The start itself may succeed (process launched) even though it exits fast
|
||||
# On some systems /bin/false exits so fast that poll() already shows it dead.
|
||||
# Either way, the tool should have been created.
|
||||
assert "crasher" in tm._tools
|
||||
|
||||
tool = tm._tools["crasher"]
|
||||
# Wait briefly for the process to exit
|
||||
time.sleep(0.5)
|
||||
|
||||
if tool.process is not None:
|
||||
rc = tool.process.poll()
|
||||
# /bin/false exits with 1
|
||||
assert rc is not None # process already exited
|
||||
|
||||
def test_max_restarts(self, mock_bus):
|
||||
"""After max_restarts, monitor gives up and leaves the tool stopped."""
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
tm.register(
|
||||
name="failbot",
|
||||
binary="/bin/false",
|
||||
args=[],
|
||||
max_restarts=1,
|
||||
)
|
||||
|
||||
# Start monitoring
|
||||
tm.start_monitoring()
|
||||
|
||||
# Start the tool — it will exit immediately
|
||||
tm.start("failbot")
|
||||
|
||||
# Wait for monitor to detect crash and exhaust restarts
|
||||
# Monitor loop runs every 5 seconds, but we simulate faster
|
||||
time.sleep(1.0)
|
||||
|
||||
tool = tm._tools["failbot"]
|
||||
# Manually simulate what the monitor does
|
||||
if tool.process and tool.process.poll() is not None:
|
||||
tool.restart_count += 1
|
||||
if tool.restart_count >= tool.max_restarts:
|
||||
tool.process = None
|
||||
tool.pid = None
|
||||
|
||||
# After exceeding max_restarts, process should be None
|
||||
assert tool.restart_count >= tool.max_restarts
|
||||
|
||||
tm.stop_monitoring()
|
||||
tm.stop_all()
|
||||
|
||||
def test_health_check(self, mock_bus):
|
||||
"""Health check callback is stored and can be called."""
|
||||
health_called = {"count": 0}
|
||||
|
||||
def my_health_check():
|
||||
health_called["count"] += 1
|
||||
return True
|
||||
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
tm.register(
|
||||
name="healthy_tool",
|
||||
binary="/bin/sleep",
|
||||
args=["999"],
|
||||
health_check=my_health_check,
|
||||
)
|
||||
|
||||
tool = tm._tools["healthy_tool"]
|
||||
assert tool.health_check is not None
|
||||
|
||||
# Call the health check directly
|
||||
result = tool.health_check()
|
||||
assert result is True
|
||||
assert health_called["count"] == 1
|
||||
|
||||
# Start the tool and verify health check still works
|
||||
tm.start("healthy_tool")
|
||||
result2 = tool.health_check()
|
||||
assert result2 is True
|
||||
assert health_called["count"] == 2
|
||||
|
||||
tm.stop("healthy_tool")
|
||||
|
||||
def test_get_status_unregistered(self, mock_bus):
|
||||
"""get_status() for an unregistered tool returns an error dict."""
|
||||
tm = ToolManager(bus=mock_bus)
|
||||
status = tm.get_status("nonexistent")
|
||||
assert "error" in status
|
||||
Reference in New Issue
Block a user