Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/qa-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions aikido_zen/context/asgi/build_url_from_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
27 changes: 26 additions & 1 deletion aikido_zen/context/asgi/build_url_from_asgi_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions aikido_zen/context/asgi/init_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 :
Expand All @@ -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 :
Expand All @@ -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 :
Expand Down
6 changes: 3 additions & 3 deletions aikido_zen/context/init_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
4 changes: 4 additions & 0 deletions aikido_zen/context/wsgi/build_url_from_wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
23 changes: 22 additions & 1 deletion aikido_zen/context/wsgi/build_url_from_wsgi_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
51 changes: 51 additions & 0 deletions aikido_zen/storage/attack_wave_detector_store_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"""
Expand Down
27 changes: 27 additions & 0 deletions aikido_zen/storage/bypassed_context_store.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 37 additions & 0 deletions aikido_zen/storage/bypassed_context_store_test.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion aikido_zen/vulnerabilities/init_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading