Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a2d632e
Create general use asgi middleware
bitterpanda63 Dec 30, 2025
d708e59
Update quart.py to start using this internal middleware
bitterpanda63 Dec 30, 2025
44b28b0
asgi_middleware, write response
bitterpanda63 Dec 30, 2025
dbdd8f7
quart: remove code that otherwise would write the resposne
bitterpanda63 Dec 30, 2025
fc3c5fd
Fix asgi_middleware bugs
bitterpanda63 Dec 30, 2025
3e17be0
quart instead of __call__, use the asgi_app hook
bitterpanda63 Dec 30, 2025
fa05e8d
create a run_with_intercepts for asgi middleware
bitterpanda63 Dec 30, 2025
d86a170
Fix interceptor (add comments), and fix lint issue
bitterpanda63 Dec 30, 2025
706bde2
Fix asgi_middleware.py (use message as default name, explain)
bitterpanda63 Dec 30, 2025
bc6b241
quart.py : remove status code handler
bitterpanda63 Dec 30, 2025
c4d1a53
Linting fix: add extra line for quart.py
bitterpanda63 Dec 30, 2025
b02ecca
remove status_code from quart.py docstring
bitterpanda63 Dec 30, 2025
840b0c5
Rename InterceptorSkeletonClass to InterceptorSkeleton
bitterpanda63 Dec 30, 2025
8448c56
asgi_middleware: add support for legacy
bitterpanda63 Dec 30, 2025
a9818dc
quart: support both legacy and non-legacy
bitterpanda63 Dec 30, 2025
a33f58d
hypercorn_asgi
bitterpanda63 Dec 31, 2025
4959447
hypercorn asgi sample app (WIP)
bitterpanda63 Dec 31, 2025
5e3587a
add cli tool
bitterpanda63 Dec 31, 2025
7f97945
pythonpath try outs
bitterpanda63 Dec 31, 2025
8b80276
Revert "pythonpath try outs"
bitterpanda63 Dec 31, 2025
0e1097a
create django app (basics)
bitterpanda63 Jan 14, 2026
f6ea71f
add poetry and makefile basics
bitterpanda63 Jan 14, 2026
924d1fc
add a small readme and update the main readme
bitterpanda63 Jan 14, 2026
a0ad361
add aux files for django app & gunicorn
bitterpanda63 Jan 14, 2026
94dd5f6
switches to psycopg for async
bitterpanda63 Jan 14, 2026
47038d8
delete unuseful wsgi.py file
bitterpanda63 Jan 14, 2026
3d2899a
Cleanup a bunch of logs & files
bitterpanda63 Jan 14, 2026
cb5972d
cleanup settings.py
bitterpanda63 Jan 14, 2026
e306e78
cleanup sample-django-asgi-uvicorn-app/urls.py
bitterpanda63 Jan 14, 2026
c8292ec
django asgi sample app: full async
bitterpanda63 Jan 14, 2026
0b0d2de
Update comments in __init__.py imports
bitterpanda63 Jan 14, 2026
a2a42f1
Add gunicorn.py Worker.__init__ source, testing for now
bitterpanda63 Jan 14, 2026
e5362f3
patch uvicorn instead of gunicron
bitterpanda63 Jan 14, 2026
5acd6dc
Merge branch 'general-use-asgi-middleware' into add-django-asgi-support
bitterpanda63 Jan 14, 2026
9f2323d
add uvicorn to asgi middleware
bitterpanda63 Jan 14, 2026
0b51773
delete quart-postgres-hypercorn
bitterpanda63 Jan 14, 2026
96d5f5c
delete cli try outs
bitterpanda63 Jan 14, 2026
8e7db0d
Delete aikido_zen/sources/hypercorn_asgi.py
bitterpanda63 Jan 14, 2026
4226b9b
Apply suggestions from code review
bitterpanda63 Jan 14, 2026
2b1714a
Revert quart changes
bitterpanda63 Jan 14, 2026
f8a3ddc
Spacing fix
bitterpanda63 Jan 14, 2026
bbd61ae
Docs update
bitterpanda63 Jan 14, 2026
1a40cfa
Add a limited support
bitterpanda63 Jan 14, 2026
596709f
specify ASGI so people don't get confused
bitterpanda63 Jan 14, 2026
a5f1bf6
quart.py: Simplify by using the send_status_code_and_text from asgi f…
bitterpanda63 Jan 14, 2026
c0bdc78
Add test cases for asgi_middleware
bitterpanda63 Jan 15, 2026
4836a3c
add an e2e test case for django asgi uvicorn
bitterpanda63 Jan 15, 2026
af0b50c
psycopg: add async support
bitterpanda63 Jan 15, 2026
cde98e8
run_init_stage: Does not get ASGI contexts
bitterpanda63 Jan 15, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/end2end.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
- { name: django-mysql, testfile: end2end/django_mysql_test.py }
- { name: django-mysql-gunicorn, testfile: end2end/django_mysql_gunicorn_test.py }
- { name: django-postgres-gunicorn, testfile: end2end/django_postgres_gunicorn_test.py }
- { name: django-asgi-uvicorn, testfile: end2end/django_asgi_uvicorn_test.py }
- { name: flask-mongo, testfile: end2end/flask_mongo_test.py }
- { name: flask-mysql, testfile: end2end/flask_mysql_test.py }
- { name: flask-mysql-uwsgi, testfile: end2end/flask_mysql_uwsgi_test.py }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Zen for Python 3 is compatible with:
* βœ… [Quart](docs/quart.md)
* βœ… [Starlette](docs/starlette.md)
* βœ… [FastAPI](docs/fastapi.md)
* 🚧 [Django ASGI](docs/django_asgi.md) (*limited support*)

### Database drivers
* βœ… [`mysqlclient`](https://pypi.org/project/mysqlclient/) ^1.5
Expand Down
17 changes: 13 additions & 4 deletions aikido_zen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,21 @@ def protect(mode="daemon", token=""):
# pylint: disable=unused-import
import aikido_zen.sinks.builtins_import

# Import sources
# 1. Import sources
## 1a. Import frameworks
import aikido_zen.sources.django
import aikido_zen.sources.flask
import aikido_zen.sources.quart
import aikido_zen.sources.starlette

## 1b. Import servers
import aikido_zen.sources.servers.uvicorn

## 1c. Import xml sources
import aikido_zen.sources.xml_sources.xml
import aikido_zen.sources.xml_sources.lxml

# Import DB Sinks
# 2. Import database sinks
import aikido_zen.sinks.pymysql
import aikido_zen.sinks.mysqlclient
import aikido_zen.sinks.pymongo
Expand All @@ -73,19 +79,22 @@ def protect(mode="daemon", token=""):
import aikido_zen.sinks.asyncpg
import aikido_zen.sinks.clickhouse_driver

# 3. Import fs sinks
import aikido_zen.sinks.builtins
import aikido_zen.sinks.os
import aikido_zen.sinks.pathlib
import aikido_zen.sinks.shutil
import aikido_zen.sinks.io

# 4. Import sinks related to http requests
import aikido_zen.sinks.http_client
import aikido_zen.sinks.socket

# Import shell sinks
# 5. Import shell sinks
import aikido_zen.sinks.os_system
import aikido_zen.sinks.subprocess

# Import AI sinks
# 6. Import AI sinks
import aikido_zen.sinks.openai
import aikido_zen.sinks.anthropic
import aikido_zen.sinks.mistralai
Expand Down
2 changes: 1 addition & 1 deletion aikido_zen/context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
current_context = contextvars.ContextVar("current_context", default=None)

WSGI_SOURCES = ["django", "flask"]
ASGI_SOURCES = ["quart", "django_async", "starlette"]
ASGI_SOURCES = ["quart", "django_async", "starlette", "uvicorn"]


def get_current_context():
Expand Down
15 changes: 12 additions & 3 deletions aikido_zen/sinks/psycopg.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
@before
def _copy(func, instance, args, kwargs):
statement = get_argument(args, kwargs, 0, "statement")

op = "psycopg.Cursor.copy"
op = f"psycopg.{instance.__class__.__name__}.copy"
register_call(op, "sql_op")

vulns.run_vulnerability_scan(
Expand All @@ -23,7 +22,7 @@ def _copy(func, instance, args, kwargs):
@before
def _execute(func, instance, args, kwargs):
query = get_argument(args, kwargs, 0, "query")
op = f"psycopg.Cursor.{func.__name__}"
op = f"psycopg.{instance.__class__.__name__}.{func.__name__}"
vulns.run_vulnerability_scan(kind="sql_injection", op=op, args=(query, "postgres"))


Expand All @@ -38,3 +37,13 @@ def patch(m):
patch_function(m, "Cursor.copy", _copy)
patch_function(m, "Cursor.execute", _execute)
patch_function(m, "Cursor.executemany", _execute)


@on_import("psycopg.cursor_async", "psycopg", version_requirement="3.1.0")
def patch_async(m):
"""

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring in patch_async only restates that it patches psycopg.cursor_async. Replace with the rationale for the async-specific patch or remove the redundant comment.

Details

✨ AI Reasoning
​An added docstring for the newly introduced async patch function merely restates that it patches the psycopg.cursor_async module and that it's similar to the normal patch. This repeats what the code (registering AsyncCursor.copy/execute/executemany) already shows and doesn't explain why the separate async patch is needed or any design rationale. A 'why' comment or removal would be more valuable.

πŸ”§ How do I fix it?
Write comments that explain the purpose, reasoning, or business logic behind the code using words like 'because', 'so that', or 'in order to'.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

patching module psycopg.cursor_async (similar to normal patch)
"""
patch_function(m, "AsyncCursor.copy", _copy)
patch_function(m, "AsyncCursor.execute", _execute)
patch_function(m, "AsyncCursor.executemany", _execute)
6 changes: 1 addition & 5 deletions aikido_zen/sources/django/run_init_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ def run_init_stage(request):
# In a separate try-catch we set the context :
try:
context = None
if (
hasattr(request, "scope") and request.scope is not None
): # This request is an ASGI request
context = Context(req=request.scope, body=body, source="django_async")
elif hasattr(request, "META") and request.META is not None: # WSGI request
if hasattr(request, "META") and request.META is not None:
context = Context(req=request.META, body=body, source="django")
else:
return
Expand Down
23 changes: 0 additions & 23 deletions aikido_zen/sources/django/run_init_stage_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,6 @@
"CONTENT_TYPE": "application/json",
"REMOTE_ADDR": "198.51.100.23",
}
asgi_scope = {
"method": "PUT",
"headers": [
(b"COOKIE", b"a=b; c=d"),
(b"header1_test-2", b"testValue2198&"),
(b"USER-AGENT", b"testUserAgent"),
(b"USER-AGENT", b"testUserAgent2"),
],
"query_string": b"a=b&b=d",
"client": ["1.1.1.1"],
"server": ["192.168.0.1", 443],
"scheme": "https",
"root_path": "192.168.0.1",
"path": "192.168.0.1/a/b/c/d",
}


@pytest.fixture
Expand Down Expand Up @@ -195,11 +180,3 @@ def test_uses_wsgi(mock_request):
context: Context = get_current_context()
assert "/hello" == context.route


def test_uses_asgi_prio(mock_request):
mock_request.scope = asgi_scope
run_init_stage(mock_request)
# Assertions
context: Context = get_current_context()
assert "/a/b/c/d" == context.route
assert "testUserAgent2" == context.get_user_agent()
93 changes: 93 additions & 0 deletions aikido_zen/sources/functions/asgi_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import inspect
from aikido_zen.context import Context
from aikido_zen.helpers.get_argument import get_argument
from aikido_zen.sinks import before_async, patch_function
from aikido_zen.sources.functions.request_handler import request_handler, post_response
from aikido_zen.thread.thread_cache import get_cache


class InternalASGIMiddleware:
def __init__(self, app, source: str):
self.client_app = app
self.source = source

async def __call__(self, scope, receive, send):
if not scope or scope.get("type") != "http":
# Zen checks requests coming into HTTP(S) server, ignore other requests (like ws)
return await self.continue_app(scope, receive, send)

context = Context(req=scope, source=self.source)

process_cache = get_cache()
if process_cache and process_cache.is_bypassed_ip(context.remote_address):
# IP address is bypassed, for simplicity we do not set a context,
# and we do not do any further handling of the request.
return await self.continue_app(scope, receive, send)

context.set_as_current_context()
if process_cache:
# Since this SHOULD be the highest level of the apps we wrap, this is the safest place
# to increment total hits.
process_cache.stats.increment_total_hits()

intercept_response = request_handler(stage="pre_response")
if intercept_response:
# The request has already been blocked (e.g. IP is on blocklist)
return await send_status_code_and_text(send, intercept_response)

return await self.run_with_intercepts(scope, receive, send)

async def run_with_intercepts(self, scope, receive, send):
# We use a skeleton class so we can use patch_function (and the logic already defined in @before_async)
class InterceptorSkeleton:
@staticmethod
async def send(*args, **kwargs):
return await send(*args, **kwargs)

patch_function(InterceptorSkeleton, "send", send_interceptor)

return await self.continue_app(scope, receive, InterceptorSkeleton.send)

async def continue_app(self, scope, receive, send):
client_app_parameters = len(inspect.signature(self.client_app).parameters)
if client_app_parameters == 2:
# This is possible if the app is still using ASGI v2.0
# See https://asgi.readthedocs.io/en/latest/specs/main.html#legacy-applications
# client_app = coroutine application_instance(receive, send)
await self.client_app(receive, send)
else:
# client_app = coroutine application(scope, receive, send)
await self.client_app(scope, receive, send)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for return here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdym, returning the value? not done for ASGI apps



async def send_status_code_and_text(send, pre_response):
await send(
{
"type": "http.response.start",
"status": pre_response[1],
"headers": [(b"content-type", b"text/plain")],
}
)
await send(
{
"type": "http.response.body",
"body": pre_response[0].encode("utf-8"),
"more_body": False,
}
)


@before_async
async def send_interceptor(func, instance, args, kwargs):
# There is no name for the send() comment in the standard, it really depends (quart uses message)
event = get_argument(args, kwargs, 0, name="message")

# https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event
if not event or "http.response.start" not in event.get("type", ""):
# If the event is not of type http.response.start it won't contain the status code.
# And this event is required before sending over a body (so even 200 status codes are intercepted).
return

if "status" in event:
# Handle post response logic (attack waves, route reporting, ...)
post_response(status_code=int(event.get("status")))
Loading
Loading