Skip to content

Commit 665c44b

Browse files
fix(iast): add code to filter out ddtrace stuff from dir() on patched modules [backport 2.17] (#11504)
Backport a9a6ad7 from #11490 to 2.17. Signed-off-by: Juanjo Alvarez <[email protected]> ## Description While testing on dd-source CI we found an issue where a module was doing a `dir(other_module)` and the changed results from the patched module were breaking stuff (because our patching would add `ddtrace_aspects`, `ddtrace_sink_points`, et cetera to the results and the original module was expecting all `other_module` symbols to have some members like `id`). This PRs fixes this problem by: - Creating a custom `__dir__` function (that will override any pre-existing ones) removing from the results all the symbols that we add ourselves while patching. - Renaming all added `ddtrace` symbols to `__ddtrace`. Also: - Adds a `_DD_IAST_NO_DIR_PATCH` config var to disable the wrapping of the patched module `__dir__` functions in case the user have some side-effect problem. - The return type of `visit_ast` has been fixed (it wrongly was `str` while is in fact a `ast.Module` type). ## 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) Co-authored-by: Juanjo Alvarez Martinez <[email protected]>
1 parent f3c25f7 commit 665c44b

File tree

12 files changed

+269
-111
lines changed

12 files changed

+269
-111
lines changed

ddtrace/appsec/_constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,10 @@ class IAST(metaclass=Constant_Class):
134134
JSON: Literal["_dd.iast.json"] = "_dd.iast.json"
135135
ENABLED: Literal["_dd.iast.enabled"] = "_dd.iast.enabled"
136136
PATCH_MODULES: Literal["_DD_IAST_PATCH_MODULES"] = "_DD_IAST_PATCH_MODULES"
137+
ENV_NO_DIR_PATCH: Literal["_DD_IAST_NO_DIR_PATCH"] = "_DD_IAST_NO_DIR_PATCH"
137138
DENY_MODULES: Literal["_DD_IAST_DENY_MODULES"] = "_DD_IAST_DENY_MODULES"
138139
SEP_MODULES: Literal[","] = ","
140+
PATCH_ADDED_SYMBOL_PREFIX: Literal["_ddtrace_"] = "_ddtrace_"
139141

140142
METRICS_REPORT_LVLS = (
141143
(TELEMETRY_DEBUG_VERBOSITY, TELEMETRY_DEBUG_NAME),

ddtrace/appsec/_iast/_ast/ast_patching.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import os
66
import re
77
from sys import builtin_module_names
8+
from sys import version_info
9+
import textwrap
810
from types import ModuleType
911
from typing import Optional
1012
from typing import Text
@@ -14,12 +16,14 @@
1416
from ddtrace.appsec._python_info.stdlib import _stdlib_for_python_version
1517
from ddtrace.internal.logger import get_logger
1618
from ddtrace.internal.module import origin
19+
from ddtrace.internal.utils.formats import asbool
1720

1821
from .visitor import AstVisitor
1922

2023

2124
_VISITOR = AstVisitor()
2225

26+
_PREFIX = IAST.PATCH_ADDED_SYMBOL_PREFIX
2327

2428
# Prefixes for modules where IAST patching is allowed
2529
IAST_ALLOWLIST: Tuple[Text, ...] = ("tests.appsec.iast.",)
@@ -278,6 +282,7 @@
278282
"protobuf.",
279283
"pycparser.", # this package is called when a module is imported, propagation is not needed
280284
"pytest.", # Testing framework
285+
"_pytest.",
281286
"setuptools.",
282287
"sklearn.", # Machine learning library
283288
"sqlalchemy.orm.interfaces.", # Performance optimization
@@ -368,7 +373,7 @@ def visit_ast(
368373
source_text: Text,
369374
module_path: Text,
370375
module_name: Text = "",
371-
) -> Optional[str]:
376+
) -> Optional[ast.Module]:
372377
parsed_ast = ast.parse(source_text, module_path)
373378
_VISITOR.update_location(filename=module_path, module_name=module_name)
374379
modified_ast = _VISITOR.visit(parsed_ast)
@@ -401,23 +406,56 @@ def _remove_flask_run(text: Text) -> Text:
401406
return new_text
402407

403408

404-
def astpatch_module(module: ModuleType, remove_flask_run: bool = False) -> Tuple[str, str]:
409+
_DIR_WRAPPER = textwrap.dedent(
410+
f"""
411+
412+
413+
def {_PREFIX}dir():
414+
orig_dir = globals().get("{_PREFIX}orig_dir__")
415+
416+
if orig_dir:
417+
# Use the original __dir__ method and filter the results
418+
results = [name for name in orig_dir() if not name.startswith("{_PREFIX}")]
419+
else:
420+
# List names from the module's __dict__ and filter out the unwanted names
421+
results = [
422+
name for name in globals()
423+
if not (name.startswith("{_PREFIX}") or name == "__dir__")
424+
]
425+
426+
return results
427+
428+
def {_PREFIX}set_dir_filter():
429+
if "__dir__" in globals():
430+
# Store the original __dir__ method
431+
globals()["{_PREFIX}orig_dir__"] = __dir__
432+
433+
# Replace the module's __dir__ with the custom one
434+
globals()["__dir__"] = {_PREFIX}dir
435+
436+
{_PREFIX}set_dir_filter()
437+
438+
"""
439+
)
440+
441+
442+
def astpatch_module(module: ModuleType, remove_flask_run: bool = False) -> Tuple[str, Optional[ast.Module]]:
405443
module_name = module.__name__
406444

407445
module_origin = origin(module)
408446
if module_origin is None:
409447
log.debug("astpatch_source couldn't find the module: %s", module_name)
410-
return "", ""
448+
return "", None
411449

412450
module_path = str(module_origin)
413451
try:
414452
if module_origin.stat().st_size == 0:
415453
# Don't patch empty files like __init__.py
416454
log.debug("empty file: %s", module_path)
417-
return "", ""
455+
return "", None
418456
except OSError:
419457
log.debug("astpatch_source couldn't find the file: %s", module_path, exc_info=True)
420-
return "", ""
458+
return "", None
421459

422460
# Get the file extension, if it's dll, os, pyd, dyn, dynlib: return
423461
# If its pyc or pyo, change to .py and check that the file exists. If not,
@@ -427,30 +465,35 @@ def astpatch_module(module: ModuleType, remove_flask_run: bool = False) -> Tuple
427465
if module_ext.lower() not in {".pyo", ".pyc", ".pyw", ".py"}:
428466
# Probably native or built-in module
429467
log.debug("extension not supported: %s for: %s", module_ext, module_path)
430-
return "", ""
468+
return "", None
431469

432470
with open(module_path, "r", encoding=get_encoding(module_path)) as source_file:
433471
try:
434472
source_text = source_file.read()
435473
except UnicodeDecodeError:
436474
log.debug("unicode decode error for file: %s", module_path, exc_info=True)
437-
return "", ""
475+
return "", None
438476

439477
if len(source_text.strip()) == 0:
440478
# Don't patch empty files like __init__.py
441479
log.debug("empty file: %s", module_path)
442-
return "", ""
480+
return "", None
443481

444482
if remove_flask_run:
445483
source_text = _remove_flask_run(source_text)
446484

447-
new_source = visit_ast(
485+
if not asbool(os.environ.get(IAST.ENV_NO_DIR_PATCH, "false")) and version_info > (3, 7):
486+
# Add the dir filter so __ddtrace stuff is not returned by dir(module)
487+
# does not work in 3.7 because it enters into infinite recursion
488+
source_text += _DIR_WRAPPER
489+
490+
new_ast = visit_ast(
448491
source_text,
449492
module_path,
450493
module_name=module_name,
451494
)
452-
if new_source is None:
495+
if new_ast is None:
453496
log.debug("file not ast patched: %s", module_path)
454-
return "", ""
497+
return "", None
455498

456-
return module_path, new_source
499+
return module_path, new_ast

ddtrace/appsec/_iast/_ast/visitor.py

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Text
1313
from typing import Tuple # noqa:F401
1414

15+
from ..._constants import IAST
1516
from .._metrics import _set_metric_iast_instrumented_propagation
1617
from ..constants import DEFAULT_PATH_TRAVERSAL_FUNCTIONS
1718
from ..constants import DEFAULT_WEAK_RANDOMNESS_FUNCTIONS
@@ -22,11 +23,12 @@
2223
PY38_PLUS = sys.version_info >= (3, 8, 0)
2324
PY39_PLUS = sys.version_info >= (3, 9, 0)
2425

26+
_PREFIX = IAST.PATCH_ADDED_SYMBOL_PREFIX
2527
CODE_TYPE_FIRST_PARTY = "first_party"
2628
CODE_TYPE_DD = "datadog"
2729
CODE_TYPE_SITE_PACKAGES = "site_packages"
2830
CODE_TYPE_STDLIB = "stdlib"
29-
TAINT_SINK_FUNCTION_REPLACEMENT = "ddtrace_taint_sinks.ast_function"
31+
TAINT_SINK_FUNCTION_REPLACEMENT = _PREFIX + "taint_sinks.ast_function"
3032

3133

3234
def _mark_avoid_convert_recursively(node):
@@ -38,71 +40,71 @@ def _mark_avoid_convert_recursively(node):
3840

3941
_ASPECTS_SPEC: Dict[Text, Any] = {
4042
"definitions_module": "ddtrace.appsec._iast._taint_tracking.aspects",
41-
"alias_module": "ddtrace_aspects",
43+
"alias_module": _PREFIX + "aspects",
4244
"functions": {
43-
"StringIO": "ddtrace_aspects.stringio_aspect",
44-
"BytesIO": "ddtrace_aspects.bytesio_aspect",
45-
"str": "ddtrace_aspects.str_aspect",
46-
"bytes": "ddtrace_aspects.bytes_aspect",
47-
"bytearray": "ddtrace_aspects.bytearray_aspect",
48-
"ddtrace_iast_flask_patch": "ddtrace_aspects.empty_func", # To avoid recursion
45+
"StringIO": _PREFIX + "aspects.stringio_aspect",
46+
"BytesIO": _PREFIX + "aspects.bytesio_aspect",
47+
"str": _PREFIX + "aspects.str_aspect",
48+
"bytes": _PREFIX + "aspects.bytes_aspect",
49+
"bytearray": _PREFIX + "aspects.bytearray_aspect",
50+
"ddtrace_iast_flask_patch": _PREFIX + "aspects.empty_func", # To avoid recursion
4951
},
5052
"stringalike_methods": {
51-
"StringIO": "ddtrace_aspects.stringio_aspect",
52-
"BytesIO": "ddtrace_aspects.bytesio_aspect",
53-
"decode": "ddtrace_aspects.decode_aspect",
54-
"join": "ddtrace_aspects.join_aspect",
55-
"encode": "ddtrace_aspects.encode_aspect",
56-
"extend": "ddtrace_aspects.bytearray_extend_aspect",
57-
"upper": "ddtrace_aspects.upper_aspect",
58-
"lower": "ddtrace_aspects.lower_aspect",
59-
"replace": "ddtrace_aspects.replace_aspect",
60-
"swapcase": "ddtrace_aspects.swapcase_aspect",
61-
"title": "ddtrace_aspects.title_aspect",
62-
"capitalize": "ddtrace_aspects.capitalize_aspect",
63-
"casefold": "ddtrace_aspects.casefold_aspect",
64-
"translate": "ddtrace_aspects.translate_aspect",
65-
"format": "ddtrace_aspects.format_aspect",
66-
"format_map": "ddtrace_aspects.format_map_aspect",
67-
"zfill": "ddtrace_aspects.zfill_aspect",
68-
"ljust": "ddtrace_aspects.ljust_aspect",
69-
"split": "ddtrace_aspects.split_aspect", # Both regular split and re.split
70-
"rsplit": "ddtrace_aspects.rsplit_aspect",
71-
"splitlines": "ddtrace_aspects.splitlines_aspect",
53+
"StringIO": _PREFIX + "aspects.stringio_aspect",
54+
"BytesIO": _PREFIX + "aspects.bytesio_aspect",
55+
"decode": _PREFIX + "aspects.decode_aspect",
56+
"join": _PREFIX + "aspects.join_aspect",
57+
"encode": _PREFIX + "aspects.encode_aspect",
58+
"extend": _PREFIX + "aspects.bytearray_extend_aspect",
59+
"upper": _PREFIX + "aspects.upper_aspect",
60+
"lower": _PREFIX + "aspects.lower_aspect",
61+
"replace": _PREFIX + "aspects.replace_aspect",
62+
"swapcase": _PREFIX + "aspects.swapcase_aspect",
63+
"title": _PREFIX + "aspects.title_aspect",
64+
"capitalize": _PREFIX + "aspects.capitalize_aspect",
65+
"casefold": _PREFIX + "aspects.casefold_aspect",
66+
"translate": _PREFIX + "aspects.translate_aspect",
67+
"format": _PREFIX + "aspects.format_aspect",
68+
"format_map": _PREFIX + "aspects.format_map_aspect",
69+
"zfill": _PREFIX + "aspects.zfill_aspect",
70+
"ljust": _PREFIX + "aspects.ljust_aspect",
71+
"split": _PREFIX + "aspects.split_aspect", # Both regular split and re.split
72+
"rsplit": _PREFIX + "aspects.rsplit_aspect",
73+
"splitlines": _PREFIX + "aspects.splitlines_aspect",
7274
# re module and re.Match methods
73-
"findall": "ddtrace_aspects.re_findall_aspect",
74-
"finditer": "ddtrace_aspects.re_finditer_aspect",
75-
"fullmatch": "ddtrace_aspects.re_fullmatch_aspect",
76-
"expand": "ddtrace_aspects.re_expand_aspect",
77-
"group": "ddtrace_aspects.re_group_aspect",
78-
"groups": "ddtrace_aspects.re_groups_aspect",
79-
"match": "ddtrace_aspects.re_match_aspect",
80-
"search": "ddtrace_aspects.re_search_aspect",
81-
"sub": "ddtrace_aspects.re_sub_aspect",
82-
"subn": "ddtrace_aspects.re_subn_aspect",
75+
"findall": _PREFIX + "aspects.re_findall_aspect",
76+
"finditer": _PREFIX + "aspects.re_finditer_aspect",
77+
"fullmatch": _PREFIX + "aspects.re_fullmatch_aspect",
78+
"expand": _PREFIX + "aspects.re_expand_aspect",
79+
"group": _PREFIX + "aspects.re_group_aspect",
80+
"groups": _PREFIX + "aspects.re_groups_aspect",
81+
"match": _PREFIX + "aspects.re_match_aspect",
82+
"search": _PREFIX + "aspects.re_search_aspect",
83+
"sub": _PREFIX + "aspects.re_sub_aspect",
84+
"subn": _PREFIX + "aspects.re_subn_aspect",
8385
},
8486
# Replacement function for indexes and ranges
8587
"slices": {
86-
"index": "ddtrace_aspects.index_aspect",
87-
"slice": "ddtrace_aspects.slice_aspect",
88+
"index": _PREFIX + "aspects.index_aspect",
89+
"slice": _PREFIX + "aspects.slice_aspect",
8890
},
8991
# Replacement functions for modules
9092
"module_functions": {
9193
"os.path": {
92-
"basename": "ddtrace_aspects.ospathbasename_aspect",
93-
"dirname": "ddtrace_aspects.ospathdirname_aspect",
94-
"join": "ddtrace_aspects.ospathjoin_aspect",
95-
"normcase": "ddtrace_aspects.ospathnormcase_aspect",
96-
"split": "ddtrace_aspects.ospathsplit_aspect",
97-
"splitext": "ddtrace_aspects.ospathsplitext_aspect",
94+
"basename": _PREFIX + "aspects.ospathbasename_aspect",
95+
"dirname": _PREFIX + "aspects.ospathdirname_aspect",
96+
"join": _PREFIX + "aspects.ospathjoin_aspect",
97+
"normcase": _PREFIX + "aspects.ospathnormcase_aspect",
98+
"split": _PREFIX + "aspects.ospathsplit_aspect",
99+
"splitext": _PREFIX + "aspects.ospathsplitext_aspect",
98100
}
99101
},
100102
"operators": {
101-
ast.Add: "ddtrace_aspects.add_aspect",
102-
"INPLACE_ADD": "ddtrace_aspects.add_inplace_aspect",
103-
"FORMAT_VALUE": "ddtrace_aspects.format_value_aspect",
104-
ast.Mod: "ddtrace_aspects.modulo_aspect",
105-
"BUILD_STRING": "ddtrace_aspects.build_string_aspect",
103+
ast.Add: _PREFIX + "aspects.add_aspect",
104+
"INPLACE_ADD": _PREFIX + "aspects.add_inplace_aspect",
105+
"FORMAT_VALUE": _PREFIX + "aspects.format_value_aspect",
106+
ast.Mod: _PREFIX + "aspects.modulo_aspect",
107+
"BUILD_STRING": _PREFIX + "aspects.build_string_aspect",
106108
},
107109
"excluded_from_patching": {
108110
# Key: module being patched
@@ -123,6 +125,8 @@ def _mark_avoid_convert_recursively(node):
123125
},
124126
"django.utils.html": {"": ("format_html", "format_html_join")},
125127
"sqlalchemy.sql.compiler": {"": ("_requires_quotes",)},
128+
# Our added functions
129+
"": {"": (f"{_PREFIX}dir", f"{_PREFIX}set_dir_filter")},
126130
},
127131
# This is a set since all functions will be replaced by taint_sink_functions
128132
"taint_sinks": {
@@ -149,10 +153,10 @@ def _mark_avoid_convert_recursively(node):
149153

150154

151155
if sys.version_info >= (3, 12):
152-
_ASPECTS_SPEC["module_functions"]["os.path"]["splitroot"] = "ddtrace_aspects.ospathsplitroot_aspect"
156+
_ASPECTS_SPEC["module_functions"]["os.path"]["splitroot"] = _PREFIX + "aspects.ospathsplitroot_aspect"
153157

154158
if sys.version_info >= (3, 12) or os.name == "nt":
155-
_ASPECTS_SPEC["module_functions"]["os.path"]["splitdrive"] = "ddtrace_aspects.ospathsplitdrive_aspect"
159+
_ASPECTS_SPEC["module_functions"]["os.path"]["splitdrive"] = _PREFIX + "aspects.ospathsplitdrive_aspect"
156160

157161

158162
class AstVisitor(ast.NodeTransformer):
@@ -163,7 +167,7 @@ def __init__(
163167
):
164168
self._sinkpoints_spec = {
165169
"definitions_module": "ddtrace.appsec._iast.taint_sinks",
166-
"alias_module": "ddtrace_taint_sinks",
170+
"alias_module": _PREFIX + "taint_sinks",
167171
"functions": {},
168172
}
169173
self._sinkpoints_functions = self._sinkpoints_spec["functions"]
@@ -458,6 +462,9 @@ def visit_FunctionDef(self, def_node: ast.FunctionDef) -> Any:
458462
Special case for some tests which would enter in a patching
459463
loop otherwise when visiting the check functions
460464
"""
465+
if f"{_PREFIX}dir" in def_node.name or f"{_PREFIX}set_dir_filter" in def_node.name:
466+
return def_node
467+
461468
self.replacements_disabled_for_functiondef = def_node.name in self.dont_patch_these_functionsdefs
462469

463470
if hasattr(def_node.args, "vararg") and def_node.args.vararg:

ddtrace/appsec/_iast/_loader.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@
1212

1313

1414
def _exec_iast_patched_module(module_watchdog, module):
15-
patched_source = None
15+
patched_ast = None
1616
compiled_code = None
1717
if IS_IAST_ENABLED:
1818
try:
19-
module_path, patched_source = astpatch_module(module)
19+
module_path, patched_ast = astpatch_module(module)
2020
except Exception:
2121
log.debug("Unexpected exception while AST patching", exc_info=True)
22-
patched_source = None
22+
patched_ast = None
2323

24-
if patched_source:
24+
if patched_ast:
2525
try:
2626
# Patched source is compiled in order to execute it
27-
compiled_code = compile(patched_source, module_path, "exec")
27+
compiled_code = compile(patched_ast, module_path, "exec")
2828
except Exception:
2929
log.debug("Unexpected exception while compiling patched code", exc_info=True)
3030
compiled_code = None
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
Code Security: patch the module dir function so original pre-patch results are not changed.

tests/appsec/appsec_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def appsec_application_server(
100100
env[IAST.ENV] = iast_enabled
101101
env[IAST.ENV_REQUEST_SAMPLING] = "100"
102102
env["_DD_APPSEC_DEDUPLICATION_ENABLED"] = "false"
103+
env[IAST.ENV_NO_DIR_PATCH] = "false"
103104
if assert_debug:
104105
env["_" + IAST.ENV_DEBUG] = iast_enabled
105106
env["_" + IAST.ENV_PROPAGATION_DEBUG] = iast_enabled

0 commit comments

Comments
 (0)