diff --git a/.github/workflows/qa-tests.yml b/.github/workflows/qa-tests.yml index 075a75b54..290624adc 100644 --- a/.github/workflows/qa-tests.yml +++ b/.github/workflows/qa-tests.yml @@ -52,4 +52,4 @@ jobs: app_port: 8080 sleep_before_test: 30 config_update_delay: 100 - skip_tests: test_bypassed_ip_for_geo_blocking,test_demo_apps_generic_tests,test_path_traversal,test_outbound_domain_blocking,test_bypassed_ip,test_wave_attack,test_block_traffic_by_countries,test_user_rate_limiting_1_minute + skip_tests: test_bypassed_ip_for_geo_blocking,test_demo_apps_generic_tests,test_outbound_domain_blocking,test_bypassed_ip,test_block_traffic_by_countries,test_user_rate_limiting_1_minute diff --git a/aikido_zen/context/asgi/build_url_from_asgi.py b/aikido_zen/context/asgi/build_url_from_asgi.py index 1ae6a32f7..80dad7d72 100644 --- a/aikido_zen/context/asgi/build_url_from_asgi.py +++ b/aikido_zen/context/asgi/build_url_from_asgi.py @@ -14,4 +14,9 @@ def build_url_from_asgi(scope): root_path = scope.get("root_path", "") path = scope.get("path", "") uri = path.replace(root_path, "", 1) + + query_string = scope.get("query_string") or b"" + if query_string: + uri = f"{uri}?{query_string.decode('utf-8', errors='replace')}" + return f"{scheme}://{host}{uri}" diff --git a/aikido_zen/context/asgi/build_url_from_asgi_test.py b/aikido_zen/context/asgi/build_url_from_asgi_test.py index 47cd6ce68..ed6cfadf8 100644 --- a/aikido_zen/context/asgi/build_url_from_asgi_test.py +++ b/aikido_zen/context/asgi/build_url_from_asgi_test.py @@ -117,7 +117,32 @@ def test_build_url_from_asgi_with_query_string(): "scheme": "http", "server": ("localhost", 8000), "root_path": "", - "path": "/api/v1/resource?query=1", + "path": "/api/v1/resource", + "query_string": b"query=1", } expected = "http://localhost:8000/api/v1/resource?query=1" assert build_url_from_asgi(scope) == expected + + +def test_build_url_from_asgi_with_empty_query_string(): + scope = { + "scheme": "http", + "server": ("localhost", 8000), + "root_path": "", + "path": "/api/v1/resource", + "query_string": b"", + } + expected = "http://localhost:8000/api/v1/resource" + assert build_url_from_asgi(scope) == expected + + +def test_build_url_from_asgi_path_traversal_query(): + scope = { + "scheme": "http", + "server": ("localhost", 3018), + "root_path": "", + "path": "/api/read", + "query_string": b"path=../secrets/key.txt", + } + expected = "http://localhost:3018/api/read?path=../secrets/key.txt" + assert build_url_from_asgi(scope) == expected diff --git a/aikido_zen/context/asgi/init_test.py b/aikido_zen/context/asgi/init_test.py index ad05d6fe7..118c4f1aa 100644 --- a/aikido_zen/context/asgi/init_test.py +++ b/aikido_zen/context/asgi/init_test.py @@ -36,7 +36,7 @@ def test_asgi_scope_1(): "HEADER1_TEST_2": ["testValue2198&"], } assert context1.cookies == {"a": "b", "c": "d"} - assert context1.url == "https://192.168.0.1:443/a/b/c/d" + assert context1.url == "https://192.168.0.1:443/a/b/c/d?a=b&b=d" # Scope 2 : @@ -63,7 +63,7 @@ def test_asgi_scope_2(): "HEADER2_TEST_1": ["anotherValue"], } assert context2.cookies == {"x": "y", "z": "w"} - assert context2.url == "http://192.168.0.2:80/path/to/resource" + assert context2.url == "http://192.168.0.2:80/path/to/resource?x=y&z=w" # Scope 3 : @@ -90,7 +90,7 @@ def test_asgi_scope_3(): "HEADER3_TEST_3": ["postValue"], } assert context3.cookies == {"session": "abc123"} - assert context3.url == "http://192.168.0.3:8080/v1/resource" + assert context3.url == "http://192.168.0.3:8080/v1/resource?key1=value1&key2=value2" # Scope 4 : diff --git a/aikido_zen/context/init_test.py b/aikido_zen/context/init_test.py index dff7e3632..156f9c93d 100644 --- a/aikido_zen/context/init_test.py +++ b/aikido_zen/context/init_test.py @@ -59,7 +59,7 @@ def test_wsgi_context_1(): "CONTENT_TYPE": ["application/x-www-form-urlencoded"], }, "cookies": {"sessionId": "abc123xyz456"}, - "url": "https://example.com/hello", + "url": "https://example.com/hello?user=JohnDoe&age=30&age=35", "query": {"user": ["JohnDoe"], "age": ["30", "35"]}, "body": 123, "route": "/hello", @@ -91,7 +91,7 @@ def test_wsgi_context_2(): "USER_AGENT": ["Mozilla/5.0"], }, "cookies": {"sessionId": "abc123xyz456"}, - "url": "http://localhost:8080/hello", + "url": "http://localhost:8080/hello?user=JohnDoe&age=30&age=35", "query": {"user": ["JohnDoe"], "age": ["30", "35"]}, "body": {"test": True}, "route": "/hello", @@ -130,7 +130,7 @@ def test_context_is_picklable(mocker): assert unpickled_obj.source == "flask" assert unpickled_obj.method == "GET" assert unpickled_obj.remote_address == "198.51.100.23" - assert unpickled_obj.url == "http://localhost:8080/hello" + assert unpickled_obj.url == "http://localhost:8080/hello?user=JohnDoe&age=30&age=35" assert unpickled_obj.body == 123 assert unpickled_obj.headers == { "HEADER_1": ["header 1 value"], diff --git a/aikido_zen/context/wsgi/build_url_from_wsgi.py b/aikido_zen/context/wsgi/build_url_from_wsgi.py index 68a67ab6b..2cba9525a 100644 --- a/aikido_zen/context/wsgi/build_url_from_wsgi.py +++ b/aikido_zen/context/wsgi/build_url_from_wsgi.py @@ -15,4 +15,8 @@ def build_url_from_wsgi(request): else: host = request["HTTP_HOST"] + query_string = request.get("QUERY_STRING", "") + if query_string: + uri = f"{uri}?{query_string}" + return f"{scheme}://{host}{uri}" diff --git a/aikido_zen/context/wsgi/build_url_from_wsgi_test.py b/aikido_zen/context/wsgi/build_url_from_wsgi_test.py index 5b2f6cb65..7330554a7 100644 --- a/aikido_zen/context/wsgi/build_url_from_wsgi_test.py +++ b/aikido_zen/context/wsgi/build_url_from_wsgi_test.py @@ -52,11 +52,32 @@ def test_build_url_from_wsgi_with_query_string(): "PATH_INFO": "/search", "QUERY_STRING": "q=test", } - # Note: The function does not currently handle query strings, so we won't include it in the expected output + expected = "http://example.com/search?q=test" + assert build_url_from_wsgi(request) == expected + + +def test_build_url_from_wsgi_with_empty_query_string(): + request = { + "wsgi.url_scheme": "http", + "HTTP_HOST": "example.com", + "PATH_INFO": "/search", + "QUERY_STRING": "", + } expected = "http://example.com/search" assert build_url_from_wsgi(request) == expected +def test_build_url_from_wsgi_path_traversal_query(): + request = { + "wsgi.url_scheme": "http", + "HTTP_HOST": "localhost:3018", + "PATH_INFO": "/api/read", + "QUERY_STRING": "path=../secrets/key.txt", + } + expected = "http://localhost:3018/api/read?path=../secrets/key.txt" + assert build_url_from_wsgi(request) == expected + + def test_build_url_from_wsgi_root_path(): request = {"wsgi.url_scheme": "http", "HTTP_HOST": "example.com", "PATH_INFO": "/"} expected = "http://example.com/" diff --git a/aikido_zen/storage/attack_wave_detector_store_test.py b/aikido_zen/storage/attack_wave_detector_store_test.py index 095f3962e..4d1c322d4 100644 --- a/aikido_zen/storage/attack_wave_detector_store_test.py +++ b/aikido_zen/storage/attack_wave_detector_store_test.py @@ -10,9 +10,17 @@ AttackWaveDetectorStore, attack_wave_detector_store, ) +from . import bypassed_context_store import aikido_zen.test_utils as test_utils +@pytest.fixture(autouse=True) +def _clear_bypass_flag(): + bypassed_context_store.clear() + yield + bypassed_context_store.clear() + + def test_attack_wave_detector_store_initialization(): """Test that the store initializes correctly""" store = AttackWaveDetectorStore() @@ -436,6 +444,49 @@ def create_context_with_url(ip, url, method="GET"): assert set(sample.keys()) == {"method", "url"} +def test_samples_collect_one_per_unique_url(): + """16 requests with distinct query strings should produce 16 unique samples.""" + store = AttackWaveDetectorStore() + ip = "2.16.53.8" + + with patch( + "aikido_zen.vulnerabilities.attack_wave_detection.attack_wave_detector.is_web_scanner", + return_value=True, + ): + for i in range(16): + ctx = test_utils.generate_context( + ip=ip, + method="GET", + url=f"http://localhost:3018/api/pets/?path=q{i}", + ) + store.is_attack_wave(ctx) + + samples = store.get_samples_for_ip(ip) + assert len(samples) == 15, f"expected 15 samples, got {len(samples)}" + + +def test_bypassed_request_skips_wave_detection(): + """When the bypass flag is set, is_attack_wave never reports a wave.""" + store = AttackWaveDetectorStore() + ip = "2.16.53.10" + + with patch( + "aikido_zen.vulnerabilities.attack_wave_detection.attack_wave_detector.is_web_scanner", + return_value=True, + ): + bypassed_context_store.set_bypassed(True) + for i in range(16): + ctx = test_utils.generate_context( + ip=ip, + method="GET", + url=f"http://localhost:3018/api/execute/.env{i}", + ) + assert store.is_attack_wave(ctx) is False + + # Bypassed requests should not collect samples either. + assert store.get_samples_for_ip(ip) == [] + + @patch("aikido_zen.storage.attack_wave_detector_store.AttackWaveDetector") def test_mock_detector_integration(mock_detector_class): """Test integration with mocked AttackWaveDetector""" diff --git a/aikido_zen/storage/bypassed_context_store.py b/aikido_zen/storage/bypassed_context_store.py new file mode 100644 index 000000000..40ddacbf3 --- /dev/null +++ b/aikido_zen/storage/bypassed_context_store.py @@ -0,0 +1,27 @@ +""" +Records whether the current request's remote IP is in the bypass list. + +Bypassed requests intentionally do not set a Context (so all per-request +protection short-circuits), but checks that run without a Context — for +example outbound DNS reporting — still need a way to detect "this work +was triggered by a bypassed request". +""" + +import contextvars + +_bypassed = contextvars.ContextVar("aikido_bypassed_ip", default=False) + + +def set_bypassed(value: bool) -> None: + _bypassed.set(bool(value)) + + +def is_bypassed() -> bool: + try: + return _bypassed.get() + except Exception: + return False + + +def clear() -> None: + _bypassed.set(False) diff --git a/aikido_zen/storage/bypassed_context_store_test.py b/aikido_zen/storage/bypassed_context_store_test.py new file mode 100644 index 000000000..b041dd4fb --- /dev/null +++ b/aikido_zen/storage/bypassed_context_store_test.py @@ -0,0 +1,37 @@ +import pytest + +from aikido_zen.storage import bypassed_context_store + + +@pytest.fixture(autouse=True) +def _reset_after_each_test(): + yield + bypassed_context_store.clear() + + +def test_default_is_false(): + assert bypassed_context_store.is_bypassed() is False + + +def test_set_bypassed_true_then_false(): + bypassed_context_store.set_bypassed(True) + assert bypassed_context_store.is_bypassed() is True + + bypassed_context_store.set_bypassed(False) + assert bypassed_context_store.is_bypassed() is False + + +def test_clear_resets_to_false(): + bypassed_context_store.set_bypassed(True) + assert bypassed_context_store.is_bypassed() is True + + bypassed_context_store.clear() + assert bypassed_context_store.is_bypassed() is False + + +def test_truthy_values_coerced_to_bool(): + bypassed_context_store.set_bypassed("yes") + assert bypassed_context_store.is_bypassed() is True + + bypassed_context_store.set_bypassed(0) + assert bypassed_context_store.is_bypassed() is False diff --git a/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py index 5897c6cb9..bfd9f2888 100644 --- a/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py +++ b/aikido_zen/vulnerabilities/attack_wave_detection/attack_wave_detector.py @@ -1,6 +1,7 @@ import aikido_zen.helpers.get_current_unixtime_ms as internal_time from aikido_zen.ratelimiting.lru_cache import LRUCache from aikido_zen.context import Context +from aikido_zen.storage import bypassed_context_store from aikido_zen.vulnerabilities.attack_wave_detection.is_web_scanner import ( is_web_scanner, ) @@ -40,6 +41,11 @@ def is_attack_wave(self, context: Context) -> bool: """ if not context or not context.remote_address: return False + if bypassed_context_store.is_bypassed(): + # Defensive: callers should normally never reach here for bypassed + # IPs (the framework entry point clears the context), but keep wave + # detection consistent with all other per-request blocking sites. + return False ip = context.remote_address if self.sent_events_map.get(ip) is not None: diff --git a/aikido_zen/vulnerabilities/init_test.py b/aikido_zen/vulnerabilities/init_test.py index 54e10e89f..36cef6617 100644 --- a/aikido_zen/vulnerabilities/init_test.py +++ b/aikido_zen/vulnerabilities/init_test.py @@ -150,7 +150,7 @@ def test_sql_injection_with_comms(caplog, get_context, monkeypatch): "method": "GET", "route": "/hello", "source": "flask", - "url": "http://localhost:8080/hello", + "url": "http://localhost:8080/hello?user=JohnDoe&age=30&age=35", "userAgent": None, } del call_args[1].event["attack"]["stack"] # Hard to test