Skip to content

Commit f594752

Browse files
juanjuxavara1986
andauthored
feat(iast): implement the stacktrace leak vulnerability (#12007)
## Description Implement the stacktrace leak vulnerability detection. STATUS: Waiting for DataDog/system-tests#3874 to be merged. Signed-off-by: Juanjo Alvarez <[email protected]> ## 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) --------- Signed-off-by: Juanjo Alvarez <[email protected]> Co-authored-by: Alberto Vara <[email protected]>
1 parent b060827 commit f594752

File tree

18 files changed

+2555
-7
lines changed

18 files changed

+2555
-7
lines changed

ddtrace/appsec/_iast/_handlers.py

+68
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from wrapt import wrap_function_wrapper as _w
66

77
from ddtrace.appsec._iast import _is_iast_enabled
8+
from ddtrace.appsec._iast._iast_request_context import get_iast_stacktrace_reported
89
from ddtrace.appsec._iast._iast_request_context import in_iast_context
10+
from ddtrace.appsec._iast._iast_request_context import set_iast_stacktrace_reported
911
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_source
1012
from ddtrace.appsec._iast._patch import _iast_instrument_starlette_request
1113
from ddtrace.appsec._iast._patch import _iast_instrument_starlette_request_body
@@ -445,3 +447,69 @@ def _on_set_request_tags_iast(request, span, flask_config):
445447
OriginType.PARAMETER,
446448
override_pyobject_tainted=True,
447449
)
450+
451+
452+
def _on_django_finalize_response_pre(ctx, after_request_tags, request, response):
453+
if not response or get_iast_stacktrace_reported() or not _is_iast_enabled() or not is_iast_request_enabled():
454+
return
455+
456+
try:
457+
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
458+
459+
content = response.content.decode("utf-8", errors="ignore")
460+
asm_check_stacktrace_leak(content)
461+
except Exception:
462+
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
463+
464+
465+
def _on_django_technical_500_response(request, response, exc_type, exc_value, tb):
466+
if not _is_iast_enabled() or not is_iast_request_enabled() or not exc_value:
467+
return
468+
469+
try:
470+
from .taint_sinks.stacktrace_leak import asm_report_stacktrace_leak_from_django_debug_page
471+
472+
exc_name = exc_type.__name__
473+
module = tb.tb_frame.f_globals.get("__name__", "")
474+
asm_report_stacktrace_leak_from_django_debug_page(exc_name, module)
475+
except Exception:
476+
log.debug("Unexpected exception checking for stacktrace leak on 500 response view", exc_info=True)
477+
478+
479+
def _on_flask_finalize_request_post(response, _):
480+
if not response or get_iast_stacktrace_reported() or not _is_iast_enabled() or not is_iast_request_enabled():
481+
return
482+
483+
try:
484+
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
485+
486+
content = response[0].decode("utf-8", errors="ignore")
487+
asm_check_stacktrace_leak(content)
488+
except Exception:
489+
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
490+
491+
492+
def _on_asgi_finalize_response(body, _):
493+
if not _is_iast_enabled() or not is_iast_request_enabled() or not body:
494+
return
495+
496+
try:
497+
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
498+
499+
content = body.decode("utf-8", errors="ignore")
500+
asm_check_stacktrace_leak(content)
501+
except Exception:
502+
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
503+
504+
505+
def _on_werkzeug_render_debugger_html(html):
506+
if not _is_iast_enabled() or not is_iast_request_enabled() or not html:
507+
return
508+
509+
try:
510+
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
511+
512+
asm_check_stacktrace_leak(html)
513+
set_iast_stacktrace_reported(True)
514+
except Exception:
515+
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)

ddtrace/appsec/_iast/_iast_request_context.py

+14
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def __init__(self, span: Optional[Span] = None):
4646
self.iast_reporter: Optional[IastSpanReporter] = None
4747
self.iast_span_metrics: Dict[str, int] = {}
4848
self.iast_stack_trace_id: int = 0
49+
self.iast_stack_trace_reported: bool = False
4950

5051

5152
def _get_iast_context() -> Optional[IASTEnvironment]:
@@ -88,6 +89,19 @@ def get_iast_reporter() -> Optional[IastSpanReporter]:
8889
return None
8990

9091

92+
def get_iast_stacktrace_reported() -> bool:
93+
env = _get_iast_context()
94+
if env:
95+
return env.iast_stack_trace_reported
96+
return False
97+
98+
99+
def set_iast_stacktrace_reported(reported: bool) -> None:
100+
env = _get_iast_context()
101+
if env:
102+
env.iast_stack_trace_reported = reported
103+
104+
91105
def get_iast_stacktrace_id() -> int:
92106
env = _get_iast_context()
93107
if env:

ddtrace/appsec/_iast/_listener.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
from ddtrace.appsec._iast._handlers import _on_asgi_finalize_response
2+
from ddtrace.appsec._iast._handlers import _on_django_finalize_response_pre
13
from ddtrace.appsec._iast._handlers import _on_django_func_wrapped
24
from ddtrace.appsec._iast._handlers import _on_django_patch
5+
from ddtrace.appsec._iast._handlers import _on_django_technical_500_response
6+
from ddtrace.appsec._iast._handlers import _on_flask_finalize_request_post
37
from ddtrace.appsec._iast._handlers import _on_flask_patch
48
from ddtrace.appsec._iast._handlers import _on_grpc_response
59
from ddtrace.appsec._iast._handlers import _on_pre_tracedrequest_iast
610
from ddtrace.appsec._iast._handlers import _on_request_init
711
from ddtrace.appsec._iast._handlers import _on_set_http_meta_iast
812
from ddtrace.appsec._iast._handlers import _on_set_request_tags_iast
13+
from ddtrace.appsec._iast._handlers import _on_werkzeug_render_debugger_html
914
from ddtrace.appsec._iast._handlers import _on_wsgi_environ
1015
from ddtrace.appsec._iast._iast_request_context import _iast_end_request
1116
from ddtrace.internal import core
@@ -18,11 +23,16 @@ def iast_listen():
1823
core.on("set_http_meta_for_asm", _on_set_http_meta_iast)
1924
core.on("django.patch", _on_django_patch)
2025
core.on("django.wsgi_environ", _on_wsgi_environ, "wrapped_result")
26+
core.on("django.finalize_response.pre", _on_django_finalize_response_pre)
2127
core.on("django.func.wrapped", _on_django_func_wrapped)
28+
core.on("django.technical_500_response", _on_django_technical_500_response)
2229
core.on("flask.patch", _on_flask_patch)
2330
core.on("flask.request_init", _on_request_init)
2431
core.on("flask._patched_request", _on_pre_tracedrequest_iast)
2532
core.on("flask.set_request_tags", _on_set_request_tags_iast)
33+
core.on("flask.finalize_request.post", _on_flask_finalize_request_post)
34+
core.on("asgi.finalize_response", _on_asgi_finalize_response)
35+
core.on("werkzeug.render_debugger_html", _on_werkzeug_render_debugger_html)
2636

2737
core.on("context.ended.wsgi.__call__", _iast_end_request)
2838
core.on("context.ended.asgi.__call__", _iast_end_request)

ddtrace/appsec/_iast/constants.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import Any
23
from typing import Dict
34

@@ -14,6 +15,7 @@
1415
VULN_HEADER_INJECTION = "HEADER_INJECTION"
1516
VULN_CODE_INJECTION = "CODE_INJECTION"
1617
VULN_SSRF = "SSRF"
18+
VULN_STACKTRACE_LEAK = "STACKTRACE_LEAK"
1719

1820
VULNERABILITY_TOKEN_TYPE = Dict[int, Dict[str, Any]]
1921

@@ -27,6 +29,12 @@
2729
RC2_DEF = "rc2"
2830
RC4_DEF = "rc4"
2931
IDEA_DEF = "idea"
32+
STACKTRACE_RE_DETECT = re.compile(r"Traceback \(most recent call last\):")
33+
HTML_TAGS_REMOVE = re.compile(r"<!--[\s\S]*?-->|<[^>]*>|&#\w+;")
34+
STACKTRACE_FILE_LINE = re.compile(r"File (.*?), line (\d+), in (.+)")
35+
STACKTRACE_EXCEPTION_REGEX = re.compile(
36+
r"^(?P<exc>[A-Za-z_]\w*(?:Error|Exception|Interrupt|Fault|Warning))" r"(?:\s*:\s*(?P<msg>.*))?$"
37+
)
3038

3139
DEFAULT_WEAK_HASH_ALGORITHMS = {MD5_DEF, SHA1_DEF}
3240

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import os
2+
import re
3+
4+
from ..._constants import IAST_SPAN_TAGS
5+
from .. import oce
6+
from .._iast_request_context import set_iast_stacktrace_reported
7+
from .._metrics import _set_metric_iast_executed_sink
8+
from .._metrics import increment_iast_span_metric
9+
from .._taint_tracking._errors import iast_taint_log_error
10+
from ..constants import HTML_TAGS_REMOVE
11+
from ..constants import STACKTRACE_EXCEPTION_REGEX
12+
from ..constants import STACKTRACE_FILE_LINE
13+
from ..constants import VULN_STACKTRACE_LEAK
14+
from ..taint_sinks._base import VulnerabilityBase
15+
16+
17+
@oce.register
18+
class StacktraceLeak(VulnerabilityBase):
19+
vulnerability_type = VULN_STACKTRACE_LEAK
20+
skip_location = True
21+
22+
23+
def asm_report_stacktrace_leak_from_django_debug_page(exc_name, module):
24+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, StacktraceLeak.vulnerability_type)
25+
_set_metric_iast_executed_sink(StacktraceLeak.vulnerability_type)
26+
evidence = "Module: %s\nException: %s" % (module, exc_name)
27+
StacktraceLeak.report(evidence_value=evidence)
28+
set_iast_stacktrace_reported(True)
29+
30+
31+
def asm_check_stacktrace_leak(content: str) -> None:
32+
if not content:
33+
return
34+
35+
try:
36+
# Quick check to avoid the slower operations if on stacktrace
37+
if "Traceback (most recent call last):" not in content:
38+
return
39+
40+
text = HTML_TAGS_REMOVE.sub("", content)
41+
lines = [line.strip() for line in text.splitlines() if line.strip()]
42+
43+
file_lines = []
44+
exception_line = ""
45+
46+
for i, line in enumerate(lines):
47+
if line.startswith("Traceback (most recent call last):"):
48+
# from here until we find an exception line
49+
continue
50+
51+
# See if this line is a "File ..." line
52+
m_file = STACKTRACE_FILE_LINE.match(line)
53+
if m_file:
54+
file_lines.append(m_file.groups())
55+
continue
56+
57+
# See if this line might be the exception line
58+
m_exc = STACKTRACE_EXCEPTION_REGEX.match(line)
59+
if m_exc:
60+
# We consider it as the "final" exception line. Keep it.
61+
exception_line = m_exc.group("exc")
62+
# We won't break immediately because sometimes Django
63+
# HTML stack traces can have repeated exception lines, etc.
64+
# But typically the last match is the real final exception
65+
# We'll keep updating exception_line if we see multiple
66+
continue
67+
68+
if not file_lines and not exception_line:
69+
return
70+
71+
module_path = None
72+
if file_lines:
73+
# file_lines looks like [ ("/path/to/file.py", "line_no", "funcname"), ... ]
74+
last_file_entry = file_lines[-1]
75+
module_path = last_file_entry[0] # the path in quotes
76+
77+
# Attempt to convert a path like "/myproj/foo/bar.py" into "foo.bar"
78+
# or "myproj.foo.bar" depending on your directory structure.
79+
# This is a *best effort* approach (it can be environment-specific).
80+
module_name = ""
81+
if module_path:
82+
mod_no_ext = re.sub(r"\.py$", "", module_path)
83+
parts: list[str] = []
84+
while True:
85+
head, tail = os.path.split(mod_no_ext)
86+
if tail:
87+
parts.insert(0, tail)
88+
mod_no_ext = head
89+
else:
90+
# might still have a leftover 'head' if it’s not just root
91+
break
92+
93+
module_name = ".".join(parts)
94+
if not module_name:
95+
module_name = module_path # fallback: just the path
96+
97+
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, StacktraceLeak.vulnerability_type)
98+
_set_metric_iast_executed_sink(StacktraceLeak.vulnerability_type)
99+
evidence = "Module: %s\nException: %s" % (module_name.strip(), exception_line.strip())
100+
StacktraceLeak.report(evidence_value=evidence)
101+
except Exception as e:
102+
iast_taint_log_error("[IAST] error in check stacktrace leak. {}".format(e))

ddtrace/contrib/internal/django/patch.py

+21
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,23 @@ def traced_as_view(django, pin, func, instance, args, kwargs):
696696
return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(instance)))
697697

698698

699+
@trace_utils.with_traced_module
700+
def traced_technical_500_response(django, pin, func, instance, args, kwargs):
701+
"""
702+
Wrapper for django's views.debug.technical_500_response
703+
"""
704+
response = func(*args, **kwargs)
705+
try:
706+
request = get_argument_value(args, kwargs, 0, "request")
707+
exc_type = get_argument_value(args, kwargs, 1, "exc_type")
708+
exc_value = get_argument_value(args, kwargs, 2, "exc_value")
709+
tb = get_argument_value(args, kwargs, 3, "tb")
710+
core.dispatch("django.technical_500_response", (request, response, exc_type, exc_value, tb))
711+
except Exception:
712+
log.debug("Error while trying to trace Django technical 500 response", exc_info=True)
713+
return response
714+
715+
699716
@trace_utils.with_traced_module
700717
def traced_get_asgi_application(django, pin, func, instance, args, kwargs):
701718
from ddtrace.contrib.asgi import TraceMiddleware
@@ -891,6 +908,9 @@ def _(m):
891908
trace_utils.wrap(m, "re_path", traced_urls_path(django))
892909

893910
when_imported("django.views.generic.base")(lambda m: trace_utils.wrap(m, "View.as_view", traced_as_view(django)))
911+
when_imported("django.views.debug")(
912+
lambda m: trace_utils.wrap(m, "technical_500_response", traced_technical_500_response(django))
913+
)
894914

895915
@when_imported("channels.routing")
896916
def _(m):
@@ -935,6 +955,7 @@ def _unpatch(django):
935955
trace_utils.unwrap(django.conf.urls, "url")
936956
trace_utils.unwrap(django.contrib.auth.login, "login")
937957
trace_utils.unwrap(django.contrib.auth.authenticate, "authenticate")
958+
trace_utils.unwrap(django.view.debug.technical_500_response, "technical_500_response")
938959
if django.VERSION >= (2, 0, 0):
939960
trace_utils.unwrap(django.urls, "path")
940961
trace_utils.unwrap(django.urls, "re_path")

ddtrace/contrib/internal/flask/patch.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ def patch():
224224
_w("flask.templating", "_render", patched_render)
225225
_w("flask", "render_template", _build_render_template_wrapper("render_template"))
226226
_w("flask", "render_template_string", _build_render_template_wrapper("render_template_string"))
227+
try:
228+
_w("werkzeug.debug.tbtools", "DebugTraceback.render_debugger_html", patched_render_debugger_html)
229+
except AttributeError:
230+
log.debug("Failed to patch DebugTraceback.render_debugger_html, not supported by this werkzeug version")
227231

228232
bp_hooks = [
229233
"after_app_request",
@@ -380,12 +384,8 @@ def patched_finalize_request(wrapped, instance, args, kwargs):
380384
Wrapper for flask.app.Flask.finalize_request
381385
"""
382386
rv = wrapped(*args, **kwargs)
383-
response = None
384-
headers = None
385387
if getattr(rv, "is_sequence", False):
386-
response = rv.response
387-
headers = rv.headers
388-
core.dispatch("flask.finalize_request.post", (response, headers))
388+
core.dispatch("flask.finalize_request.post", (rv.response, rv.headers))
389389
return rv
390390

391391

@@ -419,6 +419,12 @@ def _wrap(rule, endpoint=None, view_func=None, **kwargs):
419419
return _wrap(*args, **kwargs)
420420

421421

422+
def patched_render_debugger_html(wrapped, instance, args, kwargs):
423+
res = wrapped(*args, **kwargs)
424+
core.dispatch("werkzeug.render_debugger_html", (res,))
425+
return res
426+
427+
422428
def patched_add_url_rule(wrapped, instance, args, kwargs):
423429
"""Wrapper for flask.app.Flask.add_url_rule to wrap all views attached to this app"""
424430

hatch.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ checks = [
5757
"suitespec-check",
5858
]
5959
spelling = [
60-
"codespell -I docs/spelling_wordlist.txt --skip='ddwaf.h,*cassettes*,tests/tracer/fixtures/urls.txt' {args:ddtrace/ tests/ releasenotes/ docs/}",
60+
"codespell -I docs/spelling_wordlist.txt --skip='ddwaf.h,*cassettes*,tests/tracer/fixtures/urls.txt,tests/appsec/iast/fixtures/*' {args:ddtrace/ tests/ releasenotes/ docs/}",
6161
]
6262
typing = [
6363
"mypy {args}",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
Code Security: Implement the detection of the Stacktrace-Leak vulnerability for
5+
Django, Flask and FastAPI.

0 commit comments

Comments
 (0)