From 992e35a45e62117b5a5d013dc912738cc86ee9e9 Mon Sep 17 00:00:00 2001 From: Vaishnavi Devi Gujjari <242289011+vaish-gujjari@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:04:11 -0700 Subject: [PATCH 1/2] feat: add sanitized stack trace reporting to telemetry error events Signed-off-by: Vaishnavi Devi Gujjari <242289011+vaish-gujjari@users.noreply.github.com> --- .../client/api/_stack_trace_sanitizer.py | 80 ++++++ src/deadline/client/api/_telemetry.py | 24 ++ .../client/cli/_groups/bundle_group.py | 5 +- .../dialogs/submit_job_to_deadline_dialog.py | 6 +- .../deadline_client/api/test_api_telemetry.py | 250 ++++++++++++++++++ 5 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 src/deadline/client/api/_stack_trace_sanitizer.py diff --git a/src/deadline/client/api/_stack_trace_sanitizer.py b/src/deadline/client/api/_stack_trace_sanitizer.py new file mode 100644 index 000000000..f1dadc5a3 --- /dev/null +++ b/src/deadline/client/api/_stack_trace_sanitizer.py @@ -0,0 +1,80 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +""" +Stack trace sanitizer for Deadline Cloud client telemetry. + +Strips customer-specific file paths from Python stack traces while preserving +diagnostic value (module names, line numbers, function names, error messages). + +Conforms to ADR 2024-02-19: "No customer content or other information provided +by the customer can be submitted, such as bucket names, file names, or similar." +""" + +import re +import traceback +from typing import FrozenSet, List + +# Packages we control — safe to include relative paths for +_KNOWN_PACKAGES: FrozenSet[str] = frozenset( + { + "deadline", + "openjd", + "boto3", + "botocore", + } +) + +_FRAME_RE = re.compile(r'^( {0,10}File )"([^"]+)",( +line \d+, in .*)$') + + +def _sanitize_path(filepath: str) -> str: + """Replace a full file path with the package-relative portion or bare filename.""" + if filepath.startswith("<"): + return filepath + + parts = filepath.replace("\\", "/").split("/") + + for i, part in enumerate(parts): + stem = part.split(".")[0] + if stem in _KNOWN_PACKAGES: + return "/".join(parts[i:]) + + for i, part in enumerate(parts): + if part == "site-packages" and i + 1 < len(parts): + return "/".join(parts[i + 1 :]) + + return parts[-1] + + +# Matches file paths in exception messages — paths are typically quoted with ' or " +_MSG_PATH_RE = re.compile(r"'([^'/\\]*[/\\][^']*)'|\"([^\"/\\]*[/\\][^\"]*)\"") + + +def sanitize_message(message: str) -> str: + """Sanitize an exception message by replacing file paths with safe versions.""" + + def _replace(m: re.Match) -> str: + if m.group(1) is not None: + return f"'{_sanitize_path(m.group(1))}'" + return f'"{_sanitize_path(m.group(2))}"' + + return _MSG_PATH_RE.sub(_replace, message) + + +def sanitize_traceback_string(tb_string: str) -> str: + """Sanitize a formatted traceback string, stripping customer paths.""" + lines = tb_string.splitlines() + sanitized: List[str] = [] + for line in lines: + m = _FRAME_RE.match(line) + if m: + prefix, filepath, suffix = m.groups() + sanitized.append(f'{prefix}"{_sanitize_path(filepath)}",{suffix}') + else: + sanitized.append(line) + return "\n".join(sanitized) + + +def sanitize_exception(exc: BaseException) -> str: + """Format and sanitize a live exception's full traceback.""" + raw = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + return sanitize_traceback_string(raw) diff --git a/src/deadline/client/api/_telemetry.py b/src/deadline/client/api/_telemetry.py index a5f7d91af..b81bf112a 100644 --- a/src/deadline/client/api/_telemetry.py +++ b/src/deadline/client/api/_telemetry.py @@ -164,6 +164,30 @@ def initialize(self, config: Optional[ConfigParser] = None) -> None: # Silently swallow any exceptions return + def record_error_with_trace( + self, + exc: BaseException, + exception_scope: str, + extra_details: Optional[dict] = None, + from_gui: bool = False, + ) -> None: + from ._stack_trace_sanitizer import sanitize_exception, sanitize_message + + event_details: dict = { + "exception_type": type(exc).__qualname__, + "exception_scope": exception_scope, + "message": sanitize_message(str(exc)), + "stack_trace": sanitize_exception(exc), + } + if extra_details: + event_details.update(extra_details) + + self.record_event( + event_type="com.amazon.rum.deadline.error", + event_details=event_details, + from_gui=from_gui, + ) + @property def is_initialized(self) -> bool: return self._initialized diff --git a/src/deadline/client/cli/_groups/bundle_group.py b/src/deadline/client/cli/_groups/bundle_group.py index 21080a0b4..fc3fd308f 100644 --- a/src/deadline/client/cli/_groups/bundle_group.py +++ b/src/deadline/client/cli/_groups/bundle_group.py @@ -353,10 +353,7 @@ def _check_create_job_wait_canceled() -> bool: click.echo("Job submission canceled.") sys.exit(1) except Exception as exc: - api.get_deadline_cloud_library_telemetry_client().record_error( - event_details={"exception_scope": "on_submit"}, - exception_type=str(type(exc)), - ) + api.get_deadline_cloud_library_telemetry_client().record_error_with_trace(exc, "on_submit") raise finally: if snapshot_tmpdir: diff --git a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py index 2bd2bba9a..4653384a0 100644 --- a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py +++ b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py @@ -621,10 +621,8 @@ def on_submit(self): job_progress_dialog.close() except Exception as exc: logger.exception("error submitting job") - api.get_deadline_cloud_library_telemetry_client().record_error( - event_details={"exception_scope": "on_submit"}, - exception_type=str(type(exc)), - from_gui=True, + api.get_deadline_cloud_library_telemetry_client().record_error_with_trace( + exc, "on_submit", from_gui=True ) QMessageBox.critical( self, diff --git a/test/unit/deadline_client/api/test_api_telemetry.py b/test/unit/deadline_client/api/test_api_telemetry.py index ee8e66bbe..81501f01d 100644 --- a/test/unit/deadline_client/api/test_api_telemetry.py +++ b/test/unit/deadline_client/api/test_api_telemetry.py @@ -18,6 +18,12 @@ record_success_fail_telemetry_event, record_function_latency_telemetry_event, ) +from deadline.client.api._stack_trace_sanitizer import ( + _sanitize_path, + sanitize_message, + sanitize_traceback_string, + sanitize_exception, +) from deadline.job_attachments.progress_tracker import SummaryStatistics @@ -60,6 +66,10 @@ def test_opt_out_config(fresh_deadline_config): client.record_hashing_summary(SummaryStatistics(), from_gui=True) client.record_upload_summary(SummaryStatistics(), from_gui=False) client.record_error({}, str(type(Exception))) + try: + raise RuntimeError("opt-out test") + except RuntimeError as exc: + client.record_error_with_trace(exc, "test") @pytest.mark.parametrize( @@ -92,6 +102,10 @@ def test_opt_out_env_var(fresh_deadline_config, monkeypatch, env_var_value): client.record_hashing_summary(SummaryStatistics(), from_gui=True) client.record_upload_summary(SummaryStatistics(), from_gui=False) client.record_error({}, str(type(Exception))) + try: + raise RuntimeError("opt-out test") + except RuntimeError as exc: + client.record_error_with_trace(exc, "test") def test_initialize_failure_then_success(fresh_deadline_config): @@ -282,6 +296,105 @@ def test_record_error(fresh_deadline_config, mock_telemetry_client): queue_mock.put_nowait.assert_called_once_with(expected_event) +def test_record_error_with_trace(fresh_deadline_config, mock_telemetry_client): + """Test that record_error_with_trace sends a TelemetryEvent with sanitized stack trace fields""" + # GIVEN + queue_mock = MagicMock() + mock_telemetry_client.event_queue = queue_mock + + try: + raise ValueError("something broke") + except ValueError as exc: + with patch.object( + mock_telemetry_client, "get_account_id", return_value="111122223333" + ), patch.object(api._telemetry, "get_boto3_session"): + # WHEN + mock_telemetry_client.record_error_with_trace(exc, "test_scope") + + # THEN + queue_mock.put_nowait.assert_called_once() + event: TelemetryEvent = queue_mock.put_nowait.call_args[0][0] + assert event.event_type == "com.amazon.rum.deadline.error" + assert event.event_details["exception_type"] == "ValueError" + assert event.event_details["exception_scope"] == "test_scope" + assert event.event_details["message"] == "something broke" + assert "ValueError: something broke" in event.event_details["stack_trace"] + assert event.event_details["usage_mode"] == "CLI" + assert event.event_details["accountId"] == "111122223333" + + +def test_record_error_with_trace_extra_details(fresh_deadline_config, mock_telemetry_client): + """Test that extra_details are merged into the event""" + # GIVEN + queue_mock = MagicMock() + mock_telemetry_client.event_queue = queue_mock + + try: + raise RuntimeError("fail") + except RuntimeError as exc: + with patch.object( + mock_telemetry_client, "get_account_id", return_value="111122223333" + ), patch.object(api._telemetry, "get_boto3_session"): + # WHEN + mock_telemetry_client.record_error_with_trace( + exc, "cli", extra_details={"command": "bundle submit"} + ) + + # THEN + event: TelemetryEvent = queue_mock.put_nowait.call_args[0][0] + assert event.event_details["command"] == "bundle submit" + assert event.event_details["exception_type"] == "RuntimeError" + + +def test_record_error_with_trace_sanitizes_message(fresh_deadline_config, mock_telemetry_client): + """Test that customer paths in exception messages are sanitized""" + # GIVEN + queue_mock = MagicMock() + mock_telemetry_client.event_queue = queue_mock + + try: + raise FileNotFoundError( + "[Errno 2] No such file or directory: '/home/customer/secret/render.py'" + ) + except FileNotFoundError as exc: + with patch.object( + mock_telemetry_client, "get_account_id", return_value="111122223333" + ), patch.object(api._telemetry, "get_boto3_session"): + # WHEN + mock_telemetry_client.record_error_with_trace(exc, "test") + + # THEN + event: TelemetryEvent = queue_mock.put_nowait.call_args[0][0] + assert "customer" not in event.event_details["message"] + assert "secret" not in event.event_details["message"] + assert "render.py" in event.event_details["message"] + + +def test_record_error_with_trace_sanitizes_paths(fresh_deadline_config, mock_telemetry_client): + """Test that customer paths are stripped from the stack trace""" + # GIVEN + queue_mock = MagicMock() + mock_telemetry_client.event_queue = queue_mock + + try: + raise TypeError("bad type") + except TypeError as exc: + with patch.object( + mock_telemetry_client, "get_account_id", return_value="111122223333" + ), patch.object(api._telemetry, "get_boto3_session"): + # WHEN + mock_telemetry_client.record_error_with_trace(exc, "test") + + # THEN + event: TelemetryEvent = queue_mock.put_nowait.call_args[0][0] + stack_trace = event.event_details["stack_trace"] + # The stack trace should not contain the full absolute path to this test file + for line in stack_trace.splitlines(): + if line.strip().startswith('File "'): + path = line.split('"')[1] + assert not path.startswith("/"), f"Absolute path leaked: {path}" + + @pytest.mark.parametrize( "endpoint,prefix,expected_result", [ @@ -441,3 +554,140 @@ def fake_init(self, **kwargs): assert client_b.package_name == "package-b" telemetry_mod.__cached_telemetry_clients = {} + + +# --- Stack trace sanitizer tests --- + + +class TestSanitizePath: + def test_known_package_deadline(self): + assert ( + _sanitize_path( + "/home/customer/secret/venv/lib/python3.11/site-packages/deadline/client/api/_telemetry.py" + ) + == "deadline/client/api/_telemetry.py" + ) + + def test_known_package_openjd(self): + assert _sanitize_path("/opt/libs/openjd/sessions/runner.py") == "openjd/sessions/runner.py" + + def test_known_package_botocore(self): + assert ( + _sanitize_path("/usr/lib/python3/dist-packages/botocore/client.py") + == "botocore/client.py" + ) + + def test_site_packages_unknown_lib(self): + assert ( + _sanitize_path("/home/user/venv/lib/python3.11/site-packages/somelib/core.py") + == "somelib/core.py" + ) + + def test_customer_script_returns_filename_only(self): + assert _sanitize_path("/home/customer/my-bucket-name/scripts/render.py") == "render.py" + + def test_windows_path(self): + assert ( + _sanitize_path( + "C:\\Users\\customer\\AppData\\Local\\deadline\\client\\api\\_telemetry.py" + ) + == "deadline/client/api/_telemetry.py" + ) + + def test_frozen_module(self): + assert _sanitize_path("") == "" + + def test_string_input(self): + assert _sanitize_path("") == "" + + +SAMPLE_TB = """Traceback (most recent call last): + File "/home/jsmith/renders/s3-bucket-acme/venv/lib/python3.11/site-packages/deadline/client/api/_telemetry.py", line 42, in record_event + self._send(event) + File "/home/jsmith/renders/s3-bucket-acme/custom_scripts/submit.py", line 10, in main + client.submit_job() + File "/home/jsmith/renders/s3-bucket-acme/venv/lib/python3.11/site-packages/botocore/client.py", line 530, in _api_call + return self._make_api_call(operation_name, kwargs) +ValueError: something went wrong""" + + +class TestSanitizeTracebackString: + def test_known_packages_preserved(self): + result = sanitize_traceback_string(SAMPLE_TB) + assert '"deadline/client/api/_telemetry.py", line 42, in record_event' in result + assert '"botocore/client.py", line 530, in _api_call' in result + + def test_customer_path_stripped(self): + result = sanitize_traceback_string(SAMPLE_TB) + assert '"submit.py", line 10, in main' in result + + def test_no_customer_data_leaked(self): + result = sanitize_traceback_string(SAMPLE_TB) + assert "jsmith" not in result + assert "s3-bucket-acme" not in result + assert "/home/" not in result + + def test_error_message_preserved(self): + result = sanitize_traceback_string(SAMPLE_TB) + assert "ValueError: something went wrong" in result + + def test_structure_preserved(self): + result = sanitize_traceback_string(SAMPLE_TB) + assert "Traceback (most recent call last):" in result + assert " self._send(event)" in result + + +class TestSanitizeException: + def test_live_exception(self): + try: + raise RuntimeError("test error") + except RuntimeError as e: + result = sanitize_exception(e) + assert "RuntimeError: test error" in result + + def test_no_absolute_paths(self): + try: + raise RuntimeError("path test") + except RuntimeError as e: + result = sanitize_exception(e) + for line in result.splitlines(): + if line.strip().startswith('File "'): + path = line.split('"')[1] + assert not path.startswith("/"), f"Absolute path leaked: {path}" + + +class TestSanitizeMessage: + def test_single_quoted_unix_path(self): + msg = "[Errno 2] No such file or directory: '/home/customer/secret/render.py'" + result = sanitize_message(msg) + assert "customer" not in result + assert "secret" not in result + assert "'render.py'" in result + + def test_double_quoted_unix_path(self): + msg = 'Permission denied: "/mnt/customer-bucket/output.exr"' + result = sanitize_message(msg) + assert "customer-bucket" not in result + assert '"output.exr"' in result + + def test_windows_path(self): + msg = "Cannot open 'C:\\Users\\customer\\Documents\\scene.blend'" + result = sanitize_message(msg) + assert "customer" not in result + assert "'scene.blend'" in result + + def test_known_package_preserved(self): + msg = "Error in '/home/user/venv/lib/python3.11/site-packages/deadline/client/api/_telemetry.py'" + result = sanitize_message(msg) + assert "user" not in result + assert "deadline/client/api/_telemetry.py" in result + + def test_no_path_unchanged(self): + msg = "ValueError: something went wrong" + assert sanitize_message(msg) == msg + + def test_unquoted_unix_path(self): + msg = "Failed to process /home/user/job/input.txt" + result = sanitize_message(msg) + # Unquoted paths are not sanitized — Python exceptions typically quote paths + assert result == msg From b865d81dcb8849e15c01fbeae3f4aa98fbd6fb4b Mon Sep 17 00:00:00 2001 From: Vaishnavi Devi Gujjari <242289011+vaish-gujjari@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:54:32 -0700 Subject: [PATCH 2/2] fix: use allowlist-based sanitization and drop exception message from stack trace telemetry Signed-off-by: Vaishnavi Devi Gujjari <242289011+vaish-gujjari@users.noreply.github.com> --- .../client/api/_stack_trace_sanitizer.py | 58 ++++--- src/deadline/client/api/_telemetry.py | 4 +- .../deadline_client/api/test_api_telemetry.py | 152 ++++++------------ 3 files changed, 74 insertions(+), 140 deletions(-) diff --git a/src/deadline/client/api/_stack_trace_sanitizer.py b/src/deadline/client/api/_stack_trace_sanitizer.py index f1dadc5a3..c35f38c92 100644 --- a/src/deadline/client/api/_stack_trace_sanitizer.py +++ b/src/deadline/client/api/_stack_trace_sanitizer.py @@ -2,14 +2,15 @@ """ Stack trace sanitizer for Deadline Cloud client telemetry. -Strips customer-specific file paths from Python stack traces while preserving -diagnostic value (module names, line numbers, function names, error messages). +Uses an allowlist approach: only explicitly chosen fields (sanitized filename, +line number, function name, exception type) are emitted. Source code context +and exception messages are intentionally omitted as they could contain +customer data. Conforms to ADR 2024-02-19: "No customer content or other information provided by the customer can be submitted, such as bucket names, file names, or similar." """ -import re import traceback from typing import FrozenSet, List @@ -23,8 +24,6 @@ } ) -_FRAME_RE = re.compile(r'^( {0,10}File )"([^"]+)",( +line \d+, in .*)$') - def _sanitize_path(filepath: str) -> str: """Replace a full file path with the package-relative portion or bare filename.""" @@ -45,36 +44,33 @@ def _sanitize_path(filepath: str) -> str: return parts[-1] -# Matches file paths in exception messages — paths are typically quoted with ' or " -_MSG_PATH_RE = re.compile(r"'([^'/\\]*[/\\][^']*)'|\"([^\"/\\]*[/\\][^\"]*)\"") - - -def sanitize_message(message: str) -> str: - """Sanitize an exception message by replacing file paths with safe versions.""" +def _sanitize_traceback(te: traceback.TracebackException) -> List[str]: + """Recursively format a TracebackException chain using only allowlisted fields.""" + lines: List[str] = [] - def _replace(m: re.Match) -> str: - if m.group(1) is not None: - return f"'{_sanitize_path(m.group(1))}'" - return f'"{_sanitize_path(m.group(2))}"' + # Handle chained exceptions (cause or context) + if te.__cause__ is not None: + lines.extend(_sanitize_traceback(te.__cause__)) + lines.append("\nThe above exception was the direct cause of the following exception:\n") + elif te.__context__ is not None and not te.__suppress_context__: + lines.extend(_sanitize_traceback(te.__context__)) + lines.append("\nDuring handling of the above exception, another exception occurred:\n") - return _MSG_PATH_RE.sub(_replace, message) + lines.append("Traceback (most recent call last):") + for frame in te.stack: + safe_path = _sanitize_path(frame.filename) + lines.append(f' File "{safe_path}", line {frame.lineno}, in {frame.name}') + # Intentionally omit frame.line — source code context could + # contain credentials, customer data, or other sensitive values + # Only emit the exception type, not the message + exc_name = te.exc_type.__qualname__ if te.exc_type else "UnknownException" + lines.append(exc_name) -def sanitize_traceback_string(tb_string: str) -> str: - """Sanitize a formatted traceback string, stripping customer paths.""" - lines = tb_string.splitlines() - sanitized: List[str] = [] - for line in lines: - m = _FRAME_RE.match(line) - if m: - prefix, filepath, suffix = m.groups() - sanitized.append(f'{prefix}"{_sanitize_path(filepath)}",{suffix}') - else: - sanitized.append(line) - return "\n".join(sanitized) + return lines def sanitize_exception(exc: BaseException) -> str: - """Format and sanitize a live exception's full traceback.""" - raw = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) - return sanitize_traceback_string(raw) + """Format and sanitize a live exception using only allowlisted fields.""" + te = traceback.TracebackException.from_exception(exc) + return "\n".join(_sanitize_traceback(te)) diff --git a/src/deadline/client/api/_telemetry.py b/src/deadline/client/api/_telemetry.py index b81bf112a..05638d893 100644 --- a/src/deadline/client/api/_telemetry.py +++ b/src/deadline/client/api/_telemetry.py @@ -28,6 +28,7 @@ get_boto3_client, get_boto3_session, ) +from ._stack_trace_sanitizer import sanitize_exception from ..config import config_file from .. import version @@ -171,12 +172,9 @@ def record_error_with_trace( extra_details: Optional[dict] = None, from_gui: bool = False, ) -> None: - from ._stack_trace_sanitizer import sanitize_exception, sanitize_message - event_details: dict = { "exception_type": type(exc).__qualname__, "exception_scope": exception_scope, - "message": sanitize_message(str(exc)), "stack_trace": sanitize_exception(exc), } if extra_details: diff --git a/test/unit/deadline_client/api/test_api_telemetry.py b/test/unit/deadline_client/api/test_api_telemetry.py index 81501f01d..75812f88e 100644 --- a/test/unit/deadline_client/api/test_api_telemetry.py +++ b/test/unit/deadline_client/api/test_api_telemetry.py @@ -20,8 +20,6 @@ ) from deadline.client.api._stack_trace_sanitizer import ( _sanitize_path, - sanitize_message, - sanitize_traceback_string, sanitize_exception, ) from deadline.job_attachments.progress_tracker import SummaryStatistics @@ -66,10 +64,7 @@ def test_opt_out_config(fresh_deadline_config): client.record_hashing_summary(SummaryStatistics(), from_gui=True) client.record_upload_summary(SummaryStatistics(), from_gui=False) client.record_error({}, str(type(Exception))) - try: - raise RuntimeError("opt-out test") - except RuntimeError as exc: - client.record_error_with_trace(exc, "test") + client.record_error_with_trace(RuntimeError("opt-out test"), "test") @pytest.mark.parametrize( @@ -102,10 +97,7 @@ def test_opt_out_env_var(fresh_deadline_config, monkeypatch, env_var_value): client.record_hashing_summary(SummaryStatistics(), from_gui=True) client.record_upload_summary(SummaryStatistics(), from_gui=False) client.record_error({}, str(type(Exception))) - try: - raise RuntimeError("opt-out test") - except RuntimeError as exc: - client.record_error_with_trace(exc, "test") + client.record_error_with_trace(RuntimeError("opt-out test"), "test") def test_initialize_failure_then_success(fresh_deadline_config): @@ -317,8 +309,8 @@ def test_record_error_with_trace(fresh_deadline_config, mock_telemetry_client): assert event.event_type == "com.amazon.rum.deadline.error" assert event.event_details["exception_type"] == "ValueError" assert event.event_details["exception_scope"] == "test_scope" - assert event.event_details["message"] == "something broke" - assert "ValueError: something broke" in event.event_details["stack_trace"] + assert "ValueError" in event.event_details["stack_trace"] + assert "message" not in event.event_details assert event.event_details["usage_mode"] == "CLI" assert event.event_details["accountId"] == "111122223333" @@ -346,30 +338,6 @@ def test_record_error_with_trace_extra_details(fresh_deadline_config, mock_telem assert event.event_details["exception_type"] == "RuntimeError" -def test_record_error_with_trace_sanitizes_message(fresh_deadline_config, mock_telemetry_client): - """Test that customer paths in exception messages are sanitized""" - # GIVEN - queue_mock = MagicMock() - mock_telemetry_client.event_queue = queue_mock - - try: - raise FileNotFoundError( - "[Errno 2] No such file or directory: '/home/customer/secret/render.py'" - ) - except FileNotFoundError as exc: - with patch.object( - mock_telemetry_client, "get_account_id", return_value="111122223333" - ), patch.object(api._telemetry, "get_boto3_session"): - # WHEN - mock_telemetry_client.record_error_with_trace(exc, "test") - - # THEN - event: TelemetryEvent = queue_mock.put_nowait.call_args[0][0] - assert "customer" not in event.event_details["message"] - assert "secret" not in event.event_details["message"] - assert "render.py" in event.event_details["message"] - - def test_record_error_with_trace_sanitizes_paths(fresh_deadline_config, mock_telemetry_client): """Test that customer paths are stripped from the stack trace""" # GIVEN @@ -601,49 +569,14 @@ def test_string_input(self): assert _sanitize_path("") == "" -SAMPLE_TB = """Traceback (most recent call last): - File "/home/jsmith/renders/s3-bucket-acme/venv/lib/python3.11/site-packages/deadline/client/api/_telemetry.py", line 42, in record_event - self._send(event) - File "/home/jsmith/renders/s3-bucket-acme/custom_scripts/submit.py", line 10, in main - client.submit_job() - File "/home/jsmith/renders/s3-bucket-acme/venv/lib/python3.11/site-packages/botocore/client.py", line 530, in _api_call - return self._make_api_call(operation_name, kwargs) -ValueError: something went wrong""" - - -class TestSanitizeTracebackString: - def test_known_packages_preserved(self): - result = sanitize_traceback_string(SAMPLE_TB) - assert '"deadline/client/api/_telemetry.py", line 42, in record_event' in result - assert '"botocore/client.py", line 530, in _api_call' in result - - def test_customer_path_stripped(self): - result = sanitize_traceback_string(SAMPLE_TB) - assert '"submit.py", line 10, in main' in result - - def test_no_customer_data_leaked(self): - result = sanitize_traceback_string(SAMPLE_TB) - assert "jsmith" not in result - assert "s3-bucket-acme" not in result - assert "/home/" not in result - - def test_error_message_preserved(self): - result = sanitize_traceback_string(SAMPLE_TB) - assert "ValueError: something went wrong" in result - - def test_structure_preserved(self): - result = sanitize_traceback_string(SAMPLE_TB) - assert "Traceback (most recent call last):" in result - assert " self._send(event)" in result - - class TestSanitizeException: def test_live_exception(self): try: raise RuntimeError("test error") except RuntimeError as e: result = sanitize_exception(e) - assert "RuntimeError: test error" in result + assert "RuntimeError" in result + assert "Traceback (most recent call last):" in result def test_no_absolute_paths(self): try: @@ -655,39 +588,46 @@ def test_no_absolute_paths(self): path = line.split('"')[1] assert not path.startswith("/"), f"Absolute path leaked: {path}" + def test_no_source_code_context(self): + """Source code lines are omitted to avoid leaking customer data.""" + try: + customer_secret = "sensitive" # noqa: F841 + raise ValueError("fail") + except ValueError as e: + result = sanitize_exception(e) + assert "customer_secret" not in result + assert "sensitive" not in result -class TestSanitizeMessage: - def test_single_quoted_unix_path(self): - msg = "[Errno 2] No such file or directory: '/home/customer/secret/render.py'" - result = sanitize_message(msg) - assert "customer" not in result - assert "secret" not in result - assert "'render.py'" in result + def test_message_omitted(self): + """Exception messages are not included — only the type.""" + try: + raise FileNotFoundError("/home/customer/secret/file.txt") + except FileNotFoundError as e: + result = sanitize_exception(e) + assert "customer" not in result + assert "secret" not in result + assert "FileNotFoundError" in result - def test_double_quoted_unix_path(self): - msg = 'Permission denied: "/mnt/customer-bucket/output.exr"' - result = sanitize_message(msg) - assert "customer-bucket" not in result - assert '"output.exr"' in result + def test_chained_exception_cause(self): + try: + try: + raise KeyError("original") + except KeyError as e: + raise ValueError("wrapper") from e + except ValueError as e: + result = sanitize_exception(e) + assert "KeyError" in result + assert "ValueError" in result + assert "direct cause" in result - def test_windows_path(self): - msg = "Cannot open 'C:\\Users\\customer\\Documents\\scene.blend'" - result = sanitize_message(msg) - assert "customer" not in result - assert "'scene.blend'" in result - - def test_known_package_preserved(self): - msg = "Error in '/home/user/venv/lib/python3.11/site-packages/deadline/client/api/_telemetry.py'" - result = sanitize_message(msg) - assert "user" not in result - assert "deadline/client/api/_telemetry.py" in result - - def test_no_path_unchanged(self): - msg = "ValueError: something went wrong" - assert sanitize_message(msg) == msg - - def test_unquoted_unix_path(self): - msg = "Failed to process /home/user/job/input.txt" - result = sanitize_message(msg) - # Unquoted paths are not sanitized — Python exceptions typically quote paths - assert result == msg + def test_chained_exception_context(self): + try: + try: + raise KeyError("original") + except KeyError: + raise ValueError("during handling") + except ValueError as e: + result = sanitize_exception(e) + assert "KeyError" in result + assert "ValueError" in result + assert "During handling" in result