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,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
|
||||
Reference in New Issue
Block a user