Skip to content

Commit b9d71bf

Browse files
authored
chore(iast): refactor import modules hook (#13665)
This PR fixes several logic issues and removes code that was duplicated dozens of times. Move _iast_instrument_starlette_url to _handlers Since `iast/_patch.py` and `iast/_patch_modules.py` had overlapping responsibilities and it was unclear what each file was for, the functions that serve as the entry point for starting IAST have been moved to main.py. The new class that handles all the logic for wrapping functions and modules now lives in patch_modules. On one hand, we had the function `try_wrap_function_wrapper` defined twice — once for AppSec and once for IAST: The **AppSec** version used `ModuleWatchdog`: ```python # ddtrace/appsec/_common_module_patches.py def try_wrap_function_wrapper(module_name: str, name: str, wrapper: Callable) -> None: @ModuleWatchdog.after_module_imported(module_name) def _(module): try: wrap_object(module, name, FunctionWrapper, (wrapper,)) except (ImportError, AttributeError): log.debug("ASM patching. Module %s.%s does not exist", module_name, name) ``` Whereas the **IAST** version used `wrapt` directly: ```python # ddtrace/appsec/_iast/_patch.py def try_wrap_function_wrapper(module: Text, name: Text, wrapper: Callable): try: wrap_object(module, name, FunctionWrapper, (wrapper,)) except (ImportError, AttributeError): iast_instrumentation_wrapt_debug_log(f"Module {module}.{name} not exists") ``` Additionally, the IAST version was sometimes called like this: ```python try_wrap_function_wrapper(("_%s" % MD5_DEF), "MD5Type.digest", wrapped_md5_function) ``` And other times using `wrapt.when_imported`: ```python @when_imported("django.shortcuts") def _(m): try_wrap_function_wrapper(m, "redirect", _unvalidated_redirect_for_django) ``` These two patterns were repeated dozens of times, and now all of this logic has been centralized in the class `ddtrace.appsec._iast._patch_modules.WrapModulesForIAST`. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent b97c4d5 commit b9d71bf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+911
-742
lines changed

ddtrace/_monkey.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def _patch_all(**patch_modules: bool) -> None:
352352

353353
patch(raise_errors=False, **modules)
354354
if asm_config._iast_enabled:
355-
from ddtrace.appsec._iast._patch_modules import patch_iast
355+
from ddtrace.appsec._iast.main import patch_iast
356356
from ddtrace.appsec.iast import enable_iast_propagation
357357

358358
patch_iast()

ddtrace/appsec/_common_module_patches.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,32 @@ def _(module):
4242
subprocess_patch.patch()
4343
subprocess_patch.add_str_callback(_RASP_SYSTEM, wrapped_system_5542593D237084A7)
4444
subprocess_patch.add_lst_callback(_RASP_POPEN, popen_FD233052260D8B4D)
45+
log.debug("Patching common modules: subprocess_patch")
4546

4647
if _is_patched:
4748
return
4849

4950
try_wrap_function_wrapper("builtins", "open", wrapped_open_CFDDB7ABBA9081B6)
5051
try_wrap_function_wrapper("urllib.request", "OpenerDirector.open", wrapped_open_ED4CF71136E15EBF)
5152
core.on("asm.block.dbapi.execute", execute_4C9BAC8E228EB347)
53+
log.debug("Patching common modules: builtins and urllib.request")
5254
_is_patched = True
5355

5456

5557
def unpatch_common_modules():
5658
global _is_patched
5759
if not _is_patched:
5860
return
61+
5962
try_unwrap("builtins", "open")
6063
try_unwrap("urllib.request", "OpenerDirector.open")
6164
try_unwrap("_io", "BytesIO.read")
6265
try_unwrap("_io", "StringIO.read")
66+
subprocess_patch.unpatch()
6367
subprocess_patch.del_str_callback(_RASP_SYSTEM)
6468
subprocess_patch.del_lst_callback(_RASP_POPEN)
69+
70+
log.debug("Unpatching common modules subprocess, builtins and urllib.request")
6571
_is_patched = False
6672

6773

@@ -323,7 +329,7 @@ def try_unwrap(module, name):
323329
apply_patch(parent, attribute, original)
324330
del _DD_ORIGINAL_ATTRIBUTES[(parent, attribute)]
325331
except ModuleNotFoundError:
326-
pass
332+
log.debug("ERROR unwrapping %s.%s ", module, name)
327333

328334

329335
def try_wrap_function_wrapper(module_name: str, name: str, wrapper: Callable) -> None:
@@ -332,7 +338,7 @@ def _(module):
332338
try:
333339
wrap_object(module, name, FunctionWrapper, (wrapper,))
334340
except (ImportError, AttributeError):
335-
log.debug("ASM patching. Module %s.%s does not exist", module_name, name)
341+
log.debug("Module %s.%s does not exist", module_name, name)
336342

337343

338344
def wrap_object(module, name, factory, args=(), kwargs=None):

ddtrace/appsec/_iast/_handlers.py

Lines changed: 113 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
from collections.abc import MutableMapping
22
import functools
33

4-
from wrapt import when_imported
5-
from wrapt import wrap_function_wrapper as _w
6-
74
from ddtrace.appsec._constants import IAST
85
from ddtrace.appsec._iast._iast_request_context_base import get_iast_stacktrace_reported
96
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_endpoint
107
from ddtrace.appsec._iast._iast_request_context_base import set_iast_stacktrace_reported
118
from ddtrace.appsec._iast._logs import iast_instrumentation_wrapt_debug_log
129
from ddtrace.appsec._iast._logs import iast_propagation_listener_log_log
1310
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_source
14-
from ddtrace.appsec._iast._patch import _iast_instrument_starlette_url
15-
from ddtrace.appsec._iast._patch import try_wrap_function_wrapper
11+
from ddtrace.appsec._iast._patch_modules import WrapFunctonsForIAST
1612
from ddtrace.appsec._iast._taint_tracking import OriginType
1713
from ddtrace.appsec._iast._taint_tracking import origin_to_str
1814
from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject
@@ -62,27 +58,29 @@ def _on_flask_patch(flask_version):
6258
"""
6359
if asm_config._iast_enabled:
6460
try:
65-
try_wrap_function_wrapper(
61+
iast_funcs = WrapFunctonsForIAST()
62+
63+
iast_funcs.wrap_function(
6664
"werkzeug.datastructures",
6765
"Headers.items",
6866
functools.partial(if_iast_taint_yield_tuple_for, (OriginType.HEADER_NAME, OriginType.HEADER)),
6967
)
7068

71-
try_wrap_function_wrapper(
69+
iast_funcs.wrap_function(
7270
"werkzeug.datastructures",
7371
"EnvironHeaders.__getitem__",
7472
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
7573
)
7674
# Since werkzeug 3.1.0 get doesn't call to __getitem__
77-
try_wrap_function_wrapper(
75+
iast_funcs.wrap_function(
7876
"werkzeug.datastructures",
7977
"EnvironHeaders.get",
8078
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
8179
)
8280
_set_metric_iast_instrumented_source(OriginType.HEADER_NAME)
8381
_set_metric_iast_instrumented_source(OriginType.HEADER)
8482

85-
try_wrap_function_wrapper(
83+
iast_funcs.wrap_function(
8684
"werkzeug.datastructures",
8785
"ImmutableMultiDict.__getitem__",
8886
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
@@ -91,17 +89,17 @@ def _on_flask_patch(flask_version):
9189

9290
if flask_version >= (2, 0, 0):
9391
# instance.query_string: raising an error on werkzeug/_internal.py "AttributeError: read only property"
94-
try_wrap_function_wrapper("werkzeug.wrappers.request", "Request.__init__", _on_request_init)
92+
iast_funcs.wrap_function("werkzeug.wrappers.request", "Request.__init__", _on_request_init)
9593

9694
_set_metric_iast_instrumented_source(OriginType.PATH)
9795
_set_metric_iast_instrumented_source(OriginType.QUERY)
9896

99-
try_wrap_function_wrapper(
97+
iast_funcs.wrap_function(
10098
"werkzeug.wrappers.request",
10199
"Request.get_data",
102100
functools.partial(taint_dictionary, OriginType.BODY, OriginType.BODY),
103101
)
104-
try_wrap_function_wrapper(
102+
iast_funcs.wrap_function(
105103
"werkzeug.wrappers.request",
106104
"Request.get_json",
107105
functools.partial(taint_dictionary, OriginType.BODY, OriginType.BODY),
@@ -110,13 +108,15 @@ def _on_flask_patch(flask_version):
110108
_set_metric_iast_instrumented_source(OriginType.BODY)
111109

112110
if flask_version < (2, 0, 0):
113-
_w(
111+
iast_funcs.wrap_function(
114112
"werkzeug._internal",
115113
"_DictAccessorProperty.__get__",
116114
functools.partial(if_iast_taint_returned_object_for, OriginType.QUERY),
117115
)
118116
_set_metric_iast_instrumented_source(OriginType.QUERY)
119117

118+
iast_funcs.patch()
119+
120120
# Instrumented on _ddtrace.appsec._asm_request_context._on_wrapped_view
121121
_set_metric_iast_instrumented_source(OriginType.PATH_PARAMETER)
122122

@@ -156,14 +156,17 @@ def _on_django_patch():
156156
"""Handle Django framework patch event."""
157157
if asm_config._iast_enabled:
158158
try:
159-
when_imported("django.http.request")(
160-
lambda m: try_wrap_function_wrapper(
161-
m,
162-
"QueryDict.__getitem__",
163-
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
164-
)
159+
iast_funcs = WrapFunctonsForIAST()
160+
161+
iast_funcs.wrap_function(
162+
"django.http.request",
163+
"QueryDict.__getitem__",
164+
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
165165
)
166-
try_wrap_function_wrapper("django.utils.shlex", "quote", cmdi_sanitizer)
166+
iast_funcs.wrap_function("django.utils.shlex", "quote", cmdi_sanitizer)
167+
168+
iast_funcs.patch()
169+
167170
# we instrument those sources on _on_django_func_wrapped
168171
_set_metric_iast_instrumented_source(OriginType.HEADER_NAME)
169172
_set_metric_iast_instrumented_source(OriginType.HEADER)
@@ -354,82 +357,88 @@ def if_iast_taint_starlette_datastructures(origin, wrapped, instance, args, kwar
354357

355358

356359
def _on_iast_fastapi_patch():
357-
# Cookies sources
358-
try_wrap_function_wrapper(
359-
"starlette.requests",
360-
"cookie_parser",
361-
functools.partial(taint_dictionary, OriginType.COOKIE_NAME, OriginType.COOKIE),
362-
)
363-
_set_metric_iast_instrumented_source(OriginType.COOKIE)
364-
_set_metric_iast_instrumented_source(OriginType.COOKIE_NAME)
365-
366-
# Parameter sources
367-
try_wrap_function_wrapper(
368-
"starlette.datastructures",
369-
"QueryParams.__getitem__",
370-
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
371-
)
372-
try_wrap_function_wrapper(
373-
"starlette.datastructures",
374-
"QueryParams.get",
375-
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
376-
)
377-
_set_metric_iast_instrumented_source(OriginType.PARAMETER)
360+
try:
361+
iast_funcs = WrapFunctonsForIAST()
362+
# Cookies sources
363+
iast_funcs.wrap_function(
364+
"starlette.requests",
365+
"cookie_parser",
366+
functools.partial(taint_dictionary, OriginType.COOKIE_NAME, OriginType.COOKIE),
367+
)
368+
_set_metric_iast_instrumented_source(OriginType.COOKIE)
369+
_set_metric_iast_instrumented_source(OriginType.COOKIE_NAME)
370+
371+
# Parameter sources
372+
iast_funcs.wrap_function(
373+
"starlette.datastructures",
374+
"QueryParams.__getitem__",
375+
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
376+
)
377+
iast_funcs.wrap_function(
378+
"starlette.datastructures",
379+
"QueryParams.get",
380+
functools.partial(if_iast_taint_returned_object_for, OriginType.PARAMETER),
381+
)
382+
_set_metric_iast_instrumented_source(OriginType.PARAMETER)
378383

379-
try_wrap_function_wrapper(
380-
"starlette.datastructures",
381-
"QueryParams.keys",
382-
functools.partial(if_iast_taint_starlette_datastructures, OriginType.PARAMETER_NAME),
383-
)
384-
_set_metric_iast_instrumented_source(OriginType.PARAMETER_NAME)
384+
iast_funcs.wrap_function(
385+
"starlette.datastructures",
386+
"QueryParams.keys",
387+
functools.partial(if_iast_taint_starlette_datastructures, OriginType.PARAMETER_NAME),
388+
)
389+
_set_metric_iast_instrumented_source(OriginType.PARAMETER_NAME)
385390

386-
# Header sources
387-
try_wrap_function_wrapper(
388-
"starlette.datastructures",
389-
"Headers.__getitem__",
390-
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
391-
)
392-
try_wrap_function_wrapper(
393-
"starlette.datastructures",
394-
"Headers.get",
395-
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
396-
)
397-
_set_metric_iast_instrumented_source(OriginType.HEADER)
391+
# Header sources
392+
iast_funcs.wrap_function(
393+
"starlette.datastructures",
394+
"Headers.__getitem__",
395+
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
396+
)
397+
iast_funcs.wrap_function(
398+
"starlette.datastructures",
399+
"Headers.get",
400+
functools.partial(if_iast_taint_returned_object_for, OriginType.HEADER),
401+
)
402+
_set_metric_iast_instrumented_source(OriginType.HEADER)
398403

399-
try_wrap_function_wrapper(
400-
"starlette.datastructures",
401-
"Headers.keys",
402-
functools.partial(if_iast_taint_starlette_datastructures, OriginType.HEADER_NAME),
403-
)
404-
_set_metric_iast_instrumented_source(OriginType.HEADER_NAME)
405-
406-
# Path source
407-
try_wrap_function_wrapper("starlette.datastructures", "URL.__init__", _iast_instrument_starlette_url)
408-
_set_metric_iast_instrumented_source(OriginType.PATH)
409-
410-
# Body source
411-
try_wrap_function_wrapper("starlette.requests", "Request.__init__", _iast_instrument_starlette_request)
412-
try_wrap_function_wrapper("starlette.requests", "Request.body", _iast_instrument_starlette_request_body)
413-
try_wrap_function_wrapper(
414-
"starlette.datastructures",
415-
"FormData.__getitem__",
416-
functools.partial(if_iast_taint_returned_object_for, OriginType.BODY),
417-
)
418-
try_wrap_function_wrapper(
419-
"starlette.datastructures",
420-
"FormData.get",
421-
functools.partial(if_iast_taint_returned_object_for, OriginType.BODY),
422-
)
423-
try_wrap_function_wrapper(
424-
"starlette.datastructures",
425-
"FormData.keys",
426-
functools.partial(if_iast_taint_starlette_datastructures, OriginType.PARAMETER_NAME),
427-
)
404+
iast_funcs.wrap_function(
405+
"starlette.datastructures",
406+
"Headers.keys",
407+
functools.partial(if_iast_taint_starlette_datastructures, OriginType.HEADER_NAME),
408+
)
409+
_set_metric_iast_instrumented_source(OriginType.HEADER_NAME)
410+
411+
# Path source
412+
iast_funcs.wrap_function("starlette.datastructures", "URL.__init__", _iast_instrument_starlette_url)
413+
_set_metric_iast_instrumented_source(OriginType.PATH)
414+
415+
# Body source
416+
iast_funcs.wrap_function("starlette.requests", "Request.__init__", _iast_instrument_starlette_request)
417+
iast_funcs.wrap_function("starlette.requests", "Request.body", _iast_instrument_starlette_request_body)
418+
iast_funcs.wrap_function(
419+
"starlette.datastructures",
420+
"FormData.__getitem__",
421+
functools.partial(if_iast_taint_returned_object_for, OriginType.BODY),
422+
)
423+
iast_funcs.wrap_function(
424+
"starlette.datastructures",
425+
"FormData.get",
426+
functools.partial(if_iast_taint_returned_object_for, OriginType.BODY),
427+
)
428+
iast_funcs.wrap_function(
429+
"starlette.datastructures",
430+
"FormData.keys",
431+
functools.partial(if_iast_taint_starlette_datastructures, OriginType.PARAMETER_NAME),
432+
)
428433

429-
_set_metric_iast_instrumented_source(OriginType.BODY)
434+
iast_funcs.patch()
430435

431-
# Instrumented on _iast_starlette_scope_taint
432-
_set_metric_iast_instrumented_source(OriginType.PATH_PARAMETER)
436+
_set_metric_iast_instrumented_source(OriginType.BODY)
437+
438+
# Instrumented on _iast_starlette_scope_taint
439+
_set_metric_iast_instrumented_source(OriginType.PATH_PARAMETER)
440+
except Exception:
441+
iast_propagation_listener_log_log("Unexpected exception while tainting pyobject", exc_info=True)
433442

434443

435444
def _on_pre_tracedrequest_iast(ctx):
@@ -570,3 +579,16 @@ def _iast_instrument_starlette_scope(scope, route):
570579
)
571580
except Exception:
572581
iast_propagation_listener_log_log("Unexpected exception while tainting path parameters", exc_info=True)
582+
583+
584+
def _iast_instrument_starlette_url(wrapped, instance, args, kwargs):
585+
def path(self) -> str:
586+
return taint_pyobject(
587+
self.components.path,
588+
source_name=origin_to_str(OriginType.PATH),
589+
source_value=self.components.path,
590+
source_origin=OriginType.PATH,
591+
)
592+
593+
instance.__class__.path = property(path)
594+
wrapped(*args, **kwargs)

ddtrace/appsec/_iast/_input_info.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)