Skip to content

Commit e4127dd

Browse files
github-actions[bot]gnufedeavara1986
authored
fix(iast): ensure args are not empty in aspects to avoid index error [backport 1.20] (#7740)
Backport 20ea51d from #7738 to 1.20. IAST: Resolved an issue where certain aspects raised an IndexError when no arguments were provided, despite the replaced methods/functions accommodating calls without parameters. The fix eliminates the requirement for at least one parameter in these aspects and includes regression tests to ensure stability. ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] 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) - [x] If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. - [x] This PR doesn't touch any of that. --------- Co-authored-by: Federico Mon <[email protected]> Co-authored-by: Alberto Vara <[email protected]>
1 parent 4ae7a3a commit e4127dd

File tree

4 files changed

+145
-14
lines changed

4 files changed

+145
-14
lines changed

.circleci/config.templ.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ commands:
7878
description: "Install riot"
7979
steps:
8080
# Make sure we install and run riot on Python 3
81-
- run: pip3 install riot==0.17.7
81+
- run: pip3 install riot==0.17.7 'virtualenv<=20.24.6' 'setuptools<=68.2.0'
8282

8383
setup_hatch:
8484
description: "Install hatch"

ddtrace/appsec/iast/_taint_tracking/aspects.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def str_aspect(orig_function, flag_added_args, *args, **kwargs):
6161
else:
6262
result = args[0].str(*args[1:], **kwargs)
6363

64-
if isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
64+
if args and isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
6565
try:
6666
if isinstance(args[0], (bytes, bytearray)):
6767
check_offset = args[0].decode("utf-8")
@@ -87,7 +87,7 @@ def bytes_aspect(orig_function, flag_added_args, *args, **kwargs):
8787
else:
8888
result = args[0].bytes(*args[1:], **kwargs)
8989

90-
if isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
90+
if args and isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
9191
try:
9292
taint_pyobject_with_ranges(result, tuple(get_ranges(args[0])))
9393
except Exception as e:
@@ -106,7 +106,7 @@ def bytearray_aspect(orig_function, flag_added_args, *args, **kwargs):
106106
else:
107107
result = args[0].bytearray(*args[1:], **kwargs)
108108

109-
if isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
109+
if args and isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
110110
try:
111111
taint_pyobject_with_ranges(result, tuple(get_ranges(args[0])))
112112
except Exception as e:
@@ -123,6 +123,9 @@ def join_aspect(orig_function, flag_added_args, *args, **kwargs):
123123
args = args[flag_added_args:]
124124
return orig_function(*args, **kwargs)
125125

126+
if not args:
127+
return orig_function(*args, **kwargs)
128+
126129
joiner = args[0]
127130
args = args[flag_added_args:]
128131
if not isinstance(joiner, TEXT_TYPES):
@@ -292,6 +295,9 @@ def format_aspect(
292295
args = args[flag_added_args:]
293296
return orig_function(*args, **kwargs)
294297

298+
if not args:
299+
return orig_function(*args, **kwargs)
300+
295301
candidate_text = args[0] # type: str
296302
args = args[flag_added_args:]
297303

@@ -341,6 +347,9 @@ def format_map_aspect(
341347
args = args[flag_added_args:]
342348
return orig_function(*args, **kwargs)
343349

350+
if orig_function and not args:
351+
return orig_function(*args, **kwargs)
352+
344353
candidate_text = args[0] # type: str
345354
args = args[flag_added_args:]
346355
if not isinstance(candidate_text, TEXT_TYPES):
@@ -385,7 +394,7 @@ def repr_aspect(orig_function, flag_added_args, *args, **kwargs):
385394

386395
result = repr(*args, **kwargs)
387396

388-
if isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
397+
if args and isinstance(args[0], TEXT_TYPES) and is_pyobject_tainted(args[0]):
389398
try:
390399
if isinstance(args[0], (bytes, bytearray)):
391400
check_offset = args[0].decode("utf-8")
@@ -483,7 +492,7 @@ def incremental_translation(self, incr_coder, funcode, empty):
483492

484493

485494
def decode_aspect(orig_function, flag_added_args, *args, **kwargs):
486-
if orig_function and not flag_added_args:
495+
if orig_function and (not flag_added_args or not args):
487496
# This patch is unexpected, so we fallback
488497
# to executing the original function
489498
return orig_function(*args, **kwargs)
@@ -506,7 +515,7 @@ def decode_aspect(orig_function, flag_added_args, *args, **kwargs):
506515

507516

508517
def encode_aspect(orig_function, flag_added_args, *args, **kwargs):
509-
if orig_function and not flag_added_args:
518+
if orig_function and (not flag_added_args or not args):
510519
# This patch is unexpected, so we fallback
511520
# to executing the original function
512521
return orig_function(*args, **kwargs)
@@ -531,7 +540,7 @@ def encode_aspect(orig_function, flag_added_args, *args, **kwargs):
531540
def upper_aspect(
532541
orig_function, flag_added_args, *args, **kwargs
533542
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
534-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
543+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
535544
if flag_added_args > 0:
536545
args = args[flag_added_args:]
537546
return orig_function(*args, **kwargs)
@@ -551,7 +560,7 @@ def upper_aspect(
551560
def lower_aspect(
552561
orig_function, flag_added_args, *args, **kwargs
553562
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
554-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
563+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
555564
if flag_added_args > 0:
556565
args = args[flag_added_args:]
557566
return orig_function(*args, **kwargs)
@@ -571,7 +580,7 @@ def lower_aspect(
571580
def swapcase_aspect(
572581
orig_function, flag_added_args, *args, **kwargs
573582
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
574-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
583+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
575584
if flag_added_args > 0:
576585
args = args[flag_added_args:]
577586
return orig_function(*args, **kwargs)
@@ -590,7 +599,7 @@ def swapcase_aspect(
590599
def title_aspect(
591600
orig_function, flag_added_args, *args, **kwargs
592601
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
593-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
602+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
594603
if flag_added_args > 0:
595604
args = args[flag_added_args:]
596605
return orig_function(*args, **kwargs)
@@ -609,7 +618,7 @@ def title_aspect(
609618
def capitalize_aspect(
610619
orig_function, flag_added_args, *args, **kwargs
611620
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
612-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
621+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
613622
if flag_added_args > 0:
614623
args = args[flag_added_args:]
615624
return orig_function(*args, **kwargs)
@@ -630,7 +639,7 @@ def casefold_aspect(
630639
orig_function, flag_added_args, *args, **kwargs
631640
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
632641
if orig_function:
633-
if not isinstance(orig_function, BuiltinFunctionType):
642+
if not isinstance(orig_function, BuiltinFunctionType) or not args:
634643
if flag_added_args > 0:
635644
args = args[flag_added_args:]
636645
return orig_function(*args, **kwargs)
@@ -658,7 +667,7 @@ def casefold_aspect(
658667
def translate_aspect(
659668
orig_function, flag_added_args, *args, **kwargs
660669
): # type: (Optional[Callable], int, Any, Any) -> TEXT_TYPE
661-
if orig_function and not isinstance(orig_function, BuiltinFunctionType):
670+
if orig_function and (not isinstance(orig_function, BuiltinFunctionType) or not args):
662671
if flag_added_args > 0:
663672
args = args[flag_added_args:]
664673
return orig_function(*args, **kwargs)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
Vulnerability Management for Code-level (IAST): This fix resolves an issue where certain aspects incorrectly expected at least one argument, leading to an IndexError when none were provided. The solution removes this constraint and incorporates regression tests for stability assurance.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Common tests to aspects, ensuring that they don't break when receiving empty arguments.
4+
"""
5+
import os
6+
7+
import pytest
8+
9+
10+
try:
11+
# Disable unused import warning pylint: disable=W0611
12+
from ddtrace.appsec._iast._taint_tracking import TagMappingMode # noqa: F401
13+
except (ImportError, AttributeError):
14+
pytest.skip("IAST not supported for this Python version", allow_module_level=True)
15+
16+
from tests.appsec.iast.aspects.conftest import _iast_patched_module
17+
import tests.appsec.iast.fixtures.aspects.callees # noqa: F401
18+
19+
20+
def generate_callers_from_callees(callers_file=""):
21+
"""
22+
Generate a callers module from a callees module, calling all it's functions.
23+
"""
24+
module_functions = [
25+
"bytearray",
26+
"bytes",
27+
"str",
28+
]
29+
30+
str_functions = [
31+
"casefold",
32+
"capitalize",
33+
"translate",
34+
"title",
35+
"swapcase",
36+
"lower",
37+
"upper",
38+
"format_map",
39+
"format",
40+
"zfill",
41+
"ljust",
42+
"join",
43+
"encode",
44+
]
45+
46+
with open(callers_file, "w", encoding="utf-8") as callers:
47+
callers.write(
48+
"""
49+
import builtins as _builtins
50+
51+
def call_builtin_repr(*args, **kwargs):
52+
return _builtins.repr()\n
53+
54+
def call_builtin_decode(*args, **kwargs):
55+
return bytes.decode()\n
56+
"""
57+
)
58+
for function in str_functions:
59+
callers.write(
60+
f"""
61+
def call_builtin_{function}(*args, **kwargs):
62+
return _builtins.str.{function}()\n
63+
"""
64+
)
65+
66+
for function in module_functions:
67+
callers.write(
68+
f"""
69+
def callee_{function}(*args, **kwargs):
70+
return {function}(*args, **kwargs)\n
71+
"""
72+
)
73+
74+
75+
PATCHED_CALLERS_FILE = "tests/appsec/iast/fixtures/aspects/callers.py"
76+
UNPATCHED_CALLERS_FILE = "tests/appsec/iast/fixtures/aspects/unpatched_callers.py"
77+
78+
for _file in (PATCHED_CALLERS_FILE, UNPATCHED_CALLERS_FILE):
79+
generate_callers_from_callees(
80+
callers_file=_file,
81+
)
82+
83+
patched_callers = _iast_patched_module(PATCHED_CALLERS_FILE.replace("/", ".")[0:-3])
84+
# This import needs to be done after the file is created (previous line)
85+
# pylint: disable=[wrong-import-position],[no-name-in-module]
86+
from tests.appsec.iast.fixtures.aspects import unpatched_callers # type: ignore[attr-defined] # noqa: E402
87+
88+
89+
@pytest.mark.parametrize("aspect", [x for x in dir(unpatched_callers) if not x.startswith(("_", "@"))])
90+
def test_aspect_patched_result(aspect):
91+
"""
92+
Test that the result of the patched aspect call is the same as the unpatched one.
93+
"""
94+
if aspect.startswith("callee_"):
95+
assert getattr(patched_callers, aspect)() == getattr(unpatched_callers, aspect)()
96+
elif aspect.startswith("call_builtin_"):
97+
try:
98+
getattr(patched_callers, aspect)()
99+
except TypeError as e:
100+
aspect = aspect[len("call_builtin_") :]
101+
if aspect == "repr":
102+
assert e.args[0] == f"{aspect}() takes exactly one argument (0 given)"
103+
else:
104+
# assert exception message is "unbound method str.<aspect>() needs an argument"
105+
aspect_namespace = "bytes" if aspect == "decode" else "str"
106+
assert e.args[0] == f"unbound method {aspect_namespace}.{aspect}() needs an argument"
107+
108+
109+
def teardown_module(_):
110+
"""
111+
Remove the callers file after the tests are done.
112+
"""
113+
for _file in (PATCHED_CALLERS_FILE, UNPATCHED_CALLERS_FILE):
114+
try:
115+
os.remove(_file)
116+
except FileNotFoundError:
117+
pass
118+
assert not os.path.exists(_file)

0 commit comments

Comments
 (0)