Skip to content

Commit 7ac4431

Browse files
feat(asm): new user event sdk (#13251)
A new public SDK for ATO/user events for AAP. Available with ```python from ddtrace.appsec import track_user_sdk # to track successful login attempts track_user_sdk.track_login_success(login, user_id:optional, metadata:optional) # to track failed login attempts track_user_sdk.track_login_failure(login, exists, user_id:optional, metadata:optional) # to track any custom event track_user_sdk.track_custom_event(event, metatada) # to track signup events track_user_sdk.track_signup(login, user_id:optional, success:optional, metadata:optional) # to track authentified user (usually in middleware, using auth token) track_user_sdk.track_user(login, user_id, session_id:optional, metadata:optional) ``` Also: - minor fixes and improvements in current ATO support. - threat tests added for sdk (span tags and telemetry unit tests) This will be validated in system tests with DataDog/system-tests#4565 The documentation page to be updated is https://docs.datadoghq.com/security/application_security/threats/add-user-info/?tab=loginsuccess&code-lang=python APPSEC-56663 ## 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 0a92748 commit 7ac4431

File tree

8 files changed

+473
-44
lines changed

8 files changed

+473
-44
lines changed

ddtrace/appsec/_metrics.py

+14
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,17 @@ def _report_rasp_skipped(rule_type: str, import_error: bool) -> None:
221221
"more_info": f":waf:rasp_rule_skipped:{rule_type}:{import_error}",
222222
}
223223
logger.warning(WARNING_TAGS.TELEMETRY_METRICS, extra=extra, exc_info=True)
224+
225+
226+
def _report_ato_sdk_usage(event_type: str, v2: bool = True) -> None:
227+
version = "v2" if v2 else "v1"
228+
try:
229+
tags = (("event_type", event_type), ("sdk_version", version))
230+
telemetry.telemetry_writer.add_count_metric(TELEMETRY_NAMESPACE.APPSEC, "sdk.event", 1, tags=tags)
231+
except Exception:
232+
extra = {
233+
"product": "appsec",
234+
"exec_limit": 6,
235+
"more_info": f":waf:sdk.event:{event_type}:{version}",
236+
}
237+
logger.warning(WARNING_TAGS.TELEMETRY_METRICS, extra=extra, exc_info=True)

ddtrace/appsec/_trace_utils.py

+82-38
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Any
2+
from typing import Dict
23
from typing import Optional
34

45
from ddtrace._trace.span import Span
@@ -36,6 +37,27 @@ def _asm_manual_keep(span: Span) -> None:
3637
span.context._meta[APPSEC.PROPAGATION_HEADER] = "02"
3738

3839

