Skip to content

Commit 387b796

Browse files
authored
chore(iast): context refactor III (#14513)
## Overview It replaces the thread/process fragile map semantics with a bounded, request-scoped context backed by a native, fixed-size array, and adds end-to-end tests validating Overhead Control Engine (OCE) max-concurrency enforcement across FastAPI, Django, and Flask test apps. ## Context and motivation - Problem: The legacy IAST context map (pointer-keyed + thread-local cache) breaks under asyncio and concurrency, causing incorrect request association and performance inconsistencies. - Goal: Make the IAST request map safe and predictable under threads/async with consistent overhead, enforce request concurrency via OCE, and provide clear, framework-agnostic tests that verify behavior. ## Related work - Follows the direction and experiments from: - #14466: Initial context refactor and benchmarks groundwork - #14497, #14555, #14562: Subsequent iterations on IAST context ownership, perf and integration - This PR integrates and stabilizes those ideas into the runtime and test suites. ## What’s in this PR Core IAST context lifecycle - New request-scoped flow entrypoint - `ddtrace/appsec/_iast/_iast_request_context_base.py` - `_iast_start_request(span)` now gates with OCE (`oce.acquire_request(span)`) and creates a native request context only when needed. - Sets `IAST_CONTEXT` ContextVar to the active context id. - Attaches a per-request `IASTEnvironment(span)` via `core.set_item(IAST.REQUEST_CONTEXT_KEY, ...)`. - On finish (`_iast_finish_request(...)`) it updates global limits, discards the environment, and releases the native context. - Helper: `is_iast_request_enabled()` exposes whether the taint-tracking context is active in the current execution. ## Rollout plan - Land behind existing feature flags/env vars (default disabled for IAST if unsupported). - Monitor CI and integration tests for the new concurrency checks. - Follow-up PRs (tracked in the RFC) will: - Complete the native context array migration across all call sites. - Add more microbenchmarks and tighten perf budgets (+/- thresholds). - Expand sink coverage and per-request metrics. ## 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 e5a2e02 commit 387b796

File tree

79 files changed

+1726
-1327
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+1726
-1327
lines changed

.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -722,15 +722,15 @@ experiments:
722722
- max_rss_usage < 39.00 MB
723723
- name: iastpropagation-propagation_enabled
724724
thresholds:
725-
- execution_time < 0.16 ms
725+
- execution_time < 0.19 ms
726726
- max_rss_usage < 39.00 MB
727727
- name: iastpropagation-propagation_enabled_100
728728
thresholds:
729-
- execution_time < 1.80 ms
729+
- execution_time < 1.90 ms
730730
- max_rss_usage < 39.00 MB
731731
- name: iastpropagation-propagation_enabled_1000
732732
thresholds:
733-
- execution_time < 30.55 ms
733+
- execution_time < 35.55 ms
734734
- max_rss_usage < 39.00 MB
735735

736736
# otelsdkspan

benchmarks/appsec_iast_aspects/scenario.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,29 @@
55

66

77
try:
8-
# 3.15+
8+
# >= 3.15
99
from ddtrace.appsec._iast._iast_request_context_base import _iast_finish_request as end_iast_context
1010
from ddtrace.appsec._iast._iast_request_context_base import _iast_start_request as iast_start_request
11-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1211
except ImportError:
1312
try:
14-
# 3.6+
13+
# >= 3.6; < 3.15
1514
from ddtrace.appsec._iast._iast_request_context_base import end_iast_context
16-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1715
from ddtrace.appsec._iast._iast_request_context_base import start_iast_context as iast_start_request
1816
except ImportError:
19-
# Pre 3.6
17+
# < 3.6
2018
from ddtrace.appsec._iast._iast_request_context import end_iast_context
2119
from ddtrace.appsec._iast._iast_request_context import iast_start_request
20+
21+
try:
22+
# >= 3.6; < 3.15
23+
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
24+
except ImportError:
25+
try:
26+
# < 3.6
2227
from ddtrace.appsec._iast._iast_request_context import set_iast_request_enabled
28+
except ImportError:
29+
# >= 3.15
30+
set_iast_request_enabled = lambda x: None # noqa: E731
2331

2432

2533
def _start_iast_context_and_oce():

benchmarks/appsec_iast_aspects_ospath/scenario.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,29 @@
33

44

55
try:
6-
# 3.15+
6+
# >= 3.15
77
from ddtrace.appsec._iast._iast_request_context_base import _iast_finish_request as end_iast_context
88
from ddtrace.appsec._iast._iast_request_context_base import _iast_start_request as iast_start_request
9-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
109
except ImportError:
1110
try:
12-
# 3.6+
11+
# >= 3.6; < 3.15
1312
from ddtrace.appsec._iast._iast_request_context_base import end_iast_context
14-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1513
from ddtrace.appsec._iast._iast_request_context_base import start_iast_context as iast_start_request
1614
except ImportError:
17-
# Pre 3.6
15+
# < 3.6
1816
from ddtrace.appsec._iast._iast_request_context import end_iast_context
1917
from ddtrace.appsec._iast._iast_request_context import iast_start_request
18+
19+
try:
20+
# >= 3.6; < 3.15
21+
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
22+
except ImportError:
23+
try:
24+
# < 3.6
2025
from ddtrace.appsec._iast._iast_request_context import set_iast_request_enabled
26+
except ImportError:
27+
# >= 3.15
28+
set_iast_request_enabled = lambda x: None # noqa: E731
2129

2230

2331
def _start_iast_context_and_oce():

benchmarks/appsec_iast_aspects_re_module/scenario.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,29 @@
33

44

55
try:
6-
# 3.15+
6+
# >= 3.15
77
from ddtrace.appsec._iast._iast_request_context_base import _iast_finish_request as end_iast_context
88
from ddtrace.appsec._iast._iast_request_context_base import _iast_start_request as iast_start_request
9-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
109
except ImportError:
1110
try:
12-
# 3.6+
11+
# >= 3.6; < 3.15
1312
from ddtrace.appsec._iast._iast_request_context_base import end_iast_context
14-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1513
from ddtrace.appsec._iast._iast_request_context_base import start_iast_context as iast_start_request
1614
except ImportError:
17-
# Pre 3.6
15+
# < 3.6
1816
from ddtrace.appsec._iast._iast_request_context import end_iast_context
1917
from ddtrace.appsec._iast._iast_request_context import iast_start_request
18+
19+
try:
20+
# >= 3.6; < 3.15
21+
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
22+
except ImportError:
23+
try:
24+
# < 3.6
2025
from ddtrace.appsec._iast._iast_request_context import set_iast_request_enabled
26+
except ImportError:
27+
# >= 3.15
28+
set_iast_request_enabled = lambda x: None # noqa: E731
2129

2230

2331
def _start_iast_context_and_oce():

benchmarks/appsec_iast_aspects_split/scenario.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,29 @@
33

44

55
try:
6-
# 3.15+
6+
# >= 3.15
77
from ddtrace.appsec._iast._iast_request_context_base import _iast_finish_request as end_iast_context
88
from ddtrace.appsec._iast._iast_request_context_base import _iast_start_request as iast_start_request
9-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
109
except ImportError:
1110
try:
12-
# 3.6+
11+
# >= 3.6; < 3.15
1312
from ddtrace.appsec._iast._iast_request_context_base import end_iast_context
14-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1513
from ddtrace.appsec._iast._iast_request_context_base import start_iast_context as iast_start_request
1614
except ImportError:
17-
# Pre 3.6
15+
# < 3.6
1816
from ddtrace.appsec._iast._iast_request_context import end_iast_context
1917
from ddtrace.appsec._iast._iast_request_context import iast_start_request
18+
19+
try:
20+
# >= 3.6; < 3.15
21+
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
22+
except ImportError:
23+
try:
24+
# < 3.6
2025
from ddtrace.appsec._iast._iast_request_context import set_iast_request_enabled
26+
except ImportError:
27+
# >= 3.15
28+
set_iast_request_enabled = lambda x: None # noqa: E731
2129

2230

2331
def _start_iast_context_and_oce():

benchmarks/appsec_iast_propagation/scenario.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,29 @@
22

33

44
try:
5-
# 3.15+
5+
# >= 3.15
66
from ddtrace.appsec._iast._iast_request_context_base import _iast_finish_request as end_iast_context
77
from ddtrace.appsec._iast._iast_request_context_base import _iast_start_request as iast_start_request
8-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
98
except ImportError:
109
try:
11-
# 3.6+
10+
# >= 3.6; < 3.15
1211
from ddtrace.appsec._iast._iast_request_context_base import end_iast_context
13-
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
1412
from ddtrace.appsec._iast._iast_request_context_base import start_iast_context as iast_start_request
1513
except ImportError:
16-
# Pre 3.6
14+
# < 3.6
1715
from ddtrace.appsec._iast._iast_request_context import end_iast_context
1816
from ddtrace.appsec._iast._iast_request_context import iast_start_request
17+
18+
try:
19+
# >= 3.6; < 3.15
20+
from ddtrace.appsec._iast._iast_request_context_base import set_iast_request_enabled
21+
except ImportError:
22+
try:
23+
# < 3.6
1924
from ddtrace.appsec._iast._iast_request_context import set_iast_request_enabled
25+
except ImportError:
26+
# >= 3.15
27+
set_iast_request_enabled = lambda x: None # noqa: E731
2028

2129
from ddtrace.appsec._iast._taint_tracking import OriginType
2230
from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject

ddtrace/appsec/_iast/_iast_env.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ class IASTEnvironment:
2121
def __init__(self, span: Optional[Span] = None):
2222
self.span = span or core.get_span()
2323

24-
self.request_enabled: bool = False
2524
self.iast_reporter: Optional["IastSpanReporter"] = None
2625
self.iast_span_metrics: Dict[str, int] = {}
2726
self.iast_stack_trace_reported: bool = False

ddtrace/appsec/_iast/_iast_request_context.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from ddtrace.appsec._iast._iast_env import _get_iast_env
1010
import ddtrace.appsec._iast._iast_request_context_base as base
1111
from ddtrace.appsec._iast._metrics import _set_metric_iast_request_tainted
12-
from ddtrace.appsec._iast._overhead_control_engine import oce
1312
from ddtrace.appsec._iast._span_metrics import _set_span_tag_iast_executed_sink
1413
from ddtrace.appsec._iast._taint_tracking import OriginType
1514
from ddtrace.appsec._iast._taint_tracking import origin_to_str
@@ -62,14 +61,11 @@ def _create_and_attach_iast_report_to_span(
6261
base._set_span_tag_iast_request_tainted(req_span)
6362
_set_span_tag_iast_executed_sink(req_span)
6463

65-
base.set_iast_request_enabled(False)
6664
base._iast_finish_request(req_span)
6765

6866
if req_span.get_tag(_ORIGIN_KEY) is None:
6967
req_span.set_tag_str(_ORIGIN_KEY, APPSEC.ORIGIN_VALUE)
7068

71-
oce.release_request()
72-
7369

7470
def _iast_end_request(ctx=None, span=None, *args, **kwargs):
7571
try:
@@ -84,14 +80,14 @@ def _iast_end_request(ctx=None, span=None, *args, **kwargs):
8480
if req_span is None:
8581
log.debug("iast::propagation::context::Error finishing IAST context. There isn't a SPAN")
8682
return
83+
8784
if asm_config._iast_enabled:
8885
existing_data = req_span.get_tag(IAST.JSON) or req_span.get_struct_tag(IAST.STRUCT)
8986
if existing_data is None:
9087
if req_span.get_metric(IAST.ENABLED) is None:
9188
if not base.is_iast_request_enabled():
9289
req_span.set_metric(IAST.ENABLED, 0.0)
9390
base._iast_finish_request(req_span)
94-
oce.release_request()
9591
return
9692

9793
req_span.set_metric(IAST.ENABLED, 1.0)
Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import contextvars
22
from typing import Optional
33

4-
from ddtrace._trace.span import Span
54
from ddtrace.appsec._constants import IAST
65
from ddtrace.appsec._constants import IAST_SPAN_TAGS
76
from ddtrace.appsec._iast._iast_env import IASTEnvironment
87
from ddtrace.appsec._iast._iast_env import _get_iast_env
98
from ddtrace.appsec._iast._overhead_control_engine import oce
10-
from ddtrace.appsec._iast._taint_tracking import num_objects_tainted
11-
from ddtrace.appsec._iast._taint_tracking._context import create_context as create_propagation_context
12-
from ddtrace.appsec._iast._taint_tracking._context import reset_context as reset_propagation_context
9+
from ddtrace.appsec._iast._taint_tracking._context import debug_num_tainted_objects
10+
from ddtrace.appsec._iast._taint_tracking._context import finish_request_context
11+
from ddtrace.appsec._iast._taint_tracking._context import start_request_context
1312
from ddtrace.appsec._iast.sampling.vulnerability_detection import update_global_vulnerability_limit
1413
from ddtrace.internal import core
1514
from ddtrace.internal.logger import get_logger
@@ -20,7 +19,7 @@
2019

2120
# Stopgap module for providing ASM context for the blocking features wrapping some contextvars.
2221

23-
IAST_CONTEXT = contextvars.ContextVar("iast_var", default=None)
22+
IAST_CONTEXT: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar("iast_var", default=None)
2423

2524

2625
def _set_span_tag_iast_request_tainted(span):
@@ -30,10 +29,6 @@ def _set_span_tag_iast_request_tainted(span):
3029
span.set_tag(IAST_SPAN_TAGS.TELEMETRY_REQUEST_TAINTED, total_objects_tainted)
3130

3231

33-
def finalize_iast_env(env: IASTEnvironment) -> None:
34-
core.discard_item(IAST.REQUEST_CONTEXT_KEY)
35-
36-
3732
def get_iast_stacktrace_reported() -> bool:
3833
env = _get_iast_env()
3934
if env:
@@ -47,14 +42,6 @@ def set_iast_stacktrace_reported(reported: bool) -> None:
4742
env.iast_stack_trace_reported = reported
4843

4944

50-
def set_iast_request_enabled(request_enabled) -> None:
51-
env = _get_iast_env()
52-
if env:
53-
env.request_enabled = request_enabled
54-
else:
55-
log.debug("iast::propagation::context::Trying to set IAST reporter but no context is present")
56-
57-
5845
def set_iast_request_endpoint(method, route) -> None:
5946
if asm_config._iast_enabled:
6047
env = _get_iast_env()
@@ -67,36 +54,72 @@ def set_iast_request_endpoint(method, route) -> None:
6754
log.debug("iast::propagation::context::Trying to set IAST request endpoint but no context is present")
6855

6956

70-
def _iast_start_request(span=None, *args, **kwargs):
71-
try:
72-
if asm_config._iast_enabled:
73-
create_propagation_context()
74-
core.set_item(IAST.REQUEST_CONTEXT_KEY, IASTEnvironment(span))
75-
request_iast_enabled = False
76-
if oce.acquire_request(span):
77-
request_iast_enabled = True
78-
set_iast_request_enabled(request_iast_enabled)
79-
except Exception:
80-
log.debug("iast::propagation::context::Error starting IAST context", exc_info=True)
57+
def _iast_start_request(span=None) -> Optional[int]:
58+
"""Initialize the IAST request context for the current execution.
59+
60+
This function acquires the IAST request budget via the Overhead Control Engine,
61+
creates a new native taint context, and stores its identifier in a ContextVar so
62+
subsequent IAST operations can locate the request-local taint map. If a context
63+
is already active, the existing identifier is reused.
64+
65+
The provided span, when present, is attached to the IAST environment for later
66+
enrichment and end-of-request processing.
67+
"""
68+
context_id = None
69+
70+
if asm_config._iast_enabled:
71+
if oce.acquire_request(span):
72+
if not is_iast_request_enabled():
73+
context_id = start_request_context()
74+
IAST_CONTEXT.set(context_id)
75+
if context_id is not None:
76+
core.set_item(IAST.REQUEST_CONTEXT_KEY, IASTEnvironment(span))
77+
elif (context_id := _get_iast_context_id()) is not None:
78+
finish_request_context(context_id)
79+
IAST_CONTEXT.set(None)
80+
return context_id
8181

8282

8383
def _get_iast_context_id() -> Optional[int]:
8484
"""Retrieve the current IAST context identifier from the ContextVar."""
8585
return IAST_CONTEXT.get()
8686

8787

88-
def _iast_finish_request(span: Optional["Span"] = None):
88+
def _iast_finish_request(span=None, shoud_update_global_vulnerability_limit: bool = True) -> bool:
89+
"""Finalize the IAST request context and optionally update global limits.
90+
91+
This function discards the per-request IAST environment, optionally updates the
92+
global vulnerability optimization data, and releases the native taint context
93+
associated with the active request.
94+
"""
8995
env = _get_iast_env()
9096
if env is not None and env.span is span:
91-
update_global_vulnerability_limit(env)
92-
finalize_iast_env(env)
93-
reset_propagation_context()
97+
if shoud_update_global_vulnerability_limit:
98+
update_global_vulnerability_limit(env)
99+
core.discard_item(IAST.REQUEST_CONTEXT_KEY)
100+
101+
context_id = _get_iast_context_id()
102+
print(f"context_id: {context_id}")
103+
if context_id is not None:
104+
finish_request_context(context_id)
105+
print(f"finish_request_context: {context_id}")
106+
IAST_CONTEXT.set(None)
107+
return True
108+
109+
return False
94110

95111

96112
def is_iast_request_enabled() -> bool:
97113
"""Check whether IAST is currently operating within an active request context."""
98-
return asm_config.is_iast_request_enabled
114+
return _get_iast_context_id() is not None
115+
99116

117+
def _num_objects_tainted_in_request() -> int:
118+
"""Get the count of tainted objects tracked in the active IAST request context.
100119
101-
def _num_objects_tainted_in_request():
102-
return num_objects_tainted()
120+
Useful for span metrics and internal telemetry.
121+
"""
122+
context_id = _get_iast_context_id()
123+
if context_id is not None:
124+
return debug_num_tainted_objects(context_id)
125+
return 0

0 commit comments

Comments
 (0)