From 4683858cc1fd7a5224445d95dcc714f0db8152ac Mon Sep 17 00:00:00 2001 From: Andi <30079726+andreasmuellerka@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:13:02 +0200 Subject: [PATCH] Zugangsdaten in URLs im Log unkenntlich machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Passwort, das als URL-userinfo übergeben wird (schema://benutzer:passwort@host/), steht im Klartext im Log. RedactingFilter greift nicht, weil alle drei REDACTION_PATTERNS an einem Feldnamen ansetzen und die userinfo einer URL nach RFC 3986 keinen hat. Mehrere SoC-Module - json, http, homeassistant, psacc - bieten ein freies url-Feld und keine getrennten Felder für Benutzer und Passwort. Für einen Endpunkt hinter Basic Auth ist es der einzige Weg, die Zugangsdaten in die URL zu schreiben. Zwei Stellen protokollieren eine solche URL: - packages/modules/vehicles/json/soc.py gibt die konfigurierte URL auf Debug-Level aus, das landet in soc.log - packages/helpermodules/subdata.py protokolliert jede MQTT-Nachricht samt Payload, damit landet die Modulkonfiguration selbst in mqtt.log An beiden Handlern hängt bereits ein RedactingFilter. Die Absicht, Geheimnisse aus diesen Dateien herauszuhalten, ist also vorhanden - es fehlte lediglich das Muster für diese Form. Das neue Muster wirkt in redact_sensitive_info() und damit für jeden Handler, an dem bereits ein RedactingFilter hängt. Der Benutzername bleibt sichtbar, ersetzt wird nur das Passwort: Bei der Diagnose eines Authentifizierungsproblems ist die Auskunft, mit welchem Benutzer verbunden wurde, der nützliche Teil der Logzeile. Der Wertteil ist bewusst greedy. Enthält das Passwort selbst ein @, reicht der Treffer bis zum letzten @ vor dem Host statt nur bis zum ersten. In allen übrigen Fällen sind beide Varianten identisch. Schrägstrich und Whitespace bleiben ausgeschlossen, dadurch kann ein Treffer nie über die Authority hinaus in Pfad oder Query laufen - eine Mailadresse in einem Query-Parameter bleibt unangetastet, was ein Test abdeckt. Die beiden Excepthooks schreiben über open()/write() in thread_errors.log und umgehen damit das Logging-Framework und den Filter. Eine ungefangene Exception, die eine URL mit sich trägt - etwa ein InvalidURI aus einem Verbindungsaufruf -, landet dort im Klartext. Sie führen ihren Text jetzt durch dieselbe Funktion; aus print_tb() wurde format_tb(), weil sich nur ein String filtern lässt, die Ausgabe bleibt unverändert. --- packages/helpermodules/logger.py | 13 ++++++-- packages/helpermodules/logger_test.py | 46 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 packages/helpermodules/logger_test.py diff --git a/packages/helpermodules/logger.py b/packages/helpermodules/logger.py index 6b38179f36..85f7e42336 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -26,6 +26,9 @@ (r'"{field}":\s*"(.*?)"', r'"{field}": "***REDACTED***"'), # "field": "value", JSON formatted data (r'\'{field}\':\s*\'(.*?)\'', r"'{field}': '***REDACTED***'") # 'field': 'value', JSON formatted data ] +# Credentials in a URL (scheme://user:password@host/) carry no field name for REDACTION_PATTERNS +# to key on. The user name is kept, it is useful when diagnosing authentication problems. +URL_CREDENTIALS_PATTERN = (r'(\w+://[^/\s:@]*):[^/\s]+@', r'\1:***REDACTED***@') def redact_sensitive_info(message: str, additional_fields: list = None) -> str: @@ -37,6 +40,9 @@ def redact_sensitive_info(message: str, additional_fields: list = None) -> str: redacted are defined in the KNOWN_SENSITIVE_FIELDS list. The function uses predefined patterns to identify and replace the sensitive information. + Passwords given as URL credentials (scheme://user:password@host/) are redacted as well, + those carry no field name to key on. + Args: message (str): The log message to be redacted. @@ -44,6 +50,7 @@ def redact_sensitive_info(message: str, additional_fields: list = None) -> str: str: The redacted log message. """ fields_to_redact = KNOWN_SENSITIVE_FIELDS + (additional_fields or []) + message = re.sub(URL_CREDENTIALS_PATTERN[0], URL_CREDENTIALS_PATTERN[1], message) for field in fields_to_redact: for pattern, replacement in REDACTION_PATTERNS: pattern = pattern.replace('{field}', field) @@ -344,9 +351,9 @@ def threading_excepthook(args): with open(thread_errors_path, "a") as f: f.write("Uncaught exception in thread:\n") f.write(f"Type: {args.exc_type}\n") - f.write(f"Value: {args.exc_value}\n") + f.write(redact_sensitive_info(f"Value: {args.exc_value}\n")) import traceback - traceback.print_tb(args.exc_traceback, file=f) + f.write(redact_sensitive_info("".join(traceback.format_tb(args.exc_traceback)))) threading.excepthook = threading_excepthook def handle_unhandled_exception(exc_type, exc_value, exc_traceback): @@ -356,7 +363,7 @@ def handle_unhandled_exception(exc_type, exc_value, exc_traceback): with open(thread_errors_path, "a") as f: f.write("Uncaught exception:\n") f.write(f"Type: {exc_type}\n") - f.write(f"Value: {exc_value}\n") + f.write(redact_sensitive_info(f"Value: {exc_value}\n")) f.write(f"Traceback:{exc_traceback}\n") sys.excepthook = handle_unhandled_exception diff --git a/packages/helpermodules/logger_test.py b/packages/helpermodules/logger_test.py new file mode 100644 index 0000000000..3982367632 --- /dev/null +++ b/packages/helpermodules/logger_test.py @@ -0,0 +1,46 @@ +import pytest + +from helpermodules.logger import redact_sensitive_info + + +@pytest.mark.parametrize("message, expected", [ + pytest.param("connection to ws://openwb:secret@ocpp.example.com/v16/ established", + "connection to ws://openwb:***REDACTED***@ocpp.example.com/v16/ established", + id="websocket url"), + pytest.param("wss://openwb:secret@ocpp.example.com:443/v16/", + "wss://openwb:***REDACTED***@ocpp.example.com:443/v16/", + id="port is kept"), + pytest.param('{"url": "ws://openwb:secret@ocpp.example.com/v16/", "version": "1.6"}', + '{"url": "ws://openwb:***REDACTED***@ocpp.example.com/v16/", "version": "1.6"}', + id="url embedded in json"), + pytest.param("ws://user:p@ssword@host/", + "ws://user:***REDACTED***@host/", + id="at sign within password"), + pytest.param("ws://openwb:***REDACTED***@ocpp.example.com/v16/", + "ws://openwb:***REDACTED***@ocpp.example.com/v16/", + id="already redacted"), +]) +def test_redact_url_credentials(message, expected): + assert redact_sensitive_info(message) == expected + + +@pytest.mark.parametrize("message", [ + pytest.param("http://192.168.1.5:8080/api?value=1", id="url without credentials"), + pytest.param("https://api.example.com/mail?to=someone@example.com", id="at sign in query"), + pytest.param("http://host:8080 unreachable, contact someone@example.com", id="mail address after url"), +]) +def test_keep_url_without_credentials(message): + assert redact_sensitive_info(message) == message + + +def test_redact_url_credentials_and_known_field(): + message = '{"url": "ws://openwb:secret@ocpp.example.com/v16/", "password": "abc123"}' + expected = '{"url": "ws://openwb:***REDACTED***@ocpp.example.com/v16/", "password": "***REDACTED***"}' + + assert redact_sensitive_info(message) == expected + + +def test_redact_url_credentials_with_sensitive_field_as_user_name(): + # If the user name matches an entry of KNOWN_SENSITIVE_FIELDS, the field pattern applies on + # top and truncates the url. The password is removed in that case as well. + assert redact_sensitive_info("ws://token:secret@ocpp.example.com/v16/") == "ws://token=***REDACTED***"