Skip to content

refactor(asm): remove RASP wrapper frames from customer tracebacks - #19989

Open
avara1986 wants to merge 11 commits into
mainfrom
alberto.vara/APPSEC-69877-rasp-wrapping-context
Open

refactor(asm): remove RASP wrapper frames from customer tracebacks#19989
avara1986 wants to merge 11 commits into
mainfrom
alberto.vara/APPSEC-69877-rasp-wrapping-context

Conversation

@avara1986

@avara1986 avara1986 commented Sep 1, 2026

Copy link
Copy Markdown
Member

Description

Depends on #20016 (APPSEC-69960). This PR raises BlockingException from a wrapping
context's __enter__ to stop a blocked outgoing request, and until #20016 lands that leaks one
ContextVar storage dict per blocked request. Do not merge this first.

Unhandled exceptions are shipped to the crashtracker with their full traceback
(ddtrace/internal/core/crashtracking.py:185), and intake tags a report crash_datadog:true as
soon as a ddtrace path appears in the frames. The AppSec RASP hooks use wrapt, so the wrapper is a
Python function with its own stack frame. That frame appears in the traceback of ordinary
application exceptions passing through it — even when RASP is inactive and the wrapper only does
return original(...) — so tracer-unrelated customer errors get attributed to Datadog, inflating
dd.instrumentation_telemetry_data.datadog_crashes.

WrappingContext (ddtrace/internal/wrapping/context.py) rewrites the target's own bytecode into a
with-block around its body, so __enter__ has returned before the body runs and no ddtrace frame is
left behind. Measured on the reported reproduction:

before   open -> wrapped_open_ED4CF71136E15EBF@ddtrace/appsec/... -> urlopen
after    open -> urlopen

Worth noting for reviewers: ddtrace.internal.wrapping.wrap does not help here. The wrapper
still owns a frame, and it adds a trampoline frame on top:

open -> rasp_wrapper@ddtrace -> open@request.py:497 -> urlopen

Migrated in this PR:

target was now
urllib.request.OpenerDirector.open wrapped_open_ED4CF71136E15EBF _SsrfOpenerDirectorOpen
http.client.HTTPConnection.request wrapped_request_A7F2C6E4D3B10958 _SsrfHttpConnectionRequest
http.client.HTTPConnection.getresponse wrapped_response _SsrfHttpConnectionGetresponse

Adds try_wrap_context / try_unwrap_context in _patch_utils.py alongside
try_wrap_function_wrapper, reusing the same ModuleWatchdog bookkeeping. wrapt stays in that
module: apply_patch / patchable_builtin / patch_builtins exist for the C targets, and IAST
still uses FunctionWrapper.

Points that needed care

A with core.context_with_data(...) block spanned the wrapped call. __enter__ and
__return__ / __exit__ are separate calls, so it becomes a manual open in __enter__ released
from both __return__ and __exit__. It must be released before a response-side block
raises, because a raising __return__ sets _SKIP_EXIT_KEY and suppresses __exit__.

RASP blocking still works because BlockingException derives from BaseException
(ddtrace/internal/_exceptions.py:5). _UniversalWrappingContext.__enter__ swallows
except Exception per registered context (context.py:759-765), so an Exception-derived block
would be silently dropped. Load-bearing, and now covered by a test.

The crop_trace anchor moved from the wrapper name to the wrapped function's own name. A
context runs inside the target's frame, so there is no wrapper frame left to crop at. Anchoring on
the target keeps report_stack cropping in the same place, so frames[0] remains the target's
caller. Dropping the anchor would have shifted the reported stack by one frame, because
_INTERNAL_FRAMES does not cover the stdlib.

The migrated bodies are deliberately kept as a move rather than a rewrite — same guard nesting,
same locals in the same order — so the diff reads as relocation. The only differences from the
original wrapper functions are the argument reads going through _arg, the crop anchor, and
returning self. Refactoring can come later.