40+
def _handle_metadata(root_span: Span, prefix: str, metadata: dict) -> None:
41+
MAX_DEPTH = 6
42+
if metadata is None:
43+
return
44+
stack = [(prefix, metadata, 1)]
45+
while stack:
46+
prefix, data, level = stack.pop()
47+
if isinstance(data, list):
48+
if level < MAX_DEPTH:
49+
for i, v in enumerate(data):
50+
stack.append((f"{prefix}.{i}", v, level + 1))
51+
elif isinstance(data, dict):
52+
if level < MAX_DEPTH:
53+
for k, v in data.items():
54+
stack.append((f"{prefix}.{k}", v, level + 1))
55+
else:
56+
if isinstance(data, bool):
57+
data = "true" if data else "false"
58+
root_span.set_tag_str(f"{prefix}", str(data))
59+
60+
3961
def _track_user_login_common(
4062
tracer: Any,
4163
success: bool,
@@ -69,8 +91,7 @@ def _track_user_login_common(
6991

7092
tag_metadata_prefix = "%s.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, success_str)
7193
if metadata is not None:
72-
for k, v in metadata.items():
73-
span.set_tag_str("%s.%s" % (tag_metadata_prefix, k), str(v))
94+
_handle_metadata(span, tag_metadata_prefix, metadata)
7495

7596
if login:
7697
span.set_tag_str(f"{APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC}.{success_str}.usr.login", login)
@@ -97,7 +118,7 @@ def _track_user_login_common(
97118

98119
def track_user_login_success_event(
99120
tracer: Any,
100-
user_id: str,
121+
user_id: Optional[str],
101122
metadata: Optional[dict] = None,
102123
login: Optional[str] = None,
103124
name: Optional[str] = None,
@@ -134,9 +155,12 @@ def track_user_login_success_event(
134155
if real_mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str):
135156
user_id = _hash_user_id(user_id)
136157
span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode)
137-
if login_events_mode != LOGIN_EVENTS_MODE.SDK:
138-
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
139-
set_user(None, user_id, name, email, scope, role, session_id, propagate, span, may_block=False)
158+
if user_id:
159+
if login_events_mode != LOGIN_EVENTS_MODE.SDK:
160+
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
161+
else:
162+
span.set_tag_str(f"{APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC}.success.usr.id", str(user_id))
163+
set_user(None, user_id or "", name, email, scope, role, session_id, propagate, span, may_block=False)
140164
if in_asm_context():
141165
custom_data = {
142166
"REQUEST_USER_ID": str(initial_user_id) if initial_user_id else None,
@@ -188,7 +212,7 @@ def track_user_login_failure_event(
188212
if login_events_mode != LOGIN_EVENTS_MODE.SDK:
189213
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
190214
span.set_tag_str("%s.failure.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, user.ID), str(user_id))
191-
span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode)
215+
span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode)
192216
# if called from the SDK, set the login, email and name
193217
if login_events_mode in (LOGIN_EVENTS_MODE.SDK, LOGIN_EVENTS_MODE.AUTO):
194218
if login:
@@ -198,24 +222,43 @@ def track_user_login_failure_event(
198222
if name:
199223
span.set_tag_str("%s.failure.username" % APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, name)
200224
if in_asm_context():
201-
call_waf_callback(custom_data={"LOGIN_FAILURE": None})
225+
custom_data: dict[str, Any] = {"LOGIN_FAILURE": None}
226+
if login:
227+
custom_data["REQUEST_USERNAME"] = login
228+
res = call_waf_callback(custom_data=custom_data)
229+
if res and any(action in [WAF_ACTIONS.BLOCK_ACTION, WAF_ACTIONS.REDIRECT_ACTION] for action in res.actions):
230+
raise BlockingException(get_blocked())
202231

203232

204233
def track_user_signup_event(
205-
tracer: Any, user_id: str, success: bool, login_events_mode: str = LOGIN_EVENTS_MODE.SDK
234+
tracer: Any,
235+
user_id: Optional[str],
236+
success: bool,
237+
login: Optional[str] = None,
238+
login_events_mode: str = LOGIN_EVENTS_MODE.SDK,
206239
) -> None:
207240
span = core.get_root_span()
208241
if span:
209242
success_str = "true" if success else "false"
210243
span.set_tag_str(APPSEC.USER_SIGNUP_EVENT, success_str)
211-
span.set_tag_str(user.ID, str(user_id))
244+
if user_id:
245+
if login_events_mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str):
246+
user_id = _hash_user_id(user_id)
247+
span.set_tag_str(user.ID, str(user_id))
248+
span.set_tag_str(APPSEC.USER_SIGNUP_EVENT_USERID, str(user_id))
249+
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
250+
if login:
251+
if login_events_mode == LOGIN_EVENTS_MODE.ANON and isinstance(login, str):
252+
login = _hash_user_id(login)
253+
span.set_tag_str(APPSEC.USER_SIGNUP_EVENT_USERNAME, str(login))
254+
span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, str(login))
212255
_asm_manual_keep(span)
213256

214257
# This is used to mark if the call was done from the SDK of the automatic login events
215258
if login_events_mode == LOGIN_EVENTS_MODE.SDK:
216259
span.set_tag_str("%s.sdk" % APPSEC.USER_SIGNUP_EVENT, "true")
217260
else:
218-
span.set_tag_str("%s.auto.mode" % APPSEC.USER_SIGNUP_EVENT, str(login_events_mode))
261+
span.set_tag_str("%s.auto.mode" % APPSEC.USER_SIGNUP_EVENT_MODE, str(login_events_mode))
219262

220263
return
221264
else:
@@ -226,7 +269,7 @@ def track_user_signup_event(
226269
)
227270

228271

229-
def track_custom_event(tracer: Any, event_name: str, metadata: dict) -> None:
272+
def track_custom_event(tracer: Any, event_name: str, metadata: Dict[str, Any]) -> None:
230273
"""
231274
Add a new custom tracking event.
232275
@@ -254,14 +297,9 @@ def track_custom_event(tracer: Any, event_name: str, metadata: dict) -> None:
254297
return
255298

256299
span.set_tag_str("%s.%s.track" % (APPSEC.CUSTOM_EVENT_PREFIX, event_name), "true")
257-
258-
for k, v in metadata.items():
259-
if isinstance(v, bool):
260-
str_v = "true" if v else "false"
261-
else:
262-
str_v = str(v)
263-
span.set_tag_str("%s.%s.%s" % (APPSEC.CUSTOM_EVENT_PREFIX, event_name, k), str_v)
264-
_asm_manual_keep(span)
300+
if metadata:
301+
_handle_metadata(span, f"{APPSEC.CUSTOM_EVENT_PREFIX}.{event_name}", metadata)
302+
_asm_manual_keep(span)
265303

266304

267305
def should_block_user(tracer: Any, userid: str) -> bool:
@@ -312,24 +350,21 @@ def block_request_if_user_blocked(tracer: Any, userid: str, mode: str = "sdk") -
312350
:param userid: the ID of the user as registered by `set_user`
313351
:param mode: the mode of the login event ("sdk" by default, "auto" to simulate auto instrumentation)
314352
"""
315-
if not asm_config._asm_enabled:
353+
if not asm_config._asm_enabled or mode == LOGIN_EVENTS_MODE.DISABLED:
316354
log.warning("should_block_user call requires ASM to be enabled")
317355
return
318-
span = core.get_root_span()
319-
if span:
320-
root_span = span._local_root or span
321-
if mode == LOGIN_EVENTS_MODE.SDK:
322-
root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, LOGIN_EVENTS_MODE.SDK)
323-
else:
324-
if mode == LOGIN_EVENTS_MODE.AUTO:
325-
mode = asm_config._user_event_mode
326-
if mode == LOGIN_EVENTS_MODE.DISABLED:
327-
return
356+
if mode == LOGIN_EVENTS_MODE.AUTO:
357+
mode = asm_config._user_event_mode
358+
root_span = core.get_root_span()
359+
if root_span:
360+
root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode)
361+
if userid:
328362
if mode == LOGIN_EVENTS_MODE.ANON:
329363
userid = _hash_user_id(str(userid))
330364
root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode)
331-
root_span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(userid))
332-
root_span.set_tag_str(user.ID, str(userid))
365+
if mode != LOGIN_EVENTS_MODE.SDK:
366+
root_span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(userid))
367+
root_span.set_tag_str(user.ID, str(userid))
333368
if should_block_user(None, userid):
334369
_asm_request_context.block_request()
335370

@@ -416,16 +451,25 @@ def _on_django_process(result_user, session_key, mode, kwargs, pin, info_retriev
416451
if (not asm_config._asm_enabled) or mode == LOGIN_EVENTS_MODE.DISABLED:
417452
return
418453
user_id, user_extra = get_user_info(info_retriever, django_config, kwargs)
454+
user_login = user_extra.get("login")
419455
res = None
420456
if result_user and result_user.is_authenticated:
421457
span = core.get_root_span()
422-
if mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str):
423-
hash_id = _hash_user_id(user_id)
424-
span.set_tag_str(APPSEC.USER_LOGIN_USERID, hash_id)
458+
if mode == LOGIN_EVENTS_MODE.ANON:
459+
hash_id = ""
460+
if isinstance(user_id, str):
461+
hash_id = _hash_user_id(user_id)
462+
span.set_tag_str(APPSEC.USER_LOGIN_USERID, hash_id)
463+
if isinstance(user_login, str):
464+
hash_login = _hash_user_id(user_login)
465+
span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, hash_login)
425466
span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode)
426467
set_user(None, hash_id, propagate=True, may_block=False, span=span)
427468
elif mode == LOGIN_EVENTS_MODE.IDENT:
428-
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
469+
if user_id:
470+
span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id))
471+
if user_login:
472+
span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, str(user_login))
429473
span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode)
430474
set_user(
431475
None,
@@ -440,7 +484,7 @@ def _on_django_process(result_user, session_key, mode, kwargs, pin, info_retriev
440484
real_mode = mode if mode != LOGIN_EVENTS_MODE.AUTO else asm_config._user_event_mode
441485
custom_data = {
442486
"REQUEST_USER_ID": str(user_id) if user_id else None,
443-
"REQUEST_USERNAME": user_extra.get("login"),
487+
"REQUEST_USERNAME": user_login,
444488
"LOGIN_SUCCESS": real_mode,
445489
}
446490
if session_key:

ddtrace/appsec/track_user_sdk.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Public User Tracking SDK Version 2
3+
4+
This module provides a public interface for tracking user events.
5+
This replaces the previous version of the SDK available in ddtrace.appsec.trace_utils
6+
Implementation can change in the future, but the interface will remain compatible.
7+
"""
8+
9+
import typing as t
10+
11+
from ddtrace.appsec import _asm_request_context
12+
from ddtrace.appsec import _constants
13+
from ddtrace.appsec import _metrics
14+
from ddtrace.appsec import _trace_utils
15+
from ddtrace.appsec._asm_request_context import get_blocked as _get_blocked
16+
from ddtrace.appsec._constants import WAF_ACTIONS as _WAF_ACTIONS
17+
import ddtrace.appsec.trace_utils # noqa: F401
18+
from ddtrace.internal import core as _core
19+
from ddtrace.internal._exceptions import BlockingException
20+
21+
22+
def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None) -> None:
23+
"""
24+
Track a successful user login event.
25+
26+
This function should be called when a user successfully logs in to the application.
27+
It will create an event that can be used for monitoring and analysis.
28+
"""
29+
_metrics._report_ato_sdk_usage("login_success")
30+
_trace_utils.track_user_login_success_event(None, user_id, login=login, metadata=metadata)
31+
32+
33+
def track_login_failure(
34+
login: str, exists: bool, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None
35+
):
36+
"""
37+
Track a failed user login event.
38+
39+
This function should be called when a user fails to log in to the application.
40+
It will create an event that can be used for monitoring and analysis.
41+
"""
42+
_metrics._report_ato_sdk_usage("login_failure")
43+
_trace_utils.track_user_login_failure_event(None, user_id, exists=exists, login=login, metadata=metadata)
44+
45+
46+
def track_signup(
47+
login: str, user_id: t.Any = None, success: bool = True, metadata: t.Optional[t.Dict[str, t.Any]] = None
48+
):
49+
"""
50+
Track a user signup event.
51+
52+
This function should be called when a user successfully signs up for the application.
53+
It will create an event that can be used for monitoring and analysis.
54+
"""
55+
_metrics._report_ato_sdk_usage("signup")
56+
_trace_utils.track_user_signup_event(None, user_id, success, login=login)
57+
if metadata:
58+
_trace_utils.track_custom_event(None, "signup_sdk", metadata=metadata)
59+
60+
61+
def track_user(
62+
login: str, user_id: t.Any = None, session_id=t.Optional[str], metadata: t.Optional[t.Dict[str, t.Any]] = None
63+
):
64+
"""
65+
Track an authenticated user.
66+
67+
This function should be called when a user is authenticated in the application."
68+
"""
69+
span = _core.get_root_span()
70+
if span is None:
71+
return
72+
if user_id:
73+
span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERID, str(user_id))
74+
if login:
75+
span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERNAME, str(login))
76+
77+
_trace_utils.set_user(None, user_id, session_id=session_id, may_block=False)
78+
if metadata:
79+
_trace_utils.track_custom_event(None, "auth_sdk", metadata=metadata)
80+
span.set_tag_str(_constants.APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, _constants.LOGIN_EVENTS_MODE.SDK)
81+
if _asm_request_context.in_asm_context():
82+
custom_data = {
83+
"REQUEST_USER_ID": str(user_id) if user_id else None,
84+
"REQUEST_USERNAME": login,
85+
"LOGIN_SUCCESS": "sdk",
86+
}
87+
if session_id:
88+
custom_data["REQUEST_SESSION_ID"] = session_id
89+
res = _asm_request_context.call_waf_callback(custom_data=custom_data, force_sent=True)
90+
if res and any(action in [_WAF_ACTIONS.BLOCK_ACTION, _WAF_ACTIONS.REDIRECT_ACTION] for action in res.actions):
91+
raise BlockingException(_get_blocked())
92+
93+
94+
def track_custom_event(event_name: str, metadata: t.Dict[str, t.Any]):
95+
"""
96+
Track a custom user event.
97+
98+
This function should be called when a custom user event occurs in the application.
99+
It will create an event that can be used for monitoring and analysis.
100+
"""
101+
_metrics._report_ato_sdk_usage("custom")
102+
_trace_utils.track_custom_event(None, event_name, metadata=metadata)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
AAP: This introduces a new user event sdk available through ddtrace.appsec.track_user_sdk for manual instrumentation.
5+
More information on our documentation page.

0 commit comments

Comments
 (0)