From 69551f0c7705b0c843621ce56daa874fd4e7b472 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 28 Aug 2026 19:09:13 +0800 Subject: [PATCH] fix: redact database credentials from exception logs --- .../test_logging_security_contract.py | 40 +++++++++++++++++++ packages/shared-python/shared/core/logging.py | 37 +++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 apps/api/tests/contract/test_logging_security_contract.py diff --git a/apps/api/tests/contract/test_logging_security_contract.py b/apps/api/tests/contract/test_logging_security_contract.py new file mode 100644 index 000000000..fbe5a1e80 --- /dev/null +++ b/apps/api/tests/contract/test_logging_security_contract.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class _FakeLogfireExceptionHelper: + exception: BaseException + is_recording_exception: bool = True + + def no_record_exception(self) -> None: + self.is_recording_exception = False + + +def test_redact_sensitive_text_masks_postgresql_url_credentials() -> None: + from shared.core.logging import redact_sensitive_text + + message = ( + "invalid dsn after " + "postgresql+psycopg2://postgres:super-secret@database.example:5432/knowhere" + ) + + redacted = redact_sensitive_text(message) + + assert "super-secret" not in redacted + assert "postgresql+psycopg2://[REDACTED]@database.example:5432/knowhere" in redacted + + +def test_logfire_callback_does_not_export_exception_with_database_credentials() -> None: + from shared.core.logging import _downgrade_expected_logfire_exception + + helper = _FakeLogfireExceptionHelper( + exception=RuntimeError( + "invalid dsn: postgresql://postgres:super-secret@database.example/knowhere" + ) + ) + + _downgrade_expected_logfire_exception(helper) # pyright: ignore[reportArgumentType] + + assert helper.is_recording_exception is False diff --git a/packages/shared-python/shared/core/logging.py b/packages/shared-python/shared/core/logging.py index bb246f18e..b4be391cf 100644 --- a/packages/shared-python/shared/core/logging.py +++ b/packages/shared-python/shared/core/logging.py @@ -1,4 +1,5 @@ import logging +import re import sys from contextlib import contextmanager from contextvars import ContextVar @@ -15,6 +16,10 @@ "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<8} | {extra[event]} | {message}" ) _DEVELOPMENT_CONSOLE_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message} {extra}" +_DATABASE_URL_WITH_CREDENTIALS_PATTERN = re.compile( + r"(?Ppostgres(?:ql)?(?:\+[^:/\s]+)?://)(?P[^/\s@]+@)", + re.IGNORECASE, +) class LogEvent(Enum): @@ -79,6 +84,20 @@ def get_log_context() -> Dict[str, Any]: return _log_context.get().copy() +def redact_sensitive_text(value: object) -> str: + """Redact credentials embedded in PostgreSQL URLs before they are logged.""" + text = str(value) + return _DATABASE_URL_WITH_CREDENTIALS_PATTERN.sub( + r"\g[REDACTED]@", + text, + ) + + +def contains_database_credentials(value: object) -> bool: + """Return whether text contains a PostgreSQL URL user-info component.""" + return _DATABASE_URL_WITH_CREDENTIALS_PATTERN.search(str(value)) is not None + + def _is_expected_client_exception(exception: BaseException) -> bool: """Identify handled 4xx exceptions that should stay warnings in Logfire.""" from shared.core.exceptions.knowhere_exception import KnowhereException @@ -105,6 +124,13 @@ def _downgrade_expected_logfire_exception( helper: "ExceptionCallbackHelper", ) -> None: """Prevent handled client errors from creating Logfire exception issues.""" + if contains_database_credentials(helper.exception): + # Logfire keeps exception.message and exception.stacktrace as safe keys, + # so its normal scrubber does not redact credentials embedded in them. + # Do not export the exception object when it contains a database URL. + helper.no_record_exception() + return + if not _is_expected_client_exception(helper.exception): return @@ -280,6 +306,11 @@ def emit(self, record: logging.LogRecord) -> None: frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log( - level, record.getMessage() - ) + message = redact_sensitive_text(record.getMessage()) + exception = record.exc_info + if exception is not None and contains_database_credentials(exception[1]): + # The exception object would otherwise be serialized by Logfire with + # its raw message and stacktrace, bypassing message scrubbing. + exception = None + + logger.opt(depth=depth, exception=exception).log(level, message)