diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a9654b2a..c2bbf605 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ - Add requirement for ``TERM`` environment variable not to be ``"dumb"`` to enable colorization (`#1287 `_, thanks `@snosov1 `_). - Make ``logger.catch()`` usable as an asynchronous context manager (`#1084 `_). - Make ``logger.catch()`` compatible with asynchronous generators (`#1302 `_). +- Add new parameter ``diagnose_excludes`` to ``logger.add``, to allow excluding sensitive data from variables when ``diagnose=True`` (`#1447 `_). - Improve feedback for invalid format keys in logger format strings (`#1450 `_, thanks `@Krishnachaitanyakc `_). `0.7.3`_ (2024-12-06) diff --git a/README.md b/README.md index 470e1557..dfc87cea 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ Logging exceptions that occur in your code is important to track bugs, but it's The code: ```python -# Caution, "diagnose=True" is the default and may leak sensitive data in prod +# Caution, "diagnose=True" is the default and may leak sensitive data in prod. +# Read further for the solution logger.add("out.log", backtrace=True, diagnose=True) def func(a, b): @@ -191,6 +192,36 @@ ZeroDivisionError: division by zero Note that this feature won't work on default Python REPL due to unavailable frame data. +But for passwords and other credentials, you should exclude them using `diagnose_excludes` parameter: + +```python +logger.add("out.log", backtrace=True, diagnose=True, diagnose_excludes=["myS3cr3tP@ss!"]) + +def connect_to_db(password): + # ... + raise TimeoutError("could not connect to the database") + +password = "myS3cr3tP@ss!" +connect_to_db(password) +``` + +This will replace all occurrences of `myS3cr3tP@ss!` with `` so the result traceback would be something like + +```none +2026-03-18 17:06:44.822 | ERROR | __main__::15 - What?! +Traceback (most recent call last): + +> File "test.py", line 13, in + connect_to_db(password) + │ └ '' + └ + + File "test.py", line 9, in connect_to_db + raise TimeoutError("could not connect to the database") + +TimeoutError: could not connect to the database +``` + See also: [Security considerations when using Loguru](https://loguru.readthedocs.io/en/stable/resources/recipes.html#security-considerations-when-using-loguru). ### Structured logging as needed diff --git a/docs/resources/recipes.rst b/docs/resources/recipes.rst index fa725f30..fd41a7ff 100644 --- a/docs/resources/recipes.rst +++ b/docs/resources/recipes.rst @@ -110,11 +110,14 @@ Another danger due to external input is the possibility of a log injection attac logger.info("User " + username + " logged in.") -Note that by default, Loguru will display the value of existing variables when an ``Exception`` is logged. This is very useful for debugging but could lead to credentials appearing in log files. Make sure to turn it off in production (or set the ``LOGURU_DIAGNOSE=NO`` environment variable). +Note that by default, Loguru will display the value of existing variables when an ``Exception`` is logged. This is very useful for debugging but could lead to credentials appearing in log files. Make sure to add your sensitive data to `diagnose_excludes` or turn it off in production (or set the ``LOGURU_DIAGNOSE=NO`` environment variable). .. code:: + logger.add("out.log", diagnose=True, diagnose_excludes=["myS3cr3tP@ss!"]) + # or disable diagnose using logger.add("out.log", diagnose=False) + # or set the ``LOGURU_DIAGNOSE=NO`` environment variable Another thing you should consider is to change the access permissions of your log file. Loguru creates files using the built-in |open| function, which means by default they might be read by a different user than the owner. If this is not desirable, be sure to modify the default access rights. diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi index b15ac82e..8b4c9151 100644 --- a/loguru/__init__.pyi +++ b/loguru/__init__.pyi @@ -206,6 +206,7 @@ class Logger: serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., + diagnose_excludes: list[str] = ..., enqueue: bool = ..., context: Optional[Union[str, BaseContext]] = ..., catch: bool = ... @@ -222,6 +223,7 @@ class Logger: serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., + diagnose_excludes: list[str] = ..., enqueue: bool = ..., catch: bool = ..., context: Optional[Union[str, BaseContext]] = ..., @@ -239,6 +241,7 @@ class Logger: serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., + diagnose_excludes: list[str] = ..., enqueue: bool = ..., context: Optional[Union[str, BaseContext]] = ..., catch: bool = ..., diff --git a/loguru/_better_exceptions.py b/loguru/_better_exceptions.py index 8d88867e..2a4ce768 100644 --- a/loguru/_better_exceptions.py +++ b/loguru/_better_exceptions.py @@ -145,6 +145,7 @@ def __init__( colorize=False, backtrace=False, diagnose=True, + diagnose_excludes=None, theme=None, style=None, max_length=128, @@ -154,6 +155,11 @@ def __init__( ): self._colorize = colorize self._diagnose = diagnose + self._diagnose_excludes = ( + diagnose_excludes.split(",") + if isinstance(diagnose_excludes, str) and diagnose_excludes + else diagnose_excludes or [] + ) self._theme = theme or dict(self._default_theme) self._backtrace = backtrace self._syntax_highlighter = SyntaxHighlighter(style) @@ -345,6 +351,9 @@ def _format_value(self, v): except Exception: v = "" % type(v).__name__ + for exclude in self._diagnose_excludes: + v = v.replace(repr(exclude)[1:-1], "") + max_length = self._max_length if max_length is not None and len(v) > max_length: v = v[: max_length - 3] + "..." @@ -473,6 +482,10 @@ def _format_exception( # Remove final new line temporarily. error_message = exception_only[error_message_index][:-1] + for exclude in self._diagnose_excludes: + error_message = error_message.replace(repr(exclude)[1:-1], "") + error_message = error_message.replace(exclude, "") + if self._colorize: if ":" in error_message: exception_type, exception_value = error_message.split(":", 1) diff --git a/loguru/_defaults.py b/loguru/_defaults.py index 3ba5e12a..e080958f 100644 --- a/loguru/_defaults.py +++ b/loguru/_defaults.py @@ -42,6 +42,7 @@ def env(key, type_, default=None): LOGURU_SERIALIZE = env("LOGURU_SERIALIZE", bool, False) LOGURU_BACKTRACE = env("LOGURU_BACKTRACE", bool, True) LOGURU_DIAGNOSE = env("LOGURU_DIAGNOSE", bool, True) +LOGURU_DIAGNOSE_EXCLUDES = env("LOGURU_DIAGNOSE_EXCLUDES", str, "") LOGURU_ENQUEUE = env("LOGURU_ENQUEUE", bool, False) LOGURU_CONTEXT = env("LOGURU_CONTEXT", str, None) LOGURU_CATCH = env("LOGURU_CATCH", bool, True) diff --git a/loguru/_logger.py b/loguru/_logger.py index 8896d3f3..2efbb085 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -269,6 +269,7 @@ def add( serialize=_defaults.LOGURU_SERIALIZE, backtrace=_defaults.LOGURU_BACKTRACE, diagnose=_defaults.LOGURU_DIAGNOSE, + diagnose_excludes=_defaults.LOGURU_DIAGNOSE_EXCLUDES, enqueue=_defaults.LOGURU_ENQUEUE, context=_defaults.LOGURU_CONTEXT, catch=_defaults.LOGURU_CATCH, @@ -302,6 +303,10 @@ def add( diagnose : |bool|, optional Whether the exception trace should display the variables values to ease the debugging. This should be set to ``False`` in production to avoid leaking sensitive data. + diagnose_excludes : |list| of |str|, optional + List of strings to exclude from variables in exceptions with ``diagnose=True``. + Use this if you would like to keep more context in production logs, + but don't want to leak credentials. enqueue : |bool|, optional Whether the messages to be logged should first pass through a multiprocessing-safe queue before reaching the sink. This is useful while logging to a file through multiple @@ -1023,6 +1028,7 @@ def add( colorize=colorize, encoding=encoding, diagnose=diagnose, + diagnose_excludes=diagnose_excludes, backtrace=backtrace, hidden_frames_filename=self.catch.__code__.co_filename, prefix=exception_prefix, diff --git a/tests/exceptions/output/diagnose/excludes.txt b/tests/exceptions/output/diagnose/excludes.txt new file mode 100644 index 00000000..ee2cabd7 --- /dev/null +++ b/tests/exceptions/output/diagnose/excludes.txt @@ -0,0 +1,13 @@ + +Traceback (most recent call last): + + File "tests/exceptions/source/diagnose/excludes.py", line 23, in  + connect_to_db(password) + │ └ '' + └  + + File "tests/exceptions/source/diagnose/excludes.py", line 18, in connect_to_db + raise TimeoutError("tried to connect to " + repr(connection_string)) +  └ 'foo bar baz' + +TimeoutError: tried to connect to 'foo bar baz' diff --git a/tests/exceptions/source/diagnose/excludes.py b/tests/exceptions/source/diagnose/excludes.py new file mode 100644 index 00000000..0b91ba4b --- /dev/null +++ b/tests/exceptions/source/diagnose/excludes.py @@ -0,0 +1,25 @@ +import sys + +from loguru import logger + +logger.remove() +logger.add( + sys.stderr, + format="", + colorize=True, + backtrace=False, + diagnose=True, + diagnose_excludes=["myS3cr\n3tP@ss!"], +) + + +def connect_to_db(password): + connection_string = "foo bar " + password + " baz" + raise TimeoutError("tried to connect to " + repr(connection_string)) + + +password = "myS3cr\n3tP@ss!" +try: + connect_to_db(password) +except TimeoutError: + logger.exception("") diff --git a/tests/test_exceptions_formatting.py b/tests/test_exceptions_formatting.py index c730b149..1aeb03be 100644 --- a/tests/test_exceptions_formatting.py +++ b/tests/test_exceptions_formatting.py @@ -166,6 +166,7 @@ def test_backtrace(filename): "attributes", "chained_both", "encoding", + "excludes", "global_variable", "indentation_error", "keyword_argument", diff --git a/tests/typesafety/test_logger.yml b/tests/typesafety/test_logger.yml index 2e3bebb7..23d6974c 100644 --- a/tests/typesafety/test_logger.yml +++ b/tests/typesafety/test_logger.yml @@ -285,9 +285,9 @@ out: | main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int" [call-overload] main:2: note: Possible overload variants: - main:2: note: def add(self, sink: TextIO | Writable | Callable[[Message], None] | Handler, *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: str | BaseContext | None = ..., catch: bool = ...) -> int - main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: str | BaseContext | None = ..., loop: AbstractEventLoop | None = ...) -> int - main:2: note: def add(self, sink: str | PathLike[str], *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: str | BaseContext | None = ..., catch: bool = ..., rotation: str | int | time | timedelta | Callable[[Message, TextIO], bool] | list[str | int | time | timedelta | Callable[[Message, TextIO], bool]] | None = ..., retention: str | int | timedelta | Callable[[list[str]], None] | None = ..., compression: str | Callable[[str], None] | None = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: str | None = ..., newline: str | None = ..., closefd: bool = ..., opener: Callable[[str, int], int] | None = ...) -> int + main:2: note: def add(self, sink: TextIO | Writable | Callable[[Message], None] | Handler, *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., diagnose_excludes: list[str] = ..., enqueue: bool = ..., context: str | BaseContext | None = ..., catch: bool = ...) -> int + main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., diagnose_excludes: list[str] = ..., enqueue: bool = ..., catch: bool = ..., context: str | BaseContext | None = ..., loop: AbstractEventLoop | None = ...) -> int + main:2: note: def add(self, sink: str | PathLike[str], *, level: str | int = ..., format: str | Callable[[Record], str] = ..., filter: str | Callable[[Record], bool] | dict[str | None, str | int | bool] | None = ..., colorize: bool | None = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., diagnose_excludes: list[str] = ..., enqueue: bool = ..., context: str | BaseContext | None = ..., catch: bool = ..., rotation: str | int | time | timedelta | Callable[[Message, TextIO], bool] | list[str | int | time | timedelta | Callable[[Message, TextIO], bool]] | None = ..., retention: str | int | timedelta | Callable[[list[str]], None] | None = ..., compression: str | Callable[[str], None] | None = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: str | None = ..., newline: str | None = ..., closefd: bool = ..., opener: Callable[[str, int], int] | None = ...) -> int - case: invalid_logged_object_formatting main: |