From 7541e797e7ceb375a5ed7bfe02741b2c7f09b170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Felleg?= Date: Tue, 3 Nov 2026 13:31:58 +0100 Subject: [PATCH] fix(logging): redact credential headers in debug output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: András Felleg --- .secrets.baseline | 6 +-- ibm_cloud_sdk_core/logger.py | 39 ++++++++++++++++- test/test_logger.py | 84 +++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 3bfe744..3354880 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-06T16:25:30Z", + "generated_at": "2026-11-03T12:28:20Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -118,7 +118,7 @@ "hashed_secret": "47fcf185ee7e15fe05cae31fbe9e4ebe4a06a40d", "is_secret": false, "is_verified": false, - "line_number": 725, + "line_number": 723, "type": "Secret Keyword", "verified_result": null } @@ -128,7 +128,7 @@ "hashed_secret": "bc2f74c22f98f7b6ffbc2f67453dbfa99bce9a32", "is_secret": false, "is_verified": false, - "line_number": 83, + "line_number": 97, "type": "Secret Keyword", "verified_result": null } diff --git a/ibm_cloud_sdk_core/logger.py b/ibm_cloud_sdk_core/logger.py index 04e6996..1622ade 100644 --- a/ibm_cloud_sdk_core/logger.py +++ b/ibm_cloud_sdk_core/logger.py @@ -42,16 +42,51 @@ "thumbprint", "token_uri", ] +# Names (lower-case) of HTTP headers whose values must never be logged. +# In addition to these, any header whose name starts with REDACTED_HEADER_PREFIX is also redacted. +# NOTE: keep this list consistent with the other SDK cores (Go, Java, Node). +REDACTED_HEADER_NAMES = frozenset( + [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "secret", + ] +) +REDACTED_HEADER_PREFIX = "x-auth" + + +def _is_redacted_header(name: str) -> bool: + """Returns True iff the header 'name' (compared case-insensitively) is one whose value must be redacted.""" + lower_name = name.lower() + return lower_name in REDACTED_HEADER_NAMES or lower_name.startswith(REDACTED_HEADER_PREFIX) class LoggingFilter: """Functions used to filter messages before they are logged.""" redacted_tokens = "|".join(REDACTED_KEYWORDS) - auth_header_pattern = re.compile(r"(?m)(Authorization|X-Auth\S*): ((.*?)(\r\n.*)|(.*))") + # Matches a single "Name: value" header, found either at the start of a line, directly after + # an escaped newline (the 'http.client' debug output contains the literal characters "\r\n"), + # or after the "header: " prefix that 'http.client' adds to each received header. + # The value extends up to the next (real or escaped) line terminator. + header_pattern = re.compile( + r"(?im)(?:^|(?<=\\n))" + r"(?Pheader: )?" + r"(?P[!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*" + r"(?:(?!\\r\\n|\\n)[^\r\n])*" + ) property_settings_pattern = re.compile(r"(?im)(" + redacted_tokens + r")=[^&\n]*(&|$)") json_field_pattern = re.compile(r'(?i)"([^"]*(' + redacted_tokens + r')[^"_]*)":\s*"[^"\n]*"') + @classmethod + def _redact_header(cls, match: re.Match) -> str: + """Replaces the value of a matched header if it is a credential-bearing header.""" + if _is_redacted_header(match.group("name")): + return (match.group("prefix") or "") + match.group("name") + ": [redacted]" + return match.group(0) + @classmethod def redact_secrets(cls, text: str) -> str: """Replaces values of potential secret keywords with a placeholder value. @@ -63,7 +98,7 @@ def redact_secrets(cls, text: str) -> str: """ placeholder = "[redacted]" - redacted = cls.auth_header_pattern.sub(r"\1: " + placeholder + r"\4", text) + redacted = cls.header_pattern.sub(cls._redact_header, text) redacted = cls.property_settings_pattern.sub(r"\1=" + placeholder + r"\2", redacted) redacted = cls.json_field_pattern.sub(r'"\1":"' + placeholder + r'"', redacted) return redacted diff --git a/test/test_logger.py b/test/test_logger.py index 23af3bf..7e7d99e 100644 --- a/test/test_logger.py +++ b/test/test_logger.py @@ -148,7 +148,7 @@ def _pw(val): assert '"name": "alice"' in result assert '"role": "admin"' in result - # ── auth_header_pattern ─────────────────────────────────────────────────── + # ── header_pattern ──────────────────────────────────────────────────────── # Authorization header at EOL must not consume the line that follows. result = redact_secrets("Authorization: Bearer tok\nContent-Type: application/json") @@ -162,6 +162,88 @@ def _pw(val): assert "body" in result +def test_redact_secrets_credential_headers(): + """Verify that every credential-bearing header is redacted, regardless of case.""" + redact_secrets = LoggingFilter.redact_secrets + + headers = [ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "Secret", + "X-Auth-Token", + "X-Authorization", + "X-Auth-Foo-Bar", + ] + for header in headers: + for name in (header, header.lower(), header.upper()): + result = redact_secrets(f"{name}: s3cr3tvalue") + assert "s3cr3tvalue" not in result + assert result == f"{name}: [redacted]" + + # Cookie-style values with multiple attributes. + assert "abc123" not in redact_secrets("Set-Cookie: sid=abc123; Path=/; HttpOnly") + result = redact_secrets("Cookie: a=firstval; b=secondval") + assert "firstval" not in result + assert "secondval" not in result + + # A full CRLF-delimited HTTP dump: only credential headers are redacted, CRLF preserved. + dump = ( + "POST /v1/token HTTP/1.1\r\n" + "Host: example.com\r\n" + "authorization: Bearer tok1\r\n" + "Proxy-Authorization: Basic tok2\r\n" + "COOKIE: sid=tok3\r\n" + "Content-Type: application/json\r\n" + "X-Auth-Token: tok4\r\n" + "\r\n" + "body\r\n" + ) + result = redact_secrets(dump) + for tok in ("tok1", "tok2", "tok3", "tok4"): + assert tok not in result + assert "Host: example.com\r\n" in result + assert "Content-Type: application/json\r\n" in result + assert "authorization: [redacted]\r\n" in result + assert "\r\n\r\nbody\r\n" in result + + # Response containing Set-Cookie. + result = redact_secrets("200 OK\nSet-Cookie: session=tok5\nContent-Length: 2\n\n{}") + assert "tok5" not in result + assert "Content-Length: 2" in result + + # Non-credential headers, and names that merely resemble credential names, are untouched. + for header in ( + "Content-Type: text/plain", + "Accept: */*", + "X-Request-Id: abc", + "Cookies: yes", + "Xx-Auth: keep", + ): + assert redact_secrets(header) == header + + +def test_redact_secrets_http_client_debug_output(): + """Verify redaction of the messages emitted by http.client when debuglevel is enabled.""" + redact_secrets = LoggingFilter.redact_secrets + + # The 'send:' message is the repr() of the raw bytes, so line terminators are escaped. + sent = ( + "send: b'GET / HTTP/1.1\\r\\nHost: localhost\\r\\nCookie: sid=tok1\\r\\n" + "Proxy-Authorization: Basic tok2\\r\\nsecret: tok3\\r\\nAccept: */*\\r\\n\\r\\n'" + ) + result = redact_secrets(sent) + for tok in ("tok1", "tok2", "tok3"): + assert tok not in result + assert "Host: localhost" in result + assert "Accept: */*" in result + + # Received headers are logged one per message, prefixed by "header: ". + assert redact_secrets("header: Set-Cookie: sid=tok4; HttpOnly") == "header: Set-Cookie: [redacted]" + assert redact_secrets("header: Content-Length: 2") == "header: Content-Length: 2" + + # Simulate a real-world scenario. @local_server(3335) def test_redact_secrets_log(caplog):