Skip to content

Commit 012ca1b

Browse files
authored
chore(iast): fix iast span metrics [backport 2.21] (#13087)
backport #13075 to 2.21 Partial backport of #13046 to align the functions parameters. (cherry picked from commit 9c8c6d0) ## 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 ade3d96 commit 012ca1b

File tree

17 files changed

+353
-175
lines changed

17 files changed

+353
-175
lines changed

ddtrace/appsec/_iast/_metrics.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from ddtrace.appsec._constants import TELEMETRY_INFORMATION_VERBOSITY
99
from ddtrace.appsec._constants import TELEMETRY_MANDATORY_VERBOSITY
1010
from ddtrace.appsec._deduplications import deduplication
11+
from ddtrace.appsec._iast._taint_tracking import OriginType
12+
from ddtrace.appsec._iast._taint_tracking import origin_to_str
1113
from ddtrace.appsec._iast._utils import _is_iast_debug_enabled
1214
from ddtrace.internal import telemetry
1315
from ddtrace.internal.logger import get_logger
@@ -139,11 +141,7 @@ def _set_span_tag_iast_executed_sink(span):
139141

140142

141143
def _metric_key_as_snake_case(key):
142-
from ._taint_tracking import OriginType
143-
144144
if isinstance(key, OriginType):
145-
from ._taint_tracking import origin_to_str
146-
147145
key = origin_to_str(key)
148146
key = key.replace(".", "_")
149147
return key.lower()

ddtrace/appsec/_iast/_taint_tracking/_taint_objects.py

Lines changed: 77 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -37,35 +37,64 @@ def is_pyobject_tainted(pyobject: Any) -> bool:
3737

3838

3939
def _taint_pyobject_base(pyobject: Any, source_name: Any, source_value: Any, source_origin=None) -> Any:
40-
if not asm_config.is_iast_request_enabled:
40+
"""Mark a Python object as tainted with information about its origin.
41+
42+
This function is the base for marking objects as tainted, setting their origin and range.
43+
It is optimized for:
44+
1. Early validations to avoid unnecessary operations
45+
2. Efficient type conversions
46+
3. Special case handling (empty objects)
47+
4. Robust error handling
48+
49+
Performance optimizations:
50+
- Early return for disabled IAST or non-taintable types
51+
- Efficient string length calculation only when needed
52+
- Optimized bytes/bytearray to string conversion using decode()
53+
- Minimized object allocations and method calls
54+
55+
Args:
56+
pyobject (Any): The object to mark as tainted. Must be a taintable type.
57+
source_name (Any): Name of the taint source (e.g., parameter name).
58+
source_value (Any): Original value that caused the taint.
59+
source_origin (Optional[OriginType]): Origin of the taint. Defaults to PARAMETER.
60+
61+
Returns:
62+
Any: The tainted object if operation was successful, original object if failed.
63+
64+
Note:
65+
- Only applies to taintable types defined in IAST.TAINTEABLE_TYPES
66+
- Returns unmodified object for empty strings
67+
- Automatically handles bytes/bytearray to str conversion
68+
"""
69+
# Early type validation
70+
if not isinstance(pyobject, IAST.TAINTEABLE_TYPES): # type: ignore[misc]
4171
return pyobject
4272

43-
if not isinstance(pyobject, IAST.TAINTEABLE_TYPES): # type: ignore[misc]
73+
# Fast path for empty strings
74+
if isinstance(pyobject, IAST.TEXT_TYPES) and not pyobject:
4475
return pyobject
45-
# We need this validation in different condition if pyobject is not a text type and creates a side-effect such as
46-
# __len__ magic method call.
47-
pyobject_len = 0
48-
if isinstance(pyobject, IAST.TEXT_TYPES):
49-
pyobject_len = len(pyobject)
50-
if pyobject_len == 0:
51-
return pyobject
5276

77+
# Efficient source_name conversion
5378
if isinstance(source_name, (bytes, bytearray)):
54-
source_name = str(source_name, encoding="utf8", errors="ignore")
55-
if isinstance(source_name, OriginType):
79+
source_name = source_name.decode("utf-8", errors="ignore")
80+
elif isinstance(source_name, OriginType):
5681
source_name = origin_to_str(source_name)
5782

83+
# Efficient source_value conversion
5884
if isinstance(source_value, (bytes, bytearray)):
59-
source_value = str(source_value, encoding="utf8", errors="ignore")
85+
source_value = source_value.decode("utf-8", errors="ignore")
86+
87+
# Default source_origin
6088
if source_origin is None:
6189
source_origin = OriginType.PARAMETER
6290

6391
try:
64-
pyobject_newid = set_ranges_from_values(pyobject, pyobject_len, source_name, source_value, source_origin)
65-
return pyobject_newid
92+
# Calculate length only for text types
93+
pyobject_len = len(pyobject) if isinstance(pyobject, IAST.TEXT_TYPES) else 0
94+
return set_ranges_from_values(pyobject, pyobject_len, source_name, source_value, source_origin)
6695
except ValueError:
6796
iast_propagation_debug_log(f"Tainting object error (pyobject type {type(pyobject)})", exc_info=True)
68-
return pyobject
97+
return pyobject
6998

7099

71100
def taint_pyobject_with_ranges(pyobject: Any, ranges: Tuple) -> bool:
@@ -95,48 +124,50 @@ def get_tainted_ranges(pyobject: Any) -> Tuple:
95124

96125
def taint_pyobject(pyobject: Any, source_name: Any, source_value: Any, source_origin=None) -> Any:
97126
try:
98-
if source_origin is None:
99-
source_origin = OriginType.PARAMETER
100-
101-
res = _taint_pyobject_base(pyobject, source_name, source_value, source_origin)
102-
_set_metric_iast_executed_source(source_origin)
103-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SOURCE, source_origin)
104-
return res
127+
if asm_config.is_iast_request_enabled:
128+
if source_origin is None:
129+
source_origin = OriginType.PARAMETER
130+
131+
res = _taint_pyobject_base(pyobject, source_name, source_value, source_origin)
132+
_set_metric_iast_executed_source(source_origin)
133+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SOURCE, source_origin)
134+
return res
105135
except ValueError:
106136
iast_propagation_debug_log(f"taint_pyobject error (pyobject type {type(pyobject)})", exc_info=True)
107137
return pyobject
108138

