Skip to content

Commit 8dbdbf0

Browse files
authored
fix(iast): improve overhead control logic (#8452) (#8494)
IAST: Improve overhead control logic so the decision to analyze a request is done at span start and is saved at the span level using the core API. This should fix issues where requests were analyzed when they shouldn't be and viceversa. - [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] Risks are described (performance impact, potential for breakage, maintainability) - [x] Change is maintainable (easy to change, telemetry, documentation) - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed or label `changelog/no-changelog` is set - [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)) - [x] If this PR changes the public interface, I've notified `@DataDog/apm-tees`. - [x] If change 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] Title is accurate - [x] All changes are related to the pull request's stated goal - [x] Description motivates each change - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - [x] Testing strategy adequately addresses listed risks - [x] Change is maintainable (easy to change, telemetry, documentation) - [x] Release note makes sense to a user of the library - [x] Author has 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) (cherry picked from commit 7e8aaac)
1 parent 22fcb7f commit 8dbdbf0

File tree

14 files changed

+175
-71
lines changed

14 files changed

+175
-71
lines changed

ddtrace/appsec/_asm_request_context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@ def _on_wrapped_view(kwargs):
399399
if _is_iast_enabled() and kwargs:
400400
from ddtrace.appsec._iast._taint_tracking import OriginType
401401
from ddtrace.appsec._iast._taint_tracking import taint_pyobject
402+
from ddtrace.appsec._iast.processor import AppSecIastSpanProcessor
403+
404+
if not AppSecIastSpanProcessor.is_span_analyzed():
405+
return return_value
402406

403407
_kwargs = {}
404408
for k, v in kwargs.items():
@@ -414,9 +418,14 @@ def _on_set_request_tags(request, span, flask_config):
414418
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_source
415419
from ddtrace.appsec._iast._taint_tracking import OriginType
416420
from ddtrace.appsec._iast._taint_utils import taint_structure
421+
from ddtrace.appsec._iast.processor import AppSecIastSpanProcessor
417422

