From 6d570f42bc5f7d89db282c694b7436c51d1cae02 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Tue, 8 Apr 2025 11:08:25 +0200 Subject: [PATCH 01/12] skeleton --- ddtrace/appsec/track_user_sdk.py | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 ddtrace/appsec/track_user_sdk.py diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py new file mode 100644 index 00000000000..99533ce4e09 --- /dev/null +++ b/ddtrace/appsec/track_user_sdk.py @@ -0,0 +1,57 @@ +""" +Public User Tracking SDK Version 2 + +This module provides a public interface for tracking user events. +This replaces the previous version of the SDK available in ddtrace.appsec.trace_utils +""" + +import typing as t + + +def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None): + """ + Track a successful user login event. + + This function should be called when a user successfully logs in to the application. + It will create an event that can be used for monitoring and analysis. + """ + pass + + +def track_login_failure(login: str, exists: bool, metadata: t.Optional[t.Dict[str, t.Any]] = None): + """ + Track a failed user login event. + + This function should be called when a user fails to log in to the application. + It will create an event that can be used for monitoring and analysis. + """ + pass + + +def track_signup(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None): + """ + Track a user signup event. + + This function should be called when a user successfully signs up for the application. + It will create an event that can be used for monitoring and analysis. + """ + pass + + +def track_user(login: str, metadata: t.Optional[t.Dict[str, t.Any]] = None): + """ + Track an authenticated user. + + This function should be called when a user is authenticated in the application." + """ + pass + + +def track_custom_event(event_name: str, metadata: t.Optional[t.Dict[str, t.Any]] = None): + """ + Track a custom user event. + + This function should be called when a custom user event occurs in the application. + It will create an event that can be used for monitoring and analysis. + """ + pass From 1f104a436d229cdb41cdf4116c358a2319d554a8 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Fri, 11 Apr 2025 13:18:50 +0200 Subject: [PATCH 02/12] wip --- ddtrace/appsec/_trace_utils.py | 57 ++++++++++++++++++++++++-------- ddtrace/appsec/track_user_sdk.py | 53 +++++++++++++++++++++++------ 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index 25f56432612..f32e9d0957b 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -1,3 +1,5 @@ +from typing import Any +from typing import Dict from typing import Optional from ddtrace.appsec import _asm_request_context @@ -70,7 +72,11 @@ def _track_user_login_common( tag_metadata_prefix = "%s.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, success_str) if metadata is not None: for k, v in metadata.items(): - span.set_tag_str("%s.%s" % (tag_metadata_prefix, k), str(v)) + if isinstance(v, bool): + str_v = "true" if v else "false" + else: + str_v = str(v) + span.set_tag_str("%s.%s" % (tag_metadata_prefix, k), str_v) if login: span.set_tag_str(f"{APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC}.{success_str}.usr.login", login) @@ -97,7 +103,7 @@ def _track_user_login_common( def track_user_login_success_event( tracer: Tracer, - user_id: str, + user_id: Optional[str], metadata: Optional[dict] = None, login: Optional[str] = None, name: Optional[str] = None, @@ -136,7 +142,7 @@ def track_user_login_success_event( span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode) if login_events_mode != LOGIN_EVENTS_MODE.SDK: span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) - set_user(tracer, user_id, name, email, scope, role, session_id, propagate, span, may_block=False) + set_user(tracer, user_id or "", name, email, scope, role, session_id, propagate, span, may_block=False) if in_asm_context(): custom_data = { "REQUEST_USER_ID": str(initial_user_id) if initial_user_id else None, @@ -187,8 +193,8 @@ def track_user_login_failure_event( user_id = _hash_user_id(user_id) if login_events_mode != LOGIN_EVENTS_MODE.SDK: span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) - span.set_tag_str("%s.failure.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, user.ID), str(user_id)) - span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode) + span.set_tag_str("%s.failure.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, user.ID), str(user_id)) + span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode) # if called from the SDK, set the login, email and name if login_events_mode in (LOGIN_EVENTS_MODE.SDK, LOGIN_EVENTS_MODE.AUTO): if login: @@ -202,20 +208,34 @@ def track_user_login_failure_event( def track_user_signup_event( - tracer: Tracer, user_id: str, success: bool, login_events_mode: str = LOGIN_EVENTS_MODE.SDK + tracer: Tracer, + user_id: Optional[str], + success: bool, + login: Optional[str] = None, + login_events_mode: str = LOGIN_EVENTS_MODE.SDK, ) -> None: span = tracer.current_root_span() if span: success_str = "true" if success else "false" span.set_tag_str(APPSEC.USER_SIGNUP_EVENT, success_str) - span.set_tag_str(user.ID, str(user_id)) + if user_id: + if login_events_mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str): + user_id = _hash_user_id(user_id) + span.set_tag_str(user.ID, str(user_id)) + span.set_tag_str(APPSEC.USER_SIGNUP_EVENT_USERID, str(user_id)) + span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) + if login: + if login_events_mode == LOGIN_EVENTS_MODE.ANON and isinstance(login, str): + login = _hash_user_id(login) + span.set_tag_str(APPSEC.USER_SIGNUP_EVENT_USERNAME, str(login)) + span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, str(login)) _asm_manual_keep(span) # This is used to mark if the call was done from the SDK of the automatic login events if login_events_mode == LOGIN_EVENTS_MODE.SDK: span.set_tag_str("%s.sdk" % APPSEC.USER_SIGNUP_EVENT, "true") else: - span.set_tag_str("%s.auto.mode" % APPSEC.USER_SIGNUP_EVENT, str(login_events_mode)) + span.set_tag_str("%s.auto.mode" % APPSEC.USER_SIGNUP_EVENT_MODE, str(login_events_mode)) return else: @@ -226,7 +246,7 @@ def track_user_signup_event( ) -def track_custom_event(tracer: Tracer, event_name: str, metadata: dict) -> None: +def track_custom_event(tracer: Tracer, event_name: str, metadata: Dict[str, Any]) -> None: """ Add a new custom tracking event. @@ -418,16 +438,25 @@ def _on_django_process(result_user, session_key, mode, kwargs, pin, info_retriev if (not asm_config._asm_enabled) or mode == LOGIN_EVENTS_MODE.DISABLED: return user_id, user_extra = get_user_info(info_retriever, django_config, kwargs) + user_login = user_extra.get("login") res = None if result_user and result_user.is_authenticated: span = pin.tracer.current_root_span() - if mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str): - hash_id = _hash_user_id(user_id) - span.set_tag_str(APPSEC.USER_LOGIN_USERID, hash_id) + if mode == LOGIN_EVENTS_MODE.ANON: + hash_id = "" + if isinstance(user_id, str): + hash_id = _hash_user_id(user_id) + span.set_tag_str(APPSEC.USER_LOGIN_USERID, hash_id) + if isinstance(user_login, str): + hash_login = _hash_user_id(user_login) + span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, hash_login) span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode) set_user(pin.tracer, hash_id, propagate=True, may_block=False, span=span) elif mode == LOGIN_EVENTS_MODE.IDENT: - span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) + if user_id: + span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) + if user_login: + span.set_tag_str(APPSEC.USER_LOGIN_USERNAME, str(user_login)) span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode) set_user( pin.tracer, @@ -442,7 +471,7 @@ def _on_django_process(result_user, session_key, mode, kwargs, pin, info_retriev real_mode = mode if mode != LOGIN_EVENTS_MODE.AUTO else asm_config._user_event_mode custom_data = { "REQUEST_USER_ID": str(user_id) if user_id else None, - "REQUEST_USERNAME": user_extra.get("login"), + "REQUEST_USERNAME": user_login, "LOGIN_SUCCESS": real_mode, } if session_key: diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py index 99533ce4e09..07d4fc69954 100644 --- a/ddtrace/appsec/track_user_sdk.py +++ b/ddtrace/appsec/track_user_sdk.py @@ -3,19 +3,25 @@ This module provides a public interface for tracking user events. This replaces the previous version of the SDK available in ddtrace.appsec.trace_utils +Implementation can change in the future, but the interface will remain compatible. """ import typing as t +from ddtrace import tracer as _tracer +from ddtrace.appsec import _asm_request_context +from ddtrace.appsec import _constants +from ddtrace.appsec import _trace_utils -def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None): + +def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None) -> None: """ Track a successful user login event. This function should be called when a user successfully logs in to the application. It will create an event that can be used for monitoring and analysis. """ - pass + _trace_utils.track_user_login_success_event(_tracer, user_id, login=login, metadata=metadata) def track_login_failure(login: str, exists: bool, metadata: t.Optional[t.Dict[str, t.Any]] = None): @@ -25,33 +31,58 @@ def track_login_failure(login: str, exists: bool, metadata: t.Optional[t.Dict[st This function should be called when a user fails to log in to the application. It will create an event that can be used for monitoring and analysis. """ - pass + _trace_utils.track_user_login_failure_event(_tracer, None, exists=exists, login=login, metadata=metadata) -def track_signup(login: str, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None): +def track_signup( + login: str, user_id: t.Any = None, success: bool = True, metadata: t.Optional[t.Dict[str, t.Any]] = None +): """ Track a user signup event. This function should be called when a user successfully signs up for the application. It will create an event that can be used for monitoring and analysis. """ - pass + _trace_utils.track_user_signup_event(_tracer, user_id, success, login=login) + if metadata: + _trace_utils.track_custom_event(_tracer, "signup_sdk", metadata=metadata) -def track_user(login: str, metadata: t.Optional[t.Dict[str, t.Any]] = None): +def track_user( + login: str, user_id: t.Any = None, session=t.Optional[str], metadata: t.Optional[t.Dict[str, t.Any]] = None +): """ Track an authenticated user. This function should be called when a user is authenticated in the application." """ - pass - - -def track_custom_event(event_name: str, metadata: t.Optional[t.Dict[str, t.Any]] = None): + span = _tracer.current_root_span() + if span is None: + return + if user_id: + span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERID, str(user_id)) + if login: + span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERNAME, str(login)) + + _trace_utils.set_user(_tracer, user_id) + if metadata: + _trace_utils.track_custom_event(_tracer, "auth_sdk", metadata=metadata) + if _asm_request_context.in_asm_context(): + custom_data = { + "REQUEST_USER_ID": str(user_id) if user_id else None, + "REQUEST_USERNAME": login, + "LOGIN_SUCCESS": "sdk", + } + if session: + custom_data["REQUEST_SESSION_ID"] = session + res = _asm_request_context.call_waf_callback(custom_data=custom_data, force_sent=True) + + +def track_custom_event(event_name: str, metadata: t.Dict[str, t.Any]): """ Track a custom user event. This function should be called when a custom user event occurs in the application. It will create an event that can be used for monitoring and analysis. """ - pass + _trace_utils.track_custom_event(_tracer, event_name, metadata=metadata) From f2c5e911333b91209f3ac2f854538847c9cc6c87 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Wed, 23 Apr 2025 17:12:13 +0200 Subject: [PATCH 03/12] small fixes --- ddtrace/appsec/_trace_utils.py | 2 +- ddtrace/appsec/track_user_sdk.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index 8114e7ef354..edacb4984fd 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -192,7 +192,7 @@ def track_user_login_failure_event( user_id = _hash_user_id(user_id) if login_events_mode != LOGIN_EVENTS_MODE.SDK: span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) - span.set_tag_str("%s.failure.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, user.ID), str(user_id)) + span.set_tag_str("%s.failure.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, user.ID), str(user_id)) span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode) # if called from the SDK, set the login, email and name if login_events_mode in (LOGIN_EVENTS_MODE.SDK, LOGIN_EVENTS_MODE.AUTO): diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py index 8db0af51b4f..e54318f4fa1 100644 --- a/ddtrace/appsec/track_user_sdk.py +++ b/ddtrace/appsec/track_user_sdk.py @@ -27,14 +27,16 @@ def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[ _trace_utils.track_user_login_success_event(None, user_id, login=login, metadata=metadata) -def track_login_failure(login: str, exists: bool, metadata: t.Optional[t.Dict[str, t.Any]] = None): +def track_login_failure( + login: str, exists: bool, user_id: t.Any = None, metadata: t.Optional[t.Dict[str, t.Any]] = None +): """ Track a failed user login event. This function should be called when a user fails to log in to the application. It will create an event that can be used for monitoring and analysis. """ - _trace_utils.track_user_login_failure_event(None, None, exists=exists, login=login, metadata=metadata) + _trace_utils.track_user_login_failure_event(None, user_id, exists=exists, login=login, metadata=metadata) def track_signup( @@ -52,7 +54,7 @@ def track_signup( def track_user( - login: str, user_id: t.Any = None, session=t.Optional[str], metadata: t.Optional[t.Dict[str, t.Any]] = None + login: str, user_id: t.Any = None, session_id=t.Optional[str], metadata: t.Optional[t.Dict[str, t.Any]] = None ): """ Track an authenticated user. @@ -66,8 +68,9 @@ def track_user( span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERID, str(user_id)) if login: span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERNAME, str(login)) + span.set_tag_str(_constants.APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, _constants.LOGIN_EVENTS_MODE.SDK) - _trace_utils.set_user(None, user_id) + _trace_utils.set_user(None, user_id, session_id=session_id, may_block=False) if metadata: _trace_utils.track_custom_event(None, "auth_sdk", metadata=metadata) if _asm_request_context.in_asm_context(): @@ -76,8 +79,8 @@ def track_user( "REQUEST_USERNAME": login, "LOGIN_SUCCESS": "sdk", } - if session: - custom_data["REQUEST_SESSION_ID"] = session + if session_id: + custom_data["REQUEST_SESSION_ID"] = session_id res = _asm_request_context.call_waf_callback(custom_data=custom_data, force_sent=True) if res and any(action in [_WAF_ACTIONS.BLOCK_ACTION, _WAF_ACTIONS.REDIRECT_ACTION] for action in res.actions): raise BlockingException(_get_blocked()) From 2e2257eea0bec599350fd9cd76d12650552c48fb Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Thu, 24 Apr 2025 09:46:30 +0200 Subject: [PATCH 04/12] move sdk set-tag in track_user --- ddtrace/appsec/track_user_sdk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py index e54318f4fa1..36312a9f652 100644 --- a/ddtrace/appsec/track_user_sdk.py +++ b/ddtrace/appsec/track_user_sdk.py @@ -68,11 +68,11 @@ def track_user( span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERID, str(user_id)) if login: span.set_tag_str(_constants.APPSEC.USER_LOGIN_USERNAME, str(login)) - span.set_tag_str(_constants.APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, _constants.LOGIN_EVENTS_MODE.SDK) _trace_utils.set_user(None, user_id, session_id=session_id, may_block=False) if metadata: _trace_utils.track_custom_event(None, "auth_sdk", metadata=metadata) + span.set_tag_str(_constants.APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, _constants.LOGIN_EVENTS_MODE.SDK) if _asm_request_context.in_asm_context(): custom_data = { "REQUEST_USER_ID": str(user_id) if user_id else None, From b2bbddcbf20ca3996bdaccfd0dd7afc0e70b9de0 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Thu, 24 Apr 2025 09:54:16 +0200 Subject: [PATCH 05/12] tiny rewrite --- ddtrace/appsec/_trace_utils.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index edacb4984fd..e076d8e2b52 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -331,24 +331,21 @@ def block_request_if_user_blocked(tracer: Any, userid: str, mode: str = "sdk") - :param userid: the ID of the user as registered by `set_user` :param mode: the mode of the login event ("sdk" by default, "auto" to simulate auto instrumentation) """ - if not asm_config._asm_enabled: + if not asm_config._asm_enabled or mode == LOGIN_EVENTS_MODE.DISABLED: log.warning("should_block_user call requires ASM to be enabled") return - span = core.get_root_span() - if span: - root_span = span._local_root or span - if mode == LOGIN_EVENTS_MODE.SDK: - root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, LOGIN_EVENTS_MODE.SDK) - else: - if mode == LOGIN_EVENTS_MODE.AUTO: - mode = asm_config._user_event_mode - if mode == LOGIN_EVENTS_MODE.DISABLED: - return + if mode == LOGIN_EVENTS_MODE.AUTO: + mode = asm_config._user_event_mode + root_span = core.get_root_span() + if root_span: + root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode) + if userid: if mode == LOGIN_EVENTS_MODE.ANON: userid = _hash_user_id(str(userid)) root_span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, mode) - root_span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(userid)) - root_span.set_tag_str(user.ID, str(userid)) + if mode != LOGIN_EVENTS_MODE.SDK: + root_span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(userid)) + root_span.set_tag_str(user.ID, str(userid)) if should_block_user(None, userid): _asm_request_context.block_request() From d81b90774ebaac61c639b63be89a36f807e7d752 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Thu, 24 Apr 2025 10:01:08 +0200 Subject: [PATCH 06/12] add release notes --- releasenotes/notes/ato_sdk_v2-35c17dc258c5b690.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 releasenotes/notes/ato_sdk_v2-35c17dc258c5b690.yaml diff --git a/releasenotes/notes/ato_sdk_v2-35c17dc258c5b690.yaml b/releasenotes/notes/ato_sdk_v2-35c17dc258c5b690.yaml new file mode 100644 index 00000000000..a4745102e90 --- /dev/null +++ b/releasenotes/notes/ato_sdk_v2-35c17dc258c5b690.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + AAP: This introduces a new user event sdk available through ddtrace.appsec.track_user_sdk for manual instrumentation. + More information on our documentation page. \ No newline at end of file From 184c97e96a50d9e80c90cf6e9c7dde96282e13a9 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Thu, 24 Apr 2025 18:18:43 +0200 Subject: [PATCH 07/12] add import to enable listener --- ddtrace/appsec/track_user_sdk.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py index 36312a9f652..db10f547f61 100644 --- a/ddtrace/appsec/track_user_sdk.py +++ b/ddtrace/appsec/track_user_sdk.py @@ -13,6 +13,7 @@ from ddtrace.appsec import _trace_utils from ddtrace.appsec._asm_request_context import get_blocked as _get_blocked from ddtrace.appsec._constants import WAF_ACTIONS as _WAF_ACTIONS +import ddtrace.appsec.trace_utils # noqa: F401 from ddtrace.internal import core as _core from ddtrace.internal._exceptions import BlockingException From 9d0103bb9526f42d1e7b7035e51164e8a458c100 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Fri, 25 Apr 2025 16:35:08 +0200 Subject: [PATCH 08/12] add public tag for usr.id sdk and improve metadata handling --- ddtrace/appsec/_trace_utils.py | 45 ++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index e076d8e2b52..a805ae7a50c 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -37,6 +37,26 @@ def _asm_manual_keep(span: Span) -> None: span.context._meta[APPSEC.PROPAGATION_HEADER] = "02" +def _handle_metadata(root_span: Span, prefix: str, metadata: dict) -> None: + if metadata is None: + return + stack = [(prefix, metadata, 0)] + while stack: + prefix, data, level = stack.pop() + if isinstance(data, list): + if level < 6: + for i, v in enumerate(data): + stack.append((f"{prefix}.{i}", v, level + 1)) + elif isinstance(data, dict): + if level < 6: + for k, v in data.items(): + stack.append((f"{prefix}.{k}", v, level + 1)) + else: + if isinstance(data, bool): + data = "true" if data else "false" + root_span.set_tag_str(f"{prefix}", str(data)) + + def _track_user_login_common( tracer: Any, success: bool, @@ -70,12 +90,7 @@ def _track_user_login_common( tag_metadata_prefix = "%s.%s" % (APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, success_str) if metadata is not None: - for k, v in metadata.items(): - if isinstance(v, bool): - str_v = "true" if v else "false" - else: - str_v = str(v) - span.set_tag_str("%s.%s" % (tag_metadata_prefix, k), str_v) + _handle_metadata(span, tag_metadata_prefix, metadata) if login: span.set_tag_str(f"{APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC}.{success_str}.usr.login", login) @@ -139,8 +154,11 @@ def track_user_login_success_event( if real_mode == LOGIN_EVENTS_MODE.ANON and isinstance(user_id, str): user_id = _hash_user_id(user_id) span.set_tag_str(APPSEC.AUTO_LOGIN_EVENTS_COLLECTION_MODE, real_mode) - if login_events_mode != LOGIN_EVENTS_MODE.SDK: - span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) + if user_id: + if login_events_mode != LOGIN_EVENTS_MODE.SDK: + span.set_tag_str(APPSEC.USER_LOGIN_USERID, str(user_id)) + else: + span.set_tag_str(f"{APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC}.success.usr.id", str(user_id)) set_user(None, user_id or "", name, email, scope, role, session_id, propagate, span, may_block=False) if in_asm_context(): custom_data = { @@ -273,14 +291,9 @@ def track_custom_event(tracer: Any, event_name: str, metadata: Dict[str, Any]) - return span.set_tag_str("%s.%s.track" % (APPSEC.CUSTOM_EVENT_PREFIX, event_name), "true") - - for k, v in metadata.items(): - if isinstance(v, bool): - str_v = "true" if v else "false" - else: - str_v = str(v) - span.set_tag_str("%s.%s.%s" % (APPSEC.CUSTOM_EVENT_PREFIX, event_name, k), str_v) - _asm_manual_keep(span) + if metadata: + _handle_metadata(span, f"{APPSEC.CUSTOM_EVENT_PREFIX}.{event_name}", metadata) + _asm_manual_keep(span) def should_block_user(tracer: Any, userid: str) -> bool: From 96b902c8b9b262d732ad7945d6d84e7a6828d0c5 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Fri, 25 Apr 2025 16:43:57 +0200 Subject: [PATCH 09/12] 1 offset for max depth --- ddtrace/appsec/_trace_utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index a805ae7a50c..f094896f88c 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -38,17 +38,18 @@ def _asm_manual_keep(span: Span) -> None: def _handle_metadata(root_span: Span, prefix: str, metadata: dict) -> None: + MAX_DEPTH = 6 if metadata is None: return - stack = [(prefix, metadata, 0)] + stack = [(prefix, metadata, 1)] while stack: prefix, data, level = stack.pop() if isinstance(data, list): - if level < 6: + if level < MAX_DEPTH: for i, v in enumerate(data): stack.append((f"{prefix}.{i}", v, level + 1)) elif isinstance(data, dict): - if level < 6: + if level < MAX_DEPTH: for k, v in data.items(): stack.append((f"{prefix}.{k}", v, level + 1)) else: From a09707d3d76f1a758892291df8d6811718c1e96b Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Fri, 25 Apr 2025 17:10:39 +0200 Subject: [PATCH 10/12] add sdk metrics and block on login_failure --- ddtrace/appsec/_metrics.py | 14 ++++++++++++++ ddtrace/appsec/_trace_utils.py | 7 ++++++- ddtrace/appsec/track_user_sdk.py | 5 +++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ddtrace/appsec/_metrics.py b/ddtrace/appsec/_metrics.py index a4f59c8ee39..f0d3dc38279 100644 --- a/ddtrace/appsec/_metrics.py +++ b/ddtrace/appsec/_metrics.py @@ -221,3 +221,17 @@ def _report_rasp_skipped(rule_type: str, import_error: bool) -> None: "more_info": f":waf:rasp_rule_skipped:{rule_type}:{import_error}", } logger.warning(WARNING_TAGS.TELEMETRY_METRICS, extra=extra, exc_info=True) + + +def _report_ato_sdk_usage(event_type: str, v2: bool = True) -> None: + version = "v2" if v2 else "v1" + try: + tags = (("event_type", event_type), ("sdk_version", version)) + telemetry.telemetry_writer.add_count_metric(TELEMETRY_NAMESPACE.APPSEC, "sdk.event", 1, tags=tags) + except Exception: + extra = { + "product": "appsec", + "exec_limit": 6, + "more_info": f":waf:sdk.event:{event_type}:{version}", + } + logger.warning(WARNING_TAGS.TELEMETRY_METRICS, extra=extra, exc_info=True) diff --git a/ddtrace/appsec/_trace_utils.py b/ddtrace/appsec/_trace_utils.py index f094896f88c..76b55b20526 100644 --- a/ddtrace/appsec/_trace_utils.py +++ b/ddtrace/appsec/_trace_utils.py @@ -222,7 +222,12 @@ def track_user_login_failure_event( if name: span.set_tag_str("%s.failure.username" % APPSEC.USER_LOGIN_EVENT_PREFIX_PUBLIC, name) if in_asm_context(): - call_waf_callback(custom_data={"LOGIN_FAILURE": None}) + custom_data: dict[str, Any] = {"LOGIN_FAILURE": None} + if login: + custom_data["REQUEST_USERNAME"] = login + res = call_waf_callback(custom_data=custom_data) + if res and any(action in [WAF_ACTIONS.BLOCK_ACTION, WAF_ACTIONS.REDIRECT_ACTION] for action in res.actions): + raise BlockingException(get_blocked()) def track_user_signup_event( diff --git a/ddtrace/appsec/track_user_sdk.py b/ddtrace/appsec/track_user_sdk.py index db10f547f61..347a16cd0ec 100644 --- a/ddtrace/appsec/track_user_sdk.py +++ b/ddtrace/appsec/track_user_sdk.py @@ -10,6 +10,7 @@ from ddtrace.appsec import _asm_request_context from ddtrace.appsec import _constants +from ddtrace.appsec import _metrics from ddtrace.appsec import _trace_utils from ddtrace.appsec._asm_request_context import get_blocked as _get_blocked from ddtrace.appsec._constants import WAF_ACTIONS as _WAF_ACTIONS @@ -25,6 +26,7 @@ def track_login_success(login: str, user_id: t.Any = None, metadata: t.Optional[ This function should be called when a user successfully logs in to the application. It will create an event that can be used for monitoring and analysis. """ + _metrics._report_ato_sdk_usage("login_success") _trace_utils.track_user_login_success_event(None, user_id, login=login, metadata=metadata) @@ -37,6 +39,7 @@ def track_login_failure( This function should be called when a user fails to log in to the application. It will create an event that can be used for monitoring and analysis. """ + _metrics._report_ato_sdk_usage("login_failure") _trace_utils.track_user_login_failure_event(None, user_id, exists=exists, login=login, metadata=metadata) @@ -49,6 +52,7 @@ def track_signup( This function should be called when a user successfully signs up for the application. It will create an event that can be used for monitoring and analysis. """ + _metrics._report_ato_sdk_usage("signup") _trace_utils.track_user_signup_event(None, user_id, success, login=login) if metadata: _trace_utils.track_custom_event(None, "signup_sdk", metadata=metadata) @@ -94,4 +98,5 @@ def track_custom_event(event_name: str, metadata: t.Dict[str, t.Any]): This function should be called when a custom user event occurs in the application. It will create an event that can be used for monitoring and analysis. """ + _metrics._report_ato_sdk_usage("custom") _trace_utils.track_custom_event(None, event_name, metadata=metadata) From baf2e1438aaa43d674edd4a62bc6ec6a9593b9d9 Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Mon, 28 Apr 2025 16:39:58 +0200 Subject: [PATCH 11/12] add unit tests for threats on ato sdk v2 --- .../appsec/contrib_appsec/django_app/urls.py | 53 +++++++- .../appsec/contrib_appsec/fastapi_app/app.py | 49 ++++++- tests/appsec/contrib_appsec/flask_app/app.py | 51 ++++++- tests/appsec/contrib_appsec/utils.py | 126 ++++++++++++++++++ 4 files changed, 273 insertions(+), 6 deletions(-) diff --git a/tests/appsec/contrib_appsec/django_app/urls.py b/tests/appsec/contrib_appsec/django_app/urls.py index bb93a34ff8a..8e8f9885f8b 100644 --- a/tests/appsec/contrib_appsec/django_app/urls.py +++ b/tests/appsec/contrib_appsec/django_app/urls.py @@ -1,7 +1,9 @@ +import json import os import sqlite3 import subprocess import tempfile +from typing import Optional import django from django.contrib.auth import login @@ -183,8 +185,8 @@ def login_user(request): except Exception: pass - username = request.GET.get("username") - password = request.GET.get("password") + username = request.GET.get("username", "") + password = request.GET.get("password", "") user = authenticate(username=username, password=password) if user is not None: login(request, user) @@ -192,6 +194,51 @@ def login_user(request): return HttpResponse("login failure", status=401) +@csrf_exempt +def login_user_sdk(request): + """manual instrumentation login endpoint using SDK V2""" + try: + from ddtrace.appsec import track_user_sdk + except ImportError: + return HttpResponse("SDK V2 not available", status=422) + + USERS = { + "test": {"email": "testuser@ddog.com", "password": "1234", "name": "test", "id": "social-security-id"}, + "testuuid": { + "email": "testuseruuid@ddog.com", + "password": "1234", + "name": "testuuid", + "id": "591dc126-8431-4d0f-9509-b23318d3dce4", + }, + } + metadata = json.loads(request.GET.get("metadata", "{}")) + + def authenticate(username: str, password: str) -> Optional[str]: + """authenticate user""" + if username in USERS: + if USERS[username]["password"] == password: + return USERS[username]["id"] + else: + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None + track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) + return None + + def login(user_id: str, login: str) -> None: + """login user""" + track_user_sdk.track_login_success(login=login, user_id=user_id, metadata=metadata) + + username = request.GET.get("username", "") + password = request.GET.get("password", "") + user_id = authenticate(username=username, password=password) + if user_id is not None: + login(user_id, username) + return HttpResponse("OK") + return HttpResponse("login failure", status=401) + + @csrf_exempt def new_service(request, service_name: str): import ddtrace @@ -240,6 +287,8 @@ def shutdown(request): path("rasp/", rasp, name="rasp"), path("login/", login_user, name="login"), path("login", login_user, name="login"), + path("login_sdk/", login_user_sdk, name="login_sdk"), + path("login_sdk", login_user_sdk, name="login_sdk"), ] else: urlpatterns += [ diff --git a/tests/appsec/contrib_appsec/fastapi_app/app.py b/tests/appsec/contrib_appsec/fastapi_app/app.py index ddefe6cf33c..bb47ff34130 100644 --- a/tests/appsec/contrib_appsec/fastapi_app/app.py +++ b/tests/appsec/contrib_appsec/fastapi_app/app.py @@ -1,4 +1,5 @@ import asyncio +import json import os import sqlite3 import subprocess @@ -249,8 +250,52 @@ def login(user_id: str, username: str) -> None: tracer, user_id=user_id, login_events_mode="auto", login=username ) - username = request.query_params.get("username") - password = request.query_params.get("password") + username = request.query_params.get("username", "") + password = request.query_params.get("password", "") + user_id = authenticate(username=username, password=password) + if user_id is not None: + login(user_id, username) + return HTMLResponse("OK") + return HTMLResponse("login failure", status_code=401) + + @app.get("/login_sdk/") + async def login_user_sdk(request: Request): + """manual instrumentation login endpoint using SDK V2""" + try: + from ddtrace.appsec import track_user_sdk + except ImportError: + return HTMLResponse("SDK V2 not available", status_code=422) + + USERS = { + "test": {"email": "testuser@ddog.com", "password": "1234", "name": "test", "id": "social-security-id"}, + "testuuid": { + "email": "testuseruuid@ddog.com", + "password": "1234", + "name": "testuuid", + "id": "591dc126-8431-4d0f-9509-b23318d3dce4", + }, + } + metadata = json.loads(request.query_params.get("metadata", "{}")) + + def authenticate(username: str, password: str) -> Optional[str]: + """authenticate user""" + if username in USERS: + if USERS[username]["password"] == password: + return USERS[username]["id"] + else: + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None + track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) + return None + + def login(user_id: str, login: str) -> None: + """login user""" + track_user_sdk.track_login_success(login=login, user_id=user_id, metadata=metadata) + + username = request.query_params.get("username", "") + password = request.query_params.get("password", "") user_id = authenticate(username=username, password=password) if user_id is not None: login(user_id, username) diff --git a/tests/appsec/contrib_appsec/flask_app/app.py b/tests/appsec/contrib_appsec/flask_app/app.py index 9d387bd41fb..4ed86ab6196 100644 --- a/tests/appsec/contrib_appsec/flask_app/app.py +++ b/tests/appsec/contrib_appsec/flask_app/app.py @@ -1,3 +1,4 @@ +import json import os import sqlite3 import subprocess @@ -197,8 +198,54 @@ def login(user_id: str, login: str) -> None: tracer, user_id=user_id, login_events_mode="auto", login=login ) - username = request.args.get("username") - password = request.args.get("password") + username = request.args.get("username", "") + password = request.args.get("password", "") + user_id = authenticate(username=username, password=password) + if user_id is not None: + login(user_id, username) + return "OK" + return "login failure", 401 + + +@app.route("/login_sdk/", methods=["GET"]) +@app.route("/login_sdk", methods=["GET"]) +def login_user_sdk(): + """manual instrumentation login endpoint using SDK V2""" + try: + from ddtrace.appsec import track_user_sdk + except ImportError: + return "SDK V2 not available", 422 + + USERS = { + "test": {"email": "testuser@ddog.com", "password": "1234", "name": "test", "id": "social-security-id"}, + "testuuid": { + "email": "testuseruuid@ddog.com", + "password": "1234", + "name": "testuuid", + "id": "591dc126-8431-4d0f-9509-b23318d3dce4", + }, + } + metadata = json.loads(request.args.get("metadata", "{}")) + + def authenticate(username: str, password: str) -> Optional[str]: + """authenticate user""" + if username in USERS: + if USERS[username]["password"] == password: + return USERS[username]["id"] + else: + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None + track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) + return None + + def login(user_id: str, login: str) -> None: + """login user""" + track_user_sdk.track_login_success(login=login, user_id=user_id, metadata=metadata) + + username = request.args.get("username", "") + password = request.args.get("password", "") user_id = authenticate(username=username, password=password) if user_id is not None: login(user_id, username) diff --git a/tests/appsec/contrib_appsec/utils.py b/tests/appsec/contrib_appsec/utils.py index 2988cf8161b..3165c750c4a 100644 --- a/tests/appsec/contrib_appsec/utils.py +++ b/tests/appsec/contrib_appsec/utils.py @@ -22,6 +22,13 @@ from tests.utils import override_global_config +try: + from ddtrace.appsec import track_user_sdk as _track_user_sdk # noqa: F401 + + USER_SDK_V2 = True +except ImportError: + USER_SDK_V2 = False + # patching asm_request_context to observe waf data internals _init_finalize = _asm_request_context.finalize_asm_env @@ -1650,6 +1657,125 @@ def test_auto_user_events( assert get_tag(asm_constants.FINGERPRINTING.ENDPOINT) is None assert get_tag(asm_constants.FINGERPRINTING.SESSION) is None + @pytest.mark.parametrize("asm_enabled", [True, False]) + @pytest.mark.parametrize("auto_events_enabled", [True, False]) + @pytest.mark.parametrize("local_mode", ["disabled", "identification", "anonymization"]) + @pytest.mark.parametrize("rc_mode", [None, "disabled", "identification", "anonymization"]) + @pytest.mark.parametrize( + ("username", "password", "status_code", "user_id"), + [ + ("test", "1234", 200, "social-security-id"), + ("testuuid", "12345", 401, "591dc126-8431-4d0f-9509-b23318d3dce4"), + ("zouzou", "12345", 401, ""), + ], + ) + def test_auto_user_events_sdk_v2( + self, + interface, + root_span, + get_tag, + asm_enabled, + auto_events_enabled, + local_mode, + rc_mode, + username, + password, + status_code, + user_id, + ): + from unittest.mock import MagicMock + from unittest.mock import patch as mock_patch + + import ddtrace.internal.telemetry + + if not USER_SDK_V2: + raise pytest.skip("SDK v2 not available") + + with override_global_config( + dict( + _asm_enabled=asm_enabled, + _auto_user_instrumentation_local_mode=local_mode, + _auto_user_instrumentation_rc_mode=rc_mode, + _auto_user_instrumentation_enabled=auto_events_enabled, + ) + ), mock_patch.object(ddtrace.internal.telemetry.telemetry_writer, "_namespace", MagicMock()) as telemetry_mock: + self.update_tracer(interface) + metadata = json.dumps( + { + "a": "a", + "load_a": { + "b": True, + "load_b": { + "c": 3, + "load_c": { + "d": "value", + "load_d": { + "e": 1.32, + "load_e": { + "f": 3.1415926, + "load_f": {"g": "ghost", "load_g": {"h": "heavy", "load_h": {}}}, + }, + }, + }, + }, + }, + }, + separators=(",", ":"), + ) + response = interface.client.get(f"/login_sdk/?username={username}&password={password}&metadata={metadata}") + assert self.status(response) == status_code + assert get_tag("http.status_code") == str(status_code) + telemetry_calls = { + (c.value, f"{ns.value}.{nm}", t): v for (c, ns, nm, v, t), _ in telemetry_mock.add_metric.call_args_list + } + if status_code == 401: + assert get_tag("appsec.events.users.login.failure.track") == "true" + if user_id: + assert get_tag("appsec.events.users.login.failure.usr.id") == user_id + assert get_tag("appsec.events.users.login.failure.usr.exists") == str(username == "testuuid").lower() + assert get_tag("_dd.appsec.events.users.login.failure.sdk") == "true" + assert any( + t[:2] == ("count", "appsec.sdk.event") and ("event_type", "login_failure") == t[2][0] + for t in telemetry_calls + ), telemetry_calls + else: + assert get_tag("appsec.events.users.login.success.track") == "true" + assert get_tag("usr.id") == user_id + assert get_tag("usr.id") == user_id, (user_id, get_tag("usr.id")) + assert any(tag.startswith("appsec.events.users.login") for tag in root_span()._meta) + assert get_tag("_dd.appsec.events.users.login.success.sdk") == "true" + assert any( + t[:2] == ("count", "appsec.sdk.event") and ("event_type", "login_success") == t[2][0] + for t in telemetry_calls + ), telemetry_calls + + # no auto instrumentation + assert not any(tag.startswith("_dd_appsec.events.users.login") for tag in root_span()._meta) + + # check for fingerprints when user events + if asm_enabled: + assert get_tag(asm_constants.FINGERPRINTING.HEADER) + assert get_tag(asm_constants.FINGERPRINTING.NETWORK) + assert get_tag(asm_constants.FINGERPRINTING.ENDPOINT) + assert get_tag(asm_constants.FINGERPRINTING.SESSION) + else: + # assert get_tag(asm_constants.FINGERPRINTING.HEADER) is None + assert get_tag(asm_constants.FINGERPRINTING.NETWORK) is None + assert get_tag(asm_constants.FINGERPRINTING.ENDPOINT) is None + assert get_tag(asm_constants.FINGERPRINTING.SESSION) is None + + # metadata + success = "success" if status_code == 200 else "failure" + assert get_tag(f"appsec.events.users.login.{success}.a") == "a", root_span()._meta + assert get_tag(f"appsec.events.users.login.{success}.load_a.b") == "true", root_span()._meta + assert get_tag(f"appsec.events.users.login.{success}.load_a.load_b.c") == "3", root_span()._meta + assert ( + get_tag(f"appsec.events.users.login.{success}.load_a.load_b.load_c.load_d.e") == "1.32" + ), root_span()._meta + assert ( + get_tag(f"appsec.events.users.login.{success}.load_a.load_b.load_c.load_d.load_e.f") is None + ), root_span()._meta + @pytest.mark.parametrize("asm_enabled", [True, False]) @pytest.mark.parametrize("user_agent", ["dd-test-scanner-log-block", "UnitTestAgent"]) def test_fingerprinting(self, interface, root_span, get_tag, asm_enabled, user_agent): From 8b9f24a564c83e654d238cea1a3e3797fbcadadc Mon Sep 17 00:00:00 2001 From: Christophe Papazian Date: Mon, 28 Apr 2025 16:45:17 +0200 Subject: [PATCH 12/12] make the bot happy --- tests/appsec/contrib_appsec/django_app/urls.py | 9 ++++----- tests/appsec/contrib_appsec/fastapi_app/app.py | 9 ++++----- tests/appsec/contrib_appsec/flask_app/app.py | 9 ++++----- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/appsec/contrib_appsec/django_app/urls.py b/tests/appsec/contrib_appsec/django_app/urls.py index 8e8f9885f8b..49cec14c181 100644 --- a/tests/appsec/contrib_appsec/django_app/urls.py +++ b/tests/appsec/contrib_appsec/django_app/urls.py @@ -218,11 +218,10 @@ def authenticate(username: str, password: str) -> Optional[str]: if username in USERS: if USERS[username]["password"] == password: return USERS[username]["id"] - else: - track_user_sdk.track_login_failure( - login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata - ) - return None + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) return None diff --git a/tests/appsec/contrib_appsec/fastapi_app/app.py b/tests/appsec/contrib_appsec/fastapi_app/app.py index bb47ff34130..f82734730c5 100644 --- a/tests/appsec/contrib_appsec/fastapi_app/app.py +++ b/tests/appsec/contrib_appsec/fastapi_app/app.py @@ -282,11 +282,10 @@ def authenticate(username: str, password: str) -> Optional[str]: if username in USERS: if USERS[username]["password"] == password: return USERS[username]["id"] - else: - track_user_sdk.track_login_failure( - login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata - ) - return None + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) return None diff --git a/tests/appsec/contrib_appsec/flask_app/app.py b/tests/appsec/contrib_appsec/flask_app/app.py index 4ed86ab6196..479dad5fdd0 100644 --- a/tests/appsec/contrib_appsec/flask_app/app.py +++ b/tests/appsec/contrib_appsec/flask_app/app.py @@ -232,11 +232,10 @@ def authenticate(username: str, password: str) -> Optional[str]: if username in USERS: if USERS[username]["password"] == password: return USERS[username]["id"] - else: - track_user_sdk.track_login_failure( - login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata - ) - return None + track_user_sdk.track_login_failure( + login=username, user_id=USERS[username]["id"], exists=True, metadata=metadata + ) + return None track_user_sdk.track_login_failure(login=username, exists=False, metadata=metadata) return None