109139

110140
def copy_ranges_to_string(pyobject: str, ranges: Sequence[TaintRange]) -> str:
111141
# NB this function uses comment-based type annotation because TaintRange is conditionally imported
112-
if not isinstance(pyobject, IAST.TAINTEABLE_TYPES): # type: ignore[misc]
113-
return pyobject
114-
115-
for r in ranges:
116-
_is_string_in_source_value = False
117-
if r.source.value:
118-
if isinstance(pyobject, (bytes, bytearray)):
119-
pyobject_str = str(pyobject, encoding="utf8", errors="ignore")
120-
else:
121-
pyobject_str = pyobject
122-
_is_string_in_source_value = pyobject_str in r.source.value
142+
if asm_config.is_iast_request_enabled:
143+
if not isinstance(pyobject, IAST.TAINTEABLE_TYPES): # type: ignore[misc]
144+
return pyobject
123145

124-
if _is_string_in_source_value:
146+
for r in ranges:
147+
_is_string_in_source_value = False
148+
if r.source.value:
149+
if isinstance(pyobject, (bytes, bytearray)):
150+
pyobject_str = str(pyobject, encoding="utf8", errors="ignore")
151+
else:
152+
pyobject_str = pyobject
153+
_is_string_in_source_value = pyobject_str in r.source.value
154+
155+
if _is_string_in_source_value:
156+
pyobject = _taint_pyobject_base(
157+
pyobject=pyobject,
158+
source_name=r.source.name,
159+
source_value=r.source.value,
160+
source_origin=r.source.origin,
161+
)
162+
break
163+
else:
164+
# no total match found, maybe partial match, just take the first one
125165
pyobject = _taint_pyobject_base(
126166
pyobject=pyobject,
127-
source_name=r.source.name,
128-
source_value=r.source.value,
129-
source_origin=r.source.origin,
167+
source_name=ranges[0].source.name,
168+
source_value=ranges[0].source.value,
169+
source_origin=ranges[0].source.origin,
130170
)
131-
break
132-
else:
133-
# no total match found, maybe partial match, just take the first one
134-
pyobject = _taint_pyobject_base(
135-
pyobject=pyobject,
136-
source_name=ranges[0].source.name,
137-
source_value=ranges[0].source.value,
138-
source_origin=ranges[0].source.origin,
139-
)
140171
return pyobject
141172

142173

ddtrace/appsec/_iast/_taint_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ def supported_dbapi_integration(integration_name):
520520
return integration_name in DBAPI_INTEGRATIONS or integration_name.startswith(DBAPI_PREFIXES)
521521

522522

523-
def check_tainted_dbapi_args(args, kwargs, tracer, integration_name, method):
523+
def check_tainted_dbapi_args(args, kwargs, integration_name, method):
524524
if supported_dbapi_integration(integration_name) and method.__name__ == "execute":
525525
return len(args) and args[0] and is_pyobject_tainted(args[0])
526526