Binding a context to a wrapt proxy is unrecoverable, which matters because
contrib/internal/httplib/patch.py:229 wrapt-wraps the same two HTTPConnection attributes:

  • getattr(cls, "method") builds a fresh BoundFunctionWrapper on every access, so the
    registration can never be found again;
  • isinstance(proxy, FunctionType) is True — wrapt forwards __class__ — so a "peel until it is
    a function" loop never peels.

unwrap() then silently no-ops while the code object stays rewritten, and the next patch rewrites on
top of it, growing the code every patch/unpatch cycle (request 42 → 94 bytes) until the bytecode
library raises KeyError while parsing. Resolved by reading the owner's __dict__ with a type()
check and unwrapping the retained context instance instead of re-resolving the attribute. Wrap
failures are also swallowed now — losing a RASP hook is acceptable, breaking customer patching is
not.

Not migrated

  • The three urllib3 hooks. contrib/internal/urllib3/patch.py:55-60 installs the same appsec
    wrappers on the same targets as patch_common_modules(), deduplicated today only by
    apply_patch's FunctionWrapper guard, which a wrapping context bypasses. Migrating appsec's side
    alone would double-invoke the WAF and double-count downstream_requests. Doing it properly means
    removing the contrib registrations, which crosses the boundary
    .cursor/rules/isolated-responsibility.mdc governs. Also needs urllib3 v1 handling, where
    _make_request keeps body/headers in **httplib_request_kw rather than as named
    parameters, so _arg will need a varkwargs fallback added alongside tests that exercise it.
  • builtins.open, os.system, os.fork, builtins.eval, hashlib IAST sinks. C callables with
    no __code__; WrappingContext cannot wrap them at all.
  • Contrib-mediated RASP paths and stripe. wrapped_request_D8CB81E472AF98A2 is called inline
    from five contrib patch modules and keeps its wrapt signature.

This does not close APPSEC-69877 on its own. Those targets keep contributing ddtrace frames.

Jira: https://datadoghq.atlassian.net/browse/APPSEC-69877

Testing

New tests in tests/appsec/appsec/test_common_modules.py:

  • the regression this buys — a connection error through the SSRF hook has no ddtrace frame in its
    traceback;
  • the hook is a wrapping context and no wrapt wrapper sits on the attribute; re-patching is a no-op;
  • _arg reads fullurl by name and the hook actually runs (get the name wrong and RASP silently
    stops inspecting outgoing requests, which no other assertion would catch);
  • no core context or full_url item survives a failed request;
  • appsec and the httplib integration coexist in both patch orders, and the hook still fires in
    both, asserted via the arguments it observed;
  • len(fn.__code__.co_code) returns to baseline after 6 mixed-order patch/unpatch cycles — this is
    the assertion that catches the proxy-binding defect above; is_wrapped-style checks do not.

Verified on Python 3.13: appsec::appsec 695 passed / 1 xfailed / 0 failed;
appsec::appsec_threats_flask_no_iast 2900 passed / 2 skipped / 8 xfailed / 0 failed, which covers
the ssrf exploit-prevention cases and their top_functions assertions on the cropped RASP stack.
scripts/lint fmt and scripts/lint typing clean.

Risks

Medium — bytecode rewriting replaces wrapt for three hooks on an outbound HTTP path.

  • In-place code mutation is global, so it affects HTTPConnection and OpenerDirector subclasses
    too. Intended, and patch/unpatch symmetry is now asserted by the bytecode-size test.
  • Couples these hooks to the bytecode layer's version support. pyproject.toml is
    >=3.9,<3.15 and context.py has a branch per version, so there is no gap today.
  • Known bytecode-layer limits do not apply to these targets (none are async or t-string bearing),
    but see the skips in tests/wrapping/test_tstrings_py314.py and test_async.py.
  • Not benchmarked. Each request now pays __enter__ plus __return__ instead of one wrapt call,
    against network I/O. Happy to run scripts/lint-adjacent benchmarks if reviewers want a number.

