diff --git a/aikido_zen/background_process/api/http_api.py b/aikido_zen/background_process/api/http_api.py index 61c9d0215..e5d2b512d 100644 --- a/aikido_zen/background_process/api/http_api.py +++ b/aikido_zen/background_process/api/http_api.py @@ -35,7 +35,10 @@ def fetch_firewall_lists(self, token): If successful the current API returns : - `allowedIPAddresses` : An array with iplist entries which are the only ones allowed - `blockedIPAddresses` : An array with iplist entries that are blocked + - `monitoredIPAddresses` : An array with iplist entries that are counted but not blocked - `blockedUserAgents` : A string with a simple regex to match user agents with + - `monitoredUserAgents` : A string with a simple regex to match monitored user agents with + - `userAgentDetails` : An array of {key, pattern}, to report which user agent matched """ try: res = requests.get( diff --git a/aikido_zen/background_process/api/http_api_test.py b/aikido_zen/background_process/api/http_api_test.py index c59306b26..2a0b67e55 100644 --- a/aikido_zen/background_process/api/http_api_test.py +++ b/aikido_zen/background_process/api/http_api_test.py @@ -56,7 +56,24 @@ def test_local_gzip(): "success": True, "allowedIPAddresses": [], "blockedIPAddresses": [ - {"description": "geo restrictions", "ips": ["1.2.3.4"], "source": "geoip"} + { + "key": "geoip/blocked", + "description": "geo restrictions", + "ips": ["1.2.3.4"], + "source": "geoip", + } + ], + "monitoredIPAddresses": [ + { + "key": "tor/exit_nodes", + "description": "Tor exit nodes", + "ips": ["1.2.3.4", "9.9.9.9"], + "source": "tor", + } ], "blockedUserAgents": "", + "monitoredUserAgents": "MonitoredTestBot", + "userAgentDetails": [ + {"key": "monitoredtestbot", "pattern": "MonitoredTestBot"} + ], } diff --git a/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists.py b/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists.py index ab54600a2..54c68048b 100644 --- a/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists.py +++ b/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists.py @@ -15,6 +15,10 @@ def update_firewall_lists(connection_manager): blocked_ips = res.get("blockedIPAddresses") allowed_ips = res.get("allowedIPAddresses") blocked_user_agents = res.get("blockedUserAgents") + # Only used for heartbeat stats, so these may be missing. + monitored_ips = res.get("monitoredIPAddresses") or [] + monitored_user_agents = res.get("monitoredUserAgents") + user_agent_details = res.get("userAgentDetails") or [] # Validate response if not res.get("success"): @@ -27,6 +31,11 @@ def update_firewall_lists(connection_manager): connection_manager.firewall_lists.set_blocked_ips(blocked_ips) connection_manager.firewall_lists.set_allowed_ips(allowed_ips) connection_manager.firewall_lists.set_blocked_user_agents(blocked_user_agents) + connection_manager.firewall_lists.set_monitored_ips(monitored_ips) + connection_manager.firewall_lists.set_monitored_user_agents( + monitored_user_agents + ) + connection_manager.firewall_lists.set_user_agent_details(user_agent_details) except Exception as e: logger.debug("Exception in update_firewall_lists: %s", e) diff --git a/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists_test.py b/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists_test.py index dc8f79d46..a07e22136 100644 --- a/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists_test.py +++ b/aikido_zen/background_process/cloud_connection_manager/update_firewall_lists_test.py @@ -179,3 +179,77 @@ def fetch_firewall_lists(self, token): update_firewall_lists(connection_manager) # No changes should be made, so we check the initial state assert connection_manager.conf.bypassed_ips.is_empty() + + +class MockApiMonitoredLists: + def fetch_firewall_lists(self, token): + return { + "success": True, + "blockedIPAddresses": [ + { + "key": "geoip/blocked", + "source": "geoip", + "description": "geo restrictions", + "ips": ["1.2.3.4"], + } + ], + "allowedIPAddresses": [], + "monitoredIPAddresses": [ + { + "key": "tor/exit_nodes", + "source": "tor", + "description": "Tor exit nodes", + "ips": ["9.9.9.9"], + } + ], + "blockedUserAgents": "GPTBot", + "monitoredUserAgents": "ClaudeBot", + "userAgentDetails": [ + {"key": "gptbot", "pattern": "GPTBot"}, + {"key": "claudebot", "pattern": "ClaudeBot"}, + {"key": "invalid", "pattern": "[abc"}, + ], + } + + +def test_update_firewall_lists_monitored_lists(connection_manager): + connection_manager.api = MockApiMonitoredLists() + update_firewall_lists(connection_manager) + firewall_lists = connection_manager.firewall_lists + + assert firewall_lists.get_matching_monitored_ip_list_keys("9.9.9.9") == [ + "tor/exit_nodes" + ] + assert firewall_lists.get_matching_blocked_ip_list_keys("1.2.3.4") == [ + "geoip/blocked" + ] + assert firewall_lists.is_monitored_user_agent("Mozilla/5.0 (ClaudeBot/1.0)") + assert not firewall_lists.is_user_agent_blocked("Mozilla/5.0 (ClaudeBot/1.0)") + assert firewall_lists.get_matching_user_agent_keys("claudebot/1.0") == ["claudebot"] + assert [d["key"] for d in firewall_lists.user_agent_details] == [ + "gptbot", + "claudebot", + ] + + +def test_update_firewall_lists_without_monitored_lists(connection_manager): + update_firewall_lists(connection_manager) + firewall_lists = connection_manager.firewall_lists + + assert firewall_lists.is_blocked_ip("192.168.1.1") + assert firewall_lists.monitored_ips == [] + assert firewall_lists.monitored_user_agent_regex is None + assert firewall_lists.user_agent_details == [] + + +def test_update_firewall_lists_invalid_monitored_user_agents(connection_manager): + connection_manager.api = MockApiMonitoredLists() + lists = connection_manager.api.fetch_firewall_lists("token") + lists["monitoredUserAgents"] = "[abc" + connection_manager.api.fetch_firewall_lists = lambda token: lists + update_firewall_lists(connection_manager) + firewall_lists = connection_manager.firewall_lists + + assert firewall_lists.monitored_user_agent_regex is None + assert firewall_lists.get_matching_user_agent_keys("GPTBot/1.0") == ["gptbot"] + assert firewall_lists.is_user_agent_blocked("GPTBot/1.0") diff --git a/aikido_zen/background_process/commands/check_firewall_lists.py b/aikido_zen/background_process/commands/check_firewall_lists.py index acf323043..6c4d8133c 100644 --- a/aikido_zen/background_process/commands/check_firewall_lists.py +++ b/aikido_zen/background_process/commands/check_firewall_lists.py @@ -3,7 +3,7 @@ def process_check_firewall_lists(connection_manager, data): """ - Checks whether an IP is blocked + Checks whether an IP is blocked, and counts the lists it matches for the heartbeat data: {"ip": string, "user-agent": string} returns -> {"blocked": boolean, "type": string, "reason": string} """ @@ -15,7 +15,14 @@ def process_check_firewall_lists(connection_manager, data): # Global IP Blocklist (e.g. blocking known threat actors) reason = connection_manager.firewall_lists.is_blocked_ip(ip) + + connection_manager.statistics.on_ip_address_matches( + connection_manager.firewall_lists.get_matching_monitored_ip_list_keys(ip) + ) if reason: + connection_manager.statistics.on_ip_address_matches( + connection_manager.firewall_lists.get_matching_blocked_ip_list_keys(ip) + ) return { "blocked": True, "type": "blocklist", @@ -25,7 +32,17 @@ def process_check_firewall_lists(connection_manager, data): user_agent = data["user-agent"] if user_agent is not None and isinstance(user_agent, str): # User agent blocking (e.g. blocking AI scrapers) - if connection_manager.firewall_lists.is_user_agent_blocked(user_agent): + is_blocked = connection_manager.firewall_lists.is_user_agent_blocked(user_agent) + is_monitored = connection_manager.firewall_lists.is_monitored_user_agent( + user_agent + ) + + if is_blocked or is_monitored: + keys = connection_manager.firewall_lists.get_matching_user_agent_keys( + user_agent + ) + connection_manager.statistics.on_user_agent_matches(keys) + if is_blocked: return { "blocked": True, "type": "bot-blocking", diff --git a/aikido_zen/sources/functions/request_handler_test.py b/aikido_zen/sources/functions/request_handler_test.py index 45e9239d9..838322877 100644 --- a/aikido_zen/sources/functions/request_handler_test.py +++ b/aikido_zen/sources/functions/request_handler_test.py @@ -9,6 +9,7 @@ from ...context import Context, current_context from ...helpers.headers import Headers from ...storage.firewall_lists import FirewallLists +from ...storage.statistics import Statistics from ...vulnerabilities.attack_wave_detection.attack_wave_detector import ( AttackWaveDetector, ) @@ -43,6 +44,7 @@ def __init__(self): self.firewall_lists = FirewallLists() self.conn_manager = MagicMock() self.conn_manager.firewall_lists = self.firewall_lists + self.conn_manager.statistics = Statistics() self.conn_manager.attack_wave_detector = AttackWaveDetector() self.attacks = [] @@ -220,6 +222,8 @@ def wrapper(*args, **kwargs): kwargs["attacks"] = comms.attacks if "firewall_lists" in sig.parameters: kwargs["firewall_lists"] = comms.firewall_lists + if "statistics" in sig.parameters: + kwargs["statistics"] = comms.conn_manager.statistics return func(*args, **kwargs) @@ -978,3 +982,163 @@ def test_attack_wave_samples_json_format(firewall_lists): detector = attack_wave_detector_store._get_detector() detector.suspicious_requests_map.clear() detector.sent_events_map.clear() + + +def set_monitored_and_blocked_lists(firewall_lists): + firewall_lists.set_blocked_ips( + [ + { + "key": "geoip/blocked", + "source": "geoip", + "description": "geo restrictions", + "ips": ["1.2.3.4"], + } + ] + ) + firewall_lists.set_monitored_ips( + [ + { + "key": "tor/exit_nodes", + "source": "tor", + "description": "Tor exit nodes", + "ips": ["1.2.3.4", "9.9.9.9"], + }, + { + "key": "crowdsec/scanners", + "source": "crowdsec", + "description": "scanners", + "ips": ["9.9.9.9"], + }, + ] + ) + firewall_lists.set_blocked_user_agents("GPTBot") + firewall_lists.set_monitored_user_agents("ClaudeBot") + firewall_lists.set_user_agent_details( + [ + {"key": "gptbot", "pattern": "GPTBot"}, + {"key": "claudebot", "pattern": "ClaudeBot"}, + ] + ) + + +@patch_firewall_lists +def test_monitored_ip_is_counted_but_not_blocked(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("9.9.9.9") + + assert request_handler("pre_response") is None + assert statistics.get_record()["ipAddresses"] == { + "breakdown": {"tor/exit_nodes": 1, "crowdsec/scanners": 1} + } + + +@patch_firewall_lists +def test_blocked_ip_counts_monitored_and_blocked_lists(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("1.2.3.4") + + assert request_handler("pre_response") == ( + "Your IP address is blocked due to geo restrictions (Your IP: 1.2.3.4)", + 403, + ) + assert statistics.get_record()["ipAddresses"] == { + "breakdown": {"tor/exit_nodes": 1, "geoip/blocked": 1} + } + + +@patch_firewall_lists +def test_ip_not_in_allowlist_is_not_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + firewall_lists.set_allowed_ips( + [{"source": "geoip", "description": "allowed", "ips": ["4.4.4.4"]}] + ) + set_context("9.9.9.9") + + assert request_handler("pre_response") == ( + "Your IP address is not allowed. (Your IP: 9.9.9.9)", + 403, + ) + assert statistics.get_record()["ipAddresses"] == {"breakdown": {}} + + +@patch_firewall_lists +def test_monitored_user_agent_is_counted_but_not_blocked(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("5.5.5.5", "Mozilla/5.0 (compatible; ClaudeBot/1.0)") + + assert request_handler("pre_response") is None + assert statistics.get_record()["userAgents"] == {"breakdown": {"claudebot": 1}} + + +@patch_firewall_lists +def test_blocked_user_agent_is_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("5.5.5.5", "Mozilla/5.0 (compatible; GPTBot/1.0)") + + assert request_handler("pre_response") == ( + "You are not allowed to access this resource because you have been identified as a bot.", + 403, + ) + assert statistics.get_record()["userAgents"] == {"breakdown": {"gptbot": 1}} + + +@patch_firewall_lists +def test_other_user_agent_is_not_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("5.5.5.5", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0") + + assert request_handler("pre_response") is None + assert statistics.get_record()["userAgents"] == {"breakdown": {}} + assert statistics.get_record()["ipAddresses"] == {"breakdown": {}} + + +@patch_firewall_lists +def test_bypassed_ip_is_not_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + get_cache().config = ServiceConfig( + endpoints=[], + last_updated_at=1, + blocked_uids=set(), + bypassed_ips=["9.9.9.9"], + received_any_stats=False, + ) + set_context("9.9.9.9", "ClaudeBot") + + assert request_handler("pre_response") is None + assert statistics.get_record()["ipAddresses"] == {"breakdown": {}} + assert statistics.get_record()["userAgents"] == {"breakdown": {}} + + +@patch_firewall_lists +def test_blocked_ip_stops_before_user_agent_is_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("1.2.3.4", "ClaudeBot/1.0") + + assert request_handler("pre_response")[1] == 403 + assert statistics.get_record()["userAgents"] == {"breakdown": {}} + + +@patch_firewall_lists +def test_ip_not_allowed_by_endpoint_is_not_counted(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + create_service_config() + set_context("9.9.9.9", "ClaudeBot/1.0") + + assert request_handler("pre_response") == ( + "Your IP address is not allowed to access this resource. (Your IP: 9.9.9.9)", + 403, + ) + assert statistics.get_record()["ipAddresses"] == {"breakdown": {}} + assert statistics.get_record()["userAgents"] == {"breakdown": {}} + + +@patch_firewall_lists +def test_ip_and_user_agent_counted_in_one_request(firewall_lists, statistics): + set_monitored_and_blocked_lists(firewall_lists) + set_context("9.9.9.9", "ClaudeBot/1.0") + + assert request_handler("pre_response") is None + assert statistics.get_record()["ipAddresses"] == { + "breakdown": {"tor/exit_nodes": 1, "crowdsec/scanners": 1} + } + assert statistics.get_record()["userAgents"] == {"breakdown": {"claudebot": 1}} diff --git a/aikido_zen/storage/firewall_lists.py b/aikido_zen/storage/firewall_lists.py index 9381e82c7..c3bcc1c60 100644 --- a/aikido_zen/storage/firewall_lists.py +++ b/aikido_zen/storage/firewall_lists.py @@ -8,7 +8,10 @@ class FirewallLists: def __init__(self): self.blocked_ips = [] self.allowed_ips = [] + self.monitored_ips = [] self.blocked_user_agent_regex = None + self.monitored_user_agent_regex = None + self.user_agent_details = [] def set_blocked_ips(self, blocked_ip_entries): self.blocked_ips = list(map(parse_ip_entry, blocked_ip_entries)) @@ -19,6 +22,15 @@ def is_blocked_ip(self, ip): return entry["description"] return False + def get_matching_blocked_ip_list_keys(self, ip): + return get_matching_ip_list_keys(self.blocked_ips, ip) + + def set_monitored_ips(self, monitored_ip_entries): + self.monitored_ips = list(map(parse_ip_entry, monitored_ip_entries)) + + def get_matching_monitored_ip_list_keys(self, ip): + return get_matching_ip_list_keys(self.monitored_ips, ip) + def set_allowed_ips(self, allowed_ip_entries): self.allowed_ips = list(map(parse_ip_entry, allowed_ip_entries)) @@ -50,12 +62,53 @@ def is_user_agent_blocked(self, ua: str): return False return self.blocked_user_agent_regex.search(ua) + def set_monitored_user_agents(self, monitored_user_agents: str): + if not monitored_user_agents: + self.monitored_user_agent_regex = None + return + self.monitored_user_agent_regex = compile_pattern(monitored_user_agents) + + def is_monitored_user_agent(self, ua: str): + if not ua: + return False + if not self.monitored_user_agent_regex: + return False + return self.monitored_user_agent_regex.search(ua) + + def set_user_agent_details(self, user_agent_details): + self.user_agent_details = [] + for detail in user_agent_details: + pattern = compile_pattern(detail["pattern"]) + if pattern: + self.user_agent_details.append( + {"key": detail["key"], "pattern": pattern} + ) + + def get_matching_user_agent_keys(self, ua: str): + return [ + detail["key"] + for detail in self.user_agent_details + if detail["pattern"].search(ua) + ] + + +def compile_pattern(pattern): + try: + return re.compile(pattern, re.IGNORECASE) + except re.error: + return None + + +def get_matching_ip_list_keys(ip_lists, ip): + return [entry["key"] for entry in ip_lists if entry["iplist"].has(ip)] + def parse_ip_entry(entry): """ - Converts ip entry: {"source": "example", "description": "Example description", "ips": []} + Converts ip entry: {"key": "geoip/nl", "source": "example", "description": "Example description", "ips": []} """ return { + "key": entry.get("key"), "source": entry["source"], "description": entry["description"], "iplist": IPMatcher(entry["ips"]), diff --git a/aikido_zen/storage/firewall_lists_test.py b/aikido_zen/storage/firewall_lists_test.py new file mode 100644 index 000000000..fc51415e1 --- /dev/null +++ b/aikido_zen/storage/firewall_lists_test.py @@ -0,0 +1,50 @@ +from .firewall_lists import FirewallLists + + +def test_monitored_ip_matches_multiple_lists(): + firewall_lists = FirewallLists() + firewall_lists.set_monitored_ips( + [ + {"key": "list1", "source": "s", "description": "d", "ips": ["1.2.3.0/24"]}, + {"key": "list2", "source": "s", "description": "d", "ips": ["1.2.3.4"]}, + ] + ) + assert firewall_lists.get_matching_monitored_ip_list_keys("1.2.3.4") == [ + "list1", + "list2", + ] + assert firewall_lists.get_matching_monitored_ip_list_keys("9.9.9.9") == [] + + +def test_monitored_user_agent(): + firewall_lists = FirewallLists() + assert not firewall_lists.is_monitored_user_agent("ClaudeBot") + assert not firewall_lists.is_monitored_user_agent("") + assert not firewall_lists.is_monitored_user_agent(None) + + firewall_lists.set_monitored_user_agents("ClaudeBot|GPTBot") + assert firewall_lists.is_monitored_user_agent("claudebot/1.0") + assert not firewall_lists.is_monitored_user_agent("curl/8.0") + + firewall_lists.set_monitored_user_agents("") + assert not firewall_lists.is_monitored_user_agent("ClaudeBot/1.0") + + +def test_user_agent_details_skip_invalid_pattern(): + firewall_lists = FirewallLists() + firewall_lists.set_user_agent_details( + [ + {"key": "broken", "pattern": "["}, + {"key": "claudebot", "pattern": "ClaudeBot"}, + {"key": "gptbot", "pattern": "GPTBot"}, + ] + ) + assert firewall_lists.get_matching_user_agent_keys("ClaudeBot/1.0") == ["claudebot"] + assert firewall_lists.get_matching_user_agent_keys("curl/8.0") == [] + + +def test_invalid_monitored_user_agent_pattern_is_skipped(): + firewall_lists = FirewallLists() + firewall_lists.set_monitored_user_agents("ClaudeBot") + firewall_lists.set_monitored_user_agents("[abc") + assert not firewall_lists.is_monitored_user_agent("ClaudeBot/1.0") diff --git a/aikido_zen/storage/statistics/__init__.py b/aikido_zen/storage/statistics/__init__.py index f4510379a..ab1348eb2 100644 --- a/aikido_zen/storage/statistics/__init__.py +++ b/aikido_zen/storage/statistics/__init__.py @@ -5,7 +5,8 @@ class Statistics: """ Stores: hits, counts of attacks (split up in detected/blocked), count of rate-limited requests, - statistics for operations (i.e. how many times did we see a query being executed) + statistics for operations (i.e. how many times did we see a query being executed), + and how many requests matched each IP list and user agent list """ def __init__(self): @@ -20,6 +21,8 @@ def __init__(self): self.rate_limited_hits = 0 self.started_at = t.get_unixtime_ms() self.operations = Operations() + self.ip_addresses_breakdown = {} + self.user_agents_breakdown = {} def clear(self): self.total_hits = 0 @@ -33,6 +36,8 @@ def clear(self): self.rate_limited_hits = 0 self.started_at = t.get_unixtime_ms() self.operations.clear() + self.ip_addresses_breakdown = {} + self.user_agents_breakdown = {} def increment_total_hits(self): self.total_hits += 1 @@ -51,6 +56,12 @@ def on_detected_attack_wave(self, blocked: bool): if blocked: self.attack_waves_blocked += 1 + def on_ip_address_matches(self, keys): + count_matches(self.ip_addresses_breakdown, keys) + + def on_user_agent_matches(self, keys): + count_matches(self.user_agents_breakdown, keys) + def get_record(self): current_time = t.get_unixtime_ms() return { @@ -70,6 +81,8 @@ def get_record(self): }, }, "operations": dict(self.operations), + "userAgents": {"breakdown": dict(self.user_agents_breakdown)}, + "ipAddresses": {"breakdown": dict(self.ip_addresses_breakdown)}, } def import_from_record(self, record): @@ -93,3 +106,8 @@ def empty(self): if len(self.operations) > 0: return False return True + + +def count_matches(breakdown, keys): + for key in keys: + breakdown[key] = breakdown.get(key, 0) + 1 diff --git a/aikido_zen/storage/statistics/init_test.py b/aikido_zen/storage/statistics/init_test.py index 9e7ed0f1c..56d1d4f85 100644 --- a/aikido_zen/storage/statistics/init_test.py +++ b/aikido_zen/storage/statistics/init_test.py @@ -301,3 +301,26 @@ def test_rate_limit_clear(): stats.clear() assert stats.rate_limited_hits == 0 + + +def test_ip_address_and_user_agent_breakdown(stats): + stats.on_ip_address_matches(["tor/exit_nodes", "geoip/blocked"]) + stats.on_ip_address_matches(["tor/exit_nodes"]) + stats.on_ip_address_matches([]) + stats.on_user_agent_matches(["claudebot"]) + + record = stats.get_record() + assert record["ipAddresses"] == { + "breakdown": {"tor/exit_nodes": 2, "geoip/blocked": 1} + } + assert record["userAgents"] == {"breakdown": {"claudebot": 1}} + + +def test_breakdown_clear(stats): + stats.on_ip_address_matches(["tor/exit_nodes"]) + stats.on_user_agent_matches(["claudebot"]) + stats.clear() + + record = stats.get_record() + assert record["ipAddresses"] == {"breakdown": {}} + assert record["userAgents"] == {"breakdown": {}} diff --git a/aikido_zen/thread/thread_cache_test.py b/aikido_zen/thread/thread_cache_test.py index b56024b6b..7fa305000 100644 --- a/aikido_zen/thread/thread_cache_test.py +++ b/aikido_zen/thread/thread_cache_test.py @@ -431,6 +431,8 @@ def test_renew_called_with_correct_args(mock_get_comms, thread_cache: ThreadCach "total": 1, }, }, + "userAgents": {"breakdown": {}}, + "ipAddresses": {"breakdown": {}}, }, "ai_stats": [ { @@ -502,6 +504,8 @@ def test_sync_data_for_users(mock_get_comms, thread_cache: ThreadCache): "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, + "userAgents": {"breakdown": {}}, + "ipAddresses": {"breakdown": {}}, }, "middleware_installed": False, "hostnames": [], @@ -554,6 +558,8 @@ def test_renew_called_with_empty_routes(mock_get_comms, thread_cache: ThreadCach "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, + "userAgents": {"breakdown": {}}, + "ipAddresses": {"breakdown": {}}, }, "middleware_installed": False, "hostnames": [], @@ -934,6 +940,8 @@ def test_renew_called_with_no_requests(mock_get_comms, thread_cache: ThreadCache "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, + "userAgents": {"breakdown": {}}, + "ipAddresses": {"breakdown": {}}, }, "middleware_installed": False, "hostnames": [], diff --git a/end2end/flask_mysql_test.py b/end2end/flask_mysql_test.py index 5662dbd40..f2a16c316 100644 --- a/end2end/flask_mysql_test.py +++ b/end2end/flask_mysql_test.py @@ -1,7 +1,7 @@ import pytest import requests import time -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type +from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type, wait_for_heartbeats # e2e tests for flask_mysql sample app base_url_fw = "http://localhost:8086" base_url_nofw = "http://localhost:8087" @@ -145,3 +145,28 @@ def test_set_ip_forwarded_for(): }) assert res.status_code == 403 assert res.text == "Your IP address is blocked due to geo restrictions (Your IP: 1.2.3.4)" + + +def test_firewall_list_hits_in_heartbeat(): + res = requests.get(base_url_fw + "/", headers={ + "X-Forwarded-For": "9.9.9.9", + "User-Agent": "MonitoredTestBot/1.0", + }) + assert res.status_code == 200 + res = requests.get(base_url_fw + "/", headers={ + "X-Forwarded-For": "1.2.3.4" + }) + assert res.status_code == 403 + + def check(heartbeats): + ip_hits, user_agent_hits = {}, {} + for heartbeat in heartbeats: + for key, count in heartbeat["stats"]["ipAddresses"]["breakdown"].items(): + ip_hits[key] = ip_hits.get(key, 0) + count + for key, count in heartbeat["stats"]["userAgents"]["breakdown"].items(): + user_agent_hits[key] = user_agent_hits.get(key, 0) + count + assert ip_hits.get("tor/exit_nodes", 0) >= 2 + assert ip_hits.get("geoip/blocked", 0) >= 1 + assert user_agent_hits.get("monitoredtestbot", 0) >= 1 + + wait_for_heartbeats("http://localhost:5000", check) diff --git a/end2end/server/mock_aikido_core.py b/end2end/server/mock_aikido_core.py index bd3924741..e836c198e 100644 --- a/end2end/server/mock_aikido_core.py +++ b/end2end/server/mock_aikido_core.py @@ -33,13 +33,26 @@ "success": True, "blockedIPAddresses": [ { + "key": "geoip/blocked", "source": "geoip", "description": "geo restrictions", "ips": ["1.2.3.4"] } ], "allowedIPAddresses": [], + "monitoredIPAddresses": [ + { + "key": "tor/exit_nodes", + "source": "tor", + "description": "Tor exit nodes", + "ips": ["1.2.3.4", "9.9.9.9"] + } + ], "blockedUserAgents": "", + "monitoredUserAgents": "MonitoredTestBot", + "userAgentDetails": [ + {"key": "monitoredtestbot", "pattern": "MonitoredTestBot"} + ], }, "configUpdatedAt": {}, }