diff --git a/ddtrace/appsec/_iast/taint_sinks/code_injection.py b/ddtrace/appsec/_iast/taint_sinks/code_injection.py index 45e19f63545..8ce081ee997 100644 --- a/ddtrace/appsec/_iast/taint_sinks/code_injection.py +++ b/ddtrace/appsec/_iast/taint_sinks/code_injection.py @@ -25,6 +25,9 @@ def get_version() -> Text: _IS_PATCHED = False +# Upper bound on wrapper frames to skip when locating the caller of eval. +_MAX_WRAPPER_FRAMES = 8 + def patch(): global _IS_PATCHED @@ -53,6 +56,23 @@ class CodeInjection(VulnerabilityBase): secure_mark = VulnerabilityType.CODE_INJECTION +def _resolve_caller_frame(frame): + """Walk past the wrapping machinery so we land on the frame that called eval. + + wrapt's pure-Python FunctionWrapper adds a frame that its C extension does not, so a + fixed f_back depth resolves to the wrong scope whenever the C extension is unavailable. + """ + candidate = frame + for _ in range(_MAX_WRAPPER_FRAMES): + if candidate is None: + break + module_name = candidate.f_globals.get("__name__") or "" + if module_name != "wrapt" and not module_name.startswith("wrapt."): + return candidate + candidate = candidate.f_back + return frame + + def _iast_coi(wrapped, instance, args, kwargs): if len(args) >= 1: _iast_report_code_injection(args[0]) @@ -81,7 +101,8 @@ def _iast_coi(wrapped, instance, args, kwargs): else: frames = inspect.currentframe() if frames is not None: - caller_frame = frames.f_back + caller_frame = _resolve_caller_frame(frames.f_back) + if caller_frame is not None: func_globals = caller_frame.f_globals if len(args) > 2: @@ -92,7 +113,7 @@ def _iast_coi(wrapped, instance, args, kwargs): if caller_frame is None: frames = inspect.currentframe() if frames is not None: - caller_frame = frames.f_back + caller_frame = _resolve_caller_frame(frames.f_back) if caller_frame is not None: func_locals = caller_frame.f_locals func_locals_copy_to_check = func_locals.copy() if func_locals else None diff --git a/ddtrace/internal/settings/asm.py b/ddtrace/internal/settings/asm.py index c129617c3f8..7e1f07f7e58 100644 --- a/ddtrace/internal/settings/asm.py +++ b/ddtrace/internal/settings/asm.py @@ -263,8 +263,10 @@ class ASMConfig(DDConfig): _bypass_instrumentation_for_waf = False _is_testing_instrumentation_for_waf = False - # IAST supported on python 3.6 to 3.13 and never on windows - _iast_supported: bool = ((3, 6, 0) <= sys.version_info < (3, 15, 0)) and not ( + # AIDEV-NOTE: Only runtime gate for IAST version support; the native extensions are not + # version-gated in setup.py. Keep the upper bound in sync with requires-python. + # IAST supported on python 3.6 to 3.15 and never on windows + _iast_supported: bool = ((3, 6, 0) <= sys.version_info < (3, 16, 0)) and not ( sys.platform.startswith("win") or sys.platform.startswith("cygwin") ) diff --git a/releasenotes/notes/iast-fix-eval-caller-scope-74305dc0d86abc22.yaml b/releasenotes/notes/iast-fix-eval-caller-scope-74305dc0d86abc22.yaml new file mode 100644 index 00000000000..30a9381198a --- /dev/null +++ b/releasenotes/notes/iast-fix-eval-caller-scope-74305dc0d86abc22.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Code Security (IAST): Fixes a ``NameError`` raised from application code that calls ``eval()`` + when the ``wrapt`` C extension is unavailable and its pure-Python implementation is used, which + caused Code Security to resolve the wrong caller scope. diff --git a/releasenotes/notes/iast-python-315-support-62b7d764f5b5b5b0.yaml b/releasenotes/notes/iast-python-315-support-62b7d764f5b5b5b0.yaml new file mode 100644 index 00000000000..e5106a60b69 --- /dev/null +++ b/releasenotes/notes/iast-python-315-support-62b7d764f5b5b5b0.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Code Security (IAST): Adds support for Python 3.15. Setting ``DD_IAST_ENABLED=true`` now + enables Code Security on Python 3.15; previously it was silently ignored on that version. diff --git a/tests/appsec/iast/aspects/test_slice_aspect_fixtures.py b/tests/appsec/iast/aspects/test_slice_aspect_fixtures.py index f60ebd2630e..e3ce3c38b95 100644 --- a/tests/appsec/iast/aspects/test_slice_aspect_fixtures.py +++ b/tests/appsec/iast/aspects/test_slice_aspect_fixtures.py @@ -58,7 +58,9 @@ def test_string_slice_2(input_str, start_pos, end_pos, step, expected_result, ta if not tainted: with pytest.raises(TypeError) as excinfo: mod.do_slice_2(input_str, start_pos, end_pos, step) # pylint: disable=no-member - assert "slice indices must be integers or None or have an __index__ method" in str(excinfo.value) + # Python 3.15 dropped the "or None" clause from this message. + assert "slice indices must be integers or" in str(excinfo.value) + assert "have an __index__ method" in str(excinfo.value) else: result = mod.do_slice_2(input_str, start_pos, end_pos, step) # pylint: disable=no-member assert result == expected_result