ddtrace/appsec/_iast/taint_sinks/ast_taint.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,15 @@ def ast_function(
3636
and cls_name == "Random"
3737
and func_name in DEFAULT_WEAK_RANDOMNESS_FUNCTIONS
3838
):
39-
if asm_config._iast_enabled and asm_config.is_iast_request_enabled:
40-
# Weak, run the analyzer
39+
if asm_config.is_iast_request_enabled:
40+
if WeakRandomness.has_quota():
41+
WeakRandomness.report(evidence_value=cls_name + "." + func_name)
42+
43+
# Reports Span Metrics
4144
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, WeakRandomness.vulnerability_type)
45+
# Report Telemetry Metrics
4246
_set_metric_iast_executed_sink(WeakRandomness.vulnerability_type)
43-
WeakRandomness.report(evidence_value=cls_name + "." + func_name)
47+
4448
elif hasattr(func, "__module__") and DEFAULT_PATH_TRAVERSAL_FUNCTIONS.get(func.__module__):
4549
if func_name in DEFAULT_PATH_TRAVERSAL_FUNCTIONS[func.__module__]:
4650
check_and_report_path_traversal(*args, **kwargs)

ddtrace/appsec/_iast/taint_sinks/code_injection.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from typing import Text
33

44
from ddtrace.appsec._common_module_patches import try_unwrap
5+
from ddtrace.appsec._constants import IAST
56
from ddtrace.appsec._constants import IAST_SPAN_TAGS
67
from ddtrace.appsec._iast import oce
8+
from ddtrace.appsec._iast._logs import iast_error
79
from ddtrace.appsec._iast._metrics import _set_metric_iast_executed_sink
810
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
911
from ddtrace.appsec._iast._metrics import increment_iast_span_metric
@@ -79,8 +81,14 @@ class CodeInjection(VulnerabilityBase):
7981

8082

8183
def _iast_report_code_injection(code_string: Text):
82-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, CodeInjection.vulnerability_type)
83-
_set_metric_iast_executed_sink(CodeInjection.vulnerability_type)
84-
if asm_config.is_iast_request_enabled:
85-
if is_pyobject_tainted(code_string):
86-
CodeInjection.report(evidence_value=code_string)
84+
reported = False
85+
try:
86+
if asm_config.is_iast_request_enabled:
87+
if isinstance(code_string, IAST.TEXT_TYPES) and CodeInjection.has_quota():
88+
if is_pyobject_tainted(code_string):
89+
CodeInjection.report(evidence_value=code_string)
90+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, CodeInjection.vulnerability_type)
91+
_set_metric_iast_executed_sink(CodeInjection.vulnerability_type)
92+
except Exception as e:
93+
iast_error(f"propagation::sink_point::Error in _iast_report_code_injection. {e}")
94+
return reported

ddtrace/appsec/_iast/taint_sinks/command_injection.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from ddtrace.appsec._constants import IAST_SPAN_TAGS
55
from ddtrace.appsec._iast import oce
6+
from ddtrace.appsec._iast._logs import iast_error
67
from ddtrace.appsec._iast._metrics import _set_metric_iast_executed_sink
78
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
89
from ddtrace.appsec._iast._metrics import increment_iast_span_metric
@@ -46,19 +47,25 @@ class CommandInjection(VulnerabilityBase):
4647
def _iast_report_cmdi(shell_args: Union[str, List[str]]) -> None:
4748
report_cmdi = ""
4849

49-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, CommandInjection.vulnerability_type)
50-
_set_metric_iast_executed_sink(CommandInjection.vulnerability_type)
51-
52-
if asm_config.is_iast_request_enabled and CommandInjection.has_quota():
53-
from .._taint_tracking.aspects import join_aspect
54-
55-
if isinstance(shell_args, (list, tuple)):
56-
for arg in shell_args:
57-
if is_pyobject_tainted(arg):
58-
report_cmdi = join_aspect(" ".join, 1, " ", shell_args)
59-
break
60-
elif is_pyobject_tainted(shell_args):
61-
report_cmdi = shell_args
62-
63-
if report_cmdi:
64-
CommandInjection.report(evidence_value=report_cmdi)
50+
try:
51+
if asm_config.is_iast_request_enabled:
52+
if CommandInjection.has_quota():
53+
from .._taint_tracking.aspects import join_aspect
54+
55+
if isinstance(shell_args, (list, tuple)):
56+
for arg in shell_args:
57+
if is_pyobject_tainted(arg):
58+
report_cmdi = join_aspect(" ".join, 1, " ", shell_args)
59+
break
60+
elif is_pyobject_tainted(shell_args):
61+
report_cmdi = shell_args
62+
63+
if report_cmdi:
64+
CommandInjection.report(evidence_value=report_cmdi)
65+
66+
# Reports Span Metrics
67+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, CommandInjection.vulnerability_type)
68+
# Report Telemetry Metrics
69+
_set_metric_iast_executed_sink(CommandInjection.vulnerability_type)
70+
except Exception as e:
71+
iast_error(f"propagation::sink_point::Error in _iast_report_ssrf. {e}")

