From 2ba949424ef046fbf75cea200402e38c0128303d Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:11:54 -0300 Subject: [PATCH 01/12] feat(scrubbing): add StringUrlSanitizer and DefaultUrlSanitizer --- .../scrubbing/DefaultUrlSanitizer.java | 51 ++++++++++ .../scrubbing/StringUrlSanitizer.java | 18 ++++ .../scrubbing/DefaultUrlSanitizerTest.java | 96 +++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java create mode 100644 rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java create mode 100644 rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java new file mode 100644 index 00000000..06640a17 --- /dev/null +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java @@ -0,0 +1,51 @@ +package com.rollbar.notifier.scrubbing; + +/** + * Default {@link StringUrlSanitizer} that strips userinfo, query string, and fragment from URLs. + * Uses string scanning rather than {@code java.net.URI} to avoid allocation on clean URLs + * and to preserve the original percent-encoding without normalization. + */ +public final class DefaultUrlSanitizer implements StringUrlSanitizer { + + public static final DefaultUrlSanitizer INSTANCE = new DefaultUrlSanitizer(); + + private DefaultUrlSanitizer() { + } + + @Override + public String sanitize(String url) { + if (url == null) { + return null; + } + // Fast path: no characters that can introduce query string, fragment, or userinfo. + if (url.indexOf('?') < 0 && url.indexOf('#') < 0 && url.indexOf('@') < 0) { + return url; + } + return strip(url); + } + + private static String strip(String url) { + int end = url.length(); + int q = url.indexOf('?'); + int f = url.indexOf('#'); + if (q >= 0 && q < end) { + end = q; + } + if (f >= 0 && f < end) { + end = f; + } + // Strip userinfo: find "://" then the last "@" before the first "/" after the authority start. + String result = url.substring(0, end); + int schemeEnd = result.indexOf("://"); + if (schemeEnd >= 0) { + int hostStart = schemeEnd + 3; + int slashAfterHost = result.indexOf('/', hostStart); + int searchEnd = slashAfterHost < 0 ? result.length() : slashAfterHost; + int at = result.lastIndexOf('@', searchEnd); + if (at >= hostStart) { + result = result.substring(0, hostStart) + result.substring(at + 1); + } + } + return result; + } +} diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java new file mode 100644 index 00000000..263114c1 --- /dev/null +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java @@ -0,0 +1,18 @@ +package com.rollbar.notifier.scrubbing; + +/** + * Sanitizes a URL string before it is included in a Rollbar payload. + * Implementations should strip sensitive components such as userinfo, + * query parameters, and fragments. + */ +@FunctionalInterface +public interface StringUrlSanitizer { + /** + * Returns a sanitized version of the given URL string, or {@code null} if + * the input is {@code null}. + * + * @param url the raw URL string, may be {@code null}. + * @return the sanitized URL, or {@code null}. + */ + String sanitize(String url); +} diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java new file mode 100644 index 00000000..c11faf0f --- /dev/null +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java @@ -0,0 +1,96 @@ +package com.rollbar.notifier.scrubbing; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class DefaultUrlSanitizerTest { + + private final DefaultUrlSanitizer sanitizer = DefaultUrlSanitizer.INSTANCE; + + @Test + public void nullInputReturnsNull() { + assertNull(sanitizer.sanitize(null)); + } + + @Test + public void cleanUrlUnchanged() { + String url = "https://example.com/api/v1/things"; + assertEquals(url, sanitizer.sanitize(url)); + } + + @Test + public void queryStringStripped() { + assertEquals( + "https://example.com/search", + sanitizer.sanitize("https://example.com/search?token=abc&page=1") + ); + } + + @Test + public void fragmentStripped() { + assertEquals( + "https://example.com/page", + sanitizer.sanitize("https://example.com/page#section") + ); + } + + @Test + public void userinfoStripped() { + assertEquals( + "https://example.com/path", + sanitizer.sanitize("https://user:pass@example.com/path") + ); + } + + @Test + public void allThreeScrubbed() { + assertEquals( + "https://example.com/path", + sanitizer.sanitize("https://admin:secret@example.com/path?token=xyz#top") + ); + } + + @Test + public void malformedUrlNoException() { + // Should not throw; best-effort strip + String result = sanitizer.sanitize("not-a-url?query=sensitive"); + assertNotNull(result); + assertFalse(result.contains("sensitive")); + } + + @Test + public void malformedUrlWithUserinfo() { + String result = sanitizer.sanitize("http://user:secret@host/path?q=1"); + assertNotNull(result); + assertFalse(result.contains("secret")); + assertFalse(result.contains("q=1")); + } + + @Test + public void emptyStringUnchanged() { + assertEquals("", sanitizer.sanitize("")); + } + + @Test + public void cleanUrlReturnedAsSameInstance() { + String url = "https://example.com/api/v1/things"; + assertSame(url, sanitizer.sanitize(url)); + } + + @Test + public void percentEncodedPathPreserved() { + // No ?, #, or @ — fast path must return the same instance without normalizing encoding. + String url = "https://example.com/path%20with%20spaces"; + assertSame(url, sanitizer.sanitize(url)); + } + + @Test + public void atSignInPathNotTreatedAsUserinfo() { + // The @ is after the first path slash, so it is not userinfo. + String url = "https://example.com/users/@alice?token=x"; + String result = sanitizer.sanitize(url); + assertTrue(result.contains("@alice")); + assertFalse(result.contains("token")); + } +} From 9049736a0eedec378ef6543302a99359e2cd3843 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:13:11 -0300 Subject: [PATCH 02/12] feat(scrubbing): add ScrubDataTransformer --- .../scrubbing/ScrubDataTransformer.java | 317 ++++++++++++++++ .../scrubbing/ScrubDataTransformerTest.java | 351 ++++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java create mode 100644 rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java new file mode 100644 index 00000000..99cdd994 --- /dev/null +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -0,0 +1,317 @@ +package com.rollbar.notifier.scrubbing; + +import com.rollbar.api.payload.data.Data; +import com.rollbar.api.payload.data.Request; +import com.rollbar.api.payload.data.body.Body; +import com.rollbar.api.payload.data.body.BodyContent; +import com.rollbar.api.payload.data.body.Frame; +import com.rollbar.api.payload.data.body.Trace; +import com.rollbar.api.payload.data.body.TraceChain; +import com.rollbar.notifier.transformer.Transformer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Built-in {@link Transformer} that scrubs sensitive values from payloads before they are sent + * to Rollbar. Applied automatically after any user-provided transformer. + * + *

By default the following request headers are redacted: + * {@code Authorization}, {@code Cookie}, {@code Set-Cookie}, {@code X-Api-Key}, + * {@code X-Auth-Token}, {@code X-Access-Token}, {@code X-Secret}, + * {@code Proxy-Authorization}, {@code WWW-Authenticate}. + * + *

Additional keys (matched as case-insensitive regex against header names, query/POST + * parameter keys, custom data keys, and {@code Frame.locals} keys) can be configured via + * {@code ConfigBuilder.redactedKeys(List)}. + */ +public final class ScrubDataTransformer implements Transformer { + + public static final String SCRUBBED_VALUE = "***"; + + // O(1) set lookup; avoids Matcher allocation on every header key. + private static final Set DEFAULT_HEADERS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", + "x-access-token", "x-secret", "proxy-authorization", "www-authenticate" + )) + ); + + // Recursion cap for nested Map values in custom data and Frame.locals. + private static final int MAX_SCRUB_DEPTH = 8; + + private final List fieldPatterns; + private final StringUrlSanitizer urlSanitizer; + + public ScrubDataTransformer(List redactedKeys, StringUrlSanitizer urlSanitizer) { + this.urlSanitizer = urlSanitizer != null ? urlSanitizer : DefaultUrlSanitizer.INSTANCE; + if (redactedKeys == null || redactedKeys.isEmpty()) { + this.fieldPatterns = Collections.emptyList(); + } else { + List patterns = new ArrayList<>(redactedKeys.size()); + for (String key : redactedKeys) { + patterns.add(Pattern.compile(key, Pattern.CASE_INSENSITIVE)); + } + this.fieldPatterns = Collections.unmodifiableList(patterns); + } + } + + @Override + public Data transform(Data data) { + if (data == null) { + return null; + } + + Request originalRequest = data.getRequest(); + Map originalCustom = data.getCustom(); + Body originalBody = data.getBody(); + + Request scrubbedRequest = scrubRequest(originalRequest); + Map scrubbedCustom = scrubObjectMap(originalCustom, fieldPatterns, 0); + Body scrubbedBody = scrubBody(originalBody); + + boolean changed = scrubbedRequest != originalRequest + || scrubbedCustom != originalCustom + || scrubbedBody != originalBody; + + if (!changed) { + return data; + } + + Data.Builder builder = new Data.Builder(data); + if (scrubbedRequest != originalRequest) { + builder.request(scrubbedRequest); + } + if (scrubbedCustom != originalCustom) { + builder.custom(scrubbedCustom); + } + if (scrubbedBody != originalBody) { + builder.body(scrubbedBody); + } + return builder.build(); + } + + private Request scrubRequest(Request req) { + if (req == null) { + return null; + } + + String originalUrl = req.getUrl(); + Map originalHeaders = req.getHeaders(); + Map> originalGet = req.getGet(); + Map originalPost = req.getPost(); + String originalQueryString = req.getQueryString(); + + String scrubbedUrl = originalUrl != null ? urlSanitizer.sanitize(originalUrl) : null; + Map scrubbedHeaders = scrubStringMap(originalHeaders); + Map> scrubbedGet = scrubMultiMap(originalGet, fieldPatterns); + Map scrubbedPost = scrubObjectMap(originalPost, fieldPatterns, 0); + String scrubbedQueryString = scrubQueryString(originalQueryString, fieldPatterns); + + boolean changed = !equal(originalUrl, scrubbedUrl) + || scrubbedHeaders != originalHeaders + || scrubbedGet != originalGet + || scrubbedPost != originalPost + || !equal(originalQueryString, scrubbedQueryString); + + if (!changed) { + return req; + } + + return new Request.Builder(req) + .url(scrubbedUrl) + .headers(scrubbedHeaders) + .get(scrubbedGet) + .post(scrubbedPost) + .queryString(scrubbedQueryString) + .build(); + } + + private Body scrubBody(Body body) { + if (body == null || fieldPatterns.isEmpty()) { + return body; + } + BodyContent content = body.getContents(); + if (content instanceof Trace) { + Trace scrubbed = scrubTrace((Trace) content); + if (scrubbed != content) { + return new Body.Builder(body).bodyContent(scrubbed).build(); + } + } else if (content instanceof TraceChain) { + TraceChain chain = (TraceChain) content; + List traces = chain.getTraces(); + if (traces != null) { + List scrubbed = new ArrayList<>(traces.size()); + boolean anyChanged = false; + for (Trace trace : traces) { + Trace st = scrubTrace(trace); + scrubbed.add(st); + if (st != trace) { + anyChanged = true; + } + } + if (anyChanged) { + return new Body.Builder(body) + .bodyContent(new TraceChain.Builder(chain).traces(scrubbed).build()) + .build(); + } + } + } + return body; + } + + private Trace scrubTrace(Trace trace) { + if (trace == null) { + return null; + } + List frames = trace.getFrames(); + if (frames == null || frames.isEmpty()) { + return trace; + } + List scrubbed = new ArrayList<>(frames.size()); + boolean anyChanged = false; + for (Frame frame : frames) { + Frame sf = scrubFrame(frame); + scrubbed.add(sf); + if (sf != frame) { + anyChanged = true; + } + } + if (!anyChanged) { + return trace; + } + return new Trace.Builder(trace).frames(scrubbed).build(); + } + + private Frame scrubFrame(Frame frame) { + if (frame == null) { + return null; + } + Map locals = frame.getLocals(); + Map scrubbedLocals = scrubObjectMap(locals, fieldPatterns, 0); + if (scrubbedLocals == locals) { + return frame; + } + return new Frame.Builder(frame).locals(scrubbedLocals).build(); + } + + private Map scrubStringMap(Map map) { + if (map == null) { + return null; + } + Map result = null; + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + if (matchesDefaultHeader(key) || matchesAny(key, fieldPatterns)) { + if (result == null) { + result = new HashMap<>(map); + } + result.put(key, SCRUBBED_VALUE); + } + } + return result != null ? result : map; + } + + private Map scrubObjectMap(Map map, List patterns, + int depth) { + if (map == null || patterns.isEmpty()) { + return map; + } + Map result = null; + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (matchesAny(key, patterns)) { + if (result == null) { + result = new HashMap<>(map); + } + result.put(key, SCRUBBED_VALUE); + } else if (value instanceof Map && depth < MAX_SCRUB_DEPTH) { + @SuppressWarnings("unchecked") + Map nested = (Map) value; + Map scrubbedNested = scrubObjectMap(nested, patterns, depth + 1); + if (scrubbedNested != nested) { + if (result == null) { + result = new HashMap<>(map); + } + result.put(key, scrubbedNested); + } + } + } + return result != null ? result : map; + } + + private Map> scrubMultiMap(Map> map, + List patterns) { + if (map == null || patterns.isEmpty()) { + return map; + } + Map> result = null; + for (Map.Entry> entry : map.entrySet()) { + if (matchesAny(entry.getKey(), patterns)) { + if (result == null) { + result = new HashMap<>(map); + } + result.put(entry.getKey(), Collections.singletonList(SCRUBBED_VALUE)); + } + } + return result != null ? result : map; + } + + private String scrubQueryString(String queryString, List patterns) { + if (queryString == null || queryString.isEmpty() || patterns.isEmpty()) { + return queryString; + } + String[] pairs = queryString.split("&", -1); + boolean changed = false; + String[] output = new String[pairs.length]; + for (int i = 0; i < pairs.length; i++) { + String pair = pairs[i]; + int eq = pair.indexOf('='); + // A value-less param (e.g. "?token") is treated as key-only and scrubbed the same way. + String key = eq >= 0 ? pair.substring(0, eq) : pair; + if (matchesAny(key, patterns)) { + output[i] = key + "=" + SCRUBBED_VALUE; + changed = true; + } else { + output[i] = pair; + } + } + if (!changed) { + return queryString; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < output.length; i++) { + if (i > 0) { + sb.append('&'); + } + sb.append(output[i]); + } + return sb.toString(); + } + + private static boolean matchesDefaultHeader(String key) { + return DEFAULT_HEADERS.contains(key.toLowerCase(Locale.ROOT)); + } + + private static boolean matchesAny(String key, List patterns) { + for (Pattern p : patterns) { + if (p.matcher(key).find()) { + return true; + } + } + return false; + } + + private static boolean equal(String a, String b) { + return a == null ? b == null : a.equals(b); + } +} diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java new file mode 100644 index 00000000..a698f73b --- /dev/null +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -0,0 +1,351 @@ +package com.rollbar.notifier.scrubbing; + +import com.rollbar.api.payload.data.Data; +import com.rollbar.api.payload.data.Request; +import com.rollbar.api.payload.data.body.Body; +import com.rollbar.api.payload.data.body.Frame; +import com.rollbar.api.payload.data.body.Trace; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class ScrubDataTransformerTest { + + private static final StringUrlSanitizer NO_OP_SANITIZER = url -> url; + + // --- helpers --- + + private static Data dataWithRequest(Request request) { + return new Data.Builder() + .environment("test") + .request(request) + .build(); + } + + private static Data dataWithCustom(Map custom) { + return new Data.Builder() + .environment("test") + .custom(custom) + .build(); + } + + private static Map headers(String... kvPairs) { + Map map = new HashMap<>(); + for (int i = 0; i < kvPairs.length; i += 2) { + map.put(kvPairs[i], kvPairs[i + 1]); + } + return map; + } + + private static Map> getParams(String key, String value) { + Map> map = new HashMap<>(); + map.put(key, Collections.singletonList(value)); + return map; + } + + private static Map objectMap(String... kvPairs) { + Map map = new HashMap<>(); + for (int i = 0; i < kvPairs.length; i += 2) { + map.put(kvPairs[i], kvPairs[i + 1]); + } + return map; + } + + // --- default header deny-list --- + + @Test + public void authorizationHeaderRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("Authorization", "Bearer secret-token", "Content-Type", "application/json")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("Authorization")); + assertEquals("application/json", result.getRequest().getHeaders().get("Content-Type")); + } + + @Test + public void cookieHeaderRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("Cookie", "session=abc123")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("Cookie")); + } + + @Test + public void setCookieHeaderRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("Set-Cookie", "session=abc123; HttpOnly")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("Set-Cookie")); + } + + @Test + public void xApiKeyHeaderRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("X-Api-Key", "key-12345")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("X-Api-Key")); + } + + @Test + public void caseInsensitiveHeaderMatching() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("AUTHORIZATION", "Basic xyz", "authorization", "Bearer abc")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("AUTHORIZATION")); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("authorization")); + } + + // --- user redactedKeys --- + + @Test + public void userKeyRedactsHeaders() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("X-My-Secret"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .headers(headers("X-My-Secret", "sensitive", "Content-Type", "text/plain")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("X-My-Secret")); + assertEquals("text/plain", result.getRequest().getHeaders().get("Content-Type")); + } + + @Test + public void userKeyRedactsGetParams() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("apiToken"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .get(getParams("apiToken", "secret-value")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + result.getRequest().getGet().get("apiToken").get(0)); + } + + @Test + public void userKeyRedactsPostParams() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + Map post = objectMap("password", "hunter2", "username", "alice"); + Request req = new Request.Builder().post(post).build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getPost().get("password")); + assertEquals("alice", result.getRequest().getPost().get("username")); + } + + @Test + public void userKeyRedactsCustomMap() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("mySecret"), NO_OP_SANITIZER); + Map custom = objectMap("mySecret", "hidden", "other", "visible"); + Data result = t.transform(dataWithCustom(custom)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("mySecret")); + assertEquals("visible", result.getCustom().get("other")); + } + + @Test + public void userKeyRegexMatchesMultipleKeys() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList(".*[Pp]assword.*"), NO_OP_SANITIZER); + Map custom = objectMap( + "passwordHash", "xxx", + "oldPassword", "yyy", + "username", "alice" + ); + Data result = t.transform(dataWithCustom(custom)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("passwordHash")); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("oldPassword")); + assertEquals("alice", result.getCustom().get("username")); + } + + // --- URL sanitizer --- + + @Test + public void urlSanitizedViaProvidedSanitizer() { + StringUrlSanitizer strip = url -> "https://example.com/clean"; + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), strip); + Request req = new Request.Builder() + .url("https://example.com/api?secret=xyz") + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals("https://example.com/clean", result.getRequest().getUrl()); + } + + // --- queryString --- + + @Test + public void queryStringValueRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .queryString("token=secret&page=1") + .build(); + Data result = t.transform(dataWithRequest(req)); + String qs = result.getRequest().getQueryString(); + assertTrue(qs.contains("token=" + ScrubDataTransformer.SCRUBBED_VALUE)); + assertTrue(qs.contains("page=1")); + } + + @Test + public void queryStringUnchangedWhenNoMatch() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + String qs = "page=1&sort=asc"; + Request req = new Request.Builder().queryString(qs).build(); + Data result = t.transform(dataWithRequest(req)); + assertSame(qs, result.getRequest().getQueryString()); + } + + // --- Frame.locals --- + + @Test + public void frameLocalsMatchingKeysScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + Map locals = objectMap("password", "secret", "userId", "42"); + Frame frame = new Frame.Builder().locals(locals).build(); + Trace trace = new Trace.Builder().frames(Collections.singletonList(frame)).build(); + Body body = new Body.Builder().bodyContent(trace).build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + + List frames = ((Trace) result.getBody().getContents()).getFrames(); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, frames.get(0).getLocals().get("password")); + assertEquals("42", frames.get(0).getLocals().get("userId")); + } + + // --- null-safety --- + + @Test + public void nullRequestReturnsDataUnchanged() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Data data = new Data.Builder().environment("test").build(); + assertSame(data, t.transform(data)); + } + + @Test + public void nullHeadersMapNoNpe() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder().url("https://example.com").build(); // headers null + Data result = t.transform(dataWithRequest(req)); + assertNotNull(result); + } + + @Test + public void nullCustomMapNoNpe() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("secret"), NO_OP_SANITIZER); + Data data = new Data.Builder().environment("test").build(); // custom null + Data result = t.transform(data); + assertNotNull(result); + } + + @Test + public void noMatchReturnsSameDataInstance() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .url("https://example.com") + .headers(headers("Content-Type", "application/json")) + .build(); + Data data = dataWithRequest(req); + assertSame(data, t.transform(data)); + } + + @Test + public void emptyRedactedKeysOnlyScrubsDefaultHeaders() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Map custom = objectMap("myApiKey", "visible"); + Map hdrs = headers("Authorization", "Bearer xyz", "Content-Type", "text/html"); + Request req = new Request.Builder().headers(hdrs).build(); + Data data = new Data.Builder().environment("test").request(req).custom(custom).build(); + + Data result = t.transform(data); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("Authorization")); + assertEquals("text/html", result.getRequest().getHeaders().get("Content-Type")); + // custom key not in default deny-list → not scrubbed + assertEquals("visible", result.getCustom().get("myApiKey")); + } + + @Test + public void nullDataReturnsNull() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + assertNull(t.transform(null)); + } + + // --- value-less query params (B1 fix) --- + + @Test + public void valueLessQueryParamScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder().queryString("token&page=1").build(); + Data result = t.transform(dataWithRequest(req)); + String qs = result.getRequest().getQueryString(); + assertTrue(qs.contains("token=" + ScrubDataTransformer.SCRUBBED_VALUE)); + assertTrue(qs.contains("page=1")); + } + + @Test + public void valueLessQueryParamNoMatchPassedThrough() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder().queryString("debug&page=1").build(); + Data result = t.transform(dataWithRequest(req)); + assertSame(req.getQueryString(), result.getRequest().getQueryString()); + } + + // --- nested map scrubbing (B4 fix) --- + + @Test + public void nestedCustomMapKeysScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + Map inner = objectMap("password", "hunter2", "user", "alice"); + Map custom = new HashMap<>(); + custom.put("auth", inner); + custom.put("visible", "yes"); + Data result = t.transform(dataWithCustom(custom)); + @SuppressWarnings("unchecked") + Map scrubbed = (Map) result.getCustom().get("auth"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.get("password")); + assertEquals("alice", scrubbed.get("user")); + assertEquals("yes", result.getCustom().get("visible")); + } + + @Test + public void nestedFrameLocalsKeysScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + Map inner = objectMap("token", "secret-token", "count", "5"); + Map locals = new HashMap<>(); + locals.put("credentials", inner); + locals.put("userId", "42"); + Frame frame = new Frame.Builder().locals(locals).build(); + Trace trace = new Trace.Builder().frames(Collections.singletonList(frame)).build(); + Body body = new Body.Builder().bodyContent(trace).build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + List frames = ((Trace) result.getBody().getContents()).getFrames(); + @SuppressWarnings("unchecked") + Map scrubbedInner = + (Map) frames.get(0).getLocals().get("credentials"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbedInner.get("token")); + assertEquals("5", scrubbedInner.get("count")); + assertEquals("42", frames.get(0).getLocals().get("userId")); + } + + @Test + public void nestedMapParentKeyMatchScrubsEntireValue() { + // When the top-level key itself matches, the whole nested map is replaced, not recursed. + ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("credentials"), NO_OP_SANITIZER); + Map inner = objectMap("password", "hunter2"); + Map custom = new HashMap<>(); + custom.put("credentials", inner); + Data result = t.transform(dataWithCustom(custom)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("credentials")); + } +} From 7a9ca7302a778a596314ec1819ae8f0f458930d9 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:13:46 -0300 Subject: [PATCH 03/12] feat(config): expose redactedKeys and urlSanitizer --- .../rollbar/notifier/config/CommonConfig.java | 26 ++++++++++ .../notifier/config/ConfigBuilder.java | 51 +++++++++++++++++++ .../notifier/config/ConfigBuilder.java | 47 +++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java b/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java index c4c4ea41..92c62210 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java @@ -9,10 +9,13 @@ import com.rollbar.notifier.filter.Filter; import com.rollbar.notifier.fingerprint.FingerprintGenerator; import com.rollbar.notifier.provider.Provider; +import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; +import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.sender.json.JsonSerializer; import com.rollbar.notifier.telemetry.TelemetryEventTracker; import com.rollbar.notifier.transformer.Transformer; import com.rollbar.notifier.uuid.UuidGenerator; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -223,6 +226,29 @@ default boolean compressPayload() { return true; } + /** + * Keys (matched as case-insensitive regex) whose values should be redacted in headers, + * query/POST parameters, custom data, and {@code Frame.locals} before sending to Rollbar. + * The default header deny-list (Authorization, Cookie, etc.) is always applied regardless + * of this list. + * + * @return list of regex patterns; empty list by default. + */ + default List redactedKeys() { + return Collections.emptyList(); + } + + /** + * URL sanitizer applied to {@link com.rollbar.api.payload.data.Request#getUrl()} before the + * payload is sent. Defaults to {@link DefaultUrlSanitizer#INSTANCE} which strips userinfo, + * query string, and fragment. + * + * @return the URL sanitizer; never {@code null}. + */ + default StringUrlSanitizer urlSanitizer() { + return DefaultUrlSanitizer.INSTANCE; + } + int maximumTelemetryData(); TelemetryEventTracker telemetryEventTracker(); diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java b/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java index d91e3705..d9068719 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java @@ -19,6 +19,8 @@ import com.rollbar.notifier.sender.json.JsonSerializerImpl; import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; import com.rollbar.notifier.telemetry.TelemetryEventTracker; +import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; +import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; import com.rollbar.notifier.uuid.UuidGenerator; import java.lang.Thread.UncaughtExceptionHandler; @@ -89,6 +91,10 @@ public class ConfigBuilder { protected boolean compressPayload; + protected List redactedKeys; + + protected StringUrlSanitizer urlSanitizer; + private int maximumTelemetryData = RollbarTelemetryEventTracker.MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS; @@ -142,6 +148,8 @@ private ConfigBuilder(Config config) { this.compressPayload = config.compressPayload(); this.maximumTelemetryData = config.maximumTelemetryData(); this.telemetryEventTracker = config.telemetryEventTracker(); + this.redactedKeys = config.redactedKeys(); + this.urlSanitizer = config.urlSanitizer(); } /** @@ -523,6 +531,31 @@ public ConfigBuilder telemetryEventTracker(TelemetryEventTracker telemetryEventT return this; } + /** + * Keys (matched as case-insensitive regex) whose values will be redacted in request headers, + * query/POST parameters, custom data, and {@code Frame.locals}. These are additive to the + * built-in header deny-list (Authorization, Cookie, etc.). + * + * @param redactedKeys list of regex patterns. + * @return the builder instance. + */ + public ConfigBuilder redactedKeys(List redactedKeys) { + this.redactedKeys = redactedKeys; + return this; + } + + /** + * URL sanitizer applied to the request URL before the payload is sent. + * Defaults to {@link DefaultUrlSanitizer#INSTANCE}. + * + * @param urlSanitizer the sanitizer. + * @return the builder instance. + */ + public ConfigBuilder urlSanitizer(StringUrlSanitizer urlSanitizer) { + this.urlSanitizer = urlSanitizer; + return this; + } + /** * Builds the {@link Config config}. * @@ -624,6 +657,10 @@ private static class ConfigImpl implements Config { private final TelemetryEventTracker telemetryEventTracker; + private final List redactedKeys; + + private final StringUrlSanitizer urlSanitizer; + ConfigImpl(ConfigBuilder builder) { this.accessToken = builder.accessToken; this.endpoint = builder.endpoint; @@ -659,6 +696,10 @@ private static class ConfigImpl implements Config { this.compressPayload = builder.compressPayload; this.maximumTelemetryData = builder.maximumTelemetryData; this.telemetryEventTracker = builder.telemetryEventTracker; + this.redactedKeys = builder.redactedKeys != null + ? builder.redactedKeys : Collections.emptyList(); + this.urlSanitizer = builder.urlSanitizer != null + ? builder.urlSanitizer : DefaultUrlSanitizer.INSTANCE; } @Override @@ -820,5 +861,15 @@ public int maximumTelemetryData() { public TelemetryEventTracker telemetryEventTracker() { return this.telemetryEventTracker; } + + @Override + public List redactedKeys() { + return redactedKeys; + } + + @Override + public StringUrlSanitizer urlSanitizer() { + return urlSanitizer; + } } } diff --git a/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java b/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java index 5151e234..d05c8e62 100644 --- a/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java +++ b/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java @@ -17,6 +17,8 @@ import com.rollbar.notifier.sender.json.JsonSerializer; import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; import com.rollbar.notifier.telemetry.TelemetryEventTracker; +import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; +import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; import com.rollbar.notifier.uuid.UuidGenerator; import com.rollbar.reactivestreams.notifier.sender.AsyncSender; @@ -62,6 +64,8 @@ public final class ConfigBuilder { private DefaultLevels defaultLevels; private boolean truncateLargePayloads; private boolean compressPayload; + private List redactedKeys; + private StringUrlSanitizer urlSanitizer; private int maximumTelemetryData = RollbarTelemetryEventTracker.MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS; private TelemetryEventTracker telemetryEventTracker; @@ -111,6 +115,8 @@ private ConfigBuilder(Config config) { this.compressPayload = config.compressPayload(); this.maximumTelemetryData = config.maximumTelemetryData(); this.telemetryEventTracker = config.telemetryEventTracker(); + this.redactedKeys = config.redactedKeys(); + this.urlSanitizer = config.urlSanitizer(); } private ConfigBuilder(Sender sender) { @@ -511,6 +517,31 @@ public ConfigBuilder telemetryEventTracker(TelemetryEventTracker telemetryEventT return this; } + /** + * Keys (matched as case-insensitive regex) whose values will be redacted in request headers, + * query/POST parameters, custom data, and {@code Frame.locals}. These are additive to the + * built-in header deny-list (Authorization, Cookie, etc.). + * + * @param redactedKeys list of regex patterns. + * @return the builder instance. + */ + public ConfigBuilder redactedKeys(List redactedKeys) { + this.redactedKeys = redactedKeys; + return this; + } + + /** + * URL sanitizer applied to the request URL before the payload is sent. + * Defaults to {@link DefaultUrlSanitizer#INSTANCE}. + * + * @param urlSanitizer the sanitizer. + * @return the builder instance. + */ + public ConfigBuilder urlSanitizer(StringUrlSanitizer urlSanitizer) { + this.urlSanitizer = urlSanitizer; + return this; + } + /** * Builds the {@link Config config}. * @@ -584,6 +615,8 @@ private static class ConfigImpl implements Config { private final boolean compressPayload; private final int maximumTelemetryData; private final TelemetryEventTracker telemetryEventTracker; + private final List redactedKeys; + private final StringUrlSanitizer urlSanitizer; ConfigImpl(ConfigBuilder builder) { this.accessToken = builder.accessToken; @@ -619,6 +652,10 @@ private static class ConfigImpl implements Config { this.compressPayload = builder.compressPayload; this.maximumTelemetryData = builder.maximumTelemetryData; this.telemetryEventTracker = builder.telemetryEventTracker; + this.redactedKeys = builder.redactedKeys != null + ? builder.redactedKeys : Collections.emptyList(); + this.urlSanitizer = builder.urlSanitizer != null + ? builder.urlSanitizer : DefaultUrlSanitizer.INSTANCE; } @Override @@ -775,5 +812,15 @@ public int maximumTelemetryData() { public TelemetryEventTracker telemetryEventTracker() { return this.telemetryEventTracker; } + + @Override + public List redactedKeys() { + return redactedKeys; + } + + @Override + public StringUrlSanitizer urlSanitizer() { + return urlSanitizer; + } } } From 00dc4cbd99c4334b441e574efc958ed54d676f0b Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:14:08 -0300 Subject: [PATCH 04/12] feat(notifier): apply built-in scrubbing to every payload --- .../main/java/com/rollbar/notifier/RollbarBase.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java b/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java index ca5f45ec..8835c158 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java @@ -10,6 +10,7 @@ import com.rollbar.api.payload.data.body.Body; import com.rollbar.jvmti.ThrowableCache; import com.rollbar.notifier.config.CommonConfig; +import com.rollbar.notifier.scrubbing.ScrubDataTransformer; import com.rollbar.notifier.telemetry.TelemetryEventTracker; import com.rollbar.notifier.truncation.PayloadTruncator; import com.rollbar.notifier.util.BodyFactory; @@ -43,6 +44,8 @@ public abstract class RollbarBase { protected C config; + private volatile ScrubDataTransformer builtInScrubber; + protected final ReadWriteLock configReadWriteLock = new ReentrantReadWriteLock(); protected final Lock configReadLock = configReadWriteLock.readLock(); protected final Lock configWriteLock = configReadWriteLock.writeLock(); @@ -55,6 +58,7 @@ protected RollbarBase(C config, BodyFactory bodyFactory, RESULT emptyResult) { this.bodyFactory = bodyFactory; this.emptyResult = emptyResult; this.telemetryEventTracker = config.telemetryEventTracker(); + this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer()); } /** @@ -123,6 +127,7 @@ protected void configure(C config) { this.config = config; configureTruncation(config); processAppPackages(config); + this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer()); } finally { this.configWriteLock.unlock(); } @@ -250,10 +255,12 @@ protected Data buildData(CommonConfig config, ThrowableWrapper error, Map custom, String description, Level level, boolean isUncaught) { C config; + ScrubDataTransformer scrubber; this.configReadLock.lock(); try { config = this.config; + scrubber = this.builtInScrubber; } finally { this.configReadLock.unlock(); } @@ -280,6 +287,10 @@ protected RESULT process(ThrowableWrapper error, Map custom, Str data = config.transformer().transform(data); } + // Built-in scrubbing always runs after the user transformer + LOGGER.debug("Applying built-in scrubber."); + data = scrubber.transform(data); + // Append if needed uuid or fingerprint data. if (config.uuidGenerator() != null || config.fingerPrintGenerator() != null) { Data.Builder dataBuilder = new Data.Builder(data); From bf3629d9f8917d2935c85c60d188b4bd3b3592a4 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:14:44 -0300 Subject: [PATCH 05/12] refactor(okhttp): reuse the shared DefaultUrlSanitizer --- .../okhttp/RollbarOkHttpInterceptor.java | 18 ++++++++++-------- .../okhttp/RollbarOkHttpInterceptorTest.java | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java index edbd709e..71278043 100644 --- a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java +++ b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java @@ -1,6 +1,8 @@ package com.rollbar.okhttp; import com.rollbar.api.payload.data.Level; +import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; +import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import java.io.IOException; import java.util.Objects; @@ -18,14 +20,7 @@ public class RollbarOkHttpInterceptor implements Interceptor { private static final Logger LOGGER = LoggerFactory.getLogger(RollbarOkHttpInterceptor.class); private static final UrlSanitizer DEFAULT_URL_SANITIZER = - url -> url - .newBuilder() - .username("") - .password("") - .query(null) - .fragment(null) - .build() - .toString(); + url -> DefaultUrlSanitizer.INSTANCE.sanitize(url.toString()); private final NetworkTelemetryRecorder recorder; private final UrlSanitizer urlSanitizer; @@ -34,6 +29,13 @@ public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder) { this(recorder, DEFAULT_URL_SANITIZER); } + public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder, + StringUrlSanitizer sharedSanitizer) { + this(recorder, (UrlSanitizer) url -> + Objects.requireNonNull(sharedSanitizer, "sharedSanitizer must not be null") + .sanitize(url.toString())); + } + public RollbarOkHttpInterceptor( NetworkTelemetryRecorder recorder, UrlSanitizer urlSanitizer) { diff --git a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java index 2ca8f8d6..31a32321 100644 --- a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java +++ b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java @@ -225,7 +225,7 @@ public void customSanitizerThrows_responseStillReturnedAndRecorderNotCalled() th OkHttpClient throwingClient = new OkHttpClient.Builder() .addInterceptor(new RollbarOkHttpInterceptor(recorder, - url -> { throw new IllegalStateException("bad url"); })) + (UrlSanitizer) url -> { throw new IllegalStateException("bad url"); })) .build(); Request request = new Request.Builder().url(server.url("/error")).build(); @@ -241,7 +241,7 @@ public void customSanitizer_isAppliedToUrl() throws IOException { server.enqueue(new MockResponse().setResponseCode(500)); OkHttpClient customClient = new OkHttpClient.Builder() - .addInterceptor(new RollbarOkHttpInterceptor(recorder, url -> "Updated String")) + .addInterceptor(new RollbarOkHttpInterceptor(recorder, (UrlSanitizer) url -> "Updated String")) .build(); Request request = new Request.Builder() From 9fee327e34d61707f0071f1d7ab8d03ff2866fe5 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:31:07 -0300 Subject: [PATCH 06/12] refactor(api): update imports --- .../java/com/rollbar/api}/scrubbing/DefaultUrlSanitizer.java | 2 +- .../java/com/rollbar/api}/scrubbing/StringUrlSanitizer.java | 2 +- .../com/rollbar/api}/scrubbing/DefaultUrlSanitizerTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename {rollbar-java/src/main/java/com/rollbar/notifier => rollbar-api/src/main/java/com/rollbar/api}/scrubbing/DefaultUrlSanitizer.java (97%) rename {rollbar-java/src/main/java/com/rollbar/notifier => rollbar-api/src/main/java/com/rollbar/api}/scrubbing/StringUrlSanitizer.java (92%) rename {rollbar-java/src/test/java/com/rollbar/notifier => rollbar-api/src/test/java/com/rollbar/api}/scrubbing/DefaultUrlSanitizerTest.java (98%) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java similarity index 97% rename from rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java rename to rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java index 06640a17..1329db97 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizer.java +++ b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java @@ -1,4 +1,4 @@ -package com.rollbar.notifier.scrubbing; +package com.rollbar.api.scrubbing; /** * Default {@link StringUrlSanitizer} that strips userinfo, query string, and fragment from URLs. diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java similarity index 92% rename from rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java rename to rollbar-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java index 263114c1..f526627f 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/StringUrlSanitizer.java +++ b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java @@ -1,4 +1,4 @@ -package com.rollbar.notifier.scrubbing; +package com.rollbar.api.scrubbing; /** * Sanitizes a URL string before it is included in a Rollbar payload. diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java b/rollbar-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java similarity index 98% rename from rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java rename to rollbar-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java index c11faf0f..9ea44eda 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/DefaultUrlSanitizerTest.java +++ b/rollbar-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java @@ -1,4 +1,4 @@ -package com.rollbar.notifier.scrubbing; +package com.rollbar.api.scrubbing; import org.junit.Test; From 347ad48d528bdf96782e85d3c9ba53a622136eee Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:36:52 -0300 Subject: [PATCH 07/12] refactor(scrubbing): update imports --- .../notifier/scrubbing/ScrubDataTransformer.java | 10 ++++++++++ .../notifier/scrubbing/ScrubDataTransformerTest.java | 1 + 2 files changed, 11 insertions(+) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java index 99cdd994..29164fcf 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -7,6 +7,8 @@ import com.rollbar.api.payload.data.body.Frame; import com.rollbar.api.payload.data.body.Trace; import com.rollbar.api.payload.data.body.TraceChain; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; import java.util.ArrayList; @@ -51,6 +53,14 @@ public final class ScrubDataTransformer implements Transformer { private final List fieldPatterns; private final StringUrlSanitizer urlSanitizer; + /** + * Constructor. + * + * @param redactedKeys keys to redact, matched as case-insensitive regexes. May be {@code null} + * or empty, in which case only the built-in header deny-list and the URL sanitizer apply. + * @param urlSanitizer sanitizer applied to the request URL. Falls back to + * {@link DefaultUrlSanitizer#INSTANCE} when {@code null}. + */ public ScrubDataTransformer(List redactedKeys, StringUrlSanitizer urlSanitizer) { this.urlSanitizer = urlSanitizer != null ? urlSanitizer : DefaultUrlSanitizer.INSTANCE; if (redactedKeys == null || redactedKeys.isEmpty()) { diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java index a698f73b..d628254f 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -5,6 +5,7 @@ import com.rollbar.api.payload.data.body.Body; import com.rollbar.api.payload.data.body.Frame; import com.rollbar.api.payload.data.body.Trace; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import org.junit.Test; import java.util.Arrays; From 306b105f67b0bd0c5bb7ec0f7dcf4269e534434e Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:37:12 -0300 Subject: [PATCH 08/12] refactor(config): update imports --- .../main/java/com/rollbar/notifier/config/CommonConfig.java | 4 ++-- .../main/java/com/rollbar/notifier/config/ConfigBuilder.java | 4 ++-- .../reactivestreams/notifier/config/ConfigBuilder.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java b/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java index 92c62210..f59d9965 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/config/CommonConfig.java @@ -6,11 +6,11 @@ import com.rollbar.api.payload.data.Person; import com.rollbar.api.payload.data.Request; import com.rollbar.api.payload.data.Server; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.filter.Filter; import com.rollbar.notifier.fingerprint.FingerprintGenerator; import com.rollbar.notifier.provider.Provider; -import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; -import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.sender.json.JsonSerializer; import com.rollbar.notifier.telemetry.TelemetryEventTracker; import com.rollbar.notifier.transformer.Transformer; diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java b/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java index d9068719..0ff59a44 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/config/ConfigBuilder.java @@ -6,6 +6,8 @@ import com.rollbar.api.payload.data.Person; import com.rollbar.api.payload.data.Request; import com.rollbar.api.payload.data.Server; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.Rollbar; import com.rollbar.notifier.filter.Filter; import com.rollbar.notifier.fingerprint.FingerprintGenerator; @@ -19,8 +21,6 @@ import com.rollbar.notifier.sender.json.JsonSerializerImpl; import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; import com.rollbar.notifier.telemetry.TelemetryEventTracker; -import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; -import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; import com.rollbar.notifier.uuid.UuidGenerator; import java.lang.Thread.UncaughtExceptionHandler; diff --git a/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java b/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java index d05c8e62..b4619bb4 100644 --- a/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java +++ b/rollbar-reactive-streams/src/main/java/com/rollbar/reactivestreams/notifier/config/ConfigBuilder.java @@ -6,6 +6,8 @@ import com.rollbar.api.payload.data.Person; import com.rollbar.api.payload.data.Request; import com.rollbar.api.payload.data.Server; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.Rollbar; import com.rollbar.notifier.config.DefaultLevels; import com.rollbar.notifier.filter.Filter; @@ -17,8 +19,6 @@ import com.rollbar.notifier.sender.json.JsonSerializer; import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; import com.rollbar.notifier.telemetry.TelemetryEventTracker; -import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; -import com.rollbar.notifier.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; import com.rollbar.notifier.uuid.UuidGenerator; import com.rollbar.reactivestreams.notifier.sender.AsyncSender; From 52d5d40da78ef89804292c1cbefc44bcbc09a7cf Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:38:08 -0300 Subject: [PATCH 09/12] refactor(okhttp): reuse the shared DefaultUrlSanitizer --- .../okhttp/RollbarOkHttpInterceptor.java | 29 +++++++++++++------ .../okhttp/RollbarOkHttpInterceptorTest.java | 4 +-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java index 71278043..ab865842 100644 --- a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java +++ b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java @@ -1,8 +1,8 @@ package com.rollbar.okhttp; import com.rollbar.api.payload.data.Level; -import com.rollbar.notifier.scrubbing.DefaultUrlSanitizer; -import com.rollbar.notifier.scrubbing.StringUrlSanitizer; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import java.io.IOException; import java.util.Objects; @@ -25,15 +25,26 @@ public class RollbarOkHttpInterceptor implements Interceptor { private final NetworkTelemetryRecorder recorder; private final UrlSanitizer urlSanitizer; - public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder) { - this(recorder, DEFAULT_URL_SANITIZER); + /** + * Creates an interceptor that sanitizes URLs with the same {@link StringUrlSanitizer} used by + * the notifier configuration, so both paths redact identically. + * + *

This is a static factory rather than a constructor overload because {@link UrlSanitizer} + * and {@link StringUrlSanitizer} are both functional interfaces: overloaded constructors would + * make a lambda argument ambiguous and break existing callers. + * + * @param recorder the telemetry recorder. + * @param sanitizer the sanitizer shared with the notifier config. + * @return the interceptor. + */ + public static RollbarOkHttpInterceptor withSharedUrlSanitizer(NetworkTelemetryRecorder recorder, + StringUrlSanitizer sanitizer) { + Objects.requireNonNull(sanitizer, "sanitizer must not be null"); + return new RollbarOkHttpInterceptor(recorder, url -> sanitizer.sanitize(url.toString())); } - public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder, - StringUrlSanitizer sharedSanitizer) { - this(recorder, (UrlSanitizer) url -> - Objects.requireNonNull(sharedSanitizer, "sharedSanitizer must not be null") - .sanitize(url.toString())); + public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder) { + this(recorder, DEFAULT_URL_SANITIZER); } public RollbarOkHttpInterceptor( diff --git a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java index 31a32321..2ca8f8d6 100644 --- a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java +++ b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java @@ -225,7 +225,7 @@ public void customSanitizerThrows_responseStillReturnedAndRecorderNotCalled() th OkHttpClient throwingClient = new OkHttpClient.Builder() .addInterceptor(new RollbarOkHttpInterceptor(recorder, - (UrlSanitizer) url -> { throw new IllegalStateException("bad url"); })) + url -> { throw new IllegalStateException("bad url"); })) .build(); Request request = new Request.Builder().url(server.url("/error")).build(); @@ -241,7 +241,7 @@ public void customSanitizer_isAppliedToUrl() throws IOException { server.enqueue(new MockResponse().setResponseCode(500)); OkHttpClient customClient = new OkHttpClient.Builder() - .addInterceptor(new RollbarOkHttpInterceptor(recorder, (UrlSanitizer) url -> "Updated String")) + .addInterceptor(new RollbarOkHttpInterceptor(recorder, url -> "Updated String")) .build(); Request request = new Request.Builder() From 82fb70bd43c089211bd2cd497cfa5f9b92756b6c Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 18:35:46 -0300 Subject: [PATCH 10/12] fix(scrubbing): scrub Frame.locals in Body.rollbarThreads --- .../scrubbing/ScrubDataTransformer.java | 103 +++++++++++---- .../scrubbing/ScrubDataTransformerTest.java | 125 +++++++++++++++--- 2 files changed, 184 insertions(+), 44 deletions(-) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java index 29164fcf..114a1a05 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -5,6 +5,8 @@ import com.rollbar.api.payload.data.body.Body; import com.rollbar.api.payload.data.body.BodyContent; import com.rollbar.api.payload.data.body.Frame; +import com.rollbar.api.payload.data.body.Group; +import com.rollbar.api.payload.data.body.RollbarThread; import com.rollbar.api.payload.data.body.Trace; import com.rollbar.api.payload.data.body.TraceChain; import com.rollbar.api.scrubbing.DefaultUrlSanitizer; @@ -33,7 +35,8 @@ * *

Additional keys (matched as case-insensitive regex against header names, query/POST * parameter keys, custom data keys, and {@code Frame.locals} keys) can be configured via - * {@code ConfigBuilder.redactedKeys(List)}. + * {@code ConfigBuilder.redactedKeys(List)}. {@code Frame.locals} are scrubbed both in the + * top-level body content and in the trace chains carried by {@code Body.rollbarThreads}. */ public final class ScrubDataTransformer implements Transformer { @@ -149,33 +152,85 @@ private Body scrubBody(Body body) { if (body == null || fieldPatterns.isEmpty()) { return body; } - BodyContent content = body.getContents(); + + BodyContent originalContent = body.getContents(); + List originalThreads = body.getRollbarThreads(); + + BodyContent scrubbedContent = scrubBodyContent(originalContent); + List scrubbedThreads = scrubThreads(originalThreads); + + if (scrubbedContent == originalContent && scrubbedThreads == originalThreads) { + return body; + } + + return new Body.Builder(body) + .bodyContent(scrubbedContent) + .rollbarThreads(scrubbedThreads) + .build(); + } + + private BodyContent scrubBodyContent(BodyContent content) { if (content instanceof Trace) { - Trace scrubbed = scrubTrace((Trace) content); - if (scrubbed != content) { - return new Body.Builder(body).bodyContent(scrubbed).build(); - } + return scrubTrace((Trace) content); } else if (content instanceof TraceChain) { - TraceChain chain = (TraceChain) content; - List traces = chain.getTraces(); - if (traces != null) { - List scrubbed = new ArrayList<>(traces.size()); - boolean anyChanged = false; - for (Trace trace : traces) { - Trace st = scrubTrace(trace); - scrubbed.add(st); - if (st != trace) { - anyChanged = true; - } - } - if (anyChanged) { - return new Body.Builder(body) - .bodyContent(new TraceChain.Builder(chain).traces(scrubbed).build()) - .build(); - } + return scrubTraceChain((TraceChain) content); + } + return content; + } + + /** + * The initial thread carries the same frames as the top-level body content, so its locals must + * be scrubbed too, otherwise the {@code threads} entry leaks what {@code trace} redacted. + */ + private List scrubThreads(List threads) { + if (threads == null || threads.isEmpty()) { + return threads; + } + List scrubbed = new ArrayList<>(threads.size()); + boolean anyChanged = false; + for (RollbarThread thread : threads) { + RollbarThread st = scrubThread(thread); + scrubbed.add(st); + if (st != thread) { + anyChanged = true; + } + } + return anyChanged ? scrubbed : threads; + } + + private RollbarThread scrubThread(RollbarThread thread) { + if (thread == null || thread.getGroup() == null) { + return thread; + } + TraceChain chain = thread.getGroup().getTraceChain(); + TraceChain scrubbedChain = scrubTraceChain(chain); + if (scrubbedChain == chain) { + return thread; + } + return new RollbarThread.Builder(thread).group(new Group(scrubbedChain)).build(); + } + + private TraceChain scrubTraceChain(TraceChain chain) { + if (chain == null) { + return null; + } + List traces = chain.getTraces(); + if (traces == null || traces.isEmpty()) { + return chain; + } + List scrubbed = new ArrayList<>(traces.size()); + boolean anyChanged = false; + for (Trace trace : traces) { + Trace st = scrubTrace(trace); + scrubbed.add(st); + if (st != trace) { + anyChanged = true; } } - return body; + if (!anyChanged) { + return chain; + } + return new TraceChain.Builder(chain).traces(scrubbed).build(); } private Trace scrubTrace(Trace trace) { diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java index d628254f..3247560e 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -4,7 +4,10 @@ import com.rollbar.api.payload.data.Request; import com.rollbar.api.payload.data.body.Body; import com.rollbar.api.payload.data.body.Frame; +import com.rollbar.api.payload.data.body.Group; +import com.rollbar.api.payload.data.body.RollbarThread; import com.rollbar.api.payload.data.body.Trace; +import com.rollbar.api.payload.data.body.TraceChain; import com.rollbar.api.scrubbing.StringUrlSanitizer; import org.junit.Test; @@ -62,7 +65,7 @@ private static Map objectMap(String... kvPairs) { @Test public void authorizationHeaderRedacted() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); Request req = new Request.Builder() .headers(headers("Authorization", "Bearer secret-token", "Content-Type", "application/json")) .build(); @@ -116,7 +119,7 @@ public void caseInsensitiveHeaderMatching() { @Test public void userKeyRedactsHeaders() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("X-My-Secret"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("X-My-Secret"), NO_OP_SANITIZER); Request req = new Request.Builder() .headers(headers("X-My-Secret", "sensitive", "Content-Type", "text/plain")) .build(); @@ -127,7 +130,7 @@ public void userKeyRedactsHeaders() { @Test public void userKeyRedactsGetParams() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("apiToken"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("apiToken"), NO_OP_SANITIZER); Request req = new Request.Builder() .get(getParams("apiToken", "secret-value")) .build(); @@ -138,7 +141,7 @@ public void userKeyRedactsGetParams() { @Test public void userKeyRedactsPostParams() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); Map post = objectMap("password", "hunter2", "username", "alice"); Request req = new Request.Builder().post(post).build(); Data result = t.transform(dataWithRequest(req)); @@ -148,7 +151,7 @@ public void userKeyRedactsPostParams() { @Test public void userKeyRedactsCustomMap() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("mySecret"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("mySecret"), NO_OP_SANITIZER); Map custom = objectMap("mySecret", "hidden", "other", "visible"); Data result = t.transform(dataWithCustom(custom)); assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("mySecret")); @@ -157,7 +160,7 @@ public void userKeyRedactsCustomMap() { @Test public void userKeyRegexMatchesMultipleKeys() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList(".*[Pp]assword.*"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList(".*[Pp]assword.*"), NO_OP_SANITIZER); Map custom = objectMap( "passwordHash", "xxx", "oldPassword", "yyy", @@ -174,7 +177,7 @@ public void userKeyRegexMatchesMultipleKeys() { @Test public void urlSanitizedViaProvidedSanitizer() { StringUrlSanitizer strip = url -> "https://example.com/clean"; - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), strip); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), strip); Request req = new Request.Builder() .url("https://example.com/api?secret=xyz") .build(); @@ -186,7 +189,7 @@ public void urlSanitizedViaProvidedSanitizer() { @Test public void queryStringValueRedacted() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); Request req = new Request.Builder() .queryString("token=secret&page=1") .build(); @@ -198,7 +201,7 @@ public void queryStringValueRedacted() { @Test public void queryStringUnchangedWhenNoMatch() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); String qs = "page=1&sort=asc"; Request req = new Request.Builder().queryString(qs).build(); Data result = t.transform(dataWithRequest(req)); @@ -209,7 +212,7 @@ public void queryStringUnchangedWhenNoMatch() { @Test public void frameLocalsMatchingKeysScrubbed() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); Map locals = objectMap("password", "secret", "userId", "42"); Frame frame = new Frame.Builder().locals(locals).build(); Trace trace = new Trace.Builder().frames(Collections.singletonList(frame)).build(); @@ -227,14 +230,14 @@ public void frameLocalsMatchingKeysScrubbed() { @Test public void nullRequestReturnsDataUnchanged() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); Data data = new Data.Builder().environment("test").build(); assertSame(data, t.transform(data)); } @Test public void nullHeadersMapNoNpe() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); Request req = new Request.Builder().url("https://example.com").build(); // headers null Data result = t.transform(dataWithRequest(req)); assertNotNull(result); @@ -242,7 +245,7 @@ public void nullHeadersMapNoNpe() { @Test public void nullCustomMapNoNpe() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("secret"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("secret"), NO_OP_SANITIZER); Data data = new Data.Builder().environment("test").build(); // custom null Data result = t.transform(data); assertNotNull(result); @@ -250,7 +253,7 @@ public void nullCustomMapNoNpe() { @Test public void noMatchReturnsSameDataInstance() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); Request req = new Request.Builder() .url("https://example.com") .headers(headers("Content-Type", "application/json")) @@ -261,7 +264,7 @@ public void noMatchReturnsSameDataInstance() { @Test public void emptyRedactedKeysOnlyScrubsDefaultHeaders() { - ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); Map custom = objectMap("myApiKey", "visible"); Map hdrs = headers("Authorization", "Bearer xyz", "Content-Type", "text/html"); Request req = new Request.Builder().headers(hdrs).build(); @@ -284,7 +287,7 @@ public void nullDataReturnsNull() { @Test public void valueLessQueryParamScrubbed() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); Request req = new Request.Builder().queryString("token&page=1").build(); Data result = t.transform(dataWithRequest(req)); String qs = result.getRequest().getQueryString(); @@ -294,7 +297,7 @@ public void valueLessQueryParamScrubbed() { @Test public void valueLessQueryParamNoMatchPassedThrough() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); Request req = new Request.Builder().queryString("debug&page=1").build(); Data result = t.transform(dataWithRequest(req)); assertSame(req.getQueryString(), result.getRequest().getQueryString()); @@ -304,7 +307,7 @@ public void valueLessQueryParamNoMatchPassedThrough() { @Test public void nestedCustomMapKeysScrubbed() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("password"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); Map inner = objectMap("password", "hunter2", "user", "alice"); Map custom = new HashMap<>(); custom.put("auth", inner); @@ -319,7 +322,7 @@ public void nestedCustomMapKeysScrubbed() { @Test public void nestedFrameLocalsKeysScrubbed() { - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("token"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); Map inner = objectMap("token", "secret-token", "count", "5"); Map locals = new HashMap<>(); locals.put("credentials", inner); @@ -342,11 +345,93 @@ public void nestedFrameLocalsKeysScrubbed() { @Test public void nestedMapParentKeyMatchScrubsEntireValue() { // When the top-level key itself matches, the whole nested map is replaced, not recursed. - ScrubDataTransformer t = new ScrubDataTransformer(Arrays.asList("credentials"), NO_OP_SANITIZER); + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("credentials"), NO_OP_SANITIZER); Map inner = objectMap("password", "hunter2"); Map custom = new HashMap<>(); custom.put("credentials", inner); Data result = t.transform(dataWithCustom(custom)); assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("credentials")); } + + @Test + public void threadFrameLocalsScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Body body = new Body.Builder() + .bodyContent(traceWithLocals(objectMap("password", "hunter2", "userId", "42"))) + .rollbarThreads(Collections.singletonList( + threadWithLocals(objectMap("password", "hunter2", "userId", "42")))) + .build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + + Map locals = threadLocals(result.getBody(), 0); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, locals.get("password")); + assertEquals("42", locals.get("userId")); + // The top-level trace is still scrubbed. + List frames = ((Trace) result.getBody().getContents()).getFrames(); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, frames.get(0).getLocals().get("password")); + } + + @Test + public void threadFrameLocalsScrubbedWhenBodyContentHasNoMatch() { + // Regression: the threads entry must be scrubbed even when the body content needs no change. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); + Body body = new Body.Builder() + .bodyContent(traceWithLocals(objectMap("userId", "42"))) + .rollbarThreads(Collections.singletonList( + threadWithLocals(objectMap("token", "secret-token")))) + .build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + threadLocals(result.getBody(), 0).get("token")); + } + + @Test + public void threadsWithNoMatchReturnSameBodyInstance() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Body body = new Body.Builder() + .bodyContent(traceWithLocals(objectMap("userId", "42"))) + .rollbarThreads(Collections.singletonList(threadWithLocals(objectMap("userId", "42")))) + .build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + assertSame(data, t.transform(data)); + } + + @Test + public void nullThreadsNoNpe() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Body body = new Body.Builder() + .bodyContent(traceWithLocals(objectMap("password", "hunter2"))) + .build(); // rollbarThreads null + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + + List frames = ((Trace) result.getBody().getContents()).getFrames(); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, frames.get(0).getLocals().get("password")); + assertNull(result.getBody().getRollbarThreads()); + } + + private static Trace traceWithLocals(Map locals) { + Frame frame = new Frame.Builder().locals(locals).build(); + return new Trace.Builder().frames(Collections.singletonList(frame)).build(); + } + + private static RollbarThread threadWithLocals(Map locals) { + TraceChain chain = new TraceChain.Builder() + .traces(Collections.singletonList(traceWithLocals(locals))) + .build(); + return new RollbarThread("main", "1", "5", "RUNNABLE", new Group(chain)); + } + + private static Map threadLocals(Body body, int threadIndex) { + return body.getRollbarThreads().get(threadIndex) + .getGroup().getTraceChain().getTraces().get(0) + .getFrames().get(0).getLocals(); + } } From 8c06fd1c5466bd508502a4ac0987cd4541a1c21b Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 18:42:25 -0300 Subject: [PATCH 11/12] fix(scrubbing): match percent-encoded query parameter names --- .../scrubbing/ScrubDataTransformer.java | 19 +++++- .../scrubbing/ScrubDataTransformerTest.java | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java index 114a1a05..7d3a16a9 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -13,6 +13,8 @@ import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.notifier.transformer.Transformer; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -343,7 +345,7 @@ private String scrubQueryString(String queryString, List patterns) { int eq = pair.indexOf('='); // A value-less param (e.g. "?token") is treated as key-only and scrubbed the same way. String key = eq >= 0 ? pair.substring(0, eq) : pair; - if (matchesAny(key, patterns)) { + if (matchesAny(key, patterns) || matchesAny(decodeParamName(key), patterns)) { output[i] = key + "=" + SCRUBBED_VALUE; changed = true; } else { @@ -363,6 +365,21 @@ private String scrubQueryString(String queryString, List patterns) { return sb.toString(); } + /** + * Percent-decodes a query parameter name. Malformed escapes (e.g. {@code %zz}) are left as-is + * rather than failing the transform: the raw form is still matched by the caller. + */ + private static String decodeParamName(String key) { + if (key.indexOf('%') < 0 && key.indexOf('+') < 0) { + return key; + } + try { + return URLDecoder.decode(key, "UTF-8"); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + return key; + } + } + private static boolean matchesDefaultHeader(String key) { return DEFAULT_HEADERS.contains(key.toLowerCase(Locale.ROOT)); } diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java index 3247560e..6b28bb96 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -208,6 +208,72 @@ public void queryStringUnchangedWhenNoMatch() { assertSame(qs, result.getRequest().getQueryString()); } + @Test + public void percentEncodedQueryParamNameRedacted() { + // getQueryString() is raw, so "pass%77ord" is semantically the "password" param. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .queryString("pass%77ord=hunter2&page=1") + .build(); + Data result = t.transform(dataWithRequest(req)); + String qs = result.getRequest().getQueryString(); + assertFalse(qs.contains("hunter2")); + // The original encoding of the key is preserved; only the value is replaced. + assertTrue(qs.contains("pass%77ord=" + ScrubDataTransformer.SCRUBBED_VALUE)); + assertTrue(qs.contains("page=1")); + } + + @Test + public void fullyPercentEncodedQueryParamNameRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .queryString("%70%61%73%73%77%6F%72%64=hunter2") + .build(); + Data result = t.transform(dataWithRequest(req)); + assertFalse(result.getRequest().getQueryString().contains("hunter2")); + } + + @Test + public void plusEncodedQueryParamNameRedacted() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("user password"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .queryString("user+password=hunter2") + .build(); + Data result = t.transform(dataWithRequest(req)); + assertFalse(result.getRequest().getQueryString().contains("hunter2")); + } + + @Test + public void malformedEscapeInQueryParamNameFallsBackToRawMatch() { + // %zz is not a valid escape; decoding fails and the raw name is matched instead. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .queryString("token%zz=secret&page=1") + .build(); + Data result = t.transform(dataWithRequest(req)); + String qs = result.getRequest().getQueryString(); + assertFalse(qs.contains("secret")); + assertTrue(qs.contains("page=1")); + } + + @Test + public void malformedEscapeInNonMatchingQueryParamPassedThrough() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + String qs = "page%zz=1"; + Request req = new Request.Builder().queryString(qs).build(); + Data result = t.transform(dataWithRequest(req)); + assertSame(qs, result.getRequest().getQueryString()); + } + + @Test + public void encodedValueLessQueryParamScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder().queryString("t%6Fken").build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals("t%6Fken=" + ScrubDataTransformer.SCRUBBED_VALUE, + result.getRequest().getQueryString()); + } + // --- Frame.locals --- @Test From a09f485f6231fdb4f3938982d57421ca5495b0fd Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 18:47:46 -0300 Subject: [PATCH 12/12] fix(scrubbing): scrub Request.params and Request.metadata --- .../scrubbing/ScrubDataTransformer.java | 48 ++++++++++++++-- .../scrubbing/ScrubDataTransformerTest.java | 57 +++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java index 7d3a16a9..84923ff6 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -35,10 +35,15 @@ * {@code X-Auth-Token}, {@code X-Access-Token}, {@code X-Secret}, * {@code Proxy-Authorization}, {@code WWW-Authenticate}. * - *

Additional keys (matched as case-insensitive regex against header names, query/POST - * parameter keys, custom data keys, and {@code Frame.locals} keys) can be configured via - * {@code ConfigBuilder.redactedKeys(List)}. {@code Frame.locals} are scrubbed both in the - * top-level body content and in the trace chains carried by {@code Body.rollbarThreads}. + *

Additional keys can be configured via {@code ConfigBuilder.redactedKeys(List)}. They are + * matched as case-insensitive regexes against header names, routing parameter keys + * ({@code Request.params}), query and POST parameter keys, request metadata keys + * ({@code Request.metadata}), custom data keys, and {@code Frame.locals} keys. + * {@code Frame.locals} are scrubbed both in the top-level body content and in the trace chains + * carried by {@code Body.rollbarThreads}. + * + *

The built-in header deny-list above applies to {@code Request.headers} only; every other + * slot matches on the configured keys alone. */ public final class ScrubDataTransformer implements Transformer { @@ -121,20 +126,26 @@ private Request scrubRequest(Request req) { String originalUrl = req.getUrl(); Map originalHeaders = req.getHeaders(); + Map originalParams = req.getParams(); Map> originalGet = req.getGet(); Map originalPost = req.getPost(); + Map originalMetadata = req.getMetadata(); String originalQueryString = req.getQueryString(); String scrubbedUrl = originalUrl != null ? urlSanitizer.sanitize(originalUrl) : null; - Map scrubbedHeaders = scrubStringMap(originalHeaders); + Map scrubbedHeaders = scrubHeaders(originalHeaders); + Map scrubbedParams = scrubStringMap(originalParams, fieldPatterns); Map> scrubbedGet = scrubMultiMap(originalGet, fieldPatterns); Map scrubbedPost = scrubObjectMap(originalPost, fieldPatterns, 0); + Map scrubbedMetadata = scrubObjectMap(originalMetadata, fieldPatterns, 0); String scrubbedQueryString = scrubQueryString(originalQueryString, fieldPatterns); boolean changed = !equal(originalUrl, scrubbedUrl) || scrubbedHeaders != originalHeaders + || scrubbedParams != originalParams || scrubbedGet != originalGet || scrubbedPost != originalPost + || scrubbedMetadata != originalMetadata || !equal(originalQueryString, scrubbedQueryString); if (!changed) { @@ -144,8 +155,10 @@ private Request scrubRequest(Request req) { return new Request.Builder(req) .url(scrubbedUrl) .headers(scrubbedHeaders) + .params(scrubbedParams) .get(scrubbedGet) .post(scrubbedPost) + .metadata(scrubbedMetadata) .queryString(scrubbedQueryString) .build(); } @@ -270,7 +283,7 @@ private Frame scrubFrame(Frame frame) { return new Frame.Builder(frame).locals(scrubbedLocals).build(); } - private Map scrubStringMap(Map map) { + private Map scrubHeaders(Map map) { if (map == null) { return null; } @@ -287,6 +300,29 @@ private Map scrubStringMap(Map map) { return result != null ? result : map; } + /** + * Scrubs a flat string map against the configured keys only. The built-in header deny-list is + * deliberately not applied here: it names HTTP headers, and a routing parameter such as + * {@code /cookie/:id} is not one. This keeps routing params consistent with the GET/POST + * parameter maps, which also match on {@code redactedKeys} alone. + */ + private Map scrubStringMap(Map map, List patterns) { + if (map == null || patterns.isEmpty()) { + return map; + } + Map result = null; + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + if (matchesAny(key, patterns)) { + if (result == null) { + result = new HashMap<>(map); + } + result.put(key, SCRUBBED_VALUE); + } + } + return result != null ? result : map; + } + private Map scrubObjectMap(Map map, List patterns, int depth) { if (map == null || patterns.isEmpty()) { diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java index 6b28bb96..d231eed7 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -158,6 +158,63 @@ public void userKeyRedactsCustomMap() { assertEquals("visible", result.getCustom().get("other")); } + @Test + public void userKeyRedactsRoutingParams() { + // e.g. a /reset/:token route populating Request.params. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .params(headers("token", "reset-token-abc", "userId", "42")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getParams().get("token")); + assertEquals("42", result.getRequest().getParams().get("userId")); + } + + @Test + public void routingParamsNotMatchedByHeaderDenyList() { + // The built-in deny-list names HTTP headers; a routing param called "cookie" is not one. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .params(headers("cookie", "chocolate-chip")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals("chocolate-chip", result.getRequest().getParams().get("cookie")); + } + + @Test + public void userKeyRedactsMetadata() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("apiKey"), NO_OP_SANITIZER); + Request req = new Request.Builder() + .metadata(objectMap("apiKey", "key-12345", "region", "us-east-1")) + .build(); + Data result = t.transform(dataWithRequest(req)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getMetadata().get("apiKey")); + assertEquals("us-east-1", result.getRequest().getMetadata().get("region")); + } + + @Test + public void nestedMetadataKeysScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map inner = objectMap("password", "hunter2", "user", "alice"); + Map metadata = new HashMap<>(); + metadata.put("auth", inner); + Request req = new Request.Builder().metadata(metadata).build(); + Data result = t.transform(dataWithRequest(req)); + @SuppressWarnings("unchecked") + Map scrubbed = (Map) result.getRequest().getMetadata().get("auth"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.get("password")); + assertEquals("alice", scrubbed.get("user")); + } + + @Test + public void nullParamsAndMetadataNoNpe() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Request req = new Request.Builder().url("https://example.com").build(); + Data result = t.transform(dataWithRequest(req)); + assertNull(result.getRequest().getParams()); + assertNull(result.getRequest().getMetadata()); + } + @Test public void userKeyRegexMatchesMultipleKeys() { ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList(".*[Pp]assword.*"), NO_OP_SANITIZER);