From 38a757fc67d4f5b83660a61681ec3fc030491f97 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:56:16 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?= =?UTF-8?q?]=20Sanitize=20exceptions=20in=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrapped exception objects in `sanitize_for_log()` before logging. - Prevents potential leakage of sensitive data (e.g., tokens in URLs) from exception messages. - Added regression test `tests/test_exception_logging.py`. Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- main.py | 22 ++++++++++----- tests/test_exception_logging.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests/test_exception_logging.py diff --git a/main.py b/main.py index 20a98621..1768c1d4 100644 --- a/main.py +++ b/main.py @@ -449,11 +449,15 @@ def validate_folder_url(url: str) -> bool: ) return False except (socket.gaierror, ValueError, OSError) as e: - log.warning(f"Failed to resolve/validate domain {hostname}: {e}") + log.warning( + f"Failed to resolve/validate domain {hostname}: {sanitize_for_log(e)}" + ) return False except Exception as e: - log.warning(f"Failed to validate URL {sanitize_for_log(url)}: {e}") + log.warning( + f"Failed to validate URL {sanitize_for_log(url)}: {sanitize_for_log(e)}" + ) return False return True @@ -697,10 +701,10 @@ def check_api_access(client: httpx.Client, profile_id: str) -> bool: f"{Colors.FAIL} Please verify the Profile ID from your Control D Dashboard URL.{Colors.ENDC}" ) else: - log.error(f"API Access Check Failed ({code}): {e}") + log.error(f"API Access Check Failed ({code}): {sanitize_for_log(e)}") return False except httpx.RequestError as e: - log.error(f"Network Error during access check: {e}") + log.error(f"Network Error during access check: {sanitize_for_log(e)}") return False @@ -852,7 +856,7 @@ def _fetch_folder_rules(folder_id: str) -> List[str]: except Exception as e: # We log error but don't stop the whole process; # individual folder failure shouldn't crash the sync - log.warning(f"Error fetching rules for folder {folder_id}: {e}") + log.warning(f"Error fetching rules for folder {folder_id}: {sanitize_for_log(e)}") return [] try: @@ -888,7 +892,9 @@ def _fetch_folder_rules(folder_id: str) -> List[str]: all_rules.update(result) except Exception as e: folder_id = future_to_folder[future] - log.warning(f"Failed to fetch rules for folder ID {folder_id}: {e}") + log.warning( + f"Failed to fetch rules for folder ID {folder_id}: {sanitize_for_log(e)}" + ) log.info(f"Total existing rules across all folders: {len(all_rules)}") return all_rules @@ -1024,7 +1030,9 @@ def create_folder( ) return str(grp["PK"]) except Exception as e: - log.warning(f"Error fetching groups on attempt {attempt}: {e}") + log.warning( + f"Error fetching groups on attempt {attempt}: {sanitize_for_log(e)}" + ) if attempt < MAX_RETRIES: wait_time = FOLDER_CREATION_DELAY * (attempt + 1) diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py new file mode 100644 index 00000000..32f8c718 --- /dev/null +++ b/tests/test_exception_logging.py @@ -0,0 +1,47 @@ +import unittest +from unittest.mock import MagicMock, patch +import httpx +import main + +class TestExceptionLogging(unittest.TestCase): + def setUp(self): + # Set a dummy token for redaction testing + self.original_token = main.TOKEN + main.TOKEN = "SECRET_TOKEN_123" + + def tearDown(self): + main.TOKEN = self.original_token + + @patch('main.log') + def test_check_api_access_redacts_exception(self, mock_log): + # Setup + client = MagicMock() + profile_id = "test_profile" + + # Simulate a RequestError containing the secret token + # This simulates a situation where the exception message might contain the URL with token + error_message = "Connection error to https://api.controld.com?token=SECRET_TOKEN_123" + client.get.side_effect = httpx.RequestError(error_message, request=MagicMock()) + + # Action + main.check_api_access(client, profile_id) + + # Assertion + # We expect log.error to be called + self.assertTrue(mock_log.error.called, "log.error should have been called") + + # Check the arguments passed to log.error + # The code is: log.error(f"Network Error during access check: {e}") + # So the first argument should contain the redacted message + # Since it is an f-string, the redaction must happen BEFORE formatting or inside the f-string expression + args = mock_log.error.call_args[0] + logged_message = args[0] + + # Verify secret is NOT present + self.assertNotIn("SECRET_TOKEN_123", logged_message, "Secret token leaked in logs!") + + # Verify redaction placeholder IS present + self.assertIn("[REDACTED]", logged_message, "Redaction placeholder missing!") + +if __name__ == '__main__': + unittest.main() From 9fd79e4dcd64b8c8279f1c73e59ff9dfa8fde7da Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Mon, 9 Feb 2026 05:29:01 -0600 Subject: [PATCH 2/7] Update tests/test_exception_logging.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/test_exception_logging.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py index 32f8c718..ab7298a2 100644 --- a/tests/test_exception_logging.py +++ b/tests/test_exception_logging.py @@ -4,15 +4,8 @@ import main class TestExceptionLogging(unittest.TestCase): - def setUp(self): - # Set a dummy token for redaction testing - self.original_token = main.TOKEN - main.TOKEN = "SECRET_TOKEN_123" - - def tearDown(self): - main.TOKEN = self.original_token - @patch('main.log') + @patch('main.TOKEN', 'SECRET_TOKEN_123') def test_check_api_access_redacts_exception(self, mock_log): # Setup client = MagicMock() From f0346ccdb9398874e55647886bbdb85d55c79dbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:37:19 +0000 Subject: [PATCH 3/7] Initial plan From 01b3565f88d8d1b8b47d7c28fcc56dd1e12e9ace Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:44:07 +0000 Subject: [PATCH 4/7] Apply PR #180 review feedback - sanitize all exception logs and improve tests Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- main.py | 11 +- tests/test_exception_logging.py | 206 ++++++++++++++++++++++++++++++-- 2 files changed, 205 insertions(+), 12 deletions(-) diff --git a/main.py b/main.py index 1768c1d4..5baa92dc 100644 --- a/main.py +++ b/main.py @@ -606,7 +606,8 @@ def _retry_request(request_func, max_retries=MAX_RETRIES, delay=RETRY_DELAY): raise wait_time = delay * (2**attempt) log.warning( - f"Request failed (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {wait_time}s..." + f"Request failed (attempt {attempt + 1}/{max_retries}): " + f"{sanitize_for_log(e)}. Retrying in {wait_time}s..." ) time.sleep(wait_time) @@ -948,7 +949,8 @@ def _validate_and_fetch(url: str): sys.stderr.flush() log.warning( - f"Failed to pre-fetch {sanitize_for_log(futures[future])}: {e}" + f"Failed to pre-fetch {sanitize_for_log(futures[future])}: " + f"{sanitize_for_log(e)}" ) # Restore progress bar after warning render_progress_bar(completed, total, "Warming up cache", prefix="⏳") @@ -1013,7 +1015,10 @@ def create_folder( ) return str(grp["PK"]) except Exception as e: - log.debug(f"Could not extract ID from POST response: {e}") + log.debug( + f"Could not extract ID from POST response: " + f"{sanitize_for_log(e)}" + ) # 2. Fallback: Poll for the new folder (The Robust Retry Logic) for attempt in range(MAX_RETRIES + 1): diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py index ab7298a2..4f327d37 100644 --- a/tests/test_exception_logging.py +++ b/tests/test_exception_logging.py @@ -1,40 +1,228 @@ +"""Tests for exception logging and sanitization. + +This module verifies that sensitive data (tokens, secrets, credentials) in +exception messages are properly redacted before being written to logs. +""" + import unittest from unittest.mock import MagicMock, patch import httpx import main + class TestExceptionLogging(unittest.TestCase): + """Test suite for exception logging with sanitization.""" + @patch('main.log') - @patch('main.TOKEN', 'SECRET_TOKEN_123') + @patch('main.TOKEN', 'TEST_TOKEN_XYZ') def test_check_api_access_redacts_exception(self, mock_log): + """Test that check_api_access redacts tokens in exception messages.""" # Setup client = MagicMock() profile_id = "test_profile" - # Simulate a RequestError containing the secret token - # This simulates a situation where the exception message might contain the URL with token - error_message = "Connection error to https://api.controld.com?token=SECRET_TOKEN_123" - client.get.side_effect = httpx.RequestError(error_message, request=MagicMock()) + # Simulate a RequestError containing the test token + # This simulates a situation where the exception message might + # contain the URL with token + error_message = ( + "Connection error to " + "https://api.controld.com?token=TEST_TOKEN_XYZ" + ) + client.get.side_effect = httpx.RequestError( + error_message, request=MagicMock() + ) # Action main.check_api_access(client, profile_id) # Assertion # We expect log.error to be called - self.assertTrue(mock_log.error.called, "log.error should have been called") + self.assertTrue( + mock_log.error.called, "log.error should have been called" + ) # Check the arguments passed to log.error # The code is: log.error(f"Network Error during access check: {e}") # So the first argument should contain the redacted message - # Since it is an f-string, the redaction must happen BEFORE formatting or inside the f-string expression + # Since it is an f-string, the redaction must happen BEFORE + # formatting or inside the f-string expression args = mock_log.error.call_args[0] logged_message = args[0] # Verify secret is NOT present - self.assertNotIn("SECRET_TOKEN_123", logged_message, "Secret token leaked in logs!") + self.assertNotIn( + "TEST_TOKEN_XYZ", logged_message, "Secret token leaked in logs!" + ) # Verify redaction placeholder IS present - self.assertIn("[REDACTED]", logged_message, "Redaction placeholder missing!") + self.assertIn( + "[REDACTED]", logged_message, "Redaction placeholder missing!" + ) + + @patch('main.log') + @patch('main.socket.getaddrinfo') + def test_validate_folder_url_redacts_exception( + self, mock_getaddrinfo, mock_log + ): + """Test validate_folder_url redacts sensitive data in exceptions.""" + # Setup - simulate an exception during DNS resolution that + # contains a sensitive URL + test_url = "https://example.com/list?api_key=SECRET_API_KEY_456" + + # Create an exception that might contain the URL + mock_getaddrinfo.side_effect = OSError( + f"Failed to resolve {test_url}" + ) + + # Action + result = main.validate_folder_url(test_url) + + # Assertions + self.assertFalse(result, "URL validation should fail") + self.assertTrue( + mock_log.warning.called, "log.warning should have been called" + ) + + # Get all warning calls + warning_calls = mock_log.warning.call_args_list + + # Check that none of the logged messages contain the secret + for call in warning_calls: + logged_message = str(call[0][0]) + self.assertNotIn( + "SECRET_API_KEY_456", + logged_message, + "Secret API key leaked in logs!" + ) + + @patch('main.log') + def test_fetch_folder_rules_redacts_exception(self, mock_log): + """Test exception handlers in get_all_existing_rules redact.""" + # Setup + client = MagicMock() + profile_id = "test_profile" + folder_id = "folder_123" + + # Create the exception with sensitive data + error_with_token = ValueError( + "API error at https://api.controld.com?token=TEST_SECRET_789" + ) + + # Mock _api_get to succeed for root but fail for folder rules + with patch('main._api_get') as mock_api_get: + def api_get_side_effect(client_arg, url): + if url.endswith("/rules"): + # Root rules succeed + mock_response = MagicMock() + mock_response.json.return_value = {"body": {"rules": []}} + return mock_response + else: + # Folder rules fail with our error + raise error_with_token + + mock_api_get.side_effect = api_get_side_effect + + # Provide known_folders to ensure _fetch_folder_rules is called + main.get_all_existing_rules( + client, profile_id, known_folders={"Test Folder": folder_id} + ) + + # Assertion - verify the exception was sanitized + # Either log.warning was called from _fetch_folder_rules (line 859) + # or from the outer exception handler (line 895-896) + self.assertTrue( + mock_log.warning.called, "log.warning should have been called" + ) + + # Get warning calls + warning_calls = mock_log.warning.call_args_list + + # Check that the secret is redacted in all warnings + for call in warning_calls: + logged_message = str(call[0][0]) + self.assertNotIn( + "TEST_SECRET_789", + logged_message, + "Secret token leaked in logs!" + ) + + @patch('main.log') + @patch('main._api_post') + def test_create_folder_redacts_exception(self, mock_api_post, mock_log): + """Test that create_folder redacts tokens in debug log messages.""" + # Setup + client = MagicMock() + profile_id = "test_profile" + folder_name = "Test Folder" + + # Mock the API response to return data but cause an exception + # when trying to extract the ID + mock_response = MagicMock() + mock_response.json.return_value = { + "body": { + "groups": "invalid_format_causes_exception_with_token=ABC123" + } + } + mock_api_post.return_value = mock_response + + # We need to also mock list_existing_folders to avoid the fallback + with patch('main.list_existing_folders') as mock_list: + mock_list.return_value = {} + + # Action - this will try the direct ID extraction, fail, and log + try: + main.create_folder(client, profile_id, folder_name) + except Exception: + pass # We expect this might fail, we're testing the logging + + # Assertion - if debug was called, verify redaction + if mock_log.debug.called: + debug_calls = mock_log.debug.call_args_list + + for call in debug_calls: + logged_message = str(call) + # The message should not contain raw exception data + # that might have tokens. Note: This is checking the + # general pattern. In real scenarios, the exception + # might contain URLs with tokens + pass # Debug logging happened with sanitization + + @patch('main.log') + def test_retry_request_redacts_exception(self, mock_log): + """Test that _retry_request redacts tokens in warning messages.""" + # Setup - create an exception with sensitive data + error_with_token = httpx.RequestError( + "Connection to https://api.example.com?secret=HIDDEN_KEY_999", + request=MagicMock() + ) + + # Create a failing request function + def failing_request(): + raise error_with_token + + # Action + try: + main._retry_request(failing_request, max_retries=2, delay=0.01) + except httpx.RequestError: + pass # Expected to fail after retries + + # Assertion + self.assertTrue( + mock_log.warning.called, "log.warning should have been called" + ) + + # Get all warning calls + warning_calls = mock_log.warning.call_args_list + + # Check that the secret is redacted + for call in warning_calls: + logged_message = str(call[0][0]) + self.assertNotIn( + "HIDDEN_KEY_999", + logged_message, + "Secret key leaked in logs!" + ) + if __name__ == '__main__': unittest.main() From 1e697aff14db4114843e8a586bb1958a0930f30f Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Mon, 9 Feb 2026 06:02:56 -0600 Subject: [PATCH 5/7] Update tests/test_exception_logging.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_exception_logging.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py index 4f327d37..07bf95b0 100644 --- a/tests/test_exception_logging.py +++ b/tests/test_exception_logging.py @@ -171,7 +171,9 @@ def test_create_folder_redacts_exception(self, mock_api_post, mock_log): # Action - this will try the direct ID extraction, fail, and log try: - main.create_folder(client, profile_id, folder_name) + # Pass placeholder MagicMock instances for additional required arguments. + # We only care about logging/redaction behavior, not the actual folder creation. + main.create_folder(client, profile_id, folder_name, MagicMock(), MagicMock()) except Exception: pass # We expect this might fail, we're testing the logging From f2eb41dfc8edbe492342de2e4ee97b02dcc0bd71 Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Mon, 9 Feb 2026 06:03:04 -0600 Subject: [PATCH 6/7] Update tests/test_exception_logging.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_exception_logging.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py index 07bf95b0..f614a7ad 100644 --- a/tests/test_exception_logging.py +++ b/tests/test_exception_logging.py @@ -182,12 +182,17 @@ def test_create_folder_redacts_exception(self, mock_api_post, mock_log): debug_calls = mock_log.debug.call_args_list for call in debug_calls: - logged_message = str(call) + # Extract the logged message from the first positional arg + logged_message = str(call[0][0]) # The message should not contain raw exception data - # that might have tokens. Note: This is checking the - # general pattern. In real scenarios, the exception - # might contain URLs with tokens - pass # Debug logging happened with sanitization + # that might have tokens. In this test, the mocked + # response includes "token=ABC123" in the data, so we + # verify that value is not leaked to the logs. + self.assertNotIn( + "ABC123", + logged_message, + "Token value leaked in debug logs!" + ) @patch('main.log') def test_retry_request_redacts_exception(self, mock_log): From 8879fc22a0b35b49cb0217fc75131f309ab15d7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:20:27 +0000 Subject: [PATCH 7/7] Fix test_create_folder_redacts_exception to properly verify sanitization Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- tests/test_exception_logging.py | 86 ++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/tests/test_exception_logging.py b/tests/test_exception_logging.py index f614a7ad..555c8a6e 100644 --- a/tests/test_exception_logging.py +++ b/tests/test_exception_logging.py @@ -148,52 +148,72 @@ def api_get_side_effect(client_arg, url): @patch('main.log') @patch('main._api_post') - def test_create_folder_redacts_exception(self, mock_api_post, mock_log): + @patch('main._api_get') + def test_create_folder_redacts_exception( + self, mock_api_get, mock_api_post, mock_log + ): """Test that create_folder redacts tokens in debug log messages.""" # Setup client = MagicMock() profile_id = "test_profile" folder_name = "Test Folder" - # Mock the API response to return data but cause an exception - # when trying to extract the ID + # Create an exception with a sensitive token in the message + sensitive_url = "https://api.controld.com/create?token=SECRET_XYZ_789" + error_with_token = ValueError( + f"Failed to parse response from {sensitive_url}" + ) + + # Mock json() to raise an exception with the sensitive URL mock_response = MagicMock() - mock_response.json.return_value = { - "body": { - "groups": "invalid_format_causes_exception_with_token=ABC123" - } - } + mock_response.json.side_effect = error_with_token mock_api_post.return_value = mock_response - # We need to also mock list_existing_folders to avoid the fallback - with patch('main.list_existing_folders') as mock_list: - mock_list.return_value = {} - - # Action - this will try the direct ID extraction, fail, and log - try: - # Pass placeholder MagicMock instances for additional required arguments. - # We only care about logging/redaction behavior, not the actual folder creation. - main.create_folder(client, profile_id, folder_name, MagicMock(), MagicMock()) - except Exception: - pass # We expect this might fail, we're testing the logging - - # Assertion - if debug was called, verify redaction - if mock_log.debug.called: - debug_calls = mock_log.debug.call_args_list - - for call in debug_calls: - # Extract the logged message from the first positional arg - logged_message = str(call[0][0]) - # The message should not contain raw exception data - # that might have tokens. In this test, the mocked - # response includes "token=ABC123" in the data, so we - # verify that value is not leaked to the logs. + # Mock the fallback polling to return empty results quickly + mock_get_response = MagicMock() + mock_get_response.json.return_value = {"body": {"groups": []}} + mock_api_get.return_value = mock_get_response + + # Mock time.sleep to avoid actual delays + with patch('main.time.sleep'): + # Action - this will try to call json(), catch exception, and log + result = main.create_folder(client, profile_id, folder_name, 1, 1) + + # Should return None after retries fail + self.assertIsNone(result) + + # Assertion - verify debug was called and sanitization worked + self.assertTrue( + mock_log.debug.called, + "log.debug should have been called for exception" + ) + + debug_calls = mock_log.debug.call_args_list + logged_messages = [str(call[0][0]) for call in debug_calls] + + # At least one debug message should be about the POST response error + found_relevant_log = False + for logged_message in logged_messages: + if "Could not extract ID from POST response" in logged_message: + found_relevant_log = True + # Verify the secret token is redacted self.assertNotIn( - "ABC123", + "SECRET_XYZ_789", + logged_message, + "Secret token leaked in debug logs!" + ) + # Verify redaction placeholder is present + self.assertIn( + "[REDACTED]", logged_message, - "Token value leaked in debug logs!" + "Redaction placeholder missing in debug logs!" ) + self.assertTrue( + found_relevant_log, + "Expected debug log about POST response error not found" + ) + @patch('main.log') def test_retry_request_redacts_exception(self, mock_log): """Test that _retry_request redacts tokens in warning messages."""