418423
_set_metric_iast_instrumented_source(OriginType.COOKIE_NAME)
419424
_set_metric_iast_instrumented_source(OriginType.COOKIE)
425+
426+
if not AppSecIastSpanProcessor.is_span_analyzed(span._local_root or span):
427+
return
428+
420429
request.cookies = taint_structure(
421430
request.cookies,
422431
OriginType.COOKIE_NAME,

ddtrace/appsec/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class IAST(metaclass=Constant_Class):
8282
PATCH_MODULES = "_DD_IAST_PATCH_MODULES"
8383
DENY_MODULES = "_DD_IAST_DENY_MODULES"
8484
SEP_MODULES = ","
85+
REQUEST_IAST_ENABLED = "_dd.iast.request_enabled"
8586

8687

8788
class IAST_SPAN_TAGS(metaclass=Constant_Class):

ddtrace/appsec/_handlers.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ def _on_request_init(wrapped, instance, args, kwargs):
190190
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_source
191191
from ddtrace.appsec._iast._taint_tracking import OriginType
192192
from ddtrace.appsec._iast._taint_tracking import taint_pyobject
193+
from ddtrace.appsec._iast.processor import AppSecIastSpanProcessor
194+
195+
_set_metric_iast_instrumented_source(OriginType.PATH)
196+
_set_metric_iast_instrumented_source(OriginType.QUERY)
197+
198+
if not AppSecIastSpanProcessor.is_span_analyzed():
199+
return
193200

194201
# TODO: instance.query_string = ??
195202
instance.query_string = taint_pyobject(
@@ -204,8 +211,6 @@ def _on_request_init(wrapped, instance, args, kwargs):
204211
source_value=instance.path,
205212
source_origin=OriginType.PATH,
206213
)
207-
_set_metric_iast_instrumented_source(OriginType.PATH)
208-
_set_metric_iast_instrumented_source(OriginType.QUERY)
209214
except Exception:
210215
log.debug("Unexpected exception while tainting pyobject", exc_info=True)
211216

@@ -269,6 +274,10 @@ def _on_django_func_wrapped(fn_args, fn_kwargs, first_arg_expected_type, *_):
269274
from ddtrace.appsec._iast._taint_tracking import is_pyobject_tainted
270275
from ddtrace.appsec._iast._taint_tracking import taint_pyobject
271276
from ddtrace.appsec._iast._taint_utils import taint_structure
277+
from ddtrace.appsec._iast.processor import AppSecIastSpanProcessor
278+
279+
if not AppSecIastSpanProcessor.is_span_analyzed():
280+
return
272281

273282
http_req = fn_args[0]
274283

@@ -318,6 +327,7 @@ def _on_wsgi_environ(wrapped, _instance, args, kwargs):
318327
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_source
319328
from ddtrace.appsec._iast._taint_tracking import OriginType # noqa: F401
320329
from ddtrace.appsec._iast._taint_utils import taint_structure
330+
from ddtrace.appsec._iast.processor import AppSecIastSpanProcessor
321331

322332
_set_metric_iast_instrumented_source(OriginType.HEADER_NAME)
323333
_set_metric_iast_instrumented_source(OriginType.HEADER)
@@ -330,6 +340,9 @@ def _on_wsgi_environ(wrapped, _instance, args, kwargs):
330340
_set_metric_iast_instrumented_source(OriginType.PARAMETER_NAME)
331341
_set_metric_iast_instrumented_source(OriginType.BODY)
332342

343+
if not AppSecIastSpanProcessor.is_span_analyzed():
344+
return wrapped(*args, **kwargs)
345+
333346
return wrapped(*((taint_structure(args[0], OriginType.HEADER_NAME, OriginType.HEADER),) + args[1:]), **kwargs)
334347

335348
return wrapped(*args, **kwargs)

ddtrace/appsec/_iast/_overhead_control_engine.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,32 +90,35 @@ class OverheadControl(object):
9090
The goal is to do sampling at different levels of the IAST analysis (per process, per request, etc)
9191
"""
9292

93+
_lock = threading.Lock()
9394
_request_quota = MAX_REQUESTS
94-
_enabled = False
9595
_vulnerabilities = set() # type: Set[Type[Operation]]
9696
_sampler = RateSampler(sample_rate=get_request_sampling_value() / 100.0)
9797

9898
def reconfigure(self):
9999
self._sampler = RateSampler(sample_rate=get_request_sampling_value() / 100.0)
100100

101101
def acquire_request(self, span):
102-
# type: (Span) -> None
102+
# type: (Span) -> bool
103103
"""Decide whether if IAST analysis will be done for this request.
104104
- Block a request's quota at start of the request to limit simultaneous requests analyzed.
105105
- Use sample rating to analyze only a percentage of the total requests (30% by default).
106106
"""
107-
if self._request_quota > 0 and self._sampler.sample(span):
107+
if self._request_quota <= 0 or not self._sampler.sample(span):
108+
return False
109+
110+
with self._lock:
111+
if self._request_quota <= 0:
112+
return False
113+
108114
self._request_quota -= 1
109-
self._enabled = True
110115

111-
def release_request(self):
112-
"""increment request's quota at end of the request.
116+
return True
113117

114-
TODO: figure out how to check maximum requests per thread
115-
"""
116-
if self._request_quota < MAX_REQUESTS:
118+
def release_request(self):
119+
"""increment request's quota at end of the request."""
120+
with self._lock:
117121
self._request_quota += 1
118-
self._enabled = False
119122
self.vulnerabilities_reset_quota()
120123

121124
def register(self, klass):
@@ -124,11 +127,6 @@ def register(self, klass):
124127
self._vulnerabilities.add(klass)
125128
return klass
126129

127-
@property
128-
def request_has_quota(self):
129-
# type: () -> bool
130-
return self._enabled
131-
132130
def vulnerabilities_reset_quota(self):
133131
# type: () -> None
134132
for k in self._vulnerabilities:

ddtrace/appsec/_iast/_patch.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ def if_iast_taint_returned_object_for(origin, wrapped, instance, args, kwargs):
145145
try:
146146
from ._taint_tracking import is_pyobject_tainted
147147
from ._taint_tracking import taint_pyobject
148+
from .processor import AppSecIastSpanProcessor
149+
150+
if not AppSecIastSpanProcessor.is_span_analyzed():
151+
return value
148152

149153
if not is_pyobject_tainted(value):
150154
name = str(args[0]) if len(args) else "http.request.body"
@@ -157,6 +161,11 @@ def if_iast_taint_returned_object_for(origin, wrapped, instance, args, kwargs):
157161
def if_iast_taint_yield_tuple_for(origins, wrapped, instance, args, kwargs):
158162
if _is_iast_enabled():
159163
from ._taint_tracking import taint_pyobject
164+
from .processor import AppSecIastSpanProcessor
165+
166+
if not AppSecIastSpanProcessor.is_span_analyzed():
167+
for key, value in wrapped(*args, **kwargs):
168+
yield key, value
160169

161170
for key, value in wrapped(*args, **kwargs):
162171
new_key = taint_pyobject(pyobject=key, source_name=key, source_value=key, source_origin=origins[0])

ddtrace/appsec/_iast/_patches/json_tainting.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def wrapped_loads(wrapped, instance, args, kwargs):
4747
from .._taint_tracking import get_tainted_ranges
4848
from .._taint_tracking import is_pyobject_tainted
4949
from .._taint_tracking import taint_pyobject
50+
from ..processor import AppSecIastSpanProcessor
51+
52+
if not AppSecIastSpanProcessor.is_span_analyzed():
53+
return obj
5054

5155
if is_pyobject_tainted(args[0]) and obj:
5256
# tainting object

ddtrace/appsec/_iast/_taint_tracking/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,7 @@
8888

8989
def taint_pyobject(pyobject, source_name, source_value, source_origin=None):
9090
# type: (Any, Any, Any, OriginType) -> Any
91-
# Request is not analyzed
92-
if not oce.request_has_quota:
93-
return pyobject
91+
9492
# Pyobject must be Text with len > 1
9593
if not pyobject or not isinstance(pyobject, (str, bytes, bytearray)):
9694
return pyobject

ddtrace/appsec/_iast/processor.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,43 @@
2020

2121

2222
if TYPE_CHECKING: # pragma: no cover
23+
from typing import Optional # noqa:F401
24+
2325
from ddtrace.span import Span # noqa:F401
2426

2527
log = get_logger(__name__)
2628

2729

2830
@attr.s(eq=False)
2931
class AppSecIastSpanProcessor(SpanProcessor):
32+
@staticmethod
33+
def is_span_analyzed(span=None):
34+
# type: (Optional[Span]) -> bool
35+
if span is None:
36+
from ddtrace import tracer
37+
38+
span = tracer.current_root_span()
39+
40+
if span and span.span_type == SpanTypes.WEB and core.get_item(IAST.REQUEST_IAST_ENABLED, span=span):
41+
return True
42+
return False
43+
3044
def on_span_start(self, span):
3145
# type: (Span) -> None
3246
if span.span_type != SpanTypes.WEB:
3347
return
34-
oce.acquire_request(span)
35-
from ._taint_tracking import create_context
3648

37-
create_context()
49+
if not _is_iast_enabled():
50+
return
51+
52+
request_iast_enabled = False
53+
if oce.acquire_request(span):
54+
from ._taint_tracking import create_context
55+
56+
request_iast_enabled = True
57+
create_context()
58+
59+
core.set_item(IAST.REQUEST_IAST_ENABLED, request_iast_enabled, span=span)
3860

3961
def on_span_finish(self, span):
4062
# type: (Span) -> None
@@ -48,7 +70,7 @@ def on_span_finish(self, span):
4870
if span.span_type != SpanTypes.WEB:
4971
return
5072

51-
if not oce._enabled or not _is_iast_enabled():
73+
if not core.get_item(IAST.REQUEST_IAST_ENABLED, span=span):
5274
span.set_metric(IAST.ENABLED, 0.0)
5375
return
5476

ddtrace/appsec/_iast/taint_sinks/_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
from ddtrace.settings.asm import config as asm_config
1212

1313
from ..._deduplications import deduplication
14-
from .. import oce
1514
from .._overhead_control_engine import Operation
1615
from .._stacktrace import get_info_frame
1716
from .._utils import _has_to_scrub
1817
from .._utils import _is_evidence_value_parts
1918
from .._utils import _scrub
19+
from ..processor import AppSecIastSpanProcessor
2020
from ..reporter import Evidence
2121
from ..reporter import IastSpanReporter
2222
from ..reporter import Location
@@ -83,7 +83,7 @@ def wrapper(wrapped, instance, args, kwargs):
8383
"""Get the current root Span and attach it to the wrapped function. We need the span to report the
8484
vulnerability and update the context with the report information.
8585
"""
86-
if oce.request_has_quota and cls.has_quota():
86+
if AppSecIastSpanProcessor.is_span_analyzed() and cls.has_quota():
8787
return func(wrapped, instance, args, kwargs)
8888
else:
8989
log.debug("IAST: no vulnerability quota to analyze more sink points")

ddtrace/appsec/_iast/taint_sinks/path_traversal.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .._patch import set_module_unpatched
1111
from ..constants import EVIDENCE_PATH_TRAVERSAL
1212
from ..constants import VULN_PATH_TRAVERSAL
13+
from ..processor import AppSecIastSpanProcessor
1314
from ._base import VulnerabilityBase
1415

1516

@@ -49,7 +50,7 @@ def patch():
4950

5051

5152
def check_and_report_path_traversal(*args: Any, **kwargs: Any) -> None:
52-
if oce.request_has_quota and PathTraversal.has_quota():
53+
if AppSecIastSpanProcessor.is_span_analyzed() and PathTraversal.has_quota():
5354
try:
5455
from .._metrics import _set_metric_iast_executed_sink
5556
from .._taint_tracking import is_pyobject_tainted

0 commit comments

Comments
 (0)