Skip to content

Commit 9d2d4e6

Browse files
authored
chore(iast): add more iast tests types (#13132)
## 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 e8c41e2 commit 9d2d4e6

File tree

15 files changed

+261
-47
lines changed

15 files changed

+261
-47
lines changed

ddtrace/appsec/_iast/_ast/visitor.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def _merge_dicts(*args_functions: Set[str]) -> Set[str]:
227227

228228
@staticmethod
229229
def _is_string_node(node: Any) -> bool:
230-
if PY3 and (isinstance(node, ast.Constant) and isinstance(node.value, (str, bytes, bytearray))):
230+
if PY3 and (isinstance(node, ast.Constant) and isinstance(node.value, IAST.TEXT_TYPES)):
231231
return True
232232

233233
return False

ddtrace/appsec/_iast/_handlers.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from wrapt import when_imported
55
from wrapt import wrap_function_wrapper as _w
66

7+
from ddtrace.appsec._constants import IAST
78
from ddtrace.appsec._iast._iast_request_context import get_iast_stacktrace_reported
89
from ddtrace.appsec._iast._iast_request_context import set_iast_stacktrace_reported
910
from ddtrace.appsec._iast._logs import iast_instrumentation_wrapt_debug_log
@@ -249,7 +250,7 @@ def _on_django_func_wrapped(fn_args, fn_kwargs, first_arg_expected_type, *_):
249250

250251
def _custom_protobuf_getattribute(self, name):
251252
ret = type(self).__saved_getattr(self, name)
252-
if isinstance(ret, (str, bytes, bytearray)):
253+
if isinstance(ret, IAST.TEXT_TYPES):
253254
ret = taint_pyobject(
254255
pyobject=ret,
255256
source_name=OriginType.GRPC_BODY,

ddtrace/appsec/_iast/_patches/json_tainting.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ddtrace.internal.logger import get_logger
55
from ddtrace.settings.asm import config as asm_config
66

7+
from ..._constants import IAST
78
from .._patch import set_and_check_module_is_patched
89
from .._patch import set_module_unpatched
910
from .._patch import try_wrap_function_wrapper
@@ -55,7 +56,7 @@ def wrapped_loads(wrapped, instance, args, kwargs):
5556
obj = taint_structure(obj, source.origin, source.origin)
5657
elif isinstance(obj, list):
5758
obj = taint_structure(obj, source.origin, source.origin)
58-
elif isinstance(obj, (str, bytes, bytearray)):
59+
elif isinstance(obj, IAST.TEXT_TYPES):
5960
obj = taint_pyobject(obj, source.name, source.value, source.origin)
6061
except Exception:
6162
log.debug("Unexpected exception while reporting vulnerability", exc_info=True)

ddtrace/appsec/_iast/_taint_tracking/aspects.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def format_value_aspect(
435435
element: Any,
436436
options: int = 0,
437437
format_spec: Optional[str] = None,
438-
) -> Union[str, bytes, bytearray]:
438+
) -> TEXT_TYPES:
439439
if options == 115:
440440
new_text = str_aspect(str, 0, element)
441441
elif options == 114:

ddtrace/appsec/_iast/_taint_utils.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Optional
66
from typing import Union
77

8+
from ddtrace.appsec._constants import IAST
89
from ddtrace.appsec._iast._taint_tracking._taint_objects import is_pyobject_tainted
910
from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject
1011
from ddtrace.internal.logger import get_logger
@@ -101,7 +102,7 @@ def taint_structure(main_obj, source_key, source_value, override_pyobject_tainte
101102
if command.pre: # first processing of the object
102103
if not command.obj:
103104
command.store(command.obj)
104-
elif isinstance(command.obj, (str, bytes, bytearray)):
105+
elif isinstance(command.obj, IAST.TEXT_TYPES):
105106
if override_pyobject_tainted or not is_pyobject_tainted(command.obj):
106107
new_obj = taint_pyobject(
107108
pyobject=command.obj,
@@ -161,7 +162,7 @@ def __init__(self, original_list, origins=(0, 0), override_pyobject_tainted=Fals
161162

162163
def _taint(self, value):
163164
if value:
164-
if isinstance(value, (str, bytes, bytearray)):
165+
if isinstance(value, IAST.TEXT_TYPES):
165166
if not is_pyobject_tainted(value) or self._override_pyobject_tainted:
166167
try:
167168
# TODO: migrate this part to shift ranges instead of creating a new one
@@ -342,7 +343,7 @@ def _taint(self, value, key, origin=None):
342343
if origin is None:
343344
origin = self._origin_value
344345
if value:
345-
if isinstance(value, (str, bytes, bytearray)):
346+
if isinstance(value, IAST.TEXT_TYPES):
346347
if not is_pyobject_tainted(value) or self._override_pyobject_tainted:
347348
try:
348349
# TODO: migrate this part to shift ranges instead of creating a new one

ddtrace/appsec/_iast/taint_sinks/_base.py

+22-17
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from typing import Any
33
from typing import Callable
44
from typing import Optional
5-
from typing import Text
65
from typing import Tuple
6+
from typing import Union
77

88
from ddtrace.appsec._deduplications import deduplication
99
from ddtrace.appsec._iast._taint_tracking import get_ranges
@@ -12,6 +12,7 @@
1212
from ddtrace.internal.logger import get_logger
1313
from ddtrace.settings.asm import config as asm_config
1414

15+
from ..._constants import IAST
1516
from .._iast_request_context import get_iast_reporter
1617
from .._iast_request_context import set_iast_reporter
1718
from .._overhead_control_engine import Operation
@@ -27,6 +28,8 @@
2728

2829
CWD = os.path.abspath(os.getcwd())
2930

31+
TEXT_TYPES = Union[str, bytes, bytearray]
32+
3033

3134
class taint_sink_deduplication(deduplication):
3235
def _check_deduplication(self):
@@ -77,12 +80,12 @@ def wrapper(wrapped: Callable, instance: Any, args: Any, kwargs: Any) -> Any:
7780
@taint_sink_deduplication
7881
def _prepare_report(
7982
cls,
80-
vulnerability_type,
81-
evidence,
82-
file_name,
83-
line_number,
84-
function_name: Text = "",
85-
class_name: Text = "",
83+
vulnerability_type: str,
84+
evidence: Evidence,
85+
file_name: Optional[str],
86+
line_number: int,
87+
function_name: str = "",
88+
class_name: str = "",
8689
*args,
8790
**kwargs,
8891
) -> bool:
@@ -125,7 +128,7 @@ def _prepare_report(
125128
return True
126129

127130
@classmethod
128-
def _compute_file_line(cls) -> Tuple[Optional[Text], Optional[int], Optional[Text], Optional[Text]]:
131+
def _compute_file_line(cls) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[str]]:
129132
file_name = line_number = function_name = class_name = None
130133

131134
frame_info = get_info_frame(CWD)
@@ -145,17 +148,19 @@ def _compute_file_line(cls) -> Tuple[Optional[Text], Optional[int], Optional[Tex
145148
@classmethod
146149
def _create_evidence_and_report(
147150
cls,
148-
vulnerability_type: Text,
149-
evidence_value: Text = "",
150-
dialect: Optional[Text] = None,
151-
file_name: Optional[Text] = None,
151+
vulnerability_type: str,
152+
evidence_value: TEXT_TYPES = "",
153+
dialect: Optional[str] = None,
154+
file_name: Optional[str] = None,
152155
line_number: Optional[int] = None,
153-
function_name: Optional[Text] = None,
154-
class_name: Optional[Text] = None,
156+
function_name: Optional[str] = None,
157+
class_name: Optional[str] = None,
155158
*args,
156159
**kwargs,
157160
):
158-
if isinstance(evidence_value, (str, bytes, bytearray)):
161+
if isinstance(evidence_value, IAST.TEXT_TYPES):
162+
if isinstance(evidence_value, (bytes, bytearray)):
163+
evidence_value = evidence_value.decode("utf-8")
159164
evidence = Evidence(value=evidence_value, dialect=dialect)
160165
else:
161166
log.debug("Unexpected evidence_value type: %s", type(evidence_value))
@@ -165,7 +170,7 @@ def _create_evidence_and_report(
165170
)
166171

167172
@classmethod
168-
def report(cls, evidence_value: Text = "", dialect: Optional[Text] = None) -> None:
173+
def report(cls, evidence_value: TEXT_TYPES = "", dialect: Optional[str] = None) -> None:
169174
"""Build a IastSpanReporter instance to report it in the `AppSecIastSpanProcessor` as a string JSON"""
170175
if cls.acquire_quota():
171176
file_name = line_number = function_name = class_name = None
@@ -189,7 +194,7 @@ def report(cls, evidence_value: Text = "", dialect: Optional[Text] = None) -> No
189194
cls.increment_quota()
190195

191196
@classmethod
192-
def is_tainted_pyobject(cls, string_to_check: Text) -> bool:
197+
def is_tainted_pyobject(cls, string_to_check: TEXT_TYPES) -> bool:
193198
"""Check if a string contains tainted ranges that are not marked as secure.
194199
195200
A string is considered tainted when:

tests/appsec/iast/aspects/aspect_utils.py

+19-23
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
# -*- encoding: utf-8 -*-
21
import re
3-
from typing import Any # noqa:F401
4-
from typing import List # noqa:F401
5-
from typing import NamedTuple # noqa:F401
6-
from typing import Optional # noqa:F401
7-
from typing import Text # noqa:F401
2+
from typing import Any
3+
from typing import List
4+
from typing import NamedTuple
5+
from typing import Optional
86

97
from ddtrace.appsec._iast._taint_tracking import OriginType
108
from ddtrace.appsec._iast._taint_tracking import Source
119
from ddtrace.appsec._iast._taint_tracking import TaintRange
1210
from ddtrace.appsec._iast._taint_tracking import as_formatted_evidence
1311
from ddtrace.appsec._iast._taint_tracking import set_ranges
1412
from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject_with_ranges
13+
from tests.appsec.iast.iast_utils import TEXT_TYPE
1514
from tests.appsec.iast.iast_utils import _iast_patched_module
1615

1716

@@ -26,18 +25,16 @@
2625
TAINT_FORMAT_PATTERN_BYTES = re.compile(TAINT_FORMAT_CAPTURE_BYTES, re.MULTILINE | re.DOTALL)
2726

2827

29-
def create_taint_range_with_format(text_input, fn_origin=""): # type: (Any, str) -> Any
28+
def create_taint_range_with_format(text_input: Any, fn_origin: str = "") -> Any:
3029
is_bytes = isinstance(text_input, (bytes, bytearray))
3130
taint_format_capture = TAINT_FORMAT_CAPTURE_BYTES if is_bytes else TAINT_FORMAT_CAPTURE
3231
taint_format_pattern = TAINT_FORMAT_PATTERN_BYTES if is_bytes else TAINT_FORMAT_PATTERN
3332

34-
text_output = re.sub( # type: ignore
35-
taint_format_capture, r"\2", text_input, flags=re.MULTILINE | re.DOTALL
36-
) # type: Any
33+
text_output: Any = re.sub(taint_format_capture, r"\2", text_input, flags=re.MULTILINE | re.DOTALL)
3734
if isinstance(text_input, bytearray):
38-
text_output = bytearray(text_output) # type: ignore
35+
text_output = bytearray(text_output)
3936

40-
ranges_ = [] # type: List[TaintRange]
37+
ranges_: List[TaintRange] = []
4138
acc_input_id = 0
4239
for i, match in enumerate(taint_format_pattern.finditer(text_input)): # type: ignore[attr-defined]
4340
match_start = match.start() - (i * 6) - acc_input_id
@@ -63,24 +60,23 @@ def create_taint_range_with_format(text_input, fn_origin=""): # type: (Any, str
6360

6461
taint_pyobject_with_ranges(
6562
text_output,
66-
ranges_,
63+
tuple(ranges_),
6764
)
6865
return text_output
6966

7067

71-
class BaseReplacement(object):
72-
def _to_tainted_string_with_origin(self, text):
73-
# type: (Text) -> Text
68+
class BaseReplacement:
69+
def _to_tainted_string_with_origin(self, text: TEXT_TYPE) -> TEXT_TYPE:
7470
if not isinstance(text, (str, bytes, bytearray)):
7571
return text
7672

7773
# CAVEAT: the sequences ":+-" and "-+:" can be escaped with "::++--" and "--+*::"
7874
elements = re.split(r"(\:\+-<[0-9a-zA-Z\-]+>|<[0-9a-zA-Z\-]+>-\+\:)", text)
7975

80-
ranges = [] # type: List[TaintRange]
76+
ranges: List[TaintRange] = []
8177
ranges_append = ranges.append
8278
new_text = text.__class__()
83-
context = None # type: Optional[EscapeContext]
79+
context: Optional[EscapeContext] = None
8480
for index, element in enumerate(elements):
8581
if index % 2 == 0:
8682
element = element.replace("::++--", ":+-")
@@ -112,11 +108,11 @@ def _to_tainted_string_with_origin(self, text):
112108

113109
def _assert_format_result(
114110
self,
115-
taint_escaped_template, # type: Text
116-
taint_escaped_parameter, # type: Any
117-
expected_result, # type: Text
118-
escaped_expected_result, # type: Text
119-
): # type: (...) -> None
111+
taint_escaped_template: TEXT_TYPE,
112+
taint_escaped_parameter: Any,
113+
expected_result: TEXT_TYPE,
114+
escaped_expected_result: TEXT_TYPE,
115+
) -> None:
120116
template = self._to_tainted_string_with_origin(taint_escaped_template)
121117
parameter = self._to_tainted_string_with_origin(taint_escaped_parameter)
122118
result = mod.do_format_with_positional_parameter(template, parameter)

tests/appsec/iast/aspects/test_add_aspect.py

+30
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import logging
2+
from pathlib import Path
3+
from pathlib import PosixPath
24

5+
from hypothesis import given
6+
from hypothesis.strategies import from_type
7+
from hypothesis.strategies import one_of
38
import pytest
49

510
from ddtrace.appsec._iast._taint_tracking import OriginType
@@ -13,6 +18,7 @@
1318
from ddtrace.appsec._iast._taint_tracking.aspects import add_aspect
1419
from tests.appsec.iast.conftest import _end_iast_context_and_oce
1520
from tests.appsec.iast.conftest import _start_iast_context_and_oce
21+
from tests.appsec.iast.iast_utils import string_strategies
1622
from tests.utils import override_env
1723
from tests.utils import override_global_config
1824

@@ -34,6 +40,30 @@ def test_add_aspect_successful(obj1, obj2):
3440
assert ddtrace_aspects.add_aspect(obj1, obj2) == obj1 + obj2
3541

3642

43+
@given(one_of(string_strategies))
44+
def test_add_aspect_text_successful(text):
45+
assert ddtrace_aspects.add_aspect(text, text) == text + text
46+
47+
48+
@given(from_type(int))
49+
def test_add_aspect_int_successful(text):
50+
assert ddtrace_aspects.add_aspect(text, text) == text + text
51+
52+
53+
@given(from_type(Path))
54+
def test_add_aspect_path_error(path):
55+
with pytest.raises(TypeError) as exc_info:
56+
ddtrace_aspects.add_aspect(path, path)
57+
assert str(exc_info.value) == "unsupported operand type(s) for +: 'PosixPath' and 'PosixPath'"
58+
59+
60+
@given(from_type(PosixPath))
61+
def test_add_aspect_posixpath_error(posixpath):
62+
with pytest.raises(TypeError) as exc_info:
63+
ddtrace_aspects.add_aspect(posixpath, posixpath)
64+
assert str(exc_info.value) == "unsupported operand type(s) for +: 'PosixPath' and 'PosixPath'"
65+
66+
3767
@pytest.mark.parametrize(
3868
"obj1, obj2",
3969
[(b"Hi", ""), ("Hi", b""), ({"a", "b"}, {"c", "d"}), (dict(), dict())],

tests/appsec/iast/aspects/test_common_aspects.py

+13
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
"""
55
import os
66

7+
from hypothesis import given
8+
from hypothesis.strategies import one_of
79
import pytest
810

911
import tests.appsec.iast.fixtures.aspects.callees
1012
from tests.appsec.iast.iast_utils import _iast_patched_module
13+
from tests.appsec.iast.iast_utils import string_strategies
1114

1215

1316
def generate_callers_from_callees(callees_module, callers_file="", callees_module_str=""):
@@ -48,6 +51,16 @@ def callee_{function}_direct(*args, **kwargs):
4851
from tests.appsec.iast.fixtures.aspects import unpatched_callers # type: ignore[attr-defined] # noqa: E402
4952

5053

54+
@given(one_of(string_strategies))
55+
def test_aspect_patched_result_hypothesis(text_input):
56+
"""
57+
Test that the result of the patched aspect call is the same as the unpatched one
58+
using Hypothesis-generated inputs.
59+
"""
60+
for aspect in [x for x in dir(unpatched_callers) if not x.startswith(("_", "@"))]:
61+
assert getattr(patched_callers, aspect)(text_input) == getattr(unpatched_callers, aspect)(text_input)
62+
63+
5164
@pytest.mark.parametrize("aspect", [x for x in dir(unpatched_callers) if not x.startswith(("_", "@"))])
5265
@pytest.mark.parametrize("args", [(), ("a"), ("a", "b")])
5366
@pytest.mark.parametrize("kwargs", [{}, {"dry_run": False}, {"dry_run": True}])

0 commit comments

Comments
 (0)