From 7386885014aa2fee0925c8f3077ca61856ddc065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Felleg?= Date: Tue, 3 Nov 2026 12:01:33 +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/util/LoggingUtils.java | 44 ++++++++++++++-- .../cloud/sdk/core/util/LoggingUtilsTest.java | 51 +++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index e9fdebeab..f36084edf 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-06T16:37:16Z", + "generated_at": "2026-11-03T11:00:19Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -70,7 +70,7 @@ "hashed_secret": "b8fb27ac44ff8a4d5237588af39c5fb65bbedc3f", "is_secret": false, "is_verified": false, - "line_number": 34, + "line_number": 38, "type": "Secret Keyword", "verified_result": null } @@ -138,7 +138,7 @@ "hashed_secret": "bc2f74c22f98f7b6ffbc2f67453dbfa99bce9a32", "is_secret": false, "is_verified": false, - "line_number": 69, + "line_number": 83, "type": "Secret Keyword", "verified_result": null } diff --git a/src/main/java/com/ibm/cloud/sdk/core/util/LoggingUtils.java b/src/main/java/com/ibm/cloud/sdk/core/util/LoggingUtils.java index c48aaaa50..df0a00217 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/util/LoggingUtils.java +++ b/src/main/java/com/ibm/cloud/sdk/core/util/LoggingUtils.java @@ -6,6 +6,9 @@ package com.ibm.cloud.sdk.core.util; import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,11 +46,47 @@ private LoggingUtils() { } private static final String redactedTokens = String.join("|", Arrays.asList(redactedKeywords)); private static final String redacted = "[redacted]"; - private static final Pattern reAuthHeader = Pattern.compile("(?m)^(Authorization|X-Auth\\S*): .*"); + // Names (lower-case) of HTTP headers whose values must never be logged. + // In addition to these, any header whose name starts with "x-auth" is also redacted. + // NOTE: keep this list consistent with the other SDK cores (Go, Node, Python). + private static final Set redactedHeaderNames = new HashSet<>(Arrays.asList( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "secret")); + private static final String redactedHeaderPrefix = "x-auth"; + + // Matches a single "Name: value" header line (line terminators are not consumed). + private static final Pattern reHeaderLine = Pattern.compile("(?m)^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[^\\r\\n]*"); private static final Pattern rePropertySetting = Pattern.compile("(?im)(" + redactedTokens + ")=[^&\\n]*(&|$)"); private static final Pattern reJsonField = Pattern.compile("(?i)\"([^\"]*(" + redactedTokens + ")[^\"_]*)\":\\s*\"[^\"\\n]*\""); + /** + * Returns true iff the header "name" (compared case-insensitively) is one whose value must be redacted. + */ + private static boolean isRedactedHeader(String name) { + String lowerName = name.toLowerCase(Locale.ROOT); + return redactedHeaderNames.contains(lowerName) || lowerName.startsWith(redactedHeaderPrefix); + } + + /** + * Redacts the value of each credential-bearing HTTP header found in "s". + * Header names are parsed line-by-line and compared case-insensitively. + */ + private static String redactHeaders(String s) { + Matcher matcher = reHeaderLine.matcher(s); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String name = matcher.group(1); + String replacement = isRedactedHeader(name) ? name + ": " + redacted : matcher.group(); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + /** * Redacts secrets within string "s" and returns the resulting string * with secrets replaced by "[redacted]". @@ -59,8 +98,7 @@ public static String redactSecrets(String s) { String redactedString = s; // Redact secrets within any special headers. - Matcher matcher1 = reAuthHeader.matcher(redactedString); - redactedString = matcher1.replaceAll("$1: " + redacted); + redactedString = redactHeaders(redactedString); // Redact secrets within property settings. Matcher matcher2 = rePropertySetting.matcher(redactedString); diff --git a/src/test/java/com/ibm/cloud/sdk/core/util/LoggingUtilsTest.java b/src/test/java/com/ibm/cloud/sdk/core/util/LoggingUtilsTest.java index aa9e315c1..d44319f7f 100644 --- a/src/test/java/com/ibm/cloud/sdk/core/util/LoggingUtilsTest.java +++ b/src/test/java/com/ibm/cloud/sdk/core/util/LoggingUtilsTest.java @@ -28,6 +28,57 @@ public void testRedactHeaders() { assertEquals(LoggingUtils.redactSecrets(input), expected); } + @Test + public void testRedactCredentialHeaders() { + String[] headers = { + "Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", "Secret", + "X-Auth-Token", "X-Authorization", "X-Auth-Foo-Bar" }; + for (String header : headers) { + for (String name : new String[] { header, header.toLowerCase(), header.toUpperCase() }) { + String result = LoggingUtils.redactSecrets(name + ": s3cr3tvalue"); + assertFalse(result.contains("s3cr3tvalue"), "header: " + name); + assertEquals(result, name + ": [redacted]"); + } + } + + // Cookie-style values with multiple attributes. + String result = LoggingUtils.redactSecrets("Set-Cookie: sid=abc123; Path=/; HttpOnly"); + assertFalse(result.contains("abc123")); + result = LoggingUtils.redactSecrets("Cookie: a=firstval; b=secondval"); + assertFalse(result.contains("firstval")); + assertFalse(result.contains("secondval")); + + // A full CRLF-delimited HTTP dump: only credential headers are redacted, CRLF preserved. + String 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 = LoggingUtils.redactSecrets(dump); + for (String tok : new String[] { "tok1", "tok2", "tok3", "tok4" }) { + assertFalse(result.contains(tok), tok); + } + assertTrue(result.contains("Host: example.com\r\n")); + assertTrue(result.contains("Content-Type: application/json\r\n")); + assertTrue(result.contains("authorization: [redacted]\r\n")); + assertTrue(result.contains("\r\n\r\nbody\r\n")); + + // Response containing Set-Cookie. + result = LoggingUtils.redactSecrets("200 OK\nSet-Cookie: session=tok5\nContent-Length: 2\n\n{}"); + assertFalse(result.contains("tok5")); + assertTrue(result.contains("Content-Length: 2")); + + // Non-credential headers, and names that merely resemble credential names, are untouched. + for (String h : new String[] { + "Content-Type: text/plain", "Accept: */*", "X-Request-Id: abc", "Cookies: yes", "Xx-Auth: keep" }) { + assertEquals(LoggingUtils.redactSecrets(h), h); + } + } + @Test public void testRedactProperties() { assertEquals(LoggingUtils.redactSecrets("...&apikey=foo&..."), "...&apikey=[redacted]&...");