diff --git a/aikido_zen/background_process/aikido_background_process.py b/aikido_zen/background_process/aikido_background_process.py index 881e03b9b..d35cfa23a 100644 --- a/aikido_zen/background_process/aikido_background_process.py +++ b/aikido_zen/background_process/aikido_background_process.py @@ -21,6 +21,7 @@ ) from aikido_zen.helpers.urls.get_api_url import get_api_url from .commands import process_incoming_command +from .queue_helpers import ReportingQueueAttackWaveEvent EMPTY_QUEUE_INTERVAL = 5 # 5 seconds @@ -102,12 +103,21 @@ def send_to_connection_manager(self, event_scheduler): ) while not self.queue.empty(): queue_attack_item = self.queue.get() - self.connection_manager.on_detected_attack( - attack=queue_attack_item[0], - context=queue_attack_item[1], - blocked=queue_attack_item[2], - stack=queue_attack_item[3], - ) + # Queue can contain multiple types of events (attack, attack wave) + if isinstance(queue_attack_item, ReportingQueueAttackWaveEvent): + attack_wave_event: ReportingQueueAttackWaveEvent = queue_attack_item + self.connection_manager.on_detected_attack_wave( + attack_wave_event.context, attack_wave_event.metadata + ) + else: + # Since the queue item is not of type ReportingQueueAttackWaveEvent, + # we default to regular attack reporting. + self.connection_manager.on_detected_attack( + attack=queue_attack_item[0], + context=queue_attack_item[1], + blocked=queue_attack_item[2], + stack=queue_attack_item[3], + ) def add_exit_handlers(): diff --git a/aikido_zen/background_process/cloud_connection_manager/__init__.py b/aikido_zen/background_process/cloud_connection_manager/__init__.py index 579fa0ef7..21986ba67 100644 --- a/aikido_zen/background_process/cloud_connection_manager/__init__.py +++ b/aikido_zen/background_process/cloud_connection_manager/__init__.py @@ -4,6 +4,7 @@ from aikido_zen.background_process.routes import Routes from aikido_zen.ratelimiting.rate_limiter import RateLimiter from aikido_zen.helpers.logging import logger +from .on_detected_attack_wave import on_detected_attack_wave from .update_firewall_lists import update_firewall_lists from ..api.http_api import ReportingApiHTTP from ..service_config import ServiceConfig @@ -20,6 +21,10 @@ from .update_service_config import update_service_config from .on_start import on_start from .send_heartbeat import send_heartbeat +from aikido_zen.vulnerabilities.attack_wave_detection.attack_wave_detector import ( + AttackWaveDetector, +) +from aikido_zen.context import Context class CloudConnectionManager: @@ -51,6 +56,7 @@ def __init__(self, block, api, token, serverless): self.statistics = Statistics() self.ai_stats = AIStatistics() self.middleware_installed = False + self.attack_wave_detector = AttackWaveDetector() if isinstance(serverless, str) and len(serverless) == 0: raise ValueError("Serverless cannot be an empty string") @@ -86,6 +92,9 @@ def on_detected_attack(self, attack, context, blocked, stack): """This will send something to the API when an attack is detected""" return on_detected_attack(self, attack, context, blocked, stack) + def on_detected_attack_wave(self, context: Context, metadata): + return on_detected_attack_wave(self, context, metadata) + def on_start(self): """This will send out an Event signalling the start to the server""" return on_start(self) diff --git a/aikido_zen/background_process/cloud_connection_manager/on_detected_attack_wave.py b/aikido_zen/background_process/cloud_connection_manager/on_detected_attack_wave.py new file mode 100644 index 000000000..abc9ce4de --- /dev/null +++ b/aikido_zen/background_process/cloud_connection_manager/on_detected_attack_wave.py @@ -0,0 +1,33 @@ +from aikido_zen.context import Context +from aikido_zen.helpers.get_current_unixtime_ms import get_unixtime_ms +from aikido_zen.helpers.logging import logger + + +def on_detected_attack_wave(connection_manager, context: Context, metadata): + if not connection_manager.token: + return + try: + agent_info = connection_manager.get_manager_info() + payload = create_attack_wave_payload(agent_info, context, metadata) + + connection_manager.api.report( + connection_manager.token, + payload, + connection_manager.timeout_in_sec, + ) + except Exception as e: + logger.info("Failed to report an attack wave (%s)", str(e)) + + +def create_attack_wave_payload(agent_info, context: Context, metadata): + return { + "type": "detected_attack_wave", + "time": get_unixtime_ms(), + "agent": agent_info, + "attack": {"metadata": metadata, "user": context.user}, + "request": { + "ipAddress": context.remote_address, + "userAgent": context.get_user_agent(), + "source": context.source, + }, + } diff --git a/aikido_zen/background_process/commands/attack_test.py b/aikido_zen/background_process/commands/attack_test.py index b5cb8e81d..2a85cd39a 100644 --- a/aikido_zen/background_process/commands/attack_test.py +++ b/aikido_zen/background_process/commands/attack_test.py @@ -2,6 +2,7 @@ from queue import Queue from unittest.mock import MagicMock from .attack import process_attack +from ..queue_helpers import ReportingQueueAttackWaveEvent class MockCloudConnectionManager: @@ -57,13 +58,16 @@ def test_process_attack_with_different_data_formats(): data1 = ("injection_results", "context", True, "stacktrace") data2 = ("injection_results", "context", False, "stacktrace") data3 = ("injection_results", "context", None, "stacktrace") + data4 = ReportingQueueAttackWaveEvent(None, {"a": "b"}) process_attack(connection_manager, data1, queue) process_attack(connection_manager, data2, queue) process_attack(connection_manager, data3, queue) + process_attack(connection_manager, data4, queue) # Check if all data items are added to the queue - assert queue.qsize() == 3 + assert queue.qsize() == 4 assert queue.get() == data1 assert queue.get() == data2 assert queue.get() == data3 + assert queue.get() == data4 diff --git a/aikido_zen/background_process/commands/check_firewall_lists.py b/aikido_zen/background_process/commands/check_firewall_lists.py index 4f8a6c789..c0ae1c8b8 100644 --- a/aikido_zen/background_process/commands/check_firewall_lists.py +++ b/aikido_zen/background_process/commands/check_firewall_lists.py @@ -12,34 +12,38 @@ def process_check_firewall_lists( connection_manager: CloudConnectionManager, data, conn, queue=None ): """ - Checks whether an IP is blocked - data: {"ip": string, "user-agent": string} - returns -> {"blocked": boolean, "type": string, "reason": string} + Checks whether an IP is blocked and whether there is an ongoing attack wave + data: {"ip": string, "user-agent": string, "is_attack_wave_request" ?: bool} + returns -> CheckFirewallListsRes """ ip = data["ip"] if ip is not None and isinstance(ip, str): # Global IP Allowlist (e.g. for geofencing) if not connection_manager.firewall_lists.is_allowed_ip(ip): - return {"blocked": True, "type": "allowlist"} + return CheckFirewallListsRes(blocked=True, type="allowlist") # Global IP Blocklist (e.g. blocking known threat actors) reason = connection_manager.firewall_lists.is_blocked_ip(ip) if reason: - return { - "blocked": True, - "type": "blocklist", - "reason": reason, - } + return CheckFirewallListsRes(blocked=True, type="blocklist", reason=reason) 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): - return { - "blocked": True, - "type": "bot-blocking", - } - - return { - "blocked": False, - } + return CheckFirewallListsRes(blocked=True, type="bot-blocking") + + is_attack_wave_request = data.get("is_attack_wave_request", None) + if is_attack_wave_request and ip is not None: + if connection_manager.attack_wave_detector.is_attack_wave(ip): + return CheckFirewallListsRes(blocked=False, is_attack_wave=True) + + return CheckFirewallListsRes() + + +class CheckFirewallListsRes: + def __init__(self, blocked=False, is_attack_wave=False, type=None, reason=None): + self.blocked = blocked + self.is_attack_wave = is_attack_wave + self.type = type + self.reason = reason diff --git a/aikido_zen/background_process/commands/sync_data_test.py b/aikido_zen/background_process/commands/sync_data_test.py index 286386cbd..c512bff8b 100644 --- a/aikido_zen/background_process/commands/sync_data_test.py +++ b/aikido_zen/background_process/commands/sync_data_test.py @@ -93,6 +93,7 @@ def test_process_sync_data_initialization(setup_connection_manager): assert connection_manager.statistics.get_record()["requests"] == { "aborted": 0, "attacksDetected": {"blocked": 0, "total": 5}, + "attackWaves": {"total": 0, "blocked": 0}, "total": 10, "rateLimited": 0, } @@ -168,6 +169,7 @@ def test_process_sync_data_with_last_updated_at_below_zero(setup_connection_mana assert connection_manager.statistics.get_record()["requests"] == { "aborted": 0, "attacksDetected": {"blocked": 0, "total": 5}, + "attackWaves": {"total": 0, "blocked": 0}, "total": 10, "rateLimited": 0, } @@ -255,6 +257,7 @@ def test_process_sync_data_existing_route_and_hostnames(setup_connection_manager assert connection_manager.statistics.get_record()["requests"] == { "aborted": 0, "attacksDetected": {"blocked": 0, "total": 10}, + "attackWaves": {"total": 0, "blocked": 0}, "total": 20, "rateLimited": 0, } diff --git a/aikido_zen/background_process/queue_helpers.py b/aikido_zen/background_process/queue_helpers.py new file mode 100644 index 000000000..8582808f5 --- /dev/null +++ b/aikido_zen/background_process/queue_helpers.py @@ -0,0 +1,7 @@ +from aikido_zen.context import Context + + +class ReportingQueueAttackWaveEvent: + def __init__(self, context: Context, metadata): + self.context = context + self.metadata = metadata diff --git a/aikido_zen/context/__init__.py b/aikido_zen/context/__init__.py index ed26eba06..cc3010ac2 100644 --- a/aikido_zen/context/__init__.py +++ b/aikido_zen/context/__init__.py @@ -99,8 +99,9 @@ def __reduce__(self): def set_as_current_context(self): """ - Set the current context + Set the current context, called every time we change the context. """ + self.reset_cache() current_context.set(self) def set_cookies(self, cookies): @@ -112,6 +113,9 @@ def set_body(self, body): except Exception as e: logger.debug("Exception occurred whilst setting body: %s", e) + def reset_cache(self): + self.parsed_userinput = {} + def set_body_internal(self, body): """Sets the body and checks if it's possibly JSON""" self.body = body diff --git a/aikido_zen/ratelimiting/lru_cache.py b/aikido_zen/ratelimiting/lru_cache.py index ee56e3d40..cd2606628 100644 --- a/aikido_zen/ratelimiting/lru_cache.py +++ b/aikido_zen/ratelimiting/lru_cache.py @@ -3,7 +3,7 @@ """ from collections import OrderedDict -from aikido_zen.helpers.get_current_unixtime_ms import get_unixtime_ms +import aikido_zen.helpers.get_current_unixtime_ms as internal_time class LRUCache: @@ -24,7 +24,8 @@ def get(self, key): if key in self.cache: # Check if the item is still valid based on TTL if ( - get_unixtime_ms(monotonic=True) - self.cache[key]["startTime"] + internal_time.get_unixtime_ms(monotonic=True) + - self.cache[key]["startTime"] < self.time_to_live_in_ms ): return self.cache[key]["value"] # Return the actual value @@ -41,7 +42,7 @@ def set(self, key, value): self.cache.popitem(last=False) # Remove the oldest item self.cache[key] = { "value": value, - "startTime": get_unixtime_ms(monotonic=True), + "startTime": internal_time.get_unixtime_ms(monotonic=True), } # Store value and timestamp def clear(self): diff --git a/aikido_zen/sources/functions/request_handler.py b/aikido_zen/sources/functions/request_handler.py index 31ecc0101..e926885d3 100644 --- a/aikido_zen/sources/functions/request_handler.py +++ b/aikido_zen/sources/functions/request_handler.py @@ -7,6 +7,9 @@ from aikido_zen.thread.thread_cache import get_cache from .ip_allowed_to_access_route import ip_allowed_to_access_route import aikido_zen.background_process.comms as c +from ...background_process.commands.check_firewall_lists import CheckFirewallListsRes +from ...background_process.queue_helpers import ReportingQueueAttackWaveEvent +from ...vulnerabilities.attack_wave_detection.is_web_scanner import is_web_scanner def request_handler(stage, status_code=0): @@ -49,7 +52,11 @@ def pre_response(): message += f" (Your IP: {context.remote_address})" return message, 403 - # Do a check on firewall lists, this happens in background because of the heavy data. + is_attack_wave_request = is_web_scanner(context) + if is_attack_wave_request: + logger.debug("Web scan detected for %s:%s", context.method, context.route) + + # Do a check on firewall lists & attack waves, this happens in background because of the heavy data. # For the timeout we notice the request during heavy loads usually takes 2ms - 2.5ms, we set timeout at 10ms. # That way we have a very small timeout with very little risk of not blocking ips. comms = c.get_comms() @@ -58,28 +65,37 @@ def pre_response(): obj={ "ip": context.remote_address, "user-agent": context.get_user_agent(), + "is_attack_wave_request": bool(is_attack_wave_request), }, receive=True, timeout_in_sec=(10 / 1000), ) - if not check_fw_lists_res["success"] or not check_fw_lists_res["data"]["blocked"]: + if not check_fw_lists_res.get("success", False): return + res: CheckFirewallListsRes = check_fw_lists_res.get("data") - block_type = check_fw_lists_res["data"]["type"] - - if block_type == "allowlist": + if res.blocked and res.type == "allowlist": message = "Your IP address is not allowed." message += " (Your IP: " + context.remote_address + ")" return message, 403 - if block_type == "blocklist": + if res.blocked and res.type == "blocklist": message = "Your IP address is blocked due to " - message += check_fw_lists_res["data"]["reason"] + message += res.reason message += " (Your IP: " + context.remote_address + ")" return message, 403 - if block_type == "bot-blocking": + if res.blocked and res.type == "bot-blocking": msg = "You are not allowed to access this resource because you have been identified as a bot." return msg, 403 + # We only check for attack waves after IP/Bot blocking, the reason being that if you already block the scanner + # There is no attack wave happening. + if res.is_attack_wave: + # Report to core & increase stats + comms.send_data_to_bg_process( + "ATTACK", ReportingQueueAttackWaveEvent(context, metadata={}) + ) + cache.stats.on_detected_attack_wave(blocked=res.blocked) + def post_response(status_code): """Checks if the current route is useful""" diff --git a/aikido_zen/sources/functions/request_handler_test.py b/aikido_zen/sources/functions/request_handler_test.py index db76717f3..98945946c 100644 --- a/aikido_zen/sources/functions/request_handler_test.py +++ b/aikido_zen/sources/functions/request_handler_test.py @@ -1,3 +1,5 @@ +import inspect + import pytest from unittest.mock import patch, MagicMock from aikido_zen.thread.thread_cache import get_cache, ThreadCache @@ -7,6 +9,9 @@ from ...context import Context, current_context from ...helpers.headers import Headers from ...storage.firewall_lists import FirewallLists +from ...vulnerabilities.attack_wave_detection.attack_wave_detector import ( + AttackWaveDetector, +) @pytest.fixture @@ -38,15 +43,20 @@ def __init__(self): self.firewall_lists = FirewallLists() self.conn_manager = MagicMock() self.conn_manager.firewall_lists = self.firewall_lists + self.conn_manager.attack_wave_detector = AttackWaveDetector() + self.attacks = [] def send_data_to_bg_process(self, action, obj, receive=False, timeout_in_sec=0.1): - if action != "CHECK_FIREWALL_LISTS": - return {"success": False} - res = process_check_firewall_lists(self.conn_manager, obj, None, None) - return { - "success": True, - "data": res, - } + if action == "CHECK_FIREWALL_LISTS": + res = process_check_firewall_lists(self.conn_manager, obj, None, None) + return { + "success": True, + "data": res, + } + if action == "ATTACK": + self.attacks.append(obj) + return {"success": True} + return {"success": False} def test_post_response_useful_route(mock_context): @@ -125,7 +135,7 @@ def test_post_response_no_context(mock_get_comms): # Test firewall lists -def set_context(remote_address, user_agent=""): +def set_context(remote_address, user_agent="", route="/posts/:number"): headers = Headers() headers.store_header("USER_AGENT", user_agent) Context( @@ -138,9 +148,10 @@ def set_context(remote_address, user_agent=""): "body": None, "cookies": {}, "source": "flask", - "route": "/posts/:number", + "route": route, "user": None, "executed_middleware": False, + "parsed_userinput": {}, } ).set_as_current_context() @@ -170,7 +181,13 @@ def wrapper(*args, **kwargs): comms = MyMockComms() mock_comms.return_value = comms - return func(*args, firewall_lists=comms.firewall_lists, **kwargs) + sig = inspect.signature(func) + if "attacks" in sig.parameters: + kwargs["attacks"] = comms.attacks + if "firewall_lists" in sig.parameters: + kwargs["firewall_lists"] = comms.firewall_lists + + return func(*args, **kwargs) return wrapper @@ -579,3 +596,69 @@ def test_multiple_blocked(firewall_lists): assert request_handler("pre_response") is None set_context("fd00:1234:5678:9abc::2") assert request_handler("pre_response") is None + + +@patch_firewall_lists +def test_is_attack_waves_doesnt_work_when_ip_blocked(firewall_lists): + set_context("1.1.1.1", route="/.env") + create_service_config() + blocked_ips = [ + {"source": "test", "description": "Blocked for testing", "ips": ["1.1.1.1"]} + ] + firewall_lists.set_blocked_ips(blocked_ips) + + for i in range(16): + result = request_handler("pre_response") + assert result == ( + "Your IP address is blocked due to Blocked for testing (Your IP: 1.1.1.1)", + 403, + ) + assert get_cache().stats.get_record()["requests"]["attackWaves"] == { + "total": 0, + "blocked": 0, + } + + +@patch_firewall_lists +def test_is_attack_waves_doesnt_work_when_ip_blocked(firewall_lists, attacks): + set_context("1.1.1.1", route="/.env") + create_service_config() + + assert len(attacks) == 0 + assert get_cache().stats.get_record()["requests"]["attackWaves"] == { + "total": 0, + "blocked": 0, + } + + for i in range(15): + request_handler("pre_response") + + assert len(attacks) == 1 + assert attacks[0].context.route == "/.env" + assert attacks[0].context.remote_address == "1.1.1.1" + assert attacks[0].metadata == {} + + assert get_cache().stats.get_record()["requests"]["attackWaves"] == { + "total": 1, + "blocked": 0, + } + + # now try again (should not be possible, 20min window) + for i in range(15): + request_handler("pre_response") + + assert get_cache().stats.get_record()["requests"]["attackWaves"] == { + "total": 1, + "blocked": 0, + } + + # now try with another IP + set_context("4.4.4.4", route="/.htaccess") + for i in range(15): + request_handler("pre_response") + + assert get_cache().stats.get_record()["requests"]["attackWaves"] == { + "total": 2, + "blocked": 0, + } + assert len(attacks) == 2 diff --git a/aikido_zen/storage/statistics/__init__.py b/aikido_zen/storage/statistics/__init__.py index 47e7dec3e..4529f8681 100644 --- a/aikido_zen/storage/statistics/__init__.py +++ b/aikido_zen/storage/statistics/__init__.py @@ -10,16 +10,26 @@ class Statistics: def __init__(self): self.total_hits = 0 + self.attacks_detected = 0 self.attacks_blocked = 0 + + self.attack_waves_detected = 0 + self.attack_waves_blocked = 0 + self.rate_limited_hits = 0 self.started_at = t.get_unixtime_ms() self.operations = Operations() def clear(self): self.total_hits = 0 + self.attacks_detected = 0 self.attacks_blocked = 0 + + self.attack_waves_detected = 0 + self.attack_waves_blocked = 0 + self.rate_limited_hits = 0 self.started_at = t.get_unixtime_ms() self.operations.clear() @@ -36,6 +46,11 @@ def on_detected_attack(self, blocked, operation): def on_rate_limit(self): self.rate_limited_hits += 1 + def on_detected_attack_wave(self, blocked: bool): + self.attack_waves_detected += 1 + if blocked: + self.attack_waves_blocked += 1 + def get_record(self): current_time = t.get_unixtime_ms() return { @@ -49,6 +64,10 @@ def get_record(self): "total": self.attacks_detected, "blocked": self.attacks_blocked, }, + "attackWaves": { + "total": self.attack_waves_detected, + "blocked": self.attack_waves_blocked, + }, }, "operations": dict(self.operations), } @@ -66,6 +85,8 @@ def empty(self): return False if self.attacks_detected > 0: return False + if self.attack_waves_detected > 0: + return False if len(self.operations) > 0: return False return True diff --git a/aikido_zen/storage/statistics/init_test.py b/aikido_zen/storage/statistics/init_test.py index 7d54ec1c3..4c37e8c69 100644 --- a/aikido_zen/storage/statistics/init_test.py +++ b/aikido_zen/storage/statistics/init_test.py @@ -18,6 +18,8 @@ def test_initialization(monkeypatch): assert stats.total_hits == 0 assert stats.attacks_detected == 0 assert stats.attacks_blocked == 0 + assert stats.attack_waves_detected == 0 + assert stats.attack_waves_blocked == 0 assert stats.started_at == mock_time assert isinstance(stats.operations, Operations) @@ -33,12 +35,15 @@ def test_clear(monkeypatch): stats.total_hits = 10 stats.attacks_detected = 5 stats.attacks_blocked = 3 + stats.on_detected_attack_wave(blocked=True) stats.operations.register_call("test", "sql_op") stats.clear() assert stats.total_hits == 0 assert stats.attacks_detected == 0 assert stats.attacks_blocked == 0 + assert stats.attack_waves_blocked == 0 + assert stats.attack_waves_detected == 0 assert stats.started_at == mock_time assert stats.operations == {} @@ -59,6 +64,16 @@ def test_on_detected_attack(stats): assert stats.attacks_blocked == 1 +def test_on_detected_attack_wave(stats): + stats.on_detected_attack_wave(blocked=True) + assert stats.get_record()["requests"]["attackWaves"]["total"] == 1 + assert stats.get_record()["requests"]["attackWaves"]["blocked"] == 1 + + stats.on_detected_attack_wave(blocked=False) + assert stats.get_record()["requests"]["attackWaves"]["total"] == 2 + assert stats.get_record()["requests"]["attackWaves"]["blocked"] == 1 + + def test_get_record(monkeypatch): # Mock the current time mock_time = 1234567890000 @@ -74,6 +89,8 @@ def test_get_record(monkeypatch): stats.on_detected_attack(blocked=True, operation="test.test") stats.attacks_detected = 5 stats.attacks_blocked = 3 + stats.on_detected_attack_wave(False) + stats.on_detected_attack_wave(False) record = stats.get_record() assert record["startedAt"] == stats.started_at @@ -83,6 +100,8 @@ def test_get_record(monkeypatch): assert record["requests"]["aborted"] == 0 assert record["requests"]["attacksDetected"]["total"] == 5 assert record["requests"]["attacksDetected"]["blocked"] == 3 + assert record["requests"]["attackWaves"]["total"] == 2 + assert record["requests"]["attackWaves"]["blocked"] == 0 assert record["operations"] == { "test.test": { "attacksDetected": {"blocked": 1, "total": 1}, @@ -153,6 +172,12 @@ def test_empty(stats): assert stats.empty() == False +def test_empty_with_attack_waves(stats): + assert stats.empty() + stats.on_detected_attack_wave(blocked=False) + assert not stats.empty() + + def test_multiple_imports(stats): record1 = { "requests": { diff --git a/aikido_zen/thread/thread_cache_test.py b/aikido_zen/thread/thread_cache_test.py index 3d7308f99..8d0e3a9e5 100644 --- a/aikido_zen/thread/thread_cache_test.py +++ b/aikido_zen/thread/thread_cache_test.py @@ -45,6 +45,7 @@ def test_initialization(thread_cache: ThreadCache): "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, } @@ -81,6 +82,7 @@ def test_reset(thread_cache: ThreadCache): "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, } @@ -105,6 +107,7 @@ def test_renew_with_no_comms(thread_cache: ThreadCache): "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, } @@ -291,6 +294,7 @@ def test_renew_called_with_correct_args(mock_get_comms, thread_cache: ThreadCach "rateLimited": 0, "aborted": 0, "attacksDetected": {"blocked": 1, "total": 3}, + "attackWaves": {"total": 0, "blocked": 0}, }, "operations": { "op1": { @@ -376,6 +380,7 @@ def test_sync_data_for_users(mock_get_comms, thread_cache: ThreadCache): "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, }, @@ -429,6 +434,7 @@ def test_renew_called_with_empty_routes(mock_get_comms, thread_cache: ThreadCach "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, }, @@ -470,6 +476,7 @@ def test_renew_called_with_no_requests(mock_get_comms, thread_cache: ThreadCache "rateLimited": 0, "aborted": 0, "attacksDetected": {"total": 0, "blocked": 0}, + "attackWaves": {"total": 0, "blocked": 0}, }, "operations": {}, }, diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/__init__.py b/aikido_zen/vulnerabilities/attack_wave_detection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py new file mode 100644 index 000000000..ece4b373e --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py @@ -0,0 +1,47 @@ +import aikido_zen.helpers.get_current_unixtime_ms as internal_time +from aikido_zen.ratelimiting.lru_cache import LRUCache + + +class AttackWaveDetector: + def __init__( + self, + attack_wave_threshold: int = 15, + attack_wave_time_frame: int = 60 * 1000, # 1 minute in ms + min_time_between_events: int = 20 * 60 * 1000, # 20 minutes in ms + max_lru_entries: int = 10_000, + ): + self.attack_wave_threshold = attack_wave_threshold + self.attack_wave_time_frame = attack_wave_time_frame + self.min_time_between_events = min_time_between_events + self.max_lru_entries = max_lru_entries + + self.suspicious_requests_map = LRUCache( + max_items=self.max_lru_entries, + time_to_live_in_ms=self.attack_wave_time_frame, + ) + self.sent_events_map = LRUCache( + max_items=self.max_lru_entries, + time_to_live_in_ms=self.min_time_between_events, + ) + + def is_attack_wave(self, ip: str) -> bool: + """ + Function gets called with IP if there is an attack wave request. + """ + if not ip: + return False + + # Check if an event was sent recently + if self.sent_events_map.get(ip) is not None: + return False + + # Increment suspicious requests count -> there is a new or first suspicious request + suspicious_requests = (self.suspicious_requests_map.get(ip) or 0) + 1 + self.suspicious_requests_map.set(ip, suspicious_requests) + + if suspicious_requests < self.attack_wave_threshold: + return False + + # Mark event as sent + self.sent_events_map.set(ip, internal_time.get_unixtime_ms(monotonic=True)) + return True diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector_test.py new file mode 100644 index 000000000..a4edf0cbf --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector_test.py @@ -0,0 +1,138 @@ +import pytest +from unittest.mock import patch +from .attack_wave_detector import AttackWaveDetector + + +def new_attack_wave_detector(): + return AttackWaveDetector( + attack_wave_threshold=6, + attack_wave_time_frame=60 * 1000, + min_time_between_events=60 * 60 * 1000, + max_lru_entries=10_000, + ) + + +# Mock for get_unixtime_ms +def mock_get_unixtime_ms(monotonic=True, mock_time=0): + return mock_time + + +def test_no_ip_address(): + detector = new_attack_wave_detector() + assert not detector.is_attack_wave(None) + + +def test_a_web_scanner(): + detector = new_attack_wave_detector() + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + # Is true because the threshold is 6 + assert detector.is_attack_wave("::1") + # False again because event should have been sent last time + assert not detector.is_attack_wave("::1") + + +def test_a_web_scanner_with_delays(): + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=0), + ): + detector = new_attack_wave_detector() + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=30 * 1000), + ): + assert not detector.is_attack_wave("::1") + # Is true because the threshold is 6 + assert detector.is_attack_wave("::1") + # False again because event should have been sent last time + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=60 * 60 * 1000), + ): + # Still false because minimum time between events is 1 hour + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=92 * 60 * 1000), + ): + # Should resend event after 1 hour + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert detector.is_attack_wave("::1") + + +def test_a_slow_web_scanner_that_triggers_in_the_second_interval(): + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=0), + ): + detector = new_attack_wave_detector() + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=62 * 1000), + ): + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert detector.is_attack_wave("::1") + + +def test_a_slow_web_scanner_that_triggers_in_the_third_interval(): + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=0), + ): + detector = new_attack_wave_detector() + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=62 * 1000), + ): + # Still false because minimum time between events is 1 hour + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + + with patch( + "aikido_zen.helpers.get_current_unixtime_ms.get_unixtime_ms", + side_effect=lambda **kw: mock_get_unixtime_ms(**kw, mock_time=124 * 1000), + ): + # Should resend event after 1 hour + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert not detector.is_attack_wave("::1") + assert detector.is_attack_wave("::1") diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method.py new file mode 100644 index 000000000..9ffec2b97 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method.py @@ -0,0 +1,11 @@ +web_scan_methods = { + "BADMETHOD", + "BADHTTPMETHOD", + "BADDATA", + "BADMTHD", + "BDMTHD", +} + + +def is_web_scan_method(method: str) -> bool: + return method.upper() in web_scan_methods diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method_test.py new file mode 100644 index 000000000..91479b6a8 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_method_test.py @@ -0,0 +1,24 @@ +from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scan_method import ( + is_web_scan_method, +) + + +def test_is_web_scan_method(): + assert is_web_scan_method("BADMETHOD") + assert is_web_scan_method("BADHTTPMETHOD") + assert is_web_scan_method("BADDATA") + assert is_web_scan_method("BADMTHD") + assert is_web_scan_method("BDMTHD") + + +def test_is_not_web_scan_method(): + assert not is_web_scan_method("GET") + assert not is_web_scan_method("POST") + assert not is_web_scan_method("PUT") + assert not is_web_scan_method("DELETE") + assert not is_web_scan_method("PATCH") + assert not is_web_scan_method("OPTIONS") + assert not is_web_scan_method("HEAD") + assert not is_web_scan_method("TRACE") + assert not is_web_scan_method("CONNECT") + assert not is_web_scan_method("PURGE") diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path.py new file mode 100644 index 000000000..c13ec9627 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path.py @@ -0,0 +1,48 @@ +from aikido_zen.vulnerabilities.attack_wave_detection.paths import ( + file_names, + directory_names, +) + +file_extensions = { + "env", + "bak", + "sql", + "sqlite", + "sqlite3", + "db", + "old", + "save", + "orig", + "sqlitedb", + "sqlite3db", +} +filenames = {name.lower() for name in file_names} +directories = {name.lower() for name in directory_names} + + +def is_web_scan_path(path: str) -> bool: + """ + is_web_scan_path gets the current route and wants to determine whether it's a test by some web scanner. + Checks filename if it exists (list of suspicious filenames & list of supsicious extensions) + Checks all other segments for suspicious directories + """ + normalized = path.lower() + segments = normalized.split("/") + if not segments: + return False + + filename = segments[-1] + if filename: + if filename in filenames: + return True + + if "." in filename: + ext = filename.split(".")[-1] + if ext in file_extensions: + return True + + segments_without_filename = segments[:-1] + for directory in segments_without_filename: + if directory in directories: + return True + return False diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path_test.py new file mode 100644 index 000000000..ee9d13fa1 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scan_path_test.py @@ -0,0 +1,46 @@ +from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scan_path import ( + is_web_scan_path, +) +from aikido_zen.vulnerabilities.attack_wave_detection.paths import ( + file_names, + directory_names, +) + + +def test_is_web_scan_path(): + assert is_web_scan_path("/.env") + assert is_web_scan_path("/test/.env") + assert is_web_scan_path("/test/.env.bak") + assert is_web_scan_path("/.git/config") + assert is_web_scan_path("/.aws/config") + assert is_web_scan_path("/some/path/.git/test") + assert is_web_scan_path("/some/path/.gitlab-ci.yml") + assert is_web_scan_path("/some/path/.github/workflows/test.yml") + assert is_web_scan_path("/.travis.yml") + assert is_web_scan_path("/../example/") + assert is_web_scan_path("/./test") + assert is_web_scan_path("/Cargo.lock") + assert is_web_scan_path("/System32/test") + + +def test_is_not_web_scan_path(): + assert not is_web_scan_path("/test/file.txt") + assert not is_web_scan_path("/some/route/to/file.txt") + assert not is_web_scan_path("/some/route/to/file.json") + assert not is_web_scan_path("/en") + assert not is_web_scan_path("/") + assert not is_web_scan_path("/test/route") + assert not is_web_scan_path("/static/file.css") + assert not is_web_scan_path("/static/file.a461f56e.js") + + +def test_no_duplicates_in_file_names(): + unique_file_names = set(file_names) + assert len(unique_file_names) == len(file_names), "File names should be unique" + + +def test_no_duplicates_in_directory_names(): + unique_directory_names = set(directory_names) + assert len(unique_directory_names) == len( + directory_names + ), "Directory names should be unique" diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner.py new file mode 100644 index 000000000..e1638f89e --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner.py @@ -0,0 +1,20 @@ +from aikido_zen.context import Context +from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scan_method import ( + is_web_scan_method, +) +from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scan_path import ( + is_web_scan_path, +) +from aikido_zen.vulnerabilities.attack_wave_detection.query_params_contain_dangerous_strings import ( + query_params_contain_dangerous_strings, +) + + +def is_web_scanner(context: Context) -> bool: + if context.method and is_web_scan_method(context.method): + return True + if context.route and is_web_scan_path(context.route): + return True + if query_params_contain_dangerous_strings(context): + return True + return False diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_benchmark_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_benchmark_test.py new file mode 100644 index 000000000..9da02a694 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_benchmark_test.py @@ -0,0 +1,46 @@ +import time + +import pytest + +from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scanner import ( + is_web_scanner, +) + + +class Context: + def __init__(self, route, method, query): + self.remote_address = "::1" + self.method = method + self.url = "http://example.com" + self.query = query + self.headers = {} + self.body = {} + self.cookies = {} + self.route_params = {} + self.source = "flask" + self.route = route + self.parsed_userinput = {} + + +def get_test_context(path="/", method="GET", query=None): + return Context(path, method, query) + + +# the CI/CD results here are very unreliable, locally this test passes consistently. +@pytest.mark.skip(reason="Skipping this test in CI/CD") +def test_performance(): + iterations = 25_000 + start = time.perf_counter_ns() + for _ in range(iterations): + is_web_scanner(get_test_context("/wp-config.php", "GET", {"test": "1"})) + is_web_scanner( + get_test_context("/vulnerable", "GET", {"test": "1'; DROP TABLE users; --"}) + ) + is_web_scanner(get_test_context("/", "GET", {"test": "1"})) + end = time.perf_counter_ns() + + total_time_ms = (end - start) / 1_000_000 + time_per_check_ms = total_time_ms / iterations / 3 + assert ( + time_per_check_ms < 0.006 + ), f"Took {time_per_check_ms:.6f}ms per check (max allowed: 0.006ms)" diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_test.py new file mode 100644 index 000000000..eb03a44f4 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/is_web_scanner_test.py @@ -0,0 +1,43 @@ +import pytest +from .is_web_scanner import is_web_scanner + + +class Context: + def __init__(self, route, method, query): + self.remote_address = "::1" + self.method = method + self.url = "http://example.com" + self.query = query + self.headers = {} + self.body = {} + self.cookies = {} + self.route_params = {} + self.source = "flask" + self.route = route + self.parsed_userinput = {} + + +def get_test_context(path="/", method="GET", query=None): + return Context(path, method, query) + + +def test_is_web_scanner(): + assert is_web_scanner(get_test_context("/wp-config.php", "GET")) + assert is_web_scanner(get_test_context("/.env", "GET")) + assert is_web_scanner(get_test_context("/test/.env.bak", "GET")) + assert is_web_scanner(get_test_context("/.git/config", "GET")) + assert is_web_scanner(get_test_context("/.aws/config", "GET")) + assert is_web_scanner(get_test_context("/../secret", "GET")) + assert is_web_scanner(get_test_context("/", "BADMETHOD")) + assert is_web_scanner(get_test_context("/", "GET", {"test": "SELECT * FROM admin"})) + assert is_web_scanner(get_test_context("/", "GET", {"test": "../etc/passwd"})) + + +def test_is_not_web_scanner(): + assert not is_web_scanner(get_test_context("graphql", "POST")) + assert not is_web_scanner(get_test_context("/api/v1/users", "GET")) + assert not is_web_scanner(get_test_context("/public/index.html", "GET")) + assert not is_web_scanner(get_test_context("/static/js/app.js", "GET")) + assert not is_web_scanner(get_test_context("/uploads/image.png", "GET")) + assert not is_web_scanner(get_test_context("/", "GET", {"test": "1'"})) + assert not is_web_scanner(get_test_context("/", "GET", {"test": "abcd"})) diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/paths.py b/aikido_zen/vulnerabilities/attack_wave_detection/paths.py new file mode 100644 index 000000000..9bbd1089a --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/paths.py @@ -0,0 +1,354 @@ +# Sourced from AikidoSec/firewall-node : library/vulnerabilities/attack-wave-detection/paths/fileNames.ts +file_names = { + ".addressbook", + ".atom", + ".bashrc", + ".boto", + ".config", + ".config.json", + ".config.xml", + ".config.yaml", + ".config.yml", + ".envrc", + ".eslintignore", + ".fbcindex", + ".forward", + ".gitattributes", + ".gitconfig", + ".gitignore", + ".gitkeep", + ".gitlab-ci.yaml", + ".gitlab-ci.yml", + ".gitmodules", + ".google_authenticator", + ".hgignore", + ".htaccess", + ".htpasswd", + ".htdigest", + ".ksh_history", + ".lesshst", + ".lhistory", + ".lighttpdpassword", + ".lldb-history", + ".lynx_cookies", + ".my.cnf", + ".mysql_history", + ".nano_history", + ".netrc", + ".node_repl_history", + ".npmrc", + ".nsconfig", + ".nsr", + ".password-store", + ".pearrc", + ".pgpass", + ".php_history", + ".pinerc", + ".proclog", + ".procmailrc", + ".profile", + ".psql_history", + ".python_history", + ".rediscli_history", + ".rhosts", + ".selected_editor", + ".sh_history", + ".sqlite_history", + ".svnignore", + ".tcshrc", + ".tmux.conf", + ".travis.yaml", + ".travis.yml", + ".viminfo", + ".vimrc", + ".www_acl", + ".wwwacl", + ".xauthority", + ".yarnrc", + ".zhistory", + ".zsh_history", + ".zshenv", + ".zshrc", + "Dockerfile", + "aws-key.yaml", + "aws-key.yml", + "aws.yaml", + "aws.yml", + "docker-compose.yaml", + "docker-compose.yml", + "npm-shrinkwrap.json", + "package-lock.json", + "package.json", + "phpinfo.php", + "wp-config.php", + "wp-config.php3", + "wp-config.php4", + "wp-config.php5", + "wp-config.phtml", + "composer.json", + "composer.lock", + "composer.phar", + "yarn.lock", + ".env.local", + ".env.development", + ".env.test", + ".env.production", + ".env.prod", + ".env.dev", + ".env.example", + "php.ini", + "wp-settings.php", + "config.asp", + "config_dev.asp", + "config-dev.asp", + "config.dev.asp", + "config_prod.asp", + "config-prod.asp", + "config.prod.asp", + "config.sample.asp", + "config-sample.asp", + "config_sample.asp", + "config_test.asp", + "config-test.asp", + "config.test.asp", + "config.ini", + "config_dev.ini", + "config-dev.ini", + "config.dev.ini", + "config_prod.ini", + "config-prod.ini", + "config.prod.ini", + "config.sample.ini", + "config-sample.ini", + "config_sample.ini", + "config_test.ini", + "config-test.ini", + "config.test.ini", + "config.json", + "config_dev.json", + "config-dev.json", + "config.dev.json", + "config_prod.json", + "config-prod.json", + "config.prod.json", + "config.sample.json", + "config-sample.json", + "config_sample.json", + "config_test.json", + "config-test.json", + "config.test.json", + "config.php", + "config_dev.php", + "config-dev.php", + "config.dev.php", + "config_prod.php", + "config-prod.php", + "config.prod.php", + "config.sample.php", + "config-sample.php", + "config_sample.php", + "config_test.php", + "config-test.php", + "config.test.php", + "config.pl", + "config_dev.pl", + "config-dev.pl", + "config.dev.pl", + "config_prod.pl", + "config-prod.pl", + "config.prod.pl", + "config.sample.pl", + "config-sample.pl", + "config_sample.pl", + "config_test.pl", + "config-test.pl", + "config.test.pl", + "config.py", + "config_dev.py", + "config-dev.py", + "config.dev.py", + "config_prod.py", + "config-prod.py", + "config.prod.py", + "config.sample.py", + "config-sample.py", + "config_sample.py", + "config_test.py", + "config-test.py", + "config.test.py", + "config.rb", + "config_dev.rb", + "config-dev.rb", + "config.dev.rb", + "config_prod.rb", + "config-prod.rb", + "config.prod.rb", + "config.sample.rb", + "config-sample.rb", + "config_sample.rb", + "config_test.rb", + "config-test.rb", + "config.test.rb", + "config.toml", + "config_dev.toml", + "config-dev.toml", + "config.dev.toml", + "config_prod.toml", + "config-prod.toml", + "config.prod.toml", + "config.sample.toml", + "config-sample.toml", + "config_sample.toml", + "config_test.toml", + "config-test.toml", + "config.test.toml", + "config.txt", + "config_dev.txt", + "config-dev.txt", + "config.dev.txt", + "config_prod.txt", + "config-prod.txt", + "config.prod.txt", + "config.sample.txt", + "config-sample.txt", + "config_sample.txt", + "config_test.txt", + "config-test.txt", + "config.test.txt", + "config.xml", + "config_dev.xml", + "config-dev.xml", + "config.dev.xml", + "config_prod.xml", + "config-prod.xml", + "config.prod.xml", + "config.sample.xml", + "config-sample.xml", + "config_sample.xml", + "config_test.xml", + "config-test.xml", + "config.test.xml", + "config.yaml", + "config_dev.yaml", + "config-dev.yaml", + "config.dev.yaml", + "config_prod.yaml", + "config-prod.yaml", + "config.prod.yaml", + "config.sample.yaml", + "config-sample.yaml", + "config_sample.yaml", + "config_test.yaml", + "config-test.yaml", + "config.test.yaml", + "config.yml", + "config_dev.yml", + "config-dev.yml", + "config.dev.yml", + "config_prod.yml", + "config-prod.yml", + "config.prod.yml", + "config.sample.yml", + "config-sample.yml", + "config_sample.yml", + "config_test.yml", + "config-test.yml", + "config.test.yml", + "boot.ini", + "gruntfile.js", + "localsettings.php", + "my.ini", + "npm-debug.log", + "parameters.yml", + "parameters.yaml", + "services.yml", + "services.yaml", + "web.config", + "webpack.config.js", + "config.old", + "config.inc.php", + "error.log", + "access.log", + ".DS_Store", + "passwd", + "win.ini", + "cmd.exe", + "my.cnf", + ".bash_history", + "docker-compose-dev.yml", + "docker-compose.override.yml", + "docker-compose.dev.yml", + "Cargo.lock", + "secrets.yml", + "secrets.yaml", + "docker-compose.staging.yml", + "docker-compose.production.yml", + "yaws-key.pem", + "mysql_config.ini", + "firewall.log", + "log4j.properties", + "serviceAccountCredentials.json", + "haproxy.cfg", + "service-account-credentials.json", + "vpn.log", + "system.log", + "webuser-auth.xml", + "fastcgi.conf", + "smb.conf", + "iis.log", + "pom.xml", + "openapi.json", + "vim_settings.xml", + "winscp.ini", + "ws_ftp.ini", +} + +# Sourced from AikidoSec/firewall-node : library/vulnerabilities/attack-wave-detection/paths/directoryNames.ts +directory_names = { + ".", + "..", + ".anydesk", + ".aptitude", + ".aws", + ".azure", + ".cache", + ".circleci", + ".config", + ".dbus", + ".docker", + ".drush", + ".gem", + ".git", + ".github", + ".gnupg", + ".gsutil", + ".hg", + ".idea", + ".java", + ".kube", + ".lftp", + ".minikube", + ".npm", + ".nvm", + ".pki", + ".snap", + ".ssh", + ".subversion", + ".svn", + ".tconn", + ".thunderbird", + ".tor", + ".vagrant.d", + ".vidalia", + ".vim", + ".vmware", + ".vscode", + "apache", + "apache2", + "grub", + "System32", + "tmp", + "xampp", + "cgi-bin", + "%systemroot%", +} diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings.py b/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings.py new file mode 100644 index 000000000..c94d9ebc2 --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings.py @@ -0,0 +1,42 @@ +from aikido_zen.context import Context +from aikido_zen.helpers.extract_strings_from_user_input import ( + extract_strings_from_user_input_cached, +) + +keywords = { + "SELECT (CASE WHEN", + "SELECT COUNT(", + "SLEEP(", + "WAITFOR DELAY", + "SELECT LIKE(CHAR(", + "INFORMATION_SCHEMA.COLUMNS", + "INFORMATION_SCHEMA.TABLES", + "MD5(", + "DBMS_PIPE.RECEIVE_MESSAGE", + "SYSIBM.SYSTABLES", + "RANDOMBLOB(", + "SELECT * FROM", + "1'='1", + "PG_SLEEP(", + "UNION ALL SELECT", + "../", +} + + +def query_params_contain_dangerous_strings(context: Context) -> bool: + """ + Check the query for some common SQL or path traversal patterns. + """ + if not context.query: + return False + + for s in extract_strings_from_user_input_cached(context.query, "query"): + # skipping strings that don't match the length, we chose to start with 5 since the + # smaller inputs like `../` and `MD5(` are usually followed with more data. + if len(s) < 5 or len(s) > 200: + continue + + for keyword in keywords: + if keyword.upper() in s.upper(): + return True + return False diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings_test.py b/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings_test.py new file mode 100644 index 000000000..621df85fa --- /dev/null +++ b/aikido_zen/vulnerabilities/attack_wave_detection/query_params_contain_dangerous_strings_test.py @@ -0,0 +1,70 @@ +import pytest +from .query_params_contain_dangerous_strings import ( + query_params_contain_dangerous_strings, +) + + +class Context: + def __init__(self, query=None, body=None): + self.remote_address = "::1" + self.method = "GET" + self.url = "http://localhost:4000/test" + self.query = query or { + "test": "", + "utmSource": "newsletter", + "utmMedium": "electronicmail", + "utmCampaign": "test", + "utmTerm": "sql_injection", + } + self.headers = {"content-type": "application/json"} + self.body = body or {} + self.cookies = {} + self.route_params = {} + self.source = "express" + self.route = "/test" + + +def get_test_context(query): + return Context( + query={ + "test": query, + **{ + "utmSource": "newsletter", + "utmMedium": "electronicmail", + "utmCampaign": "test", + "utmTerm": "sql_injection", + }, + } + ) + + +def test_detects_injection_patterns(): + test_strings = [ + "' or '1'='1", + "1: SELECT * FROM users WHERE '1'='1'", + "', information_schema.tables", + "1' sleep(5)", + "WAITFOR DELAY 1", + "../etc/passwd", + ] + for s in test_strings: + ctx = get_test_context(s) + assert query_params_contain_dangerous_strings( + ctx + ), f"Expected '{s}' to match patterns" + + +def test_does_not_detect(): + non_matching = ["google.de", "some-string", "1", ""] + for s in non_matching: + ctx = get_test_context(s) + assert not query_params_contain_dangerous_strings( + ctx + ), f"Expected '{s}' to NOT match patterns" + + +def test_handles_empty_query_object(): + ctx = Context(query={}) + assert not query_params_contain_dangerous_strings( + ctx + ), "Expected empty query to NOT match injection patterns" diff --git a/end2end/django_mysql_test.py b/end2end/django_mysql_test.py index 31f11aa3d..5562875c5 100644 --- a/end2end/django_mysql_test.py +++ b/end2end/django_mysql_test.py @@ -107,6 +107,7 @@ def test_initial_heartbeat(): { "aborted": 0, "attacksDetected": {"blocked": 2, "total": 2}, + "attackWaves": {"total": 0, "blocked": 0}, "total": 3, 'rateLimited': 0 },