Skip to content

Commit fb9e2cf

Browse files
feat(aap): add support for downstream requests analysis (#14481)
OPEN FOR REVIEW, DO NOT MERGE YET API 10 support for downstream request analysis. - update ssrf rasp instrumentation to include api10 for urllib (standard cpython api) - update threat tests for request header and body analysis - update telemetry for new env var to configure the new feature System tests not done yet. ## 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 20eefc1 commit fb9e2cf

File tree

12 files changed

+353
-22
lines changed

12 files changed

+353
-22
lines changed

ddtrace/appsec/_asm_request_context.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def __init__(self, span: Optional[Span] = None, rc_products: str = ""):
105105
self.finalized: bool = False
106106
self.api_security_reported: int = 0
107107
self.rc_products: str = rc_products
108+
self.downstream_requests: int = 0
108109

109110

110111
def _get_asm_context() -> Optional[ASM_Environment]:
@@ -140,6 +141,25 @@ def get_entry_span() -> Optional[Span]:
140141
return env.entry_span
141142

142143

144+
KNUTH_FACTOR: int = 11400714819323199488
145+
UINT64_MAX: int = (1 << 64) - 1
146+
147+
148+
class DownstreamRequests:
149+
counter: int = 0
150+
sampling_rate: int = int(asm_config._dr_sample_rate * UINT64_MAX)
151+
152+
153+
def should_analyze_body_response(env) -> bool:
154+
"""Must be called only after should_analyze_downstream returned True."""
155+
DownstreamRequests.counter += 1
156+
env.downstream_requests += 1
157+
return (
158+
env.downstream_requests <= asm_config._dr_body_limit_per_request
159+
and (DownstreamRequests.counter * KNUTH_FACTOR) % UINT64_MAX <= DownstreamRequests.sampling_rate
160+
)
161+
162+
143163
def get_framework() -> str:
144164
env = _get_asm_context()
145165
if env is None:

ddtrace/appsec/_common_module_patches.py

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# This module must not import other modules unconditionally that require iast
22
import ctypes
3+
import io
4+
import json
35
import os
46
from typing import Any
57
from typing import Callable
68
from typing import Dict
79
from typing import Iterable
810
from typing import List
11+
from typing import Tuple
912
from typing import Union
1013

1114
from wrapt import FunctionWrapper
@@ -49,6 +52,7 @@ def _(module):
4952

5053
try_wrap_function_wrapper("builtins", "open", wrapped_open_CFDDB7ABBA9081B6)
5154
try_wrap_function_wrapper("urllib.request", "OpenerDirector.open", wrapped_open_ED4CF71136E15EBF)
55+
try_wrap_function_wrapper("http.client", "HTTPConnection.request", wrapped_request)
5256
core.on("asm.block.dbapi.execute", execute_4C9BAC8E228EB347)
5357
log.debug("Patching common modules: builtins and urllib.request")
5458
_is_patched = True
@@ -134,18 +138,71 @@ def wrapped_open_CFDDB7ABBA9081B6(original_open_callable, instance, args, kwargs
134138
)
135139

136140

141+
def _build_headers(lst: Iterable[Tuple[str, str]]) -> Dict[str, Union[str, List[str]]]:
142+
res: Dict[str, Union[str, List[str]]] = {}
143+
for a, b in lst:
144+
if a in res:
145+
v = res[a]
146+
if isinstance(v, str):
147+
res[a] = [v, b]
148+
else:
149+
v.append(b)
150+
else:
151+
res[a] = b
152+
return res
153+
154+
155+
def wrapped_request(original_request_callable, instance, args, kwargs):
156+
from ddtrace.appsec._asm_request_context import call_waf_callback
157+
158+
full_url = core.get_item("full_url")
159+
if full_url is not None:
160+
use_body = core.get_item("use_body", False)
161+
method = args[0] if len(args) > 0 else kwargs.get("method", None)
162+
body = args[2] if len(args) > 2 else kwargs.get("body", None)
163+
headers = args[3] if len(args) > 3 else kwargs.get("headers", {})
164+
addresses = {EXPLOIT_PREVENTION.ADDRESS.SSRF: full_url, "DOWN_REQ_METHOD": method, "DOWN_REQ_HEADERS": headers}
165+
content_type = headers.get("Content-Type", None) or headers.get("content-type", None)
166+
if use_body and content_type == "application/json":
167+
try:
168+
addresses["DOWN_REQ_BODY"] = json.loads(body)
169+
except Exception:
170+
pass # nosec
171+
res = call_waf_callback(
172+
addresses,
173+
crop_trace="wrapped_open_ED4CF71136E15EBF",
174+
rule_type=EXPLOIT_PREVENTION.TYPE.SSRF,
175+
)
176+
if res and _must_block(res.actions):
177+
raise BlockingException(get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SSRF, full_url)
178+
return original_request_callable(*args, **kwargs)
179+
180+
181+
def _parse_http_response_body(response):
182+
try:
183+
if response.length and response.headers.get("content-type", None) == "application/json":
184+
length = response.length
185+
body = response.read()
186+
response.fp = io.BytesIO(body)
187+
response.length = length
188+
return json.loads(body)
189+
except Exception:
190+
return None
191+
return None
192+
193+
137194
def wrapped_open_ED4CF71136E15EBF(original_open_callable, instance, args, kwargs):
138195
"""
139196
wrapper for open url function
140197
"""
141198
if asm_config._iast_enabled:
142199
# TODO: IAST SSRF sink to be added
143200
pass
144-
145201
if _get_rasp_capability("ssrf"):
146202
try:
203+
from ddtrace.appsec._asm_request_context import _get_asm_context
147204
from ddtrace.appsec._asm_request_context import call_waf_callback
148-
from ddtrace.appsec._asm_request_context import in_asm_context
205+
from ddtrace.appsec._asm_request_context import should_analyze_body_response
149206
except ImportError:
150207
# open is used during module initialization
151208
# and shouldn't be changed at that time
@@ -155,19 +212,42 @@ def wrapped_open_ED4CF71136E15EBF(original_open_callable, instance, args, kwargs
155212
url = args[0] if args else kwargs.get("fullurl", None)
156213
if url.__class__.__name__ == "Request":
157214
url = url.get_full_url()
158-
if isinstance(url, str) and url:
159-
if in_asm_context():
160-
res = call_waf_callback(
161-
{EXPLOIT_PREVENTION.ADDRESS.SSRF: url},
162-
crop_trace="wrapped_open_ED4CF71136E15EBF",
163-
rule_type=EXPLOIT_PREVENTION.TYPE.SSRF,
164-
)
165-
if res and _must_block(res.actions):
166-
raise BlockingException(
167-
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SSRF, url
168-
)
169-
else:
170-
_report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.SSRF, False)
215+
valid_url = isinstance(url, str) and bool(url)
216+
if valid_url and url and (ctx := _get_asm_context()):
217+
use_body = should_analyze_body_response(ctx)
218+
with core.context_with_data("url_open_analysis", full_url=url, use_body=use_body):
219+
# API10, doing all request calls in HTTPConnection.request
220+
try:
221+
response = original_open_callable(*args, **kwargs)
222+
# api10 response handler for regular reponses
223+
if response.__class__.__name__ == "HTTPResponse":
224+
addresses = {
225+
"DOWN_RES_STATUS": response.status,
226+
"DOWN_RES_HEADERS": _build_headers(response.getheaders()),
227+
}
228+
if use_body:
229+
addresses["DOWN_RES_BODY"] = _parse_http_response_body(response)
230+
call_waf_callback(addresses, rule_type=EXPLOIT_PREVENTION.TYPE.SSRF)
231+
return response
232+
except Exception as e:
233+
# api10 response handler for error reponses
234+
if e.__class__.__name__ == "HTTPError":
235+
try:
236+
status_code = e.code
237+
except Exception:
238+
status_code = None
239+
try:
240+
response_headers = _build_headers(e.headers.items())
241+
except Exception:
242+
response_headers = None
243+
if status_code is not None or response_headers is not None:
244+
call_waf_callback(
245+
{"DOWN_RES_STATUS": status_code, "DOWN_RES_HEADERS": response_headers},
246+
rule_type=EXPLOIT_PREVENTION.TYPE.SSRF,
247+
)
248+
raise
249+
elif valid_url:
250+
_report_rasp_skipped(EXPLOIT_PREVENTION.TYPE.SSRF, False)
171251
return original_open_callable(*args, **kwargs)
172252

173253

ddtrace/appsec/_constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ class WAF_DATA_NAMES(metaclass=Constant_Class):
235235
SQLI_SYSTEM_ADDRESS: Literal["server.db.system"] = "server.db.system"
236236
LOGIN_FAILURE: Literal["server.business_logic.users.login.failure"] = "server.business_logic.users.login.failure"
237237
LOGIN_SUCCESS: Literal["server.business_logic.users.login.success"] = "server.business_logic.users.login.success"
238+
DOWN_REQ_HEADERS: Literal["server.io.net.request.headers"] = "server.io.net.request.headers"
239+
DOWN_REQ_METHOD: Literal["server.io.net.request.method"] = "server.io.net.request.method"
240+
DOWN_REQ_BODY: Literal["server.io.net.request.body"] = "server.io.net.request.body"
241+
DOWN_RES_STATUS: Literal["server.io.net.response.status"] = "server.io.net.response.status"
242+
DOWN_RES_HEADERS: Literal["server.io.net.response.headers"] = "server.io.net.response.headers"
243+
DOWN_RES_BODY: Literal["server.io.net.response.body"] = "server.io.net.response.body"
238244

239245

240246
class SPAN_DATA_NAMES(metaclass=Constant_Class):

ddtrace/settings/asm.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ class ASMConfig(DDConfig):
187187
# Timeout for the request body reading in seconds.
188188
_fast_api_async_body_timeout = DDConfig.var(float, "DD_FASTAPI_ASYNC_BODY_TIMEOUT_SECONDS", default=0.1)
189189

190+
# DOWNSTREAM REQUESTS INSTRUMENTATION
191+
# sample rate for body analysis
192+
_dr_sample_rate: float = DDConfig.var(
193+
float, "DD_API_SECURITY_DOWNSTREAM_REQUEST_BODY_ANALYSIS_SAMPLE_RATE", default=0.5
194+
)
195+
# max number of downstream requests analysis with bodies per request
196+
_dr_body_limit_per_request: int = DDConfig.var(
197+
int, "DD_API_SECURITY_MAX_DOWNSTREAM_REQUEST_BODY_ANALYSIS", default=1
198+
)
199+
190200
# for tests purposes
191201
_asm_config_keys = [
192202
"_asm_enabled",
@@ -217,6 +227,8 @@ class ASMConfig(DDConfig):
217227
"_api_security_enabled",
218228
"_api_security_sample_delay",
219229
"_api_security_parse_response_body",
230+
"_dr_sample_rate",
231+
"_dr_body_limit_per_request",
220232
"_waf_timeout",
221233
"_iast_redaction_enabled",
222234
"_iast_redaction_name_pattern",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
AAP: This introduces downstream request analysis (API10).

tests/appsec/appsec/rules-rasp.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,75 @@
245245
"on_match": [
246246
"stack_trace"
247247
]
248+
},
249+
{
250+
"id": "apiA-100-001",
251+
"name": "API 10 tag rule on headers",
252+
"tags": {
253+
"type": "api10 request headers",
254+
"category": "attack_attempt"
255+
},
256+
"conditions": [
257+
{
258+
"parameters": {
259+
"inputs": [
260+
{
261+
"address": "server.io.net.request.headers",
262+
"key_path": [
263+
"Host"
264+
]
265+
}
266+
],
267+
"list": ["www.datadoghq.com"]
268+
},
269+
"operator": "exact_match"
270+
}
271+
],
272+
"output": {
273+
"event": true,
274+
"keep": true,
275+
"attributes": {
276+
"_dd.appsec.trace.mark": {
277+
"value": "TAG_API10_HEADER"
278+
}
279+
}
280+
},
281+
"on_match": []
282+
},
283+
{
284+
"id": "apiA-100-002",
285+
"name": "API 10 tag rule on body",
286+
"tags": {
287+
"type": "api10 request body",
288+
"category": "attack_attempt"
289+
},
290+
"conditions": [
291+
{
292+
"parameters": {
293+
"inputs": [
294+
{
295+
"address": "server.io.net.request.body",
296+
"key_path": [
297+
"payload"
298+
]
299+
}
300+
],
301+
"list": ["qw2jedrkjerbgol23ewpfirj2qw3or"]
302+
},
303+
"operator": "exact_match"
304+
}
305+
],
306+
"output": {
307+
"event": true,
308+
"keep": true,
309+
"attributes": {
310+
"_dd.appsec.trace.mark": {
311+
"value": "TAG_API10_BODY"
312+
}
313+
}
314+
},
315+
"on_match": []
248316
}
317+
249318
]
250319
}
32 KB
Binary file not shown.

tests/appsec/contrib_appsec/django_app/urls.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,32 @@ def rasp(request, endpoint: str):
170170
return HttpResponse(f"Unknown endpoint: {endpoint}")
171171

172172

173+
@csrf_exempt
174+
def redirect(request, url: str):
175+
import urllib.request
176+
177+
url = "http://" + url
178+
body_str = request.body.decode()
179+
if body_str:
180+
body = json.loads(body_str)
181+
else:
182+
body = None
183+
try:
184+
if body:
185+
request_urllib = urllib.request.Request(
186+
url, method="POST", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}
187+
)
188+
else:
189+
request_urllib = urllib.request.Request(url, method="GET")
190+
with urllib.request.urlopen(request_urllib, timeout=0.5) as f:
191+
payload = {"payload": f.read().decode(errors="ignore")}
192+
except Exception as e:
193+
import traceback
194+
195+
payload = {"error": repr(e), "trace": traceback.format_exc()}
196+
return JsonResponse(payload)
197+
198+
173199
@csrf_exempt
174200
def login_user(request):
175201
from django.contrib.auth import authenticate
@@ -287,6 +313,7 @@ def shutdown(request):
287313
path("new_service/<str:service_name>", new_service, name="new_service"),
288314
path("rasp/<str:endpoint>/", rasp, name="rasp"),
289315
path("rasp/<str:endpoint>", rasp, name="rasp"),
316+
path("redirect/<str:url>/", redirect, name="redirect"),
290317
path(route="login/", view=login_user, name="login"),
291318
path("login", login_user, name="login"),
292319
path("login_sdk/", login_user_sdk, name="login_sdk"),
@@ -300,6 +327,7 @@ def shutdown(request):
300327
path(r"new_service/(?P<service_name>\w+)$", new_service, name="new_service"),
301328
path(r"rasp/(?P<endpoint>\w+)/$", new_service, name="rasp"),
302329
path(r"rasp/(?P<endpoint>\w+)$", new_service, name="rasp"),
330+
path(r"redirect/(?P<url>\w+)$", redirect, name="redirect"),
303331
path("login/", login_user, name="login"),
304332
path("login", login_user, name="login"),
305333
]

0 commit comments

Comments
 (0)