Additional Notes

The wrapped_response_SsrfHttpConnectionGetresponse move also drops a pre-existing
original(*args, *kwargs) typo (*kwargs, not **kwargs), which was harmless only because
getresponse takes no keyword arguments.

🤖 Generated with Claude Code

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/appsec/_common_module_patches.py                                @DataDog/asm-python
ddtrace/appsec/_patch_utils.py                                          @DataDog/asm-python
releasenotes/notes/fix-asm-ssrf-hook-traceback-frames-6e14097eccf61dda.yaml  @DataDog/apm-python
tests/appsec/appsec/test_common_modules.py                              @DataDog/asm-python

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 1, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 1, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 230 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 230 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=134)
ddtrace.llmobs._utils -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.llmobs._integrations.google_adk -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.appsec._contrib.django -×-> ddtrace.trace  (product:appsec -> product:tracing, score=132)
ddtrace.llmobs._integrations.base -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Sep 1, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 4a056d7 | Docs | View more details | Give us feedback!

@avara1986
avara1986 force-pushed the alberto.vara/APPSEC-69877-rasp-wrapping-context branch from bea10c1 to 86379c9 Compare September 1, 2026 16:06
@pr-commenter

pr-commenter Bot commented Sep 1, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-09-04 15:34:26

Comparing candidate commit 4a056d7 in PR branch alberto.vara/APPSEC-69877-rasp-wrapping-context with baseline commit df7be7f in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 2 performance regressions! Performance is the same for 82 metrics, 0 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+128.869µs; +134.317µs] or [+32.932%; +34.324%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+18.352µs; +21.992µs] or [+12.790%; +15.327%]

@avara1986 avara1986 changed the title refactor(asm): remove RASP wrapper frames from customer tracebacks [APPSEC-69877] refactor(asm): remove RASP wrapper frames from customer tracebacks Sep 2, 2026
@avara1986
avara1986 marked this pull request as ready for review September 2, 2026 07:27
@avara1986
avara1986 requested review from a team as code owners September 2, 2026 07:27
@avara1986
avara1986 requested review from christophe-papazian and removed request for a team September 2, 2026 07:27
@avara1986
avara1986 marked this pull request as draft September 2, 2026 07:28
Base automatically changed from alberto.vara/APPSEC-69951-remove-raise-without-wrapper-frame to main September 2, 2026 09:06
avara1986 and others added 2 commits September 2, 2026 11:24
…C-69877]

The RASP hooks use wrapt, so the wrapper is a Python function with its own
stack frame. That frame lands in the traceback of any ordinary application
exception passing through it, and crash intake tags a report crash_datadog
as soon as a ddtrace path appears in the frames. Datadog gets blamed for
customer bugs.

WrappingContext rewrites the target's own bytecode into a with-block around
its body, so __enter__ has returned before the body runs and no ddtrace
frame is left behind. Measured on the reported reproduction:

  before  open -> wrapped_open_ED4CF71136E15EBF@ddtrace -> urlopen
  after   open -> urlopen

Note that ddtrace.internal.wrapping.wrap does not help here: the wrapper
still owns a frame and it adds a trampoline frame on top.

Adds try_wrap_context / try_unwrap_context alongside try_wrap_function_wrapper,
reusing the same ModuleWatchdog bookkeeping so unpatch stays symmetric. They
guard on is_wrapped because registering the same context type twice raises,
where wrapt's apply_patch silently no-ops.

Converts urllib.request.OpenerDirector.open to _SsrfOpenerDirectorOpen. The
core context that used to be a with-block spanning the call is now opened in
__enter__ and released from both __return__ and __exit__, which together cover
the return and error paths; it must be released before a response-side block
raises, because a raising __return__ suppresses __exit__. Arguments are read by
name via _arg, replacing the args/kwargs index juggling; _arg also falls back to
a **kwargs bag, which the urllib3 hooks will need for urllib3 v1.

The remaining hooks in this module keep using wrapt and are migrated in
follow-ups. builtins.open and os.system are C functions with no code object,
so they cannot be bytecode-wrapped at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…EC-69877]

Converts the two http.client hooks to wrapping contexts so no ddtrace frame
is left in the traceback of application exceptions passing through them:

  wrapped_request_A7F2C6E4D3B10958 -> _SsrfHttpConnectionRequest  (__enter__)
  wrapped_response                 -> _SsrfHttpConnectionGetresponse (__return__)

The crop anchor for the SSRF_REQ stack moves from the wrapper name to the
wrapped function's own name. A context runs inside the target's frame, so
there is no wrapper frame left to crop at; anchoring on the target keeps
report_stack cropping at the same place, and frames[0] stays the target's
caller. Dropping the anchor instead would have shifted the reported stack by
one frame, because _INTERNAL_FRAMES does not cover the stdlib.

Also fixes a real defect in try_wrap_context introduced with the urllib hook.
A contrib integration may already hold a wrapt wrapper on the attribute, and
binding the context to that proxy is unrecoverable:

  - getattr(cls, "method") builds a fresh BoundFunctionWrapper on every access,
    so the registration can never be found again;
  - isinstance(proxy, FunctionType) is True because wrapt forwards __class__,
    so the "peel until it is a function" loop never peeled.

unwrap() then silently no-opped while the code object stayed rewritten, and the
next patch rewrote on top of it, growing the code every patch/unpatch cycle
(request 42 -> 94 bytes) until the bytecode library raised KeyError while
parsing - surfacing as failures in unrelated tests far from the cause. Resolve
through the owner's __dict__ with a type() check, and unwrap the retained
context instance rather than re-resolving the attribute. Wrap failures are now
swallowed too: losing a RASP hook is acceptable, breaking patching is not.

The urllib3 hooks stay on wrapt. contrib/internal/urllib3/patch.py installs the
same appsec wrappers on the same targets, deduplicated today only by
apply_patch's FunctionWrapper guard, which a wrapping context bypasses; moving
them would double-invoke the WAF. Scoped separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@avara1986
avara1986 force-pushed the alberto.vara/APPSEC-69877-rasp-wrapping-context branch from 86379c9 to 62a53e5 Compare September 2, 2026 09:28
Reviewer feedback: keep the migrated logic as close to the current code as
possible so the diff reads as a move. There will be time to refactor later.

_SsrfHttpConnectionRequest.__enter__ keeps the original guard nesting and the
method/body/headers locals in their original order. The only differences from
wrapped_request_A7F2C6E4D3B10958 are the three argument reads going through
_arg, the crop anchor, and returning self. Same treatment for
_SsrfOpenerDirectorOpen: the _analyze_response and _analyze_http_error helpers
are inlined back into __return__ and __exit__, so those bodies read like the
original try/except block.

A cleanup pass over the migration then removed what it had accumulated:

- _RaspContext._VARKWARGS and the varkwargs branch in _arg. It existed for
  urllib3 v1, where _make_request keeps body/headers in **httplib_request_kw,
  but the urllib3 hooks are exactly what this change does not migrate. None of
  the three migrated targets has a **kwargs parameter, so the branch was
  unreachable. _arg is now a default-tolerant f_locals read; the fallback comes
  back with the urllib3 work, alongside tests that exercise it.
- The rasp_active flag, which was a second encoding of core_ctx is not None.
  Replaced by a _rasp_active() predicate over the single source of truth.
- _SsrfHttpConnectionGetresponse's _RaspContext base. It inspects only the
  return value and used neither _arg nor the core context, but inherited an
  __enter__ that wrote two storage keys nobody read on every getresponse call.

Tests: the core-context release test asserted nothing, because RASP is inactive
in that suite so __enter__ never opened a context. It now drives the lifecycle
directly and checks the full_url item appears and then disappears on both the
return and the error path; neutering _close_core_context fails it. The httplib
coexistence probe uses a plain nested subclass instead of a dynamically built
class, which also drops a super(type(self), self) that would have recursed
infinitely had it ever been subclassed.

Also merges the two release notes into one, since both hooks are the same
customer-visible fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@avara1986
avara1986 force-pushed the alberto.vara/APPSEC-69877-rasp-wrapping-context branch from 62a53e5 to 616798a Compare September 2, 2026 10:09
@avara1986

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 616798a16e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddtrace/appsec/_common_module_patches.py
Comment thread ddtrace/appsec/_patch_utils.py Outdated
… hooks

The migration PR had no coverage for the paths most likely to regress
silently. Adds nine tests, each verified to fail when the behaviour it
guards is broken:

- BlockingException must stay BaseException-derived. A registered context's
  __enter__ runs inside `except Exception: continue`, so a block deriving
  from Exception would be swallowed and the request would proceed. Nothing
  else would catch that change.
- A SSRF_REQ block still propagates out of the migrated http.client hook,
  and the WAF call carries crop_trace="request". The crop anchor moved from
  the wrapper name to the wrapped function's name and had no direct test.
- The API10 down-response WAF call, which used to sit in a `with` block
  spanning the call and now lives in __return__, and its HTTPError
  counterpart in __exit__. Neither was covered.
- A 3xx response must not be inspected here, since getresponse already
  reports it; otherwise the WAF is called twice per redirect.
- http.client tracebacks carry no ddtrace frame, the same guarantee already
  asserted for urlopen. The httplib integration is unpatched in that test so
  it measures appsec's hook alone.
- A failing hook must not surface to the customer, and a wrap failure must
  not break patching.

Mutation-checked: removing the block, leaving the stale crop anchor,
dropping either API10 call, dropping the 3xx guard, or narrowing the wrap
except each make the corresponding test fail.

Verified on Python 3.9, 3.13 and 3.14, avoiding the version-specific stdlib
details that broke an earlier assertion on 3.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Sep 3, 2026
…20016)

## Description

A wrapping context that raises is skipped by the machinery that would have popped its per-call
ContextVar storage. The storage chains one dict per call through `_STORAGE_PREV` and is never
released, so on a long-lived worker thread it grows without bound, and on two of the three paths
the universal storage is among the retained dicts — so it keeps hold of `__frame__`.

Measured on Python 3.13, before the fix:

```
__enter__ raises:   after 1 call  concrete chain=1  universal chain=0  frame_retained=False
                    after 8 calls concrete chain=8  universal chain=0  frame_retained=False

__return__ raises:  after 1 call  concrete chain=1  universal chain=1  frame_retained=True
                    after 5 calls concrete chain=5  universal chain=5  frame_retained=True

__exit__ raises:    after 5 calls concrete chain=5  universal chain=5  frame_retained=True
```

**Scope:** only the `__enter__` path is fixed here. `__return__` / `__exit__` and the 3.15
monitoring path leak the same way, but releasing the storage there is not a matter of adding a pop —
`_exit` and `on_py_unwind` read `_SKIP_EXIT_KEY` back off that storage, so popping first makes them
act on the enclosing call's storage. Documented in place and tracked in [APPSEC-69961](https://datadoghq.atlassian.net/browse/APPSEC-69961), with the two test parameters
covering them marked `xfail(strict=True)`.

Two distinct causes, both on `__enter__`:

- **`__enter__`** — `_UniversalWrappingContext.__enter__` appends to `entered` only *after* a
  successful call, so a context that raises is absent from the list `__exit__` later iterates. Its
  `__enter__` has usually already pushed storage via `super().__enter__()`. From 3.11 the universal
  storage is fine here, because the injected exception handler still reaches `_exit()`.
- **`__enter__` below 3.11** — those versions enter through a real `with` statement, and Python does
  not call `__exit__` when `__enter__` raises. So the universal storage, which holds `__frame__`,
  and the storage of contexts that *did* enter are left behind as well. Gated on the version,
  because popping here on 3.11+ would double-pop and discard an outer re-entrant call's storage.

The fix releases storage on all three paths, comparing storage identity against a snapshot taken
before the call. **That identity check is the load-bearing part**: popping unconditionally would
discard an outer re-entrant call's storage, since a context that already popped now holds a
different object.

Existing semantics are deliberately unchanged: an `Exception` from `__enter__` is still swallowed
and logged so a broken context cannot break the call, and a `BaseException` still propagates.

### Impact

Latent today. No shipped `WrappingContext` subclass raises by design, so triggering it needs a bug
in a context's `__enter__` / `__return__` / `__exit__` — which an `Exception`-derived failure does,
even though it is swallowed and logged. A debugger probe or code-origin context hitting an
unexpected error would leak.

It becomes reachable by design in #19989, where `BlockingException` is raised from `__enter__` to
stop a blocked outgoing request, i.e. one leaked dict per blocked request.

Jira: https://datadoghq.atlassian.net/browse/APPSEC-69960
Blocks: #19989

## Risks

Medium — this is shared instrumentation used by the debugger, code origin, selenium and the lazy
module loader.

- The re-entrancy hazard is the thing to review closely. `_release_storage` pops only when the
  context still holds the exact object it held before the call, so an already-popped context is
  left alone.
- Cleanup is wrapped so a failure to release cannot mask the original exception.
- The `except Exception` in `__enter__` widened to `except BaseException` in order to run cleanup,
  then re-raises non-`Exception` unchanged. Behaviour for `Exception` is identical to before.

## Additional Notes

Split out of #19989 at review request: the change is in shared wrapping infrastructure and wants
its own review rather than riding along in an AppSec PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)




[APPSEC-69961]: https://datadoghq.atlassian.net/browse/APPSEC-69961?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

Co-authored-by: alberto.vara <alberto.vara@datadoghq.com>
avara1986 and others added 4 commits September 3, 2026 22:44
…EC-69877]

Review feedback. try_wrap_context's module hook returned early whenever
(module_name, name) was already in the registry, treating the key as proof
that the current target is wrapped. It is not: when http.client or
urllib.request is reloaded, or dropped from sys.modules and imported again,
ModuleWatchdog fires the hook for freshly defined functions while the
registry still holds a context bound to the previous ones. The new
HTTPConnection and OpenerDirector methods then ran without SSRF and API10
instrumentation until a full unpatch/patch cycle.

This was a regression against the wrapt path it replaced, which handled the
case for free: wrap_object runs on every hook fire, and apply_patch wraps
whatever the attribute holds at that point.

Compare the installed context's __wrapped__ against the freshly resolved
target instead, and rebind when they differ, releasing the stale context
first. A failure to release is logged and does not stop the rebind.

Also adds the regression test for the blocking-path storage leak that review
raised against this PR. The framework fix landed separately in #20016
(APPSEC-69960), but this is the code that raises BlockingException from
__enter__, so the assertion belongs here: three blocked requests must leave
both the concrete and the universal ContextVar unset, or each block chains
another storage dict onto the worker thread.

Both are mutation-checked: removing the rebind, or removing the storage
release in _UniversalWrappingContext.__enter__, fails the corresponding test.

appsec::appsec on py3.13: 708 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…[APPSEC-69877]

Comparing the three WrappingContext users, the debugger is the one that gets
the lifecycle right: it guards its own work in __enter__/__return__/__exit__
and always chains to super(). The RASP contexts did neither consistently.

- __enter__ ran unguarded. open_rasp_subcontext_scope() is called after the
  core context is opened, so if it raised, the universal context swallowed the
  exception and left this context out of its entered list. Neither __return__
  nor __exit__ then runs, so core_ctx.__exit__ never happens: _CURRENT_CONTEXT
  stays pinned to an orphaned url_open_analysis context for the rest of the
  thread's life and later core.set_item/find_item land in the orphan. The old
  "with core.context_with_data(...)" block could not leak this way. The body
  moves to _handle_enter(), mirroring selenium's _handle_enter and the
  debugger's _open_signals, and a failure now closes the core context.