ddtrace/appsec/_iast/taint_sinks/header_injection.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from ddtrace.appsec._common_module_patches import try_unwrap
77
from ddtrace.appsec._constants import IAST_SPAN_TAGS
88
from ddtrace.appsec._iast import oce
9+
from ddtrace.appsec._iast._logs import iast_error
10+
from ddtrace.appsec._iast._logs import iast_instrumentation_wrapt_debug_log
911
from ddtrace.appsec._iast._metrics import _set_metric_iast_executed_sink
1012
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
1113
from ddtrace.appsec._iast._metrics import increment_iast_span_metric
@@ -18,7 +20,6 @@
1820
from ddtrace.internal.logger import get_logger
1921
from ddtrace.settings.asm import config as asm_config
2022

21-
from .._logs import iast_instrumentation_wrapt_debug_log
2223
from ._base import VulnerabilityBase
2324

2425

@@ -122,19 +123,23 @@ def _process_header(headers_args):
122123
header_name, header_value = headers_args
123124
if header_name is None:
124125
return
125-
126-
for header_to_exclude in HEADER_INJECTION_EXCLUSIONS:
127-
header_name_lower = header_name.lower()
128-
if header_name_lower == header_to_exclude or header_name_lower.startswith(header_to_exclude):
129-
return
130-
131-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, HeaderInjection.vulnerability_type)
132-
_set_metric_iast_executed_sink(HeaderInjection.vulnerability_type)
133-
134-
if asm_config.is_iast_request_enabled and HeaderInjection.has_quota():
135-
if is_pyobject_tainted(header_name) or is_pyobject_tainted(header_value):
136-
header_evidence = add_aspect(add_aspect(header_name, HEADER_NAME_VALUE_SEPARATOR), header_value)
137-
HeaderInjection.report(evidence_value=header_evidence)
126+
try:
127+
for header_to_exclude in HEADER_INJECTION_EXCLUSIONS:
128+
header_name_lower = header_name.lower()
129+
if header_name_lower == header_to_exclude or header_name_lower.startswith(header_to_exclude):
130+
return
131+
132+
if asm_config.is_iast_request_enabled:
133+
if HeaderInjection.has_quota() and (is_pyobject_tainted(header_name) or is_pyobject_tainted(header_value)):
134+
header_evidence = add_aspect(add_aspect(header_name, HEADER_NAME_VALUE_SEPARATOR), header_value)
135+
HeaderInjection.report(evidence_value=header_evidence)
136+
137+
# Reports Span Metrics
138+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, HeaderInjection.vulnerability_type)
139+
# Report Telemetry Metrics
140+
_set_metric_iast_executed_sink(HeaderInjection.vulnerability_type)
141+
except Exception as e:
142+
iast_error(f"propagation::sink_point::Error in _iast_report_header_injection. {e}")
138143

139144

140145
def _iast_report_header_injection(headers_or_args) -> None:

ddtrace/appsec/_iast/taint_sinks/path_traversal.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from ddtrace.appsec._constants import IAST_SPAN_TAGS
44
from ddtrace.appsec._iast import oce
5+
from ddtrace.appsec._iast._logs import iast_error
56
from ddtrace.appsec._iast._metrics import _set_metric_iast_executed_sink
67
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
78
from ddtrace.appsec._iast._metrics import increment_iast_span_metric
@@ -30,9 +31,15 @@ def check_and_report_path_traversal(*args: Any, **kwargs: Any) -> None:
3031
_set_metric_iast_instrumented_sink(VULN_PATH_TRAVERSAL)
3132
IS_REPORTED_INTRUMENTED_SINK = True
3233

33-
if asm_config.is_iast_request_enabled and PathTraversal.has_quota():
34-
filename_arg = args[0] if args else kwargs.get("file", None)
35-
if is_pyobject_tainted(filename_arg):
36-
PathTraversal.report(evidence_value=filename_arg)
37-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, PathTraversal.vulnerability_type)
38-
_set_metric_iast_executed_sink(PathTraversal.vulnerability_type)
34+
try:
35+
if asm_config.is_iast_request_enabled:
36+
filename_arg = args[0] if args else kwargs.get("file", None)
37+
if PathTraversal.has_quota() and is_pyobject_tainted(filename_arg):
38+
PathTraversal.report(evidence_value=filename_arg)
39+
40+
# Reports Span Metrics
41+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, PathTraversal.vulnerability_type)
42+
# Report Telemetry Metrics
43+
_set_metric_iast_executed_sink(PathTraversal.vulnerability_type)
44+
except Exception as e:
45+
iast_error(f"propagation::sink_point::Error in check_and_report_path_traversal. {e}")

0 commit comments

Comments
 (0)