From 0c752e880bb58ba9e3bfe6bc0faab7608c1b271f Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 28 Aug 2026 17:27:03 +0200 Subject: [PATCH 1/2] fix(appsec): normalize DBAPI queries for SQLi RASP --- ddtrace/contrib/_events/dbapi.py | 3 +- ddtrace/contrib/dbapi.py | 35 ++++++--- ddtrace/contrib/dbapi_async.py | 36 +++++++-- ddtrace/contrib/internal/aiomysql/patch.py | 25 ++++--- ddtrace/contrib/internal/aiopg/connection.py | 34 ++++++--- ddtrace/contrib/internal/aiopg/patch.py | 1 + ddtrace/contrib/internal/django/database.py | 6 +- .../contrib/internal/psycopg/connection.py | 6 +- ddtrace/contrib/internal/psycopg/cursor.py | 54 +++++++++----- ddtrace/contrib/internal/psycopg/patch.py | 2 + ddtrace/contrib/internal/vertica/patch.py | 2 +- ...-query-normalization-cdc548988f47c744.yaml | 5 ++ .../appsec/appsec/test_exploit_prevention.py | 23 ++++++ tests/contrib/aiomysql/test_aiomysql.py | 24 ++++-- tests/contrib/aiopg/test.py | 25 +++++-- tests/contrib/dbapi/test_dbapi.py | 39 ++++++++-- tests/contrib/dbapi_async/test_dbapi_async.py | 28 ++++--- tests/contrib/psycopg/test_psycopg.py | 74 +++++++++++++++++++ tests/contrib/psycopg/test_psycopg_async.py | 24 ++++++ tests/contrib/psycopg2/test_psycopg.py | 23 ++++-- tests/contrib/vertica/test_vertica.py | 15 ++-- 21 files changed, 381 insertions(+), 103 deletions(-) create mode 100644 releasenotes/notes/asm-dbapi-query-normalization-cdc548988f47c744.yaml diff --git a/ddtrace/contrib/_events/dbapi.py b/ddtrace/contrib/_events/dbapi.py index 207d57f115e..631e402362c 100644 --- a/ddtrace/contrib/_events/dbapi.py +++ b/ddtrace/contrib/_events/dbapi.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Union from ddtrace.internal.core.events import Event @@ -9,5 +10,5 @@ class DbApiEvent(Event): event_name = "dbapi.query" - query: str + query: Union[str, bytes] span_name_prefix: str diff --git a/ddtrace/contrib/dbapi.py b/ddtrace/contrib/dbapi.py index d240562010d..92d42b72636 100644 --- a/ddtrace/contrib/dbapi.py +++ b/ddtrace/contrib/dbapi.py @@ -4,6 +4,7 @@ from typing import Mapping from typing import Optional +from typing import Union import wrapt @@ -81,7 +82,7 @@ def __init__( ) self._self_datadog_name = span_name self._self_dbapi_span_name_prefix = span_name_prefix - self._self_last_execute_operation = None + self._self_last_execute_operation: object = None self._self_config = cfg or config.dbapi2 self._self_dbm_propagator = getattr(self._self_config, "_dbm_propagator", None) self._self_db_tags = dict(db_tags) if db_tags else {} @@ -92,6 +93,26 @@ def __iter__(self): def __next__(self): return self.__wrapped__.__next__() + def _normalize_dbapi_query(self, query: object) -> Optional[Union[str, bytes]]: + if isinstance(query, (str, bytes)): + return query + return None + + def _prepare_dbapi_query(self, query: object) -> object: + has_listeners = core.has_listeners(DbApiEvent.event_name) + normalized_query: Optional[Union[str, bytes]] = None + if isinstance(query, (str, bytes)) or is_tracing_enabled() or has_listeners: + try: + normalized_query = self._normalize_dbapi_query(query) + except Exception: + log.debug("Failed to normalize database query", exc_info=True) + + resource = normalized_query if normalized_query is not None else query + self._self_last_execute_operation = resource + if has_listeners and normalized_query is not None: + core.dispatch_event(DbApiEvent(query=normalized_query, span_name_prefix=self._self_dbapi_span_name_prefix)) + return resource + def _trace_method(self, method, name, resource, extra_tags, dbm_propagator, *args, **kwargs): """ Internal function to trace the call to the underlying cursor method @@ -142,9 +163,7 @@ def _trace_method(self, method, name, resource, extra_tags, dbm_propagator, *arg def executemany(self, query, *args, **kwargs): """Wraps the cursor.executemany method""" - self._self_last_execute_operation = query - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix=self._self_dbapi_span_name_prefix)) + resource = self._prepare_dbapi_query(query) # Always return the result as-is # DEV: Some libraries return `None`, others `int`, and others the cursor objects # These differences should be overridden at the integration specific layer (e.g. in `sqlite3/patch.py`) @@ -153,7 +172,7 @@ def executemany(self, query, *args, **kwargs): return self._trace_method( self.__wrapped__.executemany, self._self_datadog_name, - query, + resource, {"sql.executemany": "true"}, self._self_dbm_propagator, query, @@ -163,9 +182,7 @@ def executemany(self, query, *args, **kwargs): def execute(self, query, *args, **kwargs): """Wraps the cursor.execute method""" - self._self_last_execute_operation = query - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix=self._self_dbapi_span_name_prefix)) + resource = self._prepare_dbapi_query(query) # Always return the result as-is # DEV: Some libraries return `None`, others `int`, and others the cursor objects @@ -173,7 +190,7 @@ def execute(self, query, *args, **kwargs): return self._trace_method( self.__wrapped__.execute, self._self_datadog_name, - query, + resource, {}, self._self_dbm_propagator, query, diff --git a/ddtrace/contrib/dbapi_async.py b/ddtrace/contrib/dbapi_async.py index 9dfc541fb5f..9c21d39f330 100644 --- a/ddtrace/contrib/dbapi_async.py +++ b/ddtrace/contrib/dbapi_async.py @@ -1,6 +1,9 @@ import inspect +from typing import Optional +from typing import Union from ddtrace import config +from ddtrace._trace.pin import Pin from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.internal import core from ddtrace.internal.constants import COMPONENT @@ -29,6 +32,27 @@ def get_version(): class TracedAsyncCursor(TracedCursor): + def _prepare_dbapi_query(self, query: object) -> object: + has_listeners = core.has_listeners(DbApiEvent.event_name) + normalized_query: Optional[Union[str, bytes]] = None + if isinstance(query, (str, bytes)) or has_listeners: + should_normalize = True + else: + pin = Pin.get_from(self) + should_normalize = pin.enabled() if pin is not None else is_tracing_enabled() + + if should_normalize: + try: + normalized_query = self._normalize_dbapi_query(query) + except Exception: + log.debug("Failed to normalize database query", exc_info=True) + + resource = normalized_query if normalized_query is not None else query + self._self_last_execute_operation = resource + if has_listeners and normalized_query is not None: + core.dispatch_event(DbApiEvent(query=normalized_query, span_name_prefix=self._self_dbapi_span_name_prefix)) + return resource + async def __aenter__(self): # previous versions of the dbapi didn't support context managers. let's # reference the func that would be called to ensure that error @@ -97,9 +121,7 @@ async def _trace_method(self, method, name, resource, extra_tags, dbm_propagator async def executemany(self, query, *args, **kwargs): """Wraps the cursor.executemany method""" - self._self_last_execute_operation = query - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix=self._self_dbapi_span_name_prefix)) + resource = self._prepare_dbapi_query(query) # Always return the result as-is # DEV: Some libraries return `None`, others `int`, and others the cursor objects # These differences should be overridden at the integration specific layer (e.g. in `sqlite3/patch.py`) @@ -108,7 +130,7 @@ async def executemany(self, query, *args, **kwargs): return await self._trace_method( self.__wrapped__.executemany, self._self_datadog_name, - query, + resource, {"sql.executemany": "true"}, self._self_dbm_propagator, query, @@ -118,9 +140,7 @@ async def executemany(self, query, *args, **kwargs): async def execute(self, query, *args, **kwargs): """Wraps the cursor.execute method""" - self._self_last_execute_operation = query - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix=self._self_dbapi_span_name_prefix)) + resource = self._prepare_dbapi_query(query) # Always return the result as-is # DEV: Some libraries return `None`, others `int`, and others the cursor objects @@ -128,7 +148,7 @@ async def execute(self, query, *args, **kwargs): return await self._trace_method( self.__wrapped__.execute, self._self_datadog_name, - query, + resource, {}, self._self_dbm_propagator, query, diff --git a/ddtrace/contrib/internal/aiomysql/patch.py b/ddtrace/contrib/internal/aiomysql/patch.py index 0d86a508892..70be5b24fdb 100644 --- a/ddtrace/contrib/internal/aiomysql/patch.py +++ b/ddtrace/contrib/internal/aiomysql/patch.py @@ -1,3 +1,6 @@ +from typing import Optional +from typing import Union + import aiomysql import wrapt @@ -6,8 +9,8 @@ from ddtrace.constants import _SPAN_MEASURED_KEY from ddtrace.constants import SPAN_KIND from ddtrace.contrib import dbapi +from ddtrace.contrib import dbapi_async from ddtrace.contrib import trace_utils -from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.contrib.internal.trace_utils import _convert_to_string from ddtrace.contrib.internal.trace_utils import set_service_and_source from ddtrace.ext import SpanKind @@ -27,6 +30,7 @@ "aiomysql", dict( _default_service=schematize_service_name("mysql"), + _dbapi_span_name_prefix="mysql", _dbm_propagator=_DBM_Propagator(0, "query"), ), ) @@ -62,14 +66,19 @@ async def patched_connect(connect_func, _, args, kwargs): return c -class AIOTracedCursor(wrapt.ObjectProxy): +class AIOTracedCursor(dbapi_async.TracedAsyncCursor): """TracedCursor wraps a aiomysql cursor and traces its queries.""" def __init__(self, cursor, pin): - super(AIOTracedCursor, self).__init__(cursor) + super(AIOTracedCursor, self).__init__(cursor, cfg=config.aiomysql) pin.onto(self) self._self_datadog_name = schematize_database_operation("mysql.query", database_provider="mysql") + def _normalize_dbapi_query(self, query: object) -> Optional[Union[str, bytes]]: + if isinstance(query, (str, bytes)): + return query + return None + async def _trace_method(self, method, resource, extra_tags, *args, **kwargs): pin = Pin.get_from(self) if not pin or not pin.enabled(): @@ -107,17 +116,15 @@ async def _trace_method(self, method, resource, extra_tags, *args, **kwargs): s._set_attribute("db.rownumber", self.rownumber) async def executemany(self, query, *args, **kwargs): - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix="mysql")) + resource = self._prepare_dbapi_query(query) result = await self._trace_method( - self.__wrapped__.executemany, query, {"sql.executemany": "true"}, query, *args, **kwargs + self.__wrapped__.executemany, resource, {"sql.executemany": "true"}, query, *args, **kwargs ) return result async def execute(self, query, *args, **kwargs): - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix="mysql")) - result = await self._trace_method(self.__wrapped__.execute, query, {}, query, *args, **kwargs) + resource = self._prepare_dbapi_query(query) + result = await self._trace_method(self.__wrapped__.execute, resource, {}, query, *args, **kwargs) return result # Explicitly define `__aenter__` and `__aexit__` since they do not get proxied properly diff --git a/ddtrace/contrib/internal/aiopg/connection.py b/ddtrace/contrib/internal/aiopg/connection.py index 9cb2e99ca28..e5d885fd5b5 100644 --- a/ddtrace/contrib/internal/aiopg/connection.py +++ b/ddtrace/contrib/internal/aiopg/connection.py @@ -1,3 +1,6 @@ +from typing import Optional +from typing import Union + from aiopg import __version__ from aiopg.utils import _ContextManager import wrapt @@ -7,13 +10,12 @@ from ddtrace.constants import _SPAN_MEASURED_KEY from ddtrace.constants import SPAN_KIND from ddtrace.contrib import dbapi +from ddtrace.contrib import dbapi_async from ddtrace.contrib import trace_utils -from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.contrib.internal.trace_utils import set_service_and_source from ddtrace.ext import SpanKind from ddtrace.ext import SpanTypes from ddtrace.ext import db -from ddtrace.internal import core from ddtrace.internal.constants import COMPONENT from ddtrace.internal.schema import schematize_database_operation from ddtrace.internal.schema import schematize_service_name @@ -24,13 +26,23 @@ AIOPG_VERSION = parse_version(__version__) -class AIOTracedCursor(wrapt.ObjectProxy): +class AIOTracedCursor(dbapi_async.TracedAsyncCursor): """TracedCursor wraps a psql cursor and traces its queries.""" def __init__(self, cursor, pin): - super(AIOTracedCursor, self).__init__(cursor) + super(AIOTracedCursor, self).__init__(cursor, cfg=config.aiopg) pin.onto(self) - self._datadog_name = schematize_database_operation("postgres.query", database_provider="postgresql") + self._self_datadog_name = schematize_database_operation("postgres.query", database_provider="postgresql") + + def _normalize_dbapi_query(self, query: object) -> Optional[Union[str, bytes]]: + if isinstance(query, (str, bytes)): + return query + renderer = getattr(query, "as_string", None) + if callable(renderer): + rendered_query = renderer(self.__wrapped__) + if isinstance(rendered_query, str): + return rendered_query + return None async def _trace_method(self, method, resource, extra_tags, *args, **kwargs): pin = Pin.get_from(self) @@ -39,7 +51,7 @@ async def _trace_method(self, method, resource, extra_tags, *args, **kwargs): return result with tracer.trace( - self._datadog_name, + self._self_datadog_name, resource=resource, span_type=SpanTypes.SQL, ) as s: @@ -63,17 +75,15 @@ async def _trace_method(self, method, resource, extra_tags, *args, **kwargs): async def executemany(self, query, *args, **kwargs): # FIXME[matt] properly handle kwargs here. arg names can be different # with different libs. - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix="postgres")) + resource = self._prepare_dbapi_query(query) result = await self._trace_method( - self.__wrapped__.executemany, query, {"sql.executemany": "true"}, query, *args, **kwargs + self.__wrapped__.executemany, resource, {"sql.executemany": "true"}, query, *args, **kwargs ) return result async def execute(self, query, *args, **kwargs): - if isinstance(query, str): - core.dispatch_event(DbApiEvent(query=query, span_name_prefix="postgres")) - result = await self._trace_method(self.__wrapped__.execute, query, {}, query, *args, **kwargs) + resource = self._prepare_dbapi_query(query) + result = await self._trace_method(self.__wrapped__.execute, resource, {}, query, *args, **kwargs) return result async def callproc(self, proc, args): diff --git a/ddtrace/contrib/internal/aiopg/patch.py b/ddtrace/contrib/internal/aiopg/patch.py index 9563e77efd7..590102a5873 100644 --- a/ddtrace/contrib/internal/aiopg/patch.py +++ b/ddtrace/contrib/internal/aiopg/patch.py @@ -18,6 +18,7 @@ "aiopg", dict( _default_service=schematize_service_name("postgres"), + _dbapi_span_name_prefix="postgres", ), ) diff --git a/ddtrace/contrib/internal/django/database.py b/ddtrace/contrib/internal/django/database.py index 6913b61858f..7f2690061fc 100644 --- a/ddtrace/contrib/internal/django/database.py +++ b/ddtrace/contrib/internal/django/database.py @@ -42,11 +42,11 @@ def get_traced_cursor_cls(cursor_type: type[Any]) -> type[dbapi.TracedCursor]: traced_cursor_cls = dbapi.TracedCursor try: - if cursor_type.__module__.startswith("psycopg2.") or cursor_type.__name__ == "Psycopg2TracedCursor": + if cursor_type.__module__.startswith("psycopg2.") or cursor_type.__name__ == "PsycopgTracedCursor": # Import lazily to avoid importing psycopg if not already imported. - from ddtrace.contrib.internal.psycopg.cursor import Psycopg2TracedCursor + from ddtrace.contrib.internal.psycopg.cursor import PsycopgTracedCursor - traced_cursor_cls = Psycopg2TracedCursor + traced_cursor_cls = PsycopgTracedCursor elif cursor_type.__module__.startswith("psycopg.") or cursor_type.__name__ == "Psycopg3TracedCursor": # Import lazily to avoid importing psycopg if not already imported. from ddtrace.contrib.internal.psycopg.cursor import Psycopg3TracedCursor diff --git a/ddtrace/contrib/internal/psycopg/connection.py b/ddtrace/contrib/internal/psycopg/connection.py index af3948eecb1..27b34390765 100644 --- a/ddtrace/contrib/internal/psycopg/connection.py +++ b/ddtrace/contrib/internal/psycopg/connection.py @@ -2,10 +2,10 @@ from ddtrace._trace.pin import Pin from ddtrace.constants import SPAN_KIND from ddtrace.contrib import dbapi -from ddtrace.contrib.internal.psycopg.cursor import Psycopg2FetchTracedCursor -from ddtrace.contrib.internal.psycopg.cursor import Psycopg2TracedCursor from ddtrace.contrib.internal.psycopg.cursor import Psycopg3FetchTracedCursor from ddtrace.contrib.internal.psycopg.cursor import Psycopg3TracedCursor +from ddtrace.contrib.internal.psycopg.cursor import PsycopgFetchTracedCursor +from ddtrace.contrib.internal.psycopg.cursor import PsycopgTracedCursor from ddtrace.contrib.internal.psycopg.extensions import _patch_extensions from ddtrace.contrib.internal.trace_utils import ext_service from ddtrace.ext import SpanKind @@ -46,7 +46,7 @@ class Psycopg2TracedConnection(dbapi.TracedConnection): def __init__(self, conn, cursor_cls=None, db_tags=None): if not cursor_cls: # Do not trace `fetch*` methods by default - cursor_cls = Psycopg2FetchTracedCursor if config.psycopg.trace_fetch_methods else Psycopg2TracedCursor + cursor_cls = PsycopgFetchTracedCursor if config.psycopg.trace_fetch_methods else PsycopgTracedCursor super(Psycopg2TracedConnection, self).__init__(conn, cfg=config.psycopg, cursor_cls=cursor_cls, db_tags=db_tags) diff --git a/ddtrace/contrib/internal/psycopg/cursor.py b/ddtrace/contrib/internal/psycopg/cursor.py index db87c061b8e..75ddfcd0423 100644 --- a/ddtrace/contrib/internal/psycopg/cursor.py +++ b/ddtrace/contrib/internal/psycopg/cursor.py @@ -1,28 +1,48 @@ +from typing import Optional +from typing import Union + from ddtrace.contrib import dbapi -class Psycopg3TracedCursor(dbapi.TracedCursor): - """TracedCursor for psycopg instances""" +class PsycopgTracedCursor(dbapi.TracedCursor): + """Common cursor tracing for psycopg 2 and 3.""" def __init__(self, cursor, cfg, *args, **kwargs): - super(Psycopg3TracedCursor, self).__init__(cursor, cfg=cfg, *args, **kwargs) - - def _trace_method(self, method, name, resource, extra_tags, dbm_propagator, *args, **kwargs): - # treat Composable resource objects as strings - if resource.__class__.__name__ == "SQL" or resource.__class__.__name__ == "Composed": - resource = resource.as_string(self.__wrapped__) - return super(Psycopg3TracedCursor, self)._trace_method( - method, name, resource, extra_tags, dbm_propagator, *args, **kwargs - ) + super(PsycopgTracedCursor, self).__init__(cursor, cfg=cfg, *args, **kwargs) + + def _normalize_dbapi_query(self, query: object) -> Optional[Union[str, bytes]]: + normalized_query = super(PsycopgTracedCursor, self)._normalize_dbapi_query(query) + if normalized_query is not None: + return normalized_query + renderer = getattr(query, "as_string", None) + if callable(renderer): + rendered_query = renderer(self.__wrapped__) + if isinstance(rendered_query, str): + return rendered_query + return None + + +class Psycopg3TracedCursor(PsycopgTracedCursor): + """TracedCursor for psycopg 3 instances.""" + + def _normalize_dbapi_query(self, query: object) -> Optional[Union[str, bytes]]: + normalized_query = super(Psycopg3TracedCursor, self)._normalize_dbapi_query(query) + if normalized_query is not None: + return normalized_query + if isinstance(getattr(query, "strings", None), tuple) and isinstance( + getattr(query, "interpolations", None), tuple + ): + renderer = self._self_config.get("_query_renderer") + if callable(renderer): + rendered_query = renderer(query, self.__wrapped__) + if isinstance(rendered_query, str): + return rendered_query + return None class Psycopg3FetchTracedCursor(Psycopg3TracedCursor, dbapi.FetchTracedCursor): """Psycopg3FetchTracedCursor for psycopg""" -class Psycopg2TracedCursor(Psycopg3TracedCursor): - """TracedCursor for psycopg2""" - - -class Psycopg2FetchTracedCursor(Psycopg3FetchTracedCursor): - """FetchTracedCursor for psycopg2""" +class PsycopgFetchTracedCursor(PsycopgTracedCursor, dbapi.FetchTracedCursor): + """Fetch-tracing cursor for psycopg 2.""" diff --git a/ddtrace/contrib/internal/psycopg/patch.py b/ddtrace/contrib/internal/psycopg/patch.py index 407f2924505..61bd2ca4748 100644 --- a/ddtrace/contrib/internal/psycopg/patch.py +++ b/ddtrace/contrib/internal/psycopg/patch.py @@ -60,6 +60,7 @@ def _psycopg_sql_injector(dbm_comment, sql_statement): _dbapi_span_name_prefix="postgres", _dbapi_span_operation_name=schematize_database_operation("postgres.query", database_provider="postgresql"), _patched_modules=set(), + _query_renderer=None, trace_fetch_methods=asbool(env.get("DD_PSYCOPG_TRACE_FETCH_METHODS", default=False)), trace_connect=asbool(env.get("DD_PSYCOPG_TRACE_CONNECT", default=False)), _dbm_propagator=_DBM_Propagator(0, "query", _psycopg_sql_injector), @@ -127,6 +128,7 @@ def _patch(psycopg_module): config.psycopg["_patched_modules"].add(psycopg_module) else: _get_psycopg3_original_methods() + config.psycopg["_query_renderer"] = psycopg_module.sql.as_string _w(psycopg_module, "connect", patched_connect_factory(psycopg_module)) _w(psycopg_module, "Cursor", init_cursor_from_connection_factory(psycopg_module)) diff --git a/ddtrace/contrib/internal/vertica/patch.py b/ddtrace/contrib/internal/vertica/patch.py index e189f26c114..b84d46a8917 100644 --- a/ddtrace/contrib/internal/vertica/patch.py +++ b/ddtrace/contrib/internal/vertica/patch.py @@ -36,7 +36,7 @@ def _dispatch_query_event(patch_routine, args, kwargs): else: return - if isinstance(query, str): + if isinstance(query, (str, bytes)) and core.has_listeners(DbApiEvent.event_name): core.dispatch_event(DbApiEvent(query=query, span_name_prefix="vertica")) diff --git a/releasenotes/notes/asm-dbapi-query-normalization-cdc548988f47c744.yaml b/releasenotes/notes/asm-dbapi-query-normalization-cdc548988f47c744.yaml new file mode 100644 index 00000000000..859ad87612a --- /dev/null +++ b/releasenotes/notes/asm-dbapi-query-normalization-cdc548988f47c744.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + ASM: Fixes an issue where SQL injection attack detection and blocking do not inspect database + queries supplied as bytes or adapter-specific objects, including psycopg composables and templates. diff --git a/tests/appsec/appsec/test_exploit_prevention.py b/tests/appsec/appsec/test_exploit_prevention.py index 0415727185b..09f5ed97503 100644 --- a/tests/appsec/appsec/test_exploit_prevention.py +++ b/tests/appsec/appsec/test_exploit_prevention.py @@ -4,8 +4,12 @@ from ddtrace._trace.span import Span import ddtrace.appsec._common_module_patches as cmp +from ddtrace.appsec._constants import EXPLOIT_PREVENTION from ddtrace.appsec._constants import STACK_TRACE +from ddtrace.appsec._contrib.dbapi import subscribers as dbapi_subscribers +from ddtrace.appsec._ddwaf import DDWafSqlTokenizer from ddtrace.appsec._exploit_prevention.stack_traces import report_stack +from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.internal.module import ModuleWatchdog from ddtrace.internal.settings.asm import config as asm_config @@ -51,6 +55,25 @@ def wrapper2(original, instance, args, kargs): assert len(watchdog._hook_map.get(__name__, ())) == initial_hooks +def test_dbapi_subscriber_preserves_bytes_and_selects_tokenizer() -> None: + query = b"SELECT 1" + + with ( + mock.patch.object(dbapi_subscribers, "get_rasp_capability", return_value=True), + mock.patch.object(dbapi_subscribers, "call_waf_callback", return_value=None) as call_waf, + ): + dbapi_subscribers.AppSecDbApiSubscriber.on_event(DbApiEvent(query=query, span_name_prefix="postgres")) + + call_waf.assert_called_once_with( + { + EXPLOIT_PREVENTION.ADDRESS.SQLI: query, + EXPLOIT_PREVENTION.ADDRESS.SQLI_TYPE: DDWafSqlTokenizer.POSTGRESQL.value, + }, + crop_trace="on_event", + rule_type=EXPLOIT_PREVENTION.TYPE.SQLI, + ) + + def _first_reported_frame(span: Span, namespace: str) -> dict[str, Any]: traces = span._get_struct_tag(STACK_TRACE.TAG) assert traces is not None diff --git a/tests/contrib/aiomysql/test_aiomysql.py b/tests/contrib/aiomysql/test_aiomysql.py index 18bba8894a8..145ea075f84 100644 --- a/tests/contrib/aiomysql/test_aiomysql.py +++ b/tests/contrib/aiomysql/test_aiomysql.py @@ -68,13 +68,23 @@ async def test_query_is_blocked_before_execution() -> None: cursor = mock.AsyncMock() traced_cursor = AIOTracedCursor(cursor, Pin()) - for method in ("execute", "executemany"): - with mock.patch.object(core, "dispatch_event", side_effect=BlockingException) as dispatch_event: - with pytest.raises(BlockingException): - await getattr(traced_cursor, method)("SELECT 1") - - dispatch_event.assert_called_once_with(DbApiEvent(query="SELECT 1", span_name_prefix="mysql")) - getattr(cursor, method).assert_not_awaited() + for query in ("SELECT 1", b"SELECT 1"): + expected = BlockingException() + + def block(event: DbApiEvent) -> None: + assert event == DbApiEvent(query=query, span_name_prefix="mysql") + raise expected + + for method in ("execute", "executemany"): + core.on(DbApiEvent.event_name, block) + try: + with pytest.raises(BlockingException) as exc_info: + await getattr(traced_cursor, method)(query) + finally: + core.reset_listeners(DbApiEvent.event_name, block) + + assert exc_info.value is expected + getattr(cursor, method).assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/contrib/aiopg/test.py b/tests/contrib/aiopg/test.py index 18efe402ab7..c3761a2b761 100644 --- a/tests/contrib/aiopg/test.py +++ b/tests/contrib/aiopg/test.py @@ -42,12 +42,27 @@ async def test_query_is_blocked_before_execution(self): cursor = mock.AsyncMock() traced_cursor = AIOTracedCursor(cursor, Pin()) - for method in ("execute", "executemany"): - with mock.patch.object(core, "dispatch_event", side_effect=BlockingException) as dispatch_event: - with pytest.raises(BlockingException): - await getattr(traced_cursor, method)("SELECT 1") + class StringifiableQuery: + def __init__(self) -> None: + self.as_string = mock.Mock(return_value="SELECT 1") - dispatch_event.assert_called_once_with(DbApiEvent(query="SELECT 1", span_name_prefix="postgres")) + for method in ("execute", "executemany"): + query = StringifiableQuery() + expected = BlockingException() + + def block(event: DbApiEvent) -> None: + assert event == DbApiEvent(query="SELECT 1", span_name_prefix="postgres") + raise expected + + core.on(DbApiEvent.event_name, block) + try: + with pytest.raises(BlockingException) as exc_info: + await getattr(traced_cursor, method)(query) + finally: + core.reset_listeners(DbApiEvent.event_name, block) + + assert exc_info.value is expected + query.as_string.assert_called_once_with(cursor) getattr(cursor, method).assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/contrib/dbapi/test_dbapi.py b/tests/contrib/dbapi/test_dbapi.py index 3b29ce8d7bd..b7cc22bb617 100644 --- a/tests/contrib/dbapi/test_dbapi.py +++ b/tests/contrib/dbapi/test_dbapi.py @@ -1,6 +1,7 @@ import mock import pytest +from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.contrib.dbapi import FetchTracedCursor from ddtrace.contrib.dbapi import TracedConnection from ddtrace.contrib.dbapi import TracedCursor @@ -31,12 +32,38 @@ def test_execute_wrapped_is_called_and_returned(self): cursor.execute.assert_called_once_with("__query__", "arg_1", kwarg1="kwarg1") def test_query_is_blocked_before_execution(self): - for method in ("execute", "executemany"): - with mock.patch.object(core, "dispatch_event", side_effect=BlockingException): - with pytest.raises(BlockingException): - getattr(TracedCursor(self.cursor, cfg={}), method)("SELECT 1") - - getattr(self.cursor, method).assert_not_called() + for query in ("SELECT 1", b"SELECT 1"): + expected = BlockingException() + + def block(event: DbApiEvent) -> None: + assert event == DbApiEvent(query=query, span_name_prefix="sql") + raise expected + + for method in ("execute", "executemany"): + core.on(DbApiEvent.event_name, block) + try: + with pytest.raises(BlockingException) as exc_info: + getattr(TracedCursor(self.cursor, cfg={}), method)(query) + finally: + core.reset_listeners(DbApiEvent.event_name, block) + + assert exc_info.value is expected + getattr(self.cursor, method).assert_not_called() + + def test_query_normalization_failure_does_not_affect_execution(self): + class BrokenQueryCursor(TracedCursor): + def _normalize_dbapi_query(self, query: object): + raise ValueError("cannot render query") + + query = object() + cursor = BrokenQueryCursor(self.cursor, cfg={}) + + with mock.patch.object(core, "has_listeners", return_value=True): + with mock.patch.object(core, "dispatch_event") as dispatch_event: + cursor.execute(query) + + dispatch_event.assert_not_called() + self.cursor.execute.assert_called_once_with(query) @TracerTestCase.run_in_subprocess(env_overrides=dict(DD_DBM_PROPAGATION_MODE="full")) def test_dbm_propagation_not_supported(self): diff --git a/tests/contrib/dbapi_async/test_dbapi_async.py b/tests/contrib/dbapi_async/test_dbapi_async.py index 4c85be7b456..85729cb02b2 100644 --- a/tests/contrib/dbapi_async/test_dbapi_async.py +++ b/tests/contrib/dbapi_async/test_dbapi_async.py @@ -35,15 +35,25 @@ async def test_execute_wrapped_is_called_and_returned(self): @mark_asyncio async def test_query_is_blocked_before_execution(self): - for method in ("execute", "executemany"): - with mock.patch.object(core, "dispatch_event", side_effect=BlockingException) as dispatch_event: - with pytest.raises(BlockingException): - await getattr(TracedAsyncCursor(self.cursor, cfg={"_dbapi_span_name_prefix": "postgres"}), method)( - "SELECT 1" - ) - - dispatch_event.assert_called_once_with(DbApiEvent(query="SELECT 1", span_name_prefix="postgres")) - getattr(self.cursor, method).assert_not_awaited() + for query in ("SELECT 1", b"SELECT 1"): + expected = BlockingException() + + def block(event: DbApiEvent) -> None: + assert event == DbApiEvent(query=query, span_name_prefix="postgres") + raise expected + + for method in ("execute", "executemany"): + core.on(DbApiEvent.event_name, block) + try: + with pytest.raises(BlockingException) as exc_info: + await getattr( + TracedAsyncCursor(self.cursor, cfg={"_dbapi_span_name_prefix": "postgres"}), method + )(query) + finally: + core.reset_listeners(DbApiEvent.event_name, block) + + assert exc_info.value is expected + getattr(self.cursor, method).assert_not_awaited() @AsyncioTestCase.run_in_subprocess(env_overrides=dict(DD_DBM_PROPAGATION_MODE="full")) @mark_asyncio diff --git a/tests/contrib/psycopg/test_psycopg.py b/tests/contrib/psycopg/test_psycopg.py index 1035df026e7..e54795f10cc 100644 --- a/tests/contrib/psycopg/test_psycopg.py +++ b/tests/contrib/psycopg/test_psycopg.py @@ -1,4 +1,5 @@ # stdlib +import sys import time import mock @@ -7,9 +8,15 @@ from psycopg.sql import Composed from psycopg.sql import Identifier from psycopg.sql import Literal +import pytest +from ddtrace import config +from ddtrace.contrib._events.dbapi import DbApiEvent +from ddtrace.contrib.internal.psycopg.cursor import Psycopg3FetchTracedCursor +from ddtrace.contrib.internal.psycopg.cursor import Psycopg3TracedCursor from ddtrace.contrib.internal.psycopg.patch import patch from ddtrace.contrib.internal.psycopg.patch import unpatch +from ddtrace.internal import core from ddtrace.internal.schema.default import DEFAULT_SPAN_SERVICE_NAME from ddtrace.internal.utils.version import parse_version from tests.contrib.config import POSTGRES_CONFIG @@ -186,6 +193,73 @@ def test_rollback(self): self.assert_structure(dict(name="psycopg.connection.rollback")) + def test_composed_query_event_is_stringified(self) -> None: + cursor = mock.Mock(rowcount=0) + cursor.connection.pgconn._encoding = "utf-8" + query = SQL("SELECT ") + SQL("1") + events: list[DbApiEvent] = [] + + def capture_event(event: DbApiEvent) -> None: + events.append(event) + + core.on(DbApiEvent.event_name, capture_event) + try: + Psycopg3TracedCursor(cursor, cfg=config.psycopg).execute(query) + finally: + core.reset_listeners(DbApiEvent.event_name, capture_event) + + assert events == [DbApiEvent(query=query.as_string(cursor), span_name_prefix="postgres")] + cursor.execute.assert_called_once_with(query) + + def test_query_is_stringified_once_for_tracing_and_appsec(self) -> None: + cursor = mock.Mock(rowcount=0) + + class StringifiableQuery: + def __init__(self) -> None: + self.as_string = mock.Mock(return_value="SELECT 1") + + query = StringifiableQuery() + events: list[DbApiEvent] = [] + + def capture_event(event: DbApiEvent) -> None: + events.append(event) + + core.on(DbApiEvent.event_name, capture_event) + try: + traced_cursor = Psycopg3FetchTracedCursor(cursor, cfg=config.psycopg) + traced_cursor.execute(query) + traced_cursor.fetchone() + finally: + core.reset_listeners(DbApiEvent.event_name, capture_event) + + query.as_string.assert_called_once_with(cursor) + assert events == [DbApiEvent(query="SELECT 1", span_name_prefix="postgres")] + assert [span.resource for span in self.get_spans()] == ["SELECT 1", "SELECT 1"] + cursor.execute.assert_called_once_with(query) + cursor.fetchone.assert_called_once_with() + + @pytest.mark.skipif( + sys.version_info < (3, 14) or PSYCOPG_VERSION < (3, 3), + reason="psycopg template queries require Python 3.14 and psycopg 3.3", + ) + def test_template_query_event_is_stringified(self) -> None: + cursor = mock.Mock(rowcount=0) + cursor.connection.pgconn._encoding = "utf-8" + query = eval('t"SELECT 1"') + events: list[DbApiEvent] = [] + + def capture_event(event: DbApiEvent) -> None: + events.append(event) + + core.on(DbApiEvent.event_name, capture_event) + try: + Psycopg3TracedCursor(cursor, cfg=config.psycopg).execute(query) + finally: + core.reset_listeners(DbApiEvent.event_name, capture_event) + + assert events == [DbApiEvent(query="SELECT 1", span_name_prefix="postgres")] + cursor.execute.assert_called_once_with(query) + def test_composed_query(self): """Checks whether execution of composed SQL string is traced""" query = SQL(" union all ").join( diff --git a/tests/contrib/psycopg/test_psycopg_async.py b/tests/contrib/psycopg/test_psycopg_async.py index 238dff9a0e0..879db09c43a 100644 --- a/tests/contrib/psycopg/test_psycopg_async.py +++ b/tests/contrib/psycopg/test_psycopg_async.py @@ -1,13 +1,19 @@ # stdlib import time +import mock import psycopg from psycopg.sql import SQL from psycopg.sql import Literal +from ddtrace import config +from ddtrace.contrib._events.dbapi import DbApiEvent +from ddtrace.contrib.internal.psycopg.async_cursor import Psycopg3TracedAsyncCursor from ddtrace.contrib.internal.psycopg.patch import patch from ddtrace.contrib.internal.psycopg.patch import unpatch +from ddtrace.internal import core from tests.contrib.asyncio.utils import AsyncioTestCase +from tests.contrib.asyncio.utils import mark_asyncio from tests.contrib.config import POSTGRES_CONFIG from tests.utils import assert_is_measured @@ -156,6 +162,24 @@ async def test_rollback(self): self.assert_structure(dict(name="psycopg.connection.rollback")) + @mark_asyncio + async def test_composed_query_event_is_stringified(self) -> None: + cursor = mock.AsyncMock(rowcount=0) + query = SQL("SELECT 1") + events: list[DbApiEvent] = [] + + def capture_event(event: DbApiEvent) -> None: + events.append(event) + + core.on(DbApiEvent.event_name, capture_event) + try: + await Psycopg3TracedAsyncCursor(cursor, cfg=config.psycopg).execute(query) + finally: + core.reset_listeners(DbApiEvent.event_name, capture_event) + + assert events == [DbApiEvent(query=query.as_string(cursor), span_name_prefix="postgres")] + cursor.execute.assert_awaited_once_with(query) + async def test_composed_query(self): """Checks whether execution of composed SQL string is traced""" query = SQL(" union all ").join( diff --git a/tests/contrib/psycopg2/test_psycopg.py b/tests/contrib/psycopg2/test_psycopg.py index 245f6274b42..831d7d46e1d 100644 --- a/tests/contrib/psycopg2/test_psycopg.py +++ b/tests/contrib/psycopg2/test_psycopg.py @@ -7,8 +7,10 @@ from psycopg2 import extensions from psycopg2 import extras +from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.contrib.internal.psycopg.patch import patch from ddtrace.contrib.internal.psycopg.patch import unpatch +from ddtrace.internal import core from ddtrace.internal.schema.default import DEFAULT_SPAN_SERVICE_NAME from ddtrace.internal.utils.version import parse_version from tests.contrib.config import POSTGRES_CONFIG @@ -241,18 +243,27 @@ def test_composed_query(self): [SQL("""select {} as x""").format(Literal("one")), SQL("""select {} as x""").format(Literal("two"))] ) db = self._get_conn() + events: list[DbApiEvent] = [] - with db.cursor() as cur: - cur.execute(query=query) - rows = cur.fetchall() - assert len(rows) == 2, rows - assert rows[0][0] == "one" - assert rows[1][0] == "two" + def capture_event(event: DbApiEvent) -> None: + events.append(event) + + core.on(DbApiEvent.event_name, capture_event) + try: + with db.cursor() as cur: + cur.execute(query=query) + rows = cur.fetchall() + assert len(rows) == 2, rows + assert rows[0][0] == "one" + assert rows[1][0] == "two" + finally: + core.reset_listeners(DbApiEvent.event_name, capture_event) assert_is_measured(self.get_root_span()) self.assert_structure( dict(name="postgres.query", resource=query.as_string(db)), ) + assert events == [DbApiEvent(query=query.as_string(db), span_name_prefix="postgres")] @skipIf(PSYCOPG2_VERSION < (2, 7), "SQL string composition not available in psycopg2<2.7") def test_composed_query_identifier(self): diff --git a/tests/contrib/vertica/test_vertica.py b/tests/contrib/vertica/test_vertica.py index 665a7a9e4a7..d8e75164768 100644 --- a/tests/contrib/vertica/test_vertica.py +++ b/tests/contrib/vertica/test_vertica.py @@ -57,14 +57,15 @@ def tearDown(self): super(TestVerticaPatching, self).tearDown() unpatch() - def test_query_event_can_block(self): - cases = ( - ("execute", ("SELECT 1",)), - ("copy", ("COPY test_table (a, b) FROM STDIN DELIMITER ','", "1,foo")), - ) + @pytest.mark.parametrize("query", ("SELECT 1", b"SELECT 1")) + def test_query_event_can_block(self, query): + cases = (("execute", (query,)), ("copy", (query, "1,foo"))) for method, args in cases: - with mock.patch.object(core, "dispatch_event", side_effect=BlockingException) as dispatch_event: + with ( + mock.patch.object(core, "has_listeners", return_value=True), + mock.patch.object(core, "dispatch_event", side_effect=BlockingException) as dispatch_event, + ): with pytest.raises(BlockingException): _dispatch_query_event(method, args, {}) @@ -72,7 +73,7 @@ def test_query_event_can_block(self): def test_non_string_query_does_not_dispatch_event(self): with mock.patch.object(core, "dispatch_event") as dispatch_event: - _dispatch_query_event("execute", (b"SELECT 1",), {}) + _dispatch_query_event("execute", (object(),), {}) dispatch_event.assert_not_called() From 10bead75e44e90e843f2175f908b08b12eefdcdd Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Mon, 31 Aug 2026 16:00:39 +0200 Subject: [PATCH 2/2] fix(appsec): complete DBAPI query normalization --- ddtrace/appsec/_contrib/dbapi/subscribers.py | 1 + ddtrace/contrib/internal/psycopg/patch.py | 2 +- .../appsec/appsec/test_exploit_prevention.py | 13 ++++++-- tests/contrib/psycopg/test_psycopg.py | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/ddtrace/appsec/_contrib/dbapi/subscribers.py b/ddtrace/appsec/_contrib/dbapi/subscribers.py index c09e0be9e62..d528901bfc2 100644 --- a/ddtrace/appsec/_contrib/dbapi/subscribers.py +++ b/ddtrace/appsec/_contrib/dbapi/subscribers.py @@ -10,6 +10,7 @@ _SPAN_NAME_PREFIX_TO_SQL_TOKENIZER: dict[str, DDWafSqlTokenizer] = { + "mariadb": DDWafSqlTokenizer.MYSQL, "mysql": DDWafSqlTokenizer.MYSQL, "oracle": DDWafSqlTokenizer.ORACLE, "postgres": DDWafSqlTokenizer.POSTGRESQL, diff --git a/ddtrace/contrib/internal/psycopg/patch.py b/ddtrace/contrib/internal/psycopg/patch.py index 61bd2ca4748..64bd7ad146c 100644 --- a/ddtrace/contrib/internal/psycopg/patch.py +++ b/ddtrace/contrib/internal/psycopg/patch.py @@ -128,7 +128,7 @@ def _patch(psycopg_module): config.psycopg["_patched_modules"].add(psycopg_module) else: _get_psycopg3_original_methods() - config.psycopg["_query_renderer"] = psycopg_module.sql.as_string + config.psycopg["_query_renderer"] = getattr(psycopg_module.sql, "as_string", None) _w(psycopg_module, "connect", patched_connect_factory(psycopg_module)) _w(psycopg_module, "Cursor", init_cursor_from_connection_factory(psycopg_module)) diff --git a/tests/appsec/appsec/test_exploit_prevention.py b/tests/appsec/appsec/test_exploit_prevention.py index 09f5ed97503..ce41f89bc5c 100644 --- a/tests/appsec/appsec/test_exploit_prevention.py +++ b/tests/appsec/appsec/test_exploit_prevention.py @@ -1,6 +1,7 @@ from typing import Any import mock +import pytest from ddtrace._trace.span import Span import ddtrace.appsec._common_module_patches as cmp @@ -55,19 +56,25 @@ def wrapper2(original, instance, args, kargs): assert len(watchdog._hook_map.get(__name__, ())) == initial_hooks -def test_dbapi_subscriber_preserves_bytes_and_selects_tokenizer() -> None: +@pytest.mark.parametrize( + ("span_name_prefix", "tokenizer"), + (("mariadb", DDWafSqlTokenizer.MYSQL), ("postgres", DDWafSqlTokenizer.POSTGRESQL)), +) +def test_dbapi_subscriber_preserves_bytes_and_selects_tokenizer( + span_name_prefix: str, tokenizer: DDWafSqlTokenizer +) -> None: query = b"SELECT 1" with ( mock.patch.object(dbapi_subscribers, "get_rasp_capability", return_value=True), mock.patch.object(dbapi_subscribers, "call_waf_callback", return_value=None) as call_waf, ): - dbapi_subscribers.AppSecDbApiSubscriber.on_event(DbApiEvent(query=query, span_name_prefix="postgres")) + dbapi_subscribers.AppSecDbApiSubscriber.on_event(DbApiEvent(query=query, span_name_prefix=span_name_prefix)) call_waf.assert_called_once_with( { EXPLOIT_PREVENTION.ADDRESS.SQLI: query, - EXPLOIT_PREVENTION.ADDRESS.SQLI_TYPE: DDWafSqlTokenizer.POSTGRESQL.value, + EXPLOIT_PREVENTION.ADDRESS.SQLI_TYPE: tokenizer.value, }, crop_trace="on_event", rule_type=EXPLOIT_PREVENTION.TYPE.SQLI, diff --git a/tests/contrib/psycopg/test_psycopg.py b/tests/contrib/psycopg/test_psycopg.py index e54795f10cc..24a3e72dd45 100644 --- a/tests/contrib/psycopg/test_psycopg.py +++ b/tests/contrib/psycopg/test_psycopg.py @@ -11,6 +11,8 @@ import pytest from ddtrace import config +from ddtrace.appsec._constants import EXPLOIT_PREVENTION +from ddtrace.appsec._ddwaf import DDWafSqlTokenizer from ddtrace.contrib._events.dbapi import DbApiEvent from ddtrace.contrib.internal.psycopg.cursor import Psycopg3FetchTracedCursor from ddtrace.contrib.internal.psycopg.cursor import Psycopg3TracedCursor @@ -196,6 +198,7 @@ def test_rollback(self): def test_composed_query_event_is_stringified(self) -> None: cursor = mock.Mock(rowcount=0) cursor.connection.pgconn._encoding = "utf-8" + cursor.connection.pgconn.parameter_status.return_value = b"UTF8" query = SQL("SELECT ") + SQL("1") events: list[DbApiEvent] = [] @@ -211,6 +214,35 @@ def capture_event(event: DbApiEvent) -> None: assert events == [DbApiEvent(query=query.as_string(cursor), span_name_prefix="postgres")] cursor.execute.assert_called_once_with(query) + def test_sql_objects_reach_appsec_subscriber(self) -> None: + """SQL objects must be rendered before the AppSec subscriber receives them.""" + from ddtrace.appsec._contrib.dbapi import subscribers as dbapi_subscribers + + try: + for query in (SQL("SELECT 1"), SQL("SELECT ") + SQL("1")): + cursor = mock.Mock(rowcount=0) + cursor.connection.pgconn._encoding = "utf-8" + cursor.connection.pgconn.parameter_status.return_value = b"UTF8" + rendered_query = query.as_string(cursor) + + with ( + mock.patch.object(dbapi_subscribers, "get_rasp_capability", return_value=True), + mock.patch.object(dbapi_subscribers, "call_waf_callback", return_value=None) as call_waf, + ): + Psycopg3TracedCursor(cursor, cfg=config.psycopg).execute(query) + + call_waf.assert_called_once_with( + { + EXPLOIT_PREVENTION.ADDRESS.SQLI: rendered_query, + EXPLOIT_PREVENTION.ADDRESS.SQLI_TYPE: DDWafSqlTokenizer.POSTGRESQL.value, + }, + crop_trace="on_event", + rule_type=EXPLOIT_PREVENTION.TYPE.SQLI, + ) + cursor.execute.assert_called_once_with(query) + finally: + core.reset_listeners(DbApiEvent.event_name, dbapi_subscribers.AppSecDbApiSubscriber._on_event) + def test_query_is_stringified_once_for_tracing_and_appsec(self) -> None: cursor = mock.Mock(rowcount=0)