- __return__ and __exit__ had try/finally but no except, so a bug in the
  response analysis surfaced to the customer and, via __return__, reached the
  universal context, which sets the skip flag and strands this call's storage
  (APPSEC-69961). Both now swallow and log, as the other two products do.

- try_wrap_context read installed.__wrapped__, which is a weakref property that
  raises RuntimeError once the old function is collected - exactly the reload
  case the rebind was added for. Inside the blanket try that aborted the hook
  and left the stale entry, so the hook would stay unwrapped permanently. Read
  _wrapped_ref() instead and treat a dead ref as stale.

- _arg re-resolved __frame__ per call, which takes the wrapping registry lock
  and scans co_consts. The http.client hook read three arguments, so every
  instrumented downstream request paid that three times. Resolve once.

Both new behaviours are mutation-checked: making __enter__ or __return__ stop
swallowing fails the corresponding test.

appsec::appsec 710 passed / 1 xfailed on py3.13; test_common_modules 95 passed
on py3.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@avara1986
avara1986 marked this pull request as ready for review September 4, 2026 09:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

PoolManager calls ``HTTPConnectionPool.urlopen(method, request_uri, ...)`` with the *relative*
URI, so the buggy wrapper stored the body/``None`` (no host); the fix rebuilds the absolute URL.

P1 Badge Use plain prose in the test docstring

Remove the reStructuredText markup around HTTPConnectionPool.urlopen, “relative,” and the literal examples in this test-only docstring. Test docstrings are not rendered by Sphinx, and the repository convention explicitly requires plain prose without reStructuredText emphasis or double-backtick literals so these comments remain readable in editors.

AGENTS.md reference: AGENTS.md:L43-L50

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddtrace/appsec/_common_module_patches.py Outdated
Comment thread ddtrace/appsec/_patch_utils.py
…apper [APPSEC-69877]

_wrap_request and _wrap_putrequest guard the wrapped call with "except
Exception", but BlockingException derives from BaseException so that Exploit
Prevention's block cannot be swallowed by an intermediate handler. The span was
therefore created and activated by tracer.trace(), then never finished: it is
never flushed, it stays current for the rest of the thread's life, and every
later span on that thread is parented to it.

This is a pre-existing defect that was order-dependent, and this branch removes
the order that avoided it. Measured with the httplib integration and appsec
both patched, blocking from HTTPConnection.request:

  patch order            main            this branch
  httplib contrib first  clean           leaked
  appsec first           leaked          leaked

On main, an appsec wrapt wrapper installed after the integration sat outside
_wrap_request, so the block raised before the span existed. A wrapping context
lives in the target's own bytecode, so it is always innermost and that escape
is gone.

Catching BaseException here matches what the rest of the tracer already does
for span cleanup - redis_utils, valkey_utils, elasticsearch, asgi, pyramid,
tornado and tracer.py all do the same. _wrap_getresponse already used
try/finally and was unaffected.

Also applies two review findings on the appsec side: plain prose instead of
reStructuredText in a test docstring, and an AIDEV-NOTE anchor on the
__enter__ lifecycle invariant.

The new test covers both patch orders and fails on both without the fix.
test_common_modules 97 passed on py3.13; contrib::httplib green on py3.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…877]

The httplib span leak is a pre-existing bug in shared integration code, not
part of this migration: on main it already strands the span whenever appsec
patched http.client before the integration did. Only the second order was safe,
because appsec's wrapt wrapper then sat outside _wrap_request and the block
raised before the span existed.

