diff --git a/src/phlo/exceptions.py b/src/phlo/exceptions.py index 7eb9c69ca..be5b4f26c 100644 --- a/src/phlo/exceptions.py +++ b/src/phlo/exceptions.py @@ -4,8 +4,34 @@ Structured error classes with error codes, contextual messages, and suggestions. """ +import re from enum import Enum +_KEY_VALUE_SENSITIVE_PATTERN = re.compile( + r"\b(password|passwd|token|secret|api_key|apikey|credential)\b\s*[:=]\s*[^\s,;]+", + re.IGNORECASE, +) +_AUTHORIZATION_SENSITIVE_PATTERN = re.compile(r"\b(authorization|bearer)\b\s+\S+", re.IGNORECASE) +_CONNECTION_STRING_SENSITIVE_PATTERN = re.compile( + r"\b(connection\s+string)\b\s*[:=]\s*.+?(?=(?:[,;]\s+\w+\s*[:=])|\n|$)", + re.IGNORECASE, +) +_KEY_MATERIAL_SENSITIVE_PATTERN = re.compile( + r"\b(private_key|signing_key|encryption_key)\b(?:\s*[:=]\s*|\s+).+?(?=(?:[,;]\s+\w+\s*[:=])|\n|$)", + re.IGNORECASE, +) + + +def _redact_sensitive(s: str) -> str: + """Redact sensitive patterns from a string for safe output.""" + result = _KEY_MATERIAL_SENSITIVE_PATTERN.sub(r"\1=", s) + result = _CONNECTION_STRING_SENSITIVE_PATTERN.sub(r"\1=", result) + result = _KEY_VALUE_SENSITIVE_PATTERN.sub( + lambda m: f"{m.group(1)}=", + result, + ) + return _AUTHORIZATION_SENSITIVE_PATTERN.sub(r"\1 ", result) + class PhloErrorCode(Enum): """Error codes for Phlo exceptions.""" @@ -98,7 +124,9 @@ def _format_message(self, message: str) -> str: if self.cause: lines.append("") - lines.append(f"Caused by: {type(self.cause).__name__}: {str(self.cause)}") + lines.append( + f"Caused by: {type(self.cause).__name__}: {_redact_sensitive(str(self.cause))}" + ) lines.append("") lines.append(f"Documentation: {self.doc_url}") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 5ef48411f..e95778699 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -5,6 +5,7 @@ from phlo.exceptions import ( PhloError, PhloErrorCode, + _redact_sensitive, format_field_list, suggest_similar_field_names, ) @@ -85,3 +86,32 @@ def test_format_field_list_handles_empty_field_list() -> None: formatted = format_field_list([]) assert formatted == "" + + +def test_phlo_error_redacts_sensitive_data_in_cause() -> None: + """Sensitive patterns in cause messages are redacted.""" + error = PhloError( + message="Connection failed", + code=PhloErrorCode.INFRASTRUCTURE_ERROR, + cause=ValueError("connection string: password=secret123"), + ) + message = str(error) + assert "Caused by: ValueError: connection string=" in message + assert "secret123" not in message + + +def test_redact_sensitive_handles_colon_delimited_secret_values() -> None: + """Common `key: value` secret formats are redacted.""" + assert _redact_sensitive("password: secret123") == "password=" + assert _redact_sensitive("token: abc123") == "token=" + assert _redact_sensitive("connection string: Server=db;Password=hunter2") == ( + "connection string=" + ) + + +def test_redact_sensitive_removes_private_key_material() -> None: + """Key labels and their body are redacted together.""" + redacted = _redact_sensitive("private_key PEM_BLOCK_BODY_XYZ") + + assert redacted == "private_key=" + assert "PEM_BLOCK_BODY_XYZ" not in redacted