diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d682b944..0fb5fdcb 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -155,21 +155,26 @@ class SecurityFilter(logging.Filter): """Filter out sensitive information from log records.""" try: message = record.getMessage() + standalone_patterns = [ + r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives) + r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens + r"nsec[a-z0-9]+" # Nostr Public / Private Key + ] + for pattern in standalone_patterns: + message = re.sub( + pattern, f"[REDACTED]", message, flags=re.IGNORECASE + ) for key in self.SENSITIVE_KEYS: if key in message.lower(): - patterns = [ - rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value - rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value' - r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token - r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens + key_patterns = [ + rf'{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)', # key:value or key=value (including any variant with spaces) + rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces) ] - - for pattern in patterns: + for pattern in key_patterns: message = re.sub( pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE ) - record.msg = message record.args = () diff --git a/tests/unit/test_logging_securityfilter.py b/tests/unit/test_logging_securityfilter.py new file mode 100644 index 00000000..d73e89bf --- /dev/null +++ b/tests/unit/test_logging_securityfilter.py @@ -0,0 +1,112 @@ +"""Unit tests for the logging SecurityFilter. + +This module tests that the SecurityFilter correctly identifies and redacts +sensitive information from log messages without causing false positives. + +""" + +import logging +import pytest + +from routstr.core.logging import SecurityFilter + + +@pytest.fixture +def security_filter() -> SecurityFilter: + """Provide an instance of the SecurityFilter for testing.""" + return SecurityFilter() + + +@pytest.fixture +def filter_message(security_filter: SecurityFilter): + """A helper fixture to apply the filter to a message string.""" + def _filter(msg: str) -> str: + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + security_filter.filter(record) + return record.getMessage() + return _filter + + +def test_redacts_unquoted_key_value_pairs(filter_message) -> None: + """Test that an unquoted key-value pair is correctly redacted.""" + original = "Processing request with api_key=sk-12345abcdef" + expected = "Processing request with api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_quoted_key_value_pairs(filter_message) -> None: + """Test that a quoted token is correctly redacted.""" + original = 'User authenticated with token="cashuA123abc"' + expected = 'User authenticated with token: [REDACTED]' + assert filter_message(original) == expected + + +def test_redacts_bearer_token(filter_message) -> None: + """Test that a Bearer token of sufficient length is redacted.""" + original = "Authorization: Bearer abc1234567890xyzabcdefg" + expected = "Authorization: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_cashu_token(filter_message) -> None: + """Test that a Cashu token is redacted.""" + original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI" + expected = "Received [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_nsec_key(filter_message) -> None: + """Test that a full-length Nostr private key is redacted.""" + original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f" + expected = "Private key is [REDACTED]" + assert filter_message(original) == expected + + +def test_ignores_non_sensitive_message(filter_message) -> None: + """Test that a message with no sensitive data is left untouched.""" + original = "No token pricing configured, using base cost" + expected = "No token pricing configured, using base cost" + assert filter_message(original) == expected + + +def test_multiple_secrets_in_one_message(filter_message) -> None: + """Test that multiple different secrets in one message are all redacted.""" + original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"' + expected = "Auth with [REDACTED] and api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_key_with_no_value(filter_message) -> None: + """Test that a key with no value is not redacted.""" + original = "Request contains api_key and secret." + expected = "Request contains api_key and secret." + assert filter_message(original) == expected + + +def test_redacts_key_value_with_spaces(filter_message) -> None: + """Test that key-value pairs with extra spaces are correctly redacted.""" + original = "Auth info: api_key = 'sk-12345'" + expected = "Auth info: api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_is_case_insensitive_for_keys(filter_message) -> None: + """Test that key matching is case-insensitive.""" + original = "TOKEN=sk-abcdef12345" + expected = "token: [REDACTED]" + assert filter_message(original) == expected + + +def test_is_case_insensitive_for_standalone(filter_message) -> None: + """Test that standalone matching is case-insensitive.""" + original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc" + expected = "Using [REDACTED] and [REDACTED]" + assert filter_message(original) == expected \ No newline at end of file