It belongs on its own, so it moves to #20073 (APPSEC-70027) with tests that
raise a real BlockingException from inside the wrapped call and therefore need
neither the network nor appsec. The regression test added here goes with it -
its contract is the same one those two cover, and keeping a copy that cannot
pass until #20073 lands would only make this branch red.

This branch now depends on #20073: a wrapping context lives in the target's own
bytecode, so it is always innermost and the block always raises inside the
span, which removes the order that used to avoid the leak. Merge main in once
#20073 has landed.

test_common_modules 95 passed on py3.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Sep 4, 2026
…apper (#20073)

## Summary

Jira: [APPSEC-70027](https://datadoghq.atlassian.net/browse/APPSEC-70027)

Exploit Prevention blocks an outgoing request by raising `BlockingException`, which derives from `BaseException` so that no intermediate handler can swallow a blocking decision. `_wrap_request` and `_wrap_putrequest` guard the wrapped call with `except Exception`:

```python
    try:
        return func_to_call(*args, **kwargs)
    except Exception:
        span = getattr(instance, "_datadog_span", None)
        ...
```

The block goes straight past that handler. The span has already been created and activated by `tracer.trace()` and attached to the connection, so it is never finished: it is never flushed, it stays current for the rest of the thread's life, and every later span on that thread is parented to it.

Measured with the integration and appsec both patched, blocking from `HTTPConnection.request`:

```
span created: True   span finished: False   still current: True
Shutting down tracer with 1 spans. These spans will not be sent to Datadog: name=http.client.request
```

## Order dependence

Reachable on `main` today, but only in one patch order:

| patch order | `main` |
|---|---|
| httplib contrib patched first | clean, no span created |
| appsec patched first | **leaked** |

When appsec patches the attribute *after* the integration, its wrapt wrapper sits outside `_wrap_request` and the block raises before the span exists. When appsec patches first, the integration wraps appsec's wrapper and the block happens inside the span.

## Why now

The AppSec RASP migration to `WrappingContext` (#19989, [APPSEC-69878](https://datadoghq.atlassian.net/browse/APPSEC-69878)) removes the order that avoids this. A wrapping context lives in the target's own bytecode, so it is always innermost and the block always raises inside the span. **#19989 depends on this landing first.** Found by Codex review on that PR, split out here because it is a pre-existing bug in shared integration code rather than part of the migration.

## Fix

Catch `BaseException` around the wrapped call in both wrappers and re-raise. That is what the rest of the tracer already does for span cleanup — `redis_utils`, `valkey_utils`, `elasticsearch`, `asgi`, `pyramid`, `tornado` and `tracer.py`. `_wrap_getresponse` already used `try/finally` and was unaffected.

## Testing

Two tests in `tests/contrib/httplib/test_httplib.py` raise a real `BlockingException` from inside the wrapped call, so they need no network and no appsec setup:

- `test_span_is_finished_when_the_request_raises_a_base_exception`
- `test_span_is_finished_when_putrequest_raises_a_base_exception`

Both assert the span is finished and no span is left current. Mutation-checked — reverting to `except Exception` fails both.

`contrib::httplib` green on py3.12. `scripts/lint fmt` clean.

## Checklist

- [x] PR author has checked that all the criteria below are met
- [x] The PR description includes an overview of the change
- [x] The PR description articulates the motivation for the change
- [x] The change includes tests OR the PR description describes a testing strategy
- [x] The PR description notes risks associated with the change, if any
- [x] Newly-added code is easy to change
- [x] The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html)
- [x] The change includes or references documentation updates if necessary
- [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting))

## Reviewer Checklist

- [ ] 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)

🤖 Generated with [Claude Code](https://claude.com/claude-code)


[APPSEC-70027]: https://datadoghq.atlassian.net/browse/APPSEC-70027?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[APPSEC-69878]: https://datadoghq.atlassian.net/browse/APPSEC-69878?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

Co-authored-by: alberto.vara <alberto.vara@datadoghq.com>

@emmettbutler emmettbutler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note looks fine

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants