diff --git a/ddtrace/appsec/_common_module_patches.py b/ddtrace/appsec/_common_module_patches.py index f4c4bb7133a..6914680f0d0 100644 --- a/ddtrace/appsec/_common_module_patches.py +++ b/ddtrace/appsec/_common_module_patches.py @@ -1,6 +1,5 @@ import io import json -import os from typing import Iterable from typing import Union from urllib.parse import urlunparse @@ -10,18 +9,20 @@ from ddtrace.appsec._asm_request_context import get_blocked from ddtrace.appsec._asm_request_context import open_rasp_subcontext_scope from ddtrace.appsec._constants import EXPLOIT_PREVENTION -from ddtrace.appsec._constants import WAF_ACTIONS +from ddtrace.appsec._contrib.filesystem.patch import patch as patch_filesystem_for_appsec +from ddtrace.appsec._contrib.filesystem.patch import unpatch as unpatch_filesystem_for_appsec from ddtrace.appsec._contrib.stripe.patch import patch as patch_stripe_for_appsec from ddtrace.appsec._contrib.stripe.patch import unpatch as unpatch_stripe_for_appsec from ddtrace.appsec._metrics import report_rasp_skipped from ddtrace.appsec._patch_utils import try_unwrap from ddtrace.appsec._patch_utils import try_wrap_function_wrapper +from ddtrace.appsec._rasp import _must_block +from ddtrace.appsec._rasp import get_rasp_capability import ddtrace.contrib.internal.subprocess.patch as subprocess_patch from ddtrace.internal import core from ddtrace.internal._exceptions import BlockingException from ddtrace.internal.logger import get_logger from ddtrace.internal.module import ModuleWatchdog -from ddtrace.internal.settings.asm import config as asm_config log = get_logger(__name__) @@ -53,12 +54,11 @@ def patch_common_modules() -> None: try_wrap_function_wrapper("urllib3.connectionpool", "HTTPConnectionPool.urlopen", wrapped_urllib3_urlopen) try_wrap_function_wrapper("urllib3._request_methods", "RequestMethods.request", wrapped_request_D8CB81E472AF98A2) try_wrap_function_wrapper("urllib3.request", "RequestMethods.request", wrapped_request_D8CB81E472AF98A2) - try_wrap_function_wrapper("builtins", "open", wrapped_open_CFDDB7ABBA9081B6) - try_wrap_function_wrapper("pathlib", "Path.open", wrapped_path_open_B91CA5063FE27D84) try_wrap_function_wrapper("urllib.request", "OpenerDirector.open", wrapped_open_ED4CF71136E15EBF) try_wrap_function_wrapper("http.client", "HTTPConnection.request", wrapped_request_A7F2C6E4D3B10958) try_wrap_function_wrapper("http.client", "HTTPConnection.getresponse", wrapped_response) + patch_filesystem_for_appsec() patch_stripe_for_appsec() core.on("asm.block.dbapi.execute", execute_4C9BAC8E228EB347) @@ -75,13 +75,12 @@ def unpatch_common_modules(): try_unwrap("urllib3.connectionpool", "HTTPConnectionPool.urlopen") try_unwrap("urllib3._request_methods", "RequestMethods.request") try_unwrap("urllib3.request", "RequestMethods.request") - try_unwrap("builtins", "open") - try_unwrap("pathlib", "Path.open") try_unwrap("urllib.request", "OpenerDirector.open") try_unwrap("http.client", "HTTPConnection.request") try_unwrap("http.client", "HTTPConnection.getresponse") core.reset_listeners("asm.block.dbapi.execute", execute_4C9BAC8E228EB347) + unpatch_filesystem_for_appsec() unpatch_stripe_for_appsec() subprocess_patch.unpatch() @@ -93,112 +92,6 @@ def unpatch_common_modules(): _is_patched = False -def _must_block(actions: Iterable[str]) -> bool: - return any(action in (WAF_ACTIONS.BLOCK_ACTION, WAF_ACTIONS.REDIRECT_ACTION) for action in actions) - - -def _get_rasp_capability(capability: str) -> bool: - """Check if the RASP capability is enabled.""" - if asm_config._asm_enabled and asm_config._ep_enabled: - from ddtrace.appsec._asm_request_context import in_asm_context - - if not in_asm_context(): - return False - - try: - from ddtrace.appsec._processor import AppSecSpanProcessor - except Exception: - # load_appsec owns fatal processor load failures; wrappers only need to - # report the capability as unavailable while imports are in progress. - return False - - return AppSecSpanProcessor._instance is not None and getattr( - AppSecSpanProcessor._instance, f"rasp_{capability}_enabled", False - ) - return False - - -def wrapped_open_CFDDB7ABBA9081B6(original_open_callable, instance, args, kwargs): - """ - wrapper for open file function - """ - if _get_rasp_capability("lfi"): - try: - from ddtrace.appsec._asm_request_context import call_waf_callback - from ddtrace.appsec._asm_request_context import in_asm_context - except ImportError: - # open is used during module initialization - # and shouldn't be changed at that time - - # DEV: Do not report here for efficiency reasons - # _report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.LFI, True) - return original_open_callable(*args, **kwargs) - - filename_arg = args[0] if args else kwargs.get("file", None) - try: - filename = os.fspath(filename_arg) - except Exception: - filename = "" - if filename: - if in_asm_context(): - res = call_waf_callback( - {EXPLOIT_PREVENTION.ADDRESS.LFI: filename}, - crop_trace="wrapped_open_CFDDB7ABBA9081B6", - rule_type=EXPLOIT_PREVENTION.TYPE.LFI, - ) - if res and _must_block(res.actions): - raise BlockingException( - get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.LFI, filename - ) - else: - report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.LFI, False) - try: - return original_open_callable(*args, **kwargs) - except Exception as e: - previous_frame = e.__traceback__.tb_frame.f_back - raise e.with_traceback( - e.__traceback__.__class__(None, previous_frame, previous_frame.f_lasti, previous_frame.f_lineno) - ) - - -def wrapped_path_open_B91CA5063FE27D84(original_method_callable, instance, args, kwargs): - """ - wrapper for pathlib.Path.open() method - """ - if _get_rasp_capability("lfi"): - try: - from ddtrace.appsec._asm_request_context import call_waf_callback - from ddtrace.appsec._asm_request_context import in_asm_context - except ImportError: - # Path methods can be used during module initialization - return original_method_callable(*args, **kwargs) - - try: - filename = os.fspath(instance) - except Exception: - filename = "" - if filename: - if in_asm_context(): - res = call_waf_callback( - {EXPLOIT_PREVENTION.ADDRESS.LFI: filename}, - crop_trace="wrapped_path_open_B91CA5063FE27D84", - rule_type=EXPLOIT_PREVENTION.TYPE.LFI, - ) - if res and _must_block(res.actions): - raise BlockingException( - get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.LFI, filename - ) - else: - report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.LFI, False) - try: - return original_method_callable(*args, **kwargs) - except Exception as e: - previous_frame = e.__traceback__.tb_frame.f_back - raise e.with_traceback( - e.__traceback__.__class__(None, previous_frame, previous_frame.f_lasti, previous_frame.f_lineno) - ) - - def _build_headers(lst: Iterable[tuple[str, str]]) -> dict[str, Union[str, list[str]]]: res: dict[str, Union[str, list[str]]] = {} for a, b in lst: @@ -216,7 +109,7 @@ def _build_headers(lst: Iterable[tuple[str, str]]) -> dict[str, Union[str, list[ def wrapped_request_A7F2C6E4D3B10958(original_request_callable, instance, args, kwargs): full_url = core.find_item("full_url") env = _get_asm_context() - if _get_rasp_capability("ssrf") and full_url is not None and env is not None: + if get_rasp_capability("ssrf") and full_url is not None and env is not None: use_body = core.find_item("use_body", False) method = args[0] if len(args) > 0 else kwargs.get("method", None) body = args[2] if len(args) > 2 else kwargs.get("body", None) @@ -244,7 +137,7 @@ def wrapped_response(original_response_callable, instance, args, kwargs): response = original_response_callable(*args, *kwargs) env = _get_asm_context() try: - if _get_rasp_capability("ssrf") and response.__class__.__name__ == "HTTPResponse" and env is not None: + if get_rasp_capability("ssrf") and response.__class__.__name__ == "HTTPResponse" and env is not None: status = response.getcode() if 300 <= status < 400: # api10 for redirected response status and headers in urllib @@ -275,7 +168,7 @@ def wrapped_open_ED4CF71136E15EBF(original_open_callable, instance, args, kwargs """ wrapper for open url function """ - if _get_rasp_capability("ssrf"): + if get_rasp_capability("ssrf"): try: from ddtrace.appsec._asm_request_context import call_waf_callback from ddtrace.appsec._asm_request_context import should_analyze_body_response @@ -339,7 +232,7 @@ def _parse_headers_urllib3(headers): def wrapped_urllib3_make_request_6D4E8B2A1F095C73(original_request_callable, instance, args, kwargs): full_url = core.find_item("full_url") env = _get_asm_context() - do_rasp = _get_rasp_capability("ssrf") and full_url is not None and env is not None + do_rasp = get_rasp_capability("ssrf") and full_url is not None and env is not None if not do_rasp: return original_request_callable(*args, **kwargs) core.discard_item("full_url") @@ -403,7 +296,7 @@ def wrapped_request_D8CB81E472AF98A2(original_request_callable, instance, args, wrapper for third party requests.request function https://requests.readthedocs.io """ - if _get_rasp_capability("ssrf"): + if get_rasp_capability("ssrf"): try: from ddtrace.appsec._asm_request_context import _get_asm_context from ddtrace.appsec._asm_request_context import call_waf_callback @@ -447,7 +340,7 @@ def wrapped_system_5542593D237084A7(command: Union[str, bytes]) -> None: """ wrapper for os.system function """ - if _get_rasp_capability("shi"): + if get_rasp_capability("shi"): try: from ddtrace.appsec._asm_request_context import call_waf_callback from ddtrace.appsec._asm_request_context import in_asm_context @@ -473,7 +366,7 @@ def popen_FD233052260D8B4D(arg_list: Union[list[str], str, bytes]) -> None: """ listener for subprocess.Popen class """ - if _get_rasp_capability("cmdi"): + if get_rasp_capability("cmdi"): try: from ddtrace.appsec._asm_request_context import call_waf_callback from ddtrace.appsec._asm_request_context import in_asm_context @@ -518,7 +411,7 @@ def execute_4C9BAC8E228EB347(instrument_self, query, args, kwargs) -> None: parameters are ignored as they are properly handled by the dbapi without risk of injections """ - if _get_rasp_capability("sqli"): + if get_rasp_capability("sqli"): try: from ddtrace.appsec._asm_request_context import call_waf_callback from ddtrace.appsec._asm_request_context import in_asm_context diff --git a/ddtrace/appsec/_contrib/filesystem/__init__.py b/ddtrace/appsec/_contrib/filesystem/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ddtrace/appsec/_contrib/filesystem/events.py b/ddtrace/appsec/_contrib/filesystem/events.py new file mode 100644 index 00000000000..f8c85f6f633 --- /dev/null +++ b/ddtrace/appsec/_contrib/filesystem/events.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import ClassVar +from typing import Union + +from ddtrace.internal.core.events import Event + + +@dataclass +class FileOpenEvent(Event): + event_name: ClassVar[str] = "appsec.filesystem.open" + + filename: Union[str, bytes] diff --git a/ddtrace/appsec/_contrib/filesystem/patch.py b/ddtrace/appsec/_contrib/filesystem/patch.py new file mode 100644 index 00000000000..600fda2cf6d --- /dev/null +++ b/ddtrace/appsec/_contrib/filesystem/patch.py @@ -0,0 +1,69 @@ +import os +from typing import Protocol + +from ddtrace.appsec._contrib.filesystem.events import FileOpenEvent +from ddtrace.appsec._patch_utils import _raise_without_wrapper_frame +from ddtrace.appsec._patch_utils import try_unwrap +from ddtrace.appsec._patch_utils import try_wrap_function_wrapper +from ddtrace.internal import core + + +class _OpenCallable(Protocol): + def __call__(self, *args: object, **kwargs: object) -> object: ... + + +class _PathOpenReceiver(Protocol): + def __fspath__(self) -> str: ... + + +def wrapped_builtin_open( + original: _OpenCallable, + _instance: object, + args: tuple[object, ...], + kwargs: dict[str, object], +) -> object: + if core.has_listeners(FileOpenEvent.event_name): + value = args[0] if args else kwargs.get("file") + filename = None + if isinstance(value, (str, bytes, os.PathLike)): + try: + filename = os.fspath(value) + except Exception: + filename = None + if filename: + core.dispatch_event(FileOpenEvent(filename=filename), allow_raise=True) + + try: + return original(*args, **kwargs) + except Exception as exc: + raise _raise_without_wrapper_frame(exc) + + +def wrapped_path_open( + original: _OpenCallable, + instance: _PathOpenReceiver, + args: tuple[object, ...], + kwargs: dict[str, object], +) -> object: + if core.has_listeners(FileOpenEvent.event_name): + try: + filename = os.fspath(instance) + except Exception: + filename = None + if filename: + core.dispatch_event(FileOpenEvent(filename=filename), allow_raise=True) + + try: + return original(*args, **kwargs) + except Exception as exc: + raise _raise_without_wrapper_frame(exc) + + +def patch() -> None: + try_wrap_function_wrapper("builtins", "open", wrapped_builtin_open) + try_wrap_function_wrapper("pathlib", "Path.open", wrapped_path_open) + + +def unpatch() -> None: + try_unwrap("builtins", "open") + try_unwrap("pathlib", "Path.open") diff --git a/ddtrace/appsec/_contrib/filesystem/subscribers.py b/ddtrace/appsec/_contrib/filesystem/subscribers.py new file mode 100644 index 00000000000..74dff252afe --- /dev/null +++ b/ddtrace/appsec/_contrib/filesystem/subscribers.py @@ -0,0 +1,33 @@ +from ddtrace.appsec._asm_request_context import call_waf_callback +from ddtrace.appsec._asm_request_context import get_blocked +from ddtrace.appsec._asm_request_context import in_asm_context +from ddtrace.appsec._constants import EXPLOIT_PREVENTION +from ddtrace.appsec._contrib.filesystem.events import FileOpenEvent +from ddtrace.appsec._metrics import report_rasp_skipped +from ddtrace.appsec._rasp import _must_block +from ddtrace.appsec._rasp import get_rasp_capability +from ddtrace.internal._exceptions import BlockingException +from ddtrace.internal.core.subscriber import Subscriber + + +class AppSecFileOpenSubscriber(Subscriber): + event_names = (FileOpenEvent.event_name,) + + @classmethod + def on_event(cls, event: FileOpenEvent) -> None: + if not get_rasp_capability("lfi"): + return + + if not in_asm_context(): + report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.LFI, False) + return + + result = call_waf_callback( + {EXPLOIT_PREVENTION.ADDRESS.LFI: event.filename}, + crop_trace="on_event", + rule_type=EXPLOIT_PREVENTION.TYPE.LFI, + ) + if result is None or not _must_block(result.actions): + return + + raise BlockingException(get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.LFI, event.filename) diff --git a/ddtrace/appsec/_contrib/httpx/subscribers.py b/ddtrace/appsec/_contrib/httpx/subscribers.py index bf89dfd4334..58a772fceac 100644 --- a/ddtrace/appsec/_contrib/httpx/subscribers.py +++ b/ddtrace/appsec/_contrib/httpx/subscribers.py @@ -9,8 +9,8 @@ from ddtrace.appsec._asm_request_context import get_blocked from ddtrace.appsec._asm_request_context import open_rasp_subcontext_scope from ddtrace.appsec._asm_request_context import should_analyze_body_response -from ddtrace.appsec._common_module_patches import _get_rasp_capability from ddtrace.appsec._constants import EXPLOIT_PREVENTION +from ddtrace.appsec._rasp import get_rasp_capability from ddtrace.contrib._events.http_client import HttpClientEvents from ddtrace.contrib._events.http_client import HttpClientRequestEvent from ddtrace.contrib._events.http_client import HttpClientSendEvent @@ -27,7 +27,7 @@ class AppSecHttpxRequestContextSubscriber(ContextSubscriber[HttpClientRequestEve @classmethod def on_started(cls, ctx: core.ExecutionContext[HttpClientRequestEvent]) -> None: - if not _get_rasp_capability("ssrf"): + if not get_rasp_capability("ssrf"): return asm_context = _get_asm_context() if asm_context is None: @@ -50,7 +50,7 @@ def on_ended( if exc_type is not None: return - if not _get_rasp_capability("ssrf"): + if not get_rasp_capability("ssrf"): return event: HttpClientRequestEvent = ctx.event @@ -78,7 +78,7 @@ class AppSecHttpxSingleRequestContextSubscriber(ContextSubscriber[HttpClientSend @classmethod def on_started(cls, ctx: core.ExecutionContext[HttpClientSendEvent]) -> None: - if not _get_rasp_capability("ssrf"): + if not get_rasp_capability("ssrf"): return asm_context = _get_asm_context() @@ -117,7 +117,7 @@ def on_ended( if exc_type is not None: return - if not _get_rasp_capability("ssrf"): + if not get_rasp_capability("ssrf"): return status = ctx.event.response_status_code diff --git a/ddtrace/appsec/_exploit_prevention/stack_traces.py b/ddtrace/appsec/_exploit_prevention/stack_traces.py index 903e85a01b6..e225433d4f4 100644 --- a/ddtrace/appsec/_exploit_prevention/stack_traces.py +++ b/ddtrace/appsec/_exploit_prevention/stack_traces.py @@ -62,6 +62,11 @@ def report_stack( if frame.frame.f_code.co_name == crop_stack: crop_index = i + 1 break + strip_internal_frames = True + else: + strip_internal_frames = namespace == STACK_TRACE.RASP + + if strip_internal_frames: # Strip any remaining ddtrace/wrapt frames from the top of the stack while crop_index < len(stack) and any(d in stack[crop_index].filename for d in _INTERNAL_FRAMES): crop_index += 1 diff --git a/ddtrace/appsec/_listeners.py b/ddtrace/appsec/_listeners.py index 86f6c28ef30..d025d8e55ac 100644 --- a/ddtrace/appsec/_listeners.py +++ b/ddtrace/appsec/_listeners.py @@ -109,6 +109,7 @@ def load_appsec(reconfigure_tracer: bool = False, origin: str = "") -> bool: flask_listen() django_listen() fastapi_listen() + import ddtrace.appsec._contrib.filesystem.subscribers # noqa: F401 import ddtrace.appsec._contrib.httpx.subscribers # noqa: F401 openai_listen() diff --git a/ddtrace/appsec/_patch_utils.py b/ddtrace/appsec/_patch_utils.py index 6cc046cc4d0..5302a552587 100644 --- a/ddtrace/appsec/_patch_utils.py +++ b/ddtrace/appsec/_patch_utils.py @@ -1,6 +1,7 @@ import ctypes import os import sysconfig +from types import TracebackType from typing import Any from typing import Callable from typing import Optional @@ -80,6 +81,17 @@ def get_caller_frame_info() -> tuple[Optional[str], Optional[int], Optional[str] _MODULE_HOOKS: dict[tuple[str, str], list[Callable[[Any], None]]] = {} +def _raise_without_wrapper_frame(exc: Exception) -> Exception: + """Prepare an exception so its caller can raise it without the wrapped-call frame.""" + traceback = exc.__traceback__ + if traceback is None: + return exc + previous_frame = traceback.tb_frame.f_back + if previous_frame is None: + return exc + return exc.with_traceback(TracebackType(None, previous_frame, previous_frame.f_lasti, previous_frame.f_lineno)) + + def _module_name(module: Any) -> str: return module if isinstance(module, str) else module.__name__ diff --git a/ddtrace/appsec/_rasp.py b/ddtrace/appsec/_rasp.py new file mode 100644 index 00000000000..af864e6fb8b --- /dev/null +++ b/ddtrace/appsec/_rasp.py @@ -0,0 +1,29 @@ +from typing import Iterable + +from ddtrace.appsec._constants import WAF_ACTIONS +from ddtrace.internal.settings.asm import config as asm_config + + +def _must_block(actions: Iterable[str]) -> bool: + return any(action in (WAF_ACTIONS.BLOCK_ACTION, WAF_ACTIONS.REDIRECT_ACTION) for action in actions) + + +def get_rasp_capability(capability: str) -> bool: + """Return whether a RASP capability is active for the current request.""" + if not asm_config._asm_enabled or not asm_config._ep_enabled: + return False + + from ddtrace.appsec._asm_request_context import in_asm_context + + if not in_asm_context(): + return False + + try: + from ddtrace.appsec._processor import AppSecSpanProcessor + except Exception: + # load_appsec owns fatal processor load failures; listeners only need to + # report the capability as unavailable while imports are in progress. + return False + + processor = AppSecSpanProcessor._instance + return processor is not None and bool(getattr(processor, f"rasp_{capability}_enabled", False)) diff --git a/tests/appsec/appsec/test_exploit_prevention.py b/tests/appsec/appsec/test_exploit_prevention.py index 846055e4c99..0415727185b 100644 --- a/tests/appsec/appsec/test_exploit_prevention.py +++ b/tests/appsec/appsec/test_exploit_prevention.py @@ -1,71 +1,13 @@ -from inspect import currentframe -from inspect import getframeinfo -from pathlib import Path -import traceback +from typing import Any -import pytest +import mock +from ddtrace._trace.span import Span import ddtrace.appsec._common_module_patches as cmp +from ddtrace.appsec._constants import STACK_TRACE +from ddtrace.appsec._exploit_prevention.stack_traces import report_stack from ddtrace.internal.module import ModuleWatchdog - - -def test_lfi_normal_exception(): - """ - Ensure the top frame is the one where the exception is raised in the customer code - """ - exception_repr = """Traceback (most recent call last): - File "{}", line {}, in test_lfi_normal_exception - with open("/unknown/do_not_exist_test.txt", "w"): -""" - try: - cmp.patch_common_modules() - with pytest.raises(Exception) as e: - with open("/unknown/do_not_exist_test.txt", "w"): - pass - assert e.type is FileNotFoundError - # ensure the last frame is from the file where open was called - assert e.traceback[-1].path.as_posix() == __file__ - # Does not work as we can't remove futur frames at raising point - # assert len(e.traceback) == 1 - line_number = getframeinfo(currentframe()).lineno - try: - with open("/unknown/do_not_exist_test.txt", "w"): - pass - except Exception as e: - assert e.__class__.__name__ == "FileNotFoundError" - assert e.__traceback__.tb_frame.f_code.co_filename == __file__ - assert traceback.format_exc(limit=1).startswith(exception_repr.format(__file__, line_number + 2)) - finally: - cmp.unpatch_common_modules() - - -def test_lfi_normal_exception_pathlib(): - """ - Ensure the top frame is the one where the exception is raised in the customer code - when using pathlib.Path.open() - """ - exception_repr = """Traceback (most recent call last): - File "{}", line {}, in test_lfi_normal_exception_pathlib - with Path("/unknown/do_not_exist_test.txt").open("w"): -""" - try: - cmp.patch_common_modules() - with pytest.raises(Exception) as e: - with Path("/unknown/do_not_exist_test.txt").open("w"): - pass - assert e.type is FileNotFoundError - # ensure the last frame is from the file where open was called - assert e.traceback[-1].path.as_posix() == __file__ - line_number = getframeinfo(currentframe()).lineno - try: - with Path("/unknown/do_not_exist_test.txt").open("w"): - pass - except Exception as e: - assert e.__class__.__name__ == "FileNotFoundError" - assert e.__traceback__.tb_frame.f_code.co_filename == __file__ - assert traceback.format_exc(limit=1).startswith(exception_repr.format(__file__, line_number + 2)) - finally: - cmp.unpatch_common_modules() +from ddtrace.internal.settings.asm import config as asm_config def get_result(v: str) -> str: @@ -107,3 +49,69 @@ def wrapper2(original, instance, args, kargs): cmp.try_unwrap(__name__, "get_result") assert get_result("1") == "A1" assert len(watchdog._hook_map.get(__name__, ())) == initial_hooks + + +def _first_reported_frame(span: Span, namespace: str) -> dict[str, Any]: + traces = span._get_struct_tag(STACK_TRACE.TAG) + assert traces is not None + frame: dict[str, Any] = traces[namespace][0]["frames"][0] + return frame + + +def test_report_stack_strips_internal_frame_when_crop_stack_matches(): + span = Span("test-report-stack-match") + with ( + mock.patch.object(asm_config, "_ep_stack_trace_enabled", True), + mock.patch.object(asm_config, "_asm_enabled", True), + mock.patch.object(asm_config, "_ep_enabled", True), + ): + reported = report_stack( + span=span, + crop_stack="test_report_stack_strips_internal_frame_when_crop_stack_matches", + stack_id="stack-match", + namespace=STACK_TRACE.RASP, + ) + + assert reported + frame = _first_reported_frame(span, STACK_TRACE.RASP) + assert not frame["file"].endswith("stack_traces.py") + + +def test_report_stack_strips_internal_frame_when_crop_stack_is_stale(): + """ + A crop_stack that no longer matches any frame (e.g. after the anchor function was renamed) + must still fall back to stripping ddtrace/wrapt frames from the top of the stack, instead of + leaking report_stack's own internal frame into the reported trace. + """ + span = Span("test-report-stack-stale") + with ( + mock.patch.object(asm_config, "_ep_stack_trace_enabled", True), + mock.patch.object(asm_config, "_asm_enabled", True), + mock.patch.object(asm_config, "_ep_enabled", True), + ): + reported = report_stack( + span=span, + crop_stack="this_function_name_does_not_exist_on_the_stack", + stack_id="stack-stale", + namespace=STACK_TRACE.RASP, + ) + + assert reported + frame = _first_reported_frame(span, STACK_TRACE.RASP) + assert not frame["file"].endswith("stack_traces.py") + assert frame["function"] == "test_report_stack_strips_internal_frame_when_crop_stack_is_stale" + + +def test_report_stack_strips_internal_frame_without_crop_stack_for_rasp(): + span = Span("test-report-stack-no-crop") + with ( + mock.patch.object(asm_config, "_ep_stack_trace_enabled", True), + mock.patch.object(asm_config, "_asm_enabled", True), + mock.patch.object(asm_config, "_ep_enabled", True), + ): + reported = report_stack(span=span, stack_id="stack-no-crop", namespace=STACK_TRACE.RASP) + + assert reported + frame = _first_reported_frame(span, STACK_TRACE.RASP) + assert not frame["file"].endswith("stack_traces.py") + assert frame["function"] == "test_report_stack_strips_internal_frame_without_crop_stack_for_rasp" diff --git a/tests/appsec/appsec/test_filesystem.py b/tests/appsec/appsec/test_filesystem.py new file mode 100644 index 00000000000..a9e3c422074 --- /dev/null +++ b/tests/appsec/appsec/test_filesystem.py @@ -0,0 +1,214 @@ +from inspect import currentframe +from inspect import getframeinfo +from pathlib import Path +import traceback + +import mock +import pytest + +import ddtrace.appsec._common_module_patches as cmp +from ddtrace.appsec._constants import EXPLOIT_PREVENTION +from ddtrace.appsec._constants import WAF_ACTIONS +from ddtrace.appsec._contrib.filesystem import subscribers +from ddtrace.appsec._contrib.filesystem.events import FileOpenEvent +from ddtrace.appsec._contrib.filesystem.patch import wrapped_builtin_open +from ddtrace.appsec._contrib.filesystem.patch import wrapped_path_open +from ddtrace.appsec._contrib.filesystem.subscribers import AppSecFileOpenSubscriber +from ddtrace.appsec._utils import DDWaf_result +from ddtrace.appsec._utils import _observator +from ddtrace.internal import core +from ddtrace.internal._exceptions import BlockingException + + +class _OriginalOpen: + def __init__(self) -> None: + self.called = False + + def __call__(self, *args: object, **kwargs: object) -> object: + self.called = True + return "opened" + + +class _CountingPath: + def __init__(self, filename: str) -> None: + self.filename = filename + self.calls = 0 + + def __fspath__(self) -> str: + self.calls += 1 + return self.filename + + +class _ProxiedPath: + def __init__(self) -> None: + self.calls = 0 + + def __fspath__(self) -> str: + self.calls += 1 + return "actual.txt" + + def __getattribute__(self, name: str) -> object: + if name == "__fspath__": + return lambda: "alias.txt" + return super().__getattribute__(name) + + +def test_builtin_open_skips_filename_extraction_without_listener() -> None: + original = _OriginalOpen() + filename = _CountingPath("example.txt") + + with mock.patch.object(core, "has_listeners", return_value=False): + result = wrapped_builtin_open(original, None, (filename,), {}) + + assert result == "opened" + assert original.called + assert filename.calls == 0 + + +def test_builtin_open_dispatches_typed_event() -> None: + original = _OriginalOpen() + filename = _ProxiedPath() + + with ( + mock.patch.object(core, "has_listeners", return_value=True), + mock.patch.object(core, "dispatch_event") as dispatch, + ): + result = wrapped_builtin_open(original, None, (filename,), {}) + + assert result == "opened" + assert filename.calls == 1 + dispatch.assert_called_once_with(FileOpenEvent(filename="actual.txt"), allow_raise=True) + + +def test_path_open_dispatches_typed_event() -> None: + original = _OriginalOpen() + filename = Path("example.txt") + + with ( + mock.patch.object(core, "has_listeners", return_value=True), + mock.patch.object(core, "dispatch_event") as dispatch, + ): + result = wrapped_path_open(original, filename, (), {}) + + assert result == "opened" + dispatch.assert_called_once_with(FileOpenEvent(filename="example.txt"), allow_raise=True) + + +def test_blocking_listener_prevents_open() -> None: + original = _OriginalOpen() + + with ( + mock.patch.object(core, "has_listeners", return_value=True), + mock.patch.object(core, "dispatch_event", side_effect=BlockingException("blocked")), + ): + with pytest.raises(BlockingException): + wrapped_builtin_open(original, None, ("blocked.txt",), {}) + + assert not original.called + + +def test_appsec_listener_blocks_lfi() -> None: + result = DDWaf_result( + 1, + [], + {WAF_ACTIONS.BLOCK_ACTION: {}}, + 0.0, + 0.0, + False, + _observator(), + {}, + ) + block_config = {"status_code": 403} + event = FileOpenEvent(filename="blocked.txt") + + with ( + mock.patch.object(subscribers, "get_rasp_capability", return_value=True), + mock.patch.object(subscribers, "in_asm_context", return_value=True), + mock.patch.object(subscribers, "call_waf_callback", return_value=result) as call_waf, + mock.patch.object(subscribers, "get_blocked", return_value=block_config), + pytest.raises(BlockingException) as raised, + ): + AppSecFileOpenSubscriber.on_event(event) + + call_waf.assert_called_once_with( + {EXPLOIT_PREVENTION.ADDRESS.LFI: "blocked.txt"}, + crop_trace="on_event", + rule_type=EXPLOIT_PREVENTION.TYPE.LFI, + ) + assert raised.value.args == ( + block_config, + EXPLOIT_PREVENTION.BLOCKING, + EXPLOIT_PREVENTION.TYPE.LFI, + "blocked.txt", + ) + + +def test_appsec_listener_reports_skip_outside_asm_context() -> None: + event = FileOpenEvent(filename="example.txt") + + with ( + mock.patch.object(subscribers, "get_rasp_capability", return_value=True), + mock.patch.object(subscribers, "in_asm_context", return_value=False), + mock.patch.object(subscribers, "call_waf_callback") as call_waf, + mock.patch.object(subscribers, "report_rasp_skipped") as report_skipped, + ): + AppSecFileOpenSubscriber.on_event(event) + + report_skipped.assert_called_once_with(EXPLOIT_PREVENTION.TYPE.LFI, False) + call_waf.assert_not_called() + + +def test_lfi_normal_exception() -> None: + """Ensure builtins.open exceptions start at the customer call site.""" + exception_repr = """Traceback (most recent call last): + File "{}", line {}, in test_lfi_normal_exception + with open("/unknown/do_not_exist_test.txt", "w"): +""" + try: + cmp.patch_common_modules() + with pytest.raises(Exception) as raised: + with open("/unknown/do_not_exist_test.txt", "w"): + pass + assert raised.type is FileNotFoundError + assert raised.traceback[-1].path.as_posix() == __file__ + line_number = getframeinfo(currentframe()).lineno + try: + with open("/unknown/do_not_exist_test.txt", "w"): + pass + except Exception as exc: + assert exc.__class__.__name__ == "FileNotFoundError" + assert exc.__traceback__.tb_frame.f_code.co_filename == __file__ + assert traceback.format_exc(limit=1).startswith(exception_repr.format(__file__, line_number + 2)) + assert "_raise_without_wrapper_frame" not in ( + frame.name for frame in traceback.extract_tb(exc.__traceback__) + ) + finally: + cmp.unpatch_common_modules() + + +def test_lfi_normal_exception_pathlib() -> None: + """Ensure pathlib.Path.open exceptions start at the customer call site.""" + exception_repr = """Traceback (most recent call last): + File "{}", line {}, in test_lfi_normal_exception_pathlib + with Path("/unknown/do_not_exist_test.txt").open("w"): +""" + try: + cmp.patch_common_modules() + with pytest.raises(Exception) as raised: + with Path("/unknown/do_not_exist_test.txt").open("w"): + pass + assert raised.type is FileNotFoundError + assert raised.traceback[-1].path.as_posix() == __file__ + line_number = getframeinfo(currentframe()).lineno + try: + with Path("/unknown/do_not_exist_test.txt").open("w"): + pass + except Exception as exc: + assert exc.__class__.__name__ == "FileNotFoundError" + assert exc.__traceback__.tb_frame.f_code.co_filename == __file__ + assert traceback.format_exc(limit=1).startswith(exception_repr.format(__file__, line_number + 2)) + assert "_raise_without_wrapper_frame" not in ( + frame.name for frame in traceback.extract_tb(exc.__traceback__) + ) + finally: + cmp.unpatch_common_modules()