From 2ba949424ef046fbf75cea200402e38c0128303d Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 13 Jul 2026 03:11:54 -0300 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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); From 57399ecef067873d43f427c2f40c9b14be2b785e Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 3 Aug 2026 03:49:21 -0300 Subject: [PATCH 13/21] fix(scrubbing): traverse collections and arrays when scrubbing nested data --- .../scrubbing/ScrubDataTransformer.java | 102 +++++++-- .../scrubbing/ScrubDataTransformerTest.java | 206 ++++++++++++++++++ 2 files changed, 291 insertions(+), 17 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 84923ff6..4ba6dac7 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 @@ -17,9 +17,12 @@ import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -42,6 +45,10 @@ * {@code Frame.locals} are scrubbed both in the top-level body content and in the trace chains * carried by {@code Body.rollbarThreads}. * + *

Nested data is walked recursively: maps reachable through other maps, through + * {@link Collection}s and through object arrays are all scrubbed, up to 8 levels of nesting. The + * surrounding shape is preserved, so a list stays a list and an array stays an array. + * *

The built-in header deny-list above applies to {@code Request.headers} only; every other * slot matches on the configured keys alone. */ @@ -57,7 +64,8 @@ public final class ScrubDataTransformer implements Transformer { )) ); - // Recursion cap for nested Map values in custom data and Frame.locals. + // Recursion cap for nested containers in custom data, request payloads and Frame.locals. Every + // map, collection or array counts as one level; this also terminates cyclic structures. private static final int MAX_SCRUB_DEPTH = 8; private final List fieldPatterns; @@ -323,35 +331,95 @@ private Map scrubStringMap(Map map, List 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(); + // scrubMap only ever copies keys across, so a Map in stays one on the way out. + return (Map) scrubMap(map, patterns, depth); + } + + /** + * Recursively scrubs a nested value. Maps are scrubbed by key; collections and object arrays are + * traversed so that the maps they contain are scrubbed too, preserving the surrounding shape. + * Every container counts as one level against {@code MAX_SCRUB_DEPTH}, which also terminates + * cyclic structures. Anything else is returned untouched. + */ + private Object scrubNested(Object value, List patterns, int depth) { + if (depth >= MAX_SCRUB_DEPTH) { + return value; + } + if (value instanceof Map) { + return scrubMap((Map) value, patterns, depth + 1); + } + if (value instanceof Collection) { + return scrubCollection((Collection) value, patterns, depth + 1); + } + if (value instanceof Object[]) { + return scrubArray((Object[]) value, patterns, depth + 1); + } + return value; + } + + private Object scrubMap(Map map, List patterns, int depth) { + Map result = null; + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); Object value = entry.getValue(); - if (matchesAny(key, patterns)) { + // A non-String key cannot match a redactedKeys pattern, but its value is still traversed. + boolean keyMatches = key instanceof String && matchesAny((String) key, patterns); + Object scrubbed = keyMatches ? SCRUBBED_VALUE : scrubNested(value, patterns, depth); + if (keyMatches || scrubbed != value) { 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); + result = new LinkedHashMap<>(map); } + result.put(key, scrubbed); } } return result != null ? result : map; } + private Object scrubCollection(Collection collection, List patterns, int depth) { + if (collection.isEmpty()) { + return collection; + } + List scrubbed = new ArrayList<>(collection.size()); + boolean changed = false; + for (Object element : collection) { + Object scrubbedElement = scrubNested(element, patterns, depth); + scrubbed.add(scrubbedElement); + if (scrubbedElement != element) { + changed = true; + } + } + if (!changed) { + return collection; + } + // Sets keep set semantics; any other Collection serializes as a JSON array either way. + // A SortedSet is deliberately downgraded to insertion order: a rebuilt map is not Comparable. + return collection instanceof Set ? new LinkedHashSet<>(scrubbed) : scrubbed; + } + + private Object scrubArray(Object[] array, List patterns, int depth) { + Object[] result = null; + for (int i = 0; i < array.length; i++) { + Object scrubbedElement = scrubNested(array[i], patterns, depth); + if (scrubbedElement != array[i]) { + if (result == null) { + // Object[] rather than array.clone(): a rebuilt value may not fit the original component + // type (e.g. a HashMap[] receiving a LinkedHashMap), which would throw + // ArrayStoreException. + result = new Object[array.length]; + System.arraycopy(array, 0, result, 0, array.length); + } + result[i] = scrubbedElement; + } + } + return result != null ? result : array; + } + private Map> scrubMultiMap(Map> map, List patterns) { 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 d231eed7..d72817ad 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 @@ -11,11 +11,14 @@ import com.rollbar.api.scrubbing.StringUrlSanitizer; import org.junit.Test; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.Assert.*; @@ -540,6 +543,209 @@ public void nullThreadsNoNpe() { assertNull(result.getBody().getRollbarThreads()); } + // --- collections and arrays (P1 fix) --- + + @Test + public void listOfMapsInCustomScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map custom = new HashMap<>(); + custom.put("users", Collections.singletonList(objectMap("password", "hunter2", "name", "alice"))); + Data result = t.transform(dataWithCustom(custom)); + + List users = (List) result.getCustom().get("users"); + Map user = (Map) users.get(0); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, user.get("password")); + assertEquals("alice", user.get("name")); + } + + @Test + public void arrayOfMapsInCustomScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map custom = new HashMap<>(); + custom.put("users", new Object[] {objectMap("password", "hunter2", "name", "alice")}); + Data result = t.transform(dataWithCustom(custom)); + + Object[] users = (Object[]) result.getCustom().get("users"); + Map user = (Map) users[0]; + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, user.get("password")); + assertEquals("alice", user.get("name")); + } + + @Test + public void nestedListScrubbedInRequestPost() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map post = new HashMap<>(); + post.put("users", Collections.singletonList(objectMap("password", "hunter2"))); + Request req = new Request.Builder().post(post).build(); + + Data result = t.transform(dataWithRequest(req)); + + List users = (List) result.getRequest().getPost().get("users"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, ((Map) users.get(0)).get("password")); + } + + @Test + public void nestedListScrubbedInRequestMetadata() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map metadata = new HashMap<>(); + metadata.put("users", Collections.singletonList(objectMap("password", "hunter2"))); + Request req = new Request.Builder().metadata(metadata).build(); + + Data result = t.transform(dataWithRequest(req)); + + List users = (List) result.getRequest().getMetadata().get("users"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, ((Map) users.get(0)).get("password")); + } + + @Test + public void nestedArrayScrubbedInFrameLocals() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("token"), NO_OP_SANITIZER); + Map locals = new HashMap<>(); + locals.put("sessions", new Object[] {objectMap("token", "secret-token")}); + Body body = new Body.Builder().bodyContent(traceWithLocals(locals)).build(); + Data data = new Data.Builder().environment("test").body(body).build(); + + Data result = t.transform(data); + + List frames = ((Trace) result.getBody().getContents()).getFrames(); + Object[] sessions = (Object[]) frames.get(0).getLocals().get("sessions"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, ((Map) sessions[0]).get("token")); + } + + @Test + public void listOrderAndSizePreservedWhenScrubbing() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map custom = new HashMap<>(); + custom.put("rows", Arrays.asList(objectMap("password", "hunter2"), "plain", objectMap("name", "bob"))); + Data result = t.transform(dataWithCustom(custom)); + + Object scrubbed = result.getCustom().get("rows"); + assertTrue(scrubbed instanceof List); + List rows = (List) scrubbed; + assertEquals(3, rows.size()); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, ((Map) rows.get(0)).get("password")); + assertEquals("plain", rows.get(1)); + assertEquals("bob", ((Map) rows.get(2)).get("name")); + } + + @Test + public void setShapePreservedWhenScrubbing() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Set rows = new LinkedHashSet<>(); + rows.add(objectMap("password", "hunter2")); + rows.add("plain"); + Map custom = new HashMap<>(); + custom.put("rows", rows); + Data result = t.transform(dataWithCustom(custom)); + + Object scrubbed = result.getCustom().get("rows"); + assertTrue(scrubbed instanceof Set); + Set scrubbedRows = (Set) scrubbed; + assertEquals(2, scrubbedRows.size()); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + ((Map) scrubbedRows.iterator().next()).get("password")); + } + + @Test + public void typedArrayScrubbedWithoutArrayStoreException() { + // The rebuilt map may not fit the original component type, so the array is widened on copy. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + HashMap[] rows = new HashMap[] {(HashMap) objectMap("password", "hunter2")}; + Map custom = new HashMap<>(); + custom.put("rows", rows); + + Data result = t.transform(dataWithCustom(custom)); + + Object[] scrubbed = (Object[]) result.getCustom().get("rows"); + assertEquals(1, scrubbed.length); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, ((Map) scrubbed[0]).get("password")); + } + + @Test + public void collectionWithNoMatchReturnsSameInstances() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + List users = Collections.singletonList(objectMap("name", "alice")); + Map custom = new HashMap<>(); + custom.put("users", users); + Data data = dataWithCustom(custom); + + Data result = t.transform(data); + + assertSame(data, result); + assertSame(users, result.getCustom().get("users")); + } + + @Test + public void collectionNestingWithinDepthCapScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map custom = new HashMap<>(); + custom.put("root", nestInLists(objectMap("password", "hunter2"), 7)); + + Data result = t.transform(dataWithCustom(custom)); + + Map leaf = (Map) unwrapLists(result.getCustom().get("root"), 7); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, leaf.get("password")); + } + + @Test + public void collectionNestingBeyondDepthCapNotScrubbed() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map custom = new HashMap<>(); + custom.put("root", nestInLists(objectMap("password", "hunter2"), 8)); + + Data result = t.transform(dataWithCustom(custom)); + + Map leaf = (Map) unwrapLists(result.getCustom().get("root"), 8); + assertEquals("hunter2", leaf.get("password")); + } + + @Test + public void selfReferencingCollectionTerminates() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + List cycle = new ArrayList<>(); + cycle.add(objectMap("password", "hunter2")); + cycle.add(cycle); + Map custom = new HashMap<>(); + custom.put("cycle", cycle); + + Data result = t.transform(dataWithCustom(custom)); + + List scrubbed = (List) result.getCustom().get("cycle"); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + ((Map) scrubbed.get(0)).get("password")); + } + + @Test + public void nonStringMapKeysInsideCollectionDoNotThrow() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("password"), NO_OP_SANITIZER); + Map byId = new HashMap<>(); + byId.put(1, objectMap("password", "hunter2")); + Map custom = new HashMap<>(); + custom.put("rows", Collections.singletonList(byId)); + + Data result = t.transform(dataWithCustom(custom)); + + Map scrubbedById = (Map) ((List) result.getCustom().get("rows")).get(0); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + ((Map) scrubbedById.get(1)).get("password")); + } + + private static Object nestInLists(Object leaf, int levels) { + Object current = leaf; + for (int i = 0; i < levels; i++) { + current = new ArrayList<>(Collections.singletonList(current)); + } + return current; + } + + private static Object unwrapLists(Object value, int levels) { + Object current = value; + for (int i = 0; i < levels; i++) { + current = ((List) current).get(0); + } + return current; + } + private static Trace traceWithLocals(Map locals) { Frame frame = new Frame.Builder().locals(locals).build(); return new Trace.Builder().frames(Collections.singletonList(frame)).build(); From 5482e724bbf9c419b945169191794b3265b75891 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 3 Aug 2026 03:50:50 -0300 Subject: [PATCH 14/21] fix(telemetry): sanitize URLs recorded as network telemetry events --- .../com/rollbar/notifier/RollbarBase.java | 30 ++++++++++- .../notifier/RollbarRecordTelemetryTest.java | 52 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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 8835c158..8f53a9f3 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java @@ -8,6 +8,8 @@ import com.rollbar.api.payload.data.TelemetryEvent; import com.rollbar.api.payload.data.TelemetryType; import com.rollbar.api.payload.data.body.Body; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import com.rollbar.jvmti.ThrowableCache; import com.rollbar.notifier.config.CommonConfig; import com.rollbar.notifier.scrubbing.ScrubDataTransformer; @@ -46,6 +48,11 @@ public abstract class RollbarBase { private volatile ScrubDataTransformer builtInScrubber; + // Network telemetry URLs are sanitized when they are recorded rather than when the payload is + // built: TelemetryEvent is opaque once constructed, and an event is recorded once but can be + // attached to many payloads. + private volatile StringUrlSanitizer telemetryUrlSanitizer; + protected final ReadWriteLock configReadWriteLock = new ReentrantReadWriteLock(); protected final Lock configReadLock = configReadWriteLock.readLock(); protected final Lock configWriteLock = configReadWriteLock.writeLock(); @@ -59,6 +66,7 @@ protected RollbarBase(C config, BodyFactory bodyFactory, RESULT emptyResult) { this.emptyResult = emptyResult; this.telemetryEventTracker = config.telemetryEventTracker(); this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer()); + this.telemetryUrlSanitizer = urlSanitizerOf(config); } /** @@ -97,6 +105,12 @@ public void recordNavigationEventFor(Level level, String from, String to) { * Record network telemetry event with method, url, and status code. * ({@link TelemetryType#NETWORK}). * + *

The url is sanitized with the configured + * {@link CommonConfig#urlSanitizer() url sanitizer} before it is recorded, so credentials and + * query strings do not reach Rollbar. Callers that record through + * {@link #getTelemetryEventTracker()} directly bypass this and are responsible for sanitizing + * themselves. + * * @param level the TelemetryEvent severity (e.g. {@link Level#DEBUG}). * @param method the verb used (e.g. "POST"). * @param url the api url (e.g. " @@ -104,7 +118,10 @@ public void recordNavigationEventFor(Level level, String from, String to) { * @param statusCode the response status code (e.g. "404"). */ public void recordNetworkEventFor(Level level, String method, String url, String statusCode) { - telemetryEventTracker.recordNetworkEventFor(level, getSource(), method, url, statusCode); + StringUrlSanitizer sanitizer = this.telemetryUrlSanitizer; + String sanitizedUrl = url != null ? sanitizer.sanitize(url) : null; + telemetryEventTracker.recordNetworkEventFor(level, getSource(), method, sanitizedUrl, + statusCode); } /** @@ -128,11 +145,22 @@ protected void configure(C config) { configureTruncation(config); processAppPackages(config); this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer()); + this.telemetryUrlSanitizer = urlSanitizerOf(config); } finally { this.configWriteLock.unlock(); } } + /** + * {@link CommonConfig#urlSanitizer()} is documented as never {@code null}, but it is a default + * method a third-party implementation can override, so fall back the same way + * {@link ScrubDataTransformer} does. + */ + private static StringUrlSanitizer urlSanitizerOf(CommonConfig config) { + StringUrlSanitizer sanitizer = config.urlSanitizer(); + return sanitizer != null ? sanitizer : DefaultUrlSanitizer.INSTANCE; + } + private void configureTruncation(C config) { if (config.truncateLargePayloads()) { ObjectsUtils.requireNonNull(config.jsonSerializer(), diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/RollbarRecordTelemetryTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/RollbarRecordTelemetryTest.java index e645fae3..efc7c774 100644 --- a/rollbar-java/src/test/java/com/rollbar/notifier/RollbarRecordTelemetryTest.java +++ b/rollbar-java/src/test/java/com/rollbar/notifier/RollbarRecordTelemetryTest.java @@ -115,6 +115,54 @@ public void shouldRecordANavigationEventWithClientSourceWhenThePlatformIsAndroid verify(telemetryEventTracker).recordNavigationEventFor(level, Source.CLIENT, from, to); } + @Test + public void shouldSanitizeTheNetworkEventUrlWithTheDefaultSanitizer() { + RollbarBase sut = new RollbarBaseImpl(getConfigWith("any"), dummyFactory, null); + + sut.recordNetworkEventFor(level, "GET", "https://user:pass@example.com/p?token=secret#f", "500"); + + verify(telemetryEventTracker) + .recordNetworkEventFor(level, Source.SERVER, "GET", "https://example.com/p", "500"); + } + + @Test + public void shouldSanitizeTheNetworkEventUrlWithTheConfiguredSanitizer() { + Config config = withAccessToken("dummy token") + .telemetryEventTracker(telemetryEventTracker) + .urlSanitizer(url -> "sanitized") + .build(); + RollbarBase sut = new RollbarBaseImpl(config, dummyFactory, null); + + sut.recordNetworkEventFor(level, "GET", "https://example.com/p?token=secret", "500"); + + verify(telemetryEventTracker) + .recordNetworkEventFor(level, Source.SERVER, "GET", "sanitized", "500"); + } + + @Test + public void shouldUseTheReconfiguredSanitizerForLaterNetworkEvents() { + RollbarBaseImpl sut = new RollbarBaseImpl(getConfigWith("any"), dummyFactory, null); + + sut.reconfigure(withAccessToken("dummy token") + .telemetryEventTracker(telemetryEventTracker) + .urlSanitizer(url -> "reconfigured") + .build()); + sut.recordNetworkEventFor(level, "GET", "https://example.com/p?token=secret", "500"); + + verify(telemetryEventTracker) + .recordNetworkEventFor(level, Source.SERVER, "GET", "reconfigured", "500"); + } + + @Test + public void shouldRecordANetworkEventWithANullUrl() { + RollbarBase sut = new RollbarBaseImpl(getConfigWith("any"), dummyFactory, null); + + sut.recordNetworkEventFor(level, "GET", null, "500"); + + verify(telemetryEventTracker) + .recordNetworkEventFor(level, Source.SERVER, "GET", null, "500"); + } + private Config getConfigWith(String platform) { return withAccessToken("dummy token") .telemetryEventTracker(telemetryEventTracker) @@ -128,6 +176,10 @@ protected RollbarBaseImpl(Config config, BodyFactory bodyFactory, Void emptyResu super(config, bodyFactory, emptyResult); } + void reconfigure(Config config) { + configure(config); + } + @Override protected Void sendPayload(Config config, Payload payload) { return null; From f7bc6f36a618bebbb9302fa387915e77a3303a87 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 3 Aug 2026 03:53:36 -0300 Subject: [PATCH 15/21] test(scrubbing): cover ordering, reconfiguration and the okhttp sanitizer --- .../com/rollbar/notifier/ScrubbingITest.java | 203 ++++++++++++++++++ .../okhttp/RollbarOkHttpInterceptorTest.java | 56 +++++ .../notifier/ScrubbingReactiveITest.java | 185 ++++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java create mode 100644 rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java diff --git a/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java b/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java new file mode 100644 index 00000000..d3ba637c --- /dev/null +++ b/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java @@ -0,0 +1,203 @@ +package com.rollbar.notifier; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.rollbar.notifier.config.ConfigBuilder.withAccessToken; +import static java.lang.String.format; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import com.google.gson.Gson; +import com.rollbar.api.payload.data.Data; +import com.rollbar.api.payload.data.Level; +import com.rollbar.notifier.config.Config; +import com.rollbar.notifier.config.ConfigBuilder; +import com.rollbar.notifier.scrubbing.ScrubDataTransformer; +import com.rollbar.notifier.sender.Sender; +import com.rollbar.notifier.sender.SyncSender; +import com.rollbar.notifier.transformer.Transformer; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * End-to-end coverage of the built-in scrubber: it must run after any user transformer, follow + * reconfiguration, reach data nested inside collections, and keep network telemetry URLs clean. + * Assertions are made against the JSON WireMock actually received, so the whole serialization + * path is exercised. + */ +public class ScrubbingITest { + + private static final String ACCESS_TOKEN = UUID.randomUUID().toString(); + + private static final String SCRUBBED = ScrubDataTransformer.SCRUBBED_VALUE; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort()); + + private Sender sender; + + private ConfigBuilder configBuilder; + + @Before + public void setUp() { + this.sender = buildSender(getUrl()); + this.configBuilder = withAccessToken(ACCESS_TOKEN).sender(sender); + + stubFor(post(urlEqualTo("/api/1/item/")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"err\":0,\"result\":{\"uuid\":\"" + UUID.randomUUID() + "\"}}"))); + } + + @After + public void tearDown() throws Exception { + this.sender.close(true); + } + + @Test + public void builtInScrubbingRunsAfterTheUserTransformer() { + // The user transformer injects the secret, so it can only be redacted if the built-in + // scrubber runs afterwards. + Transformer injectSecret = data -> new Data.Builder(data) + .custom(objectMap("password", "hunter2", "user", "alice")) + .build(); + + Config config = configBuilder + .transformer(injectSecret) + .redactedKeys(Collections.singletonList("password")) + .build(); + + new Rollbar(config).error("boom"); + + Map custom = getValue(sentData(0), "custom"); + assertThat(custom.get("password"), is(SCRUBBED)); + assertThat(custom.get("user"), is("alice")); + } + + @Test + public void reconfigurationChangesTheRedactedKeys() { + Rollbar rollbar = new Rollbar(configBuilder + .redactedKeys(Collections.singletonList("password")) + .build()); + + rollbar.error("boom", objectMap("password", "hunter2", "token", "secret-token")); + + Map before = getValue(sentData(0), "custom"); + assertThat(before.get("password"), is(SCRUBBED)); + assertThat(before.get("token"), is("secret-token")); + + rollbar.configure(configBuilder + .redactedKeys(Collections.singletonList("token")) + .build()); + + rollbar.error("boom", objectMap("password", "hunter2", "token", "secret-token")); + + Map after = getValue(sentData(1), "custom"); + assertThat(after.get("password"), is("hunter2")); + assertThat(after.get("token"), is(SCRUBBED)); + } + + @Test + public void nestedCollectionsAreScrubbedEndToEnd() { + Config config = configBuilder + .redactedKeys(Collections.singletonList("password")) + .build(); + + Map custom = new HashMap<>(); + custom.put("users", Arrays.asList(objectMap("password", "hunter2"), objectMap("name", "bob"))); + custom.put("keys", new Object[] {objectMap("password", "hunter2")}); + + new Rollbar(config).error("boom", custom); + + Map sentCustom = getValue(sentData(0), "custom"); + + List> users = getValue(sentCustom, "users"); + assertThat(users, hasSize(2)); + assertThat(users.get(0).get("password"), is(SCRUBBED)); + assertThat(users.get(1).get("name"), is("bob")); + + List> keys = getValue(sentCustom, "keys"); + assertThat(keys, hasSize(1)); + assertThat(keys.get(0).get("password"), is(SCRUBBED)); + } + + @Test + public void networkTelemetryUrlsAreSanitized() { + Rollbar rollbar = new Rollbar(configBuilder.build()); + + rollbar.recordNetworkEventFor(Level.CRITICAL, "GET", + "https://user:pass@example.com/orders?token=secret#frag", "500"); + rollbar.error("boom"); + + List> telemetry = getValue(sentData(0), "body", "telemetry"); + assertThat(telemetry, hasSize(1)); + Map body = getValue(telemetry.get(0), "body"); + assertThat(body.get("url"), is("https://example.com/orders")); + } + + // --- helpers --- + + protected Sender buildSender(String url) { + return new SyncSender.Builder().url(url).accessToken(ScrubbingITest.ACCESS_TOKEN).build(); + } + + /** The parsed {@code data} object of the nth payload WireMock received. */ + @SuppressWarnings("unchecked") + private Map sentData(int index) { + List requests = + WireMock.findAll(postRequestedFor(urlEqualTo("/api/1/item/"))); + Map payload = + new Gson().fromJson(requests.get(index).getBodyAsString(), Map.class); + return getValue(payload, "data"); + } + + @SuppressWarnings("unchecked") + private static T getValue(Map source, String attribute, + String... attributes) { + Object value = source.get(attribute); + + if (attributes.length == 0) { + return (T) value; + } + + if (value == null) { + throw new NullPointerException("No value with key " + attribute); + } + + Map asMap = (Map) value; + String[] newAttributes = new String[attributes.length - 1]; + System.arraycopy(attributes, 1, newAttributes, 0, newAttributes.length); + + return getValue(asMap, attributes[0], newAttributes); + } + + 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; + } + + private String getUrl() { + return format(Locale.US, "http://localhost:%d/api/1/item/", wireMockRule.port()); + } +} 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..43be84cd 100644 --- a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java +++ b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java @@ -1,6 +1,8 @@ package com.rollbar.okhttp; import com.rollbar.api.payload.data.Level; +import com.rollbar.api.scrubbing.DefaultUrlSanitizer; +import com.rollbar.api.scrubbing.StringUrlSanitizer; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -236,6 +238,60 @@ public void customSanitizerThrows_responseStillReturnedAndRecorderNotCalled() th verify(recorder, never()).recordNetworkEvent(any(), any(), any(), any()); } + /** + * {@code DefaultUrlSanitizer.INSTANCE} is what {@code CommonConfig.urlSanitizer()} returns by + * default, so passing it here is the shared-sanitizer path a notifier user would configure. + * rollbar-okhttp only depends on rollbar-api, so the notifier config is not referenced directly. + */ + @Test + public void sharedUrlSanitizer_redactsIdenticallyToTheNotifier() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + OkHttpClient sharedClient = new OkHttpClient.Builder() + .addInterceptor(RollbarOkHttpInterceptor.withSharedUrlSanitizer( + recorder, DefaultUrlSanitizer.INSTANCE)) + .build(); + + HttpUrl url = server.url("/charge") + .newBuilder() + .username("anyUser") + .password("anyPassword") + .addQueryParameter("token", "abc") + .fragment("section") + .build(); + + Response response = sharedClient.newCall(new Request.Builder().url(url).build()).execute(); + response.close(); + + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), + eq(DefaultUrlSanitizer.INSTANCE.sanitize(url.toString())), + eq("500")); + } + + @Test + public void sharedUrlSanitizer_appliesACustomSanitizer() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + StringUrlSanitizer sanitizer = url -> "shared-sanitized"; + OkHttpClient sharedClient = new OkHttpClient.Builder() + .addInterceptor(RollbarOkHttpInterceptor.withSharedUrlSanitizer(recorder, sanitizer)) + .build(); + + Request request = new Request.Builder().url(server.url("/path?secret=abc")).build(); + Response response = sharedClient.newCall(request).execute(); + response.close(); + + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), eq("shared-sanitized"), eq("500")); + } + + @Test + public void sharedUrlSanitizer_rejectsANullSanitizer() { + assertThrows(NullPointerException.class, + () -> RollbarOkHttpInterceptor.withSharedUrlSanitizer(recorder, null)); + } + @Test public void customSanitizer_isAppliedToUrl() throws IOException { server.enqueue(new MockResponse().setResponseCode(500)); diff --git a/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java b/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java new file mode 100644 index 00000000..a0defef0 --- /dev/null +++ b/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java @@ -0,0 +1,185 @@ +package com.rollbar.reactivestreams.notifier; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.rollbar.reactivestreams.notifier.config.ConfigBuilder.withAccessToken; +import static java.lang.String.format; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import com.google.gson.Gson; +import com.rollbar.api.payload.data.Data; +import com.rollbar.api.payload.data.Level; +import com.rollbar.notifier.scrubbing.ScrubDataTransformer; +import com.rollbar.notifier.transformer.Transformer; +import com.rollbar.reactivestreams.notifier.config.Config; +import com.rollbar.reactivestreams.notifier.config.ConfigBuilder; +import com.rollbar.reactivestreams.notifier.sender.AsyncSender; +import com.rollbar.reactivestreams.notifier.sender.http.ApacheAsyncHttpClient; +import com.rollbar.notifier.sender.result.Response; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * The reactive {@link ConfigBuilder} duplicates the {@code redactedKeys} and {@code urlSanitizer} + * plumbing of the synchronous one, so it needs its own end-to-end coverage. The scrubbing itself + * is inherited from {@code RollbarBase} and is exercised in depth by + * {@code com.rollbar.notifier.ScrubbingITest}. + */ +public class ScrubbingReactiveITest { + + private static final String ACCESS_TOKEN = UUID.randomUUID().toString(); + + private static final String SCRUBBED = ScrubDataTransformer.SCRUBBED_VALUE; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort()); + + private ConfigBuilder configBuilder; + + @Before + public void setUp() { + AsyncSender sender = new AsyncSender.Builder(new ApacheAsyncHttpClient.Builder().build(), + getUrl()) + .accessToken(ACCESS_TOKEN) + .build(); + + this.configBuilder = withAccessToken(ACCESS_TOKEN).sender(sender); + + stubFor(post(urlEqualTo("/api/1/item/")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"err\":0,\"result\":{\"uuid\":\"" + UUID.randomUUID() + "\"}}"))); + } + + @Test + public void redactedKeysFromTheReactiveBuilderAreAppliedAfterTheUserTransformer() + throws Exception { + Transformer injectSecret = data -> new Data.Builder(data) + .custom(nestedCustom()) + .build(); + + Config config = configBuilder + .transformer(injectSecret) + .redactedKeys(Collections.singletonList("password")) + .build(); + + try (Rollbar rollbar = new Rollbar(config)) { + await(rollbar.error("boom")); + } + + Map custom = getValue(sentData(), "custom"); + assertThat(custom.get("password"), is(SCRUBBED)); + + List> users = getValue(custom, "users"); + assertThat(users, hasSize(1)); + assertThat(users.get(0).get("password"), is(SCRUBBED)); + } + + @Test + public void networkTelemetryUrlsAreSanitized() throws Exception { + try (Rollbar rollbar = new Rollbar(configBuilder.build())) { + rollbar.recordNetworkEventFor(Level.CRITICAL, "GET", + "https://user:pass@example.com/orders?token=secret", "500"); + await(rollbar.error("boom")); + } + + List> telemetry = getValue(sentData(), "body", "telemetry"); + assertThat(telemetry, hasSize(1)); + Map body = getValue(telemetry.get(0), "body"); + assertThat(body.get("url"), is("https://example.com/orders")); + } + + // --- helpers --- + + private static Map nestedCustom() { + Map custom = new HashMap<>(); + custom.put("password", "hunter2"); + Map user = new HashMap<>(); + user.put("password", "hunter2"); + custom.put("users", List.of(user)); + return custom; + } + + private static void await(Publisher publisher) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + publisher.subscribe(new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Response response) { + } + + @Override + public void onError(Throwable throwable) { + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + assertTrue("Timed out waiting for the payload to be sent", latch.await(20, TimeUnit.SECONDS)); + } + + /** The parsed {@code data} object of the nth payload WireMock received. */ + @SuppressWarnings("unchecked") + private Map sentData() { + List requests = + WireMock.findAll(postRequestedFor(urlEqualTo("/api/1/item/"))); + Map payload = + new Gson().fromJson(requests.get(0).getBodyAsString(), Map.class); + return getValue(payload, "data"); + } + + @SuppressWarnings("unchecked") + private static T getValue(Map source, String attribute, + String... attributes) { + Object value = source.get(attribute); + + if (attributes.length == 0) { + return (T) value; + } + + if (value == null) { + throw new NullPointerException("No value with key " + attribute); + } + + Map asMap = (Map) value; + String[] newAttributes = new String[attributes.length - 1]; + System.arraycopy(attributes, 1, newAttributes, 0, newAttributes.length); + + return getValue(asMap, attributes[0], newAttributes); + } + + private String getUrl() { + return format(Locale.US, "http://localhost:%d/api/1/item/", wireMockRule.port()); + } +} From 4c21b07b8aa6d87bfdd676f2449dad41bf345c5d Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 10 Aug 2026 02:50:59 -0300 Subject: [PATCH 16/21] docs: add scrubbing documentation --- README.md | 10 +++++ SCRUBBING.md | 87 ++++++++++++++++++++++++++++++++++++++++ rollbar-okhttp/README.md | 11 +++++ 3 files changed, 108 insertions(+) create mode 100644 SCRUBBING.md diff --git a/README.md b/README.md index f0e50249..e1b7e865 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ For actual usage, the easiest way to get started is by looking at the examples: - [rollbar-spring-boot-webmvc](https://github.com/rollbar/rollbar-java/tree/master/examples/rollbar-spring-boot-webmvc) - [rollbar-reactive-streams-reactor](https://github.com/rollbar/rollbar-java/tree/master/examples/rollbar-reactive-streams-reactor) +## Data scrubbing + +Payloads are scrubbed before they are sent, with no configuration required: a deny-list of +authentication headers is redacted, and URLs have their userinfo, query string and fragment +stripped. You can add your own keys with `redactedKeys` and change the URL handling with +`urlSanitizer`. + +See [SCRUBBING.md](SCRUBBING.md) for what is redacted by default, how to configure it, and the +migration impact if you are upgrading. + ## Release History & Changelog See our [Releases](https://github.com/rollbar/rollbar-java/releases) page for a list of all releases, including changes. diff --git a/SCRUBBING.md b/SCRUBBING.md new file mode 100644 index 00000000..99023393 --- /dev/null +++ b/SCRUBBING.md @@ -0,0 +1,87 @@ +# Data scrubbing + +Every occurrence the notifier builds — anything reported through `log`, `debug`, `info`, +`warning`, `error` or `critical`, including uncaught exceptions — is passed through a built-in +scrubber before it is sent. It runs **after** any `Transformer` you configure, so a transformer +cannot be used to opt out of it. + +This applies to all three notifiers, since they share the same configuration and send path: + +| Module | Covered | +| --- | --- | +| `rollbar-java` | yes | +| `rollbar-reactive-streams` | yes | +| `rollbar-android` | yes | + +The exception is `Rollbar.sendJsonPayload(String)`, which hands an already-serialized payload +straight to the sender and skips transformers, filters and scrubbing alike. Nothing on this page +applies to it; scrub that JSON yourself before passing it in. + +## What is redacted without any configuration + +- **Request headers**, matched case-insensitively against a built-in deny-list: + `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Access-Token`, + `X-Secret`, `Proxy-Authorization`, `WWW-Authenticate`. The value becomes `***`. +- **URLs**, which have their userinfo, query string and fragment stripped. This covers + `request.url` and the URLs recorded by `Rollbar.recordNetworkEventFor(...)`, so + `https://user:pass@example.com/orders?token=secret` is reported as + `https://example.com/orders`. + +## Redacting your own keys + +`redactedKeys` takes a list of **case-insensitive regexes**. A key is redacted when the regex is +found anywhere in it, so `"password"` also matches `user_password`. + +```java +Config config = ConfigBuilder.withAccessToken(ACCESS_TOKEN) + .redactedKeys(Arrays.asList("password", "secret", "ssn")) + .build(); +``` + +They are matched against the keys of: request headers, routing parameters (`request.params`), +GET and POST parameters, `request.metadata`, the raw `request.query_string`, custom data, and +`Frame.locals` — including the copies carried by `body.threads` when JVMTI locals capture is +enabled. Matching values are replaced with `***`. + +Nested data is walked recursively through maps, collections and arrays, up to 8 levels of +nesting, and the surrounding shape is preserved. Given `redactedKeys(["password"])`: + +```java +rollbar.error(exception, Collections.singletonMap( + "users", Arrays.asList(Collections.singletonMap("password", "hunter2")))); +// sent as: {"users": [{"password": "***"}]} +``` + +When a key itself matches, its whole value is replaced rather than descended into. + +## Customizing URL sanitization + +Supply a `StringUrlSanitizer` to change or disable the URL handling: + +```java +Config config = ConfigBuilder.withAccessToken(ACCESS_TOKEN) + .urlSanitizer(url -> url) // keep URLs verbatim + .build(); +``` + +If you use the OkHttp interceptor, share the same sanitizer so both paths redact identically: + +```java +OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(RollbarOkHttpInterceptor.withSharedUrlSanitizer( + recorder, config.urlSanitizer())) + .build(); +``` + +See the [rollbar-okhttp README](rollbar-okhttp/README.md) for the interceptor's own sanitizer +options. + +## Migrating + +This is a behaviour change: no configuration is required to get the redaction above, and it +cannot be disabled from a `Transformer`. If you are upgrading, expect that + +- values matching the header deny-list or your `redactedKeys` now arrive as `***`; +- `request.url` and network telemetry URLs no longer carry credentials, query strings or + fragments. If you rely on query parameters for grouping or search, configure a + `urlSanitizer` that preserves them. diff --git a/rollbar-okhttp/README.md b/rollbar-okhttp/README.md index 14341ad2..dee23daa 100644 --- a/rollbar-okhttp/README.md +++ b/rollbar-okhttp/README.md @@ -93,3 +93,14 @@ OkHttpClient client = new OkHttpClient.Builder() ``` When using a custom sanitizer, you are responsible for ensuring that sensitive query parameters are removed before the URL reaches Rollbar. + +To keep the interceptor and the notifier redacting identically, share the sanitizer from your Rollbar config: + +```java +OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(RollbarOkHttpInterceptor.withSharedUrlSanitizer( + recorder, config.urlSanitizer())) + .build(); +``` + +See [SCRUBBING.md](../SCRUBBING.md) for the notifier's scrubbing behaviour. From 1fc3ed6a85344a3c67c0a021449d94a17c5fb46f Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Tue, 11 Aug 2026 16:22:26 -0300 Subject: [PATCH 17/21] feat(scrubbing): seed field scrubbing with a built-in key list --- .../scrubbing/ScrubDataTransformer.java | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 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 4ba6dac7..994c02e1 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 @@ -38,24 +38,44 @@ * {@code X-Auth-Token}, {@code X-Access-Token}, {@code X-Secret}, * {@code Proxy-Authorization}, {@code WWW-Authenticate}. * - *

Additional keys can be configured via {@code ConfigBuilder.redactedKeys(List)}. They are - * matched as case-insensitive regexes against header names, routing parameter keys + *

Field keys are matched against {@link #DEFAULT_REDACTED_KEYS} plus any key configured via + * {@code ConfigBuilder.redactedKeys(List)}; the two lists are additive. 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}. + * carried by {@code Body.rollbarThreads}. The built-in defaults can be turned off with + * {@code ConfigBuilder.useDefaultRedactedKeys(false)}, leaving only the configured keys. * *

Nested data is walked recursively: maps reachable through other maps, through * {@link Collection}s and through object arrays are all scrubbed, up to 8 levels of nesting. The * surrounding shape is preserved, so a list stays a list and an array stays an array. * *

The built-in header deny-list above applies to {@code Request.headers} only; every other - * slot matches on the configured keys alone. + * slot matches on the field keys alone. */ public final class ScrubDataTransformer implements Transformer { public static final String SCRUBBED_VALUE = "***"; + /** + * Field keys redacted out of the box, matched as case-insensitive regexes anywhere in the key + * unless anchored. Substring matching makes these cover the usual variants: {@code password} + * also matches {@code user_password} and {@code passwordConfirmation}, {@code token} also + * matches {@code access_token}, {@code auth_token} and {@code csrfToken}, and {@code secret} + * also matches {@code client_secret}. + * + *

{@code auth} is anchored so that only a key that is exactly {@code auth} matches; leaving + * it unanchored would redact innocuous keys such as {@code author}. Its longer forms are listed + * separately. + */ + public static final List DEFAULT_REDACTED_KEYS = Collections.unmodifiableList( + Arrays.asList( + "password", "passwd", "secret", "token", "authorization", "authentication", "^auth$", + "api[-_]?key" + ) + ); + // O(1) set lookup; avoids Matcher allocation on every header key. private static final Set DEFAULT_HEADERS = Collections.unmodifiableSet( new HashSet<>(Arrays.asList( @@ -71,25 +91,51 @@ public final class ScrubDataTransformer implements Transformer { private final List fieldPatterns; private final StringUrlSanitizer urlSanitizer; + /** + * Constructor using the built-in {@link #DEFAULT_REDACTED_KEYS}. + * + * @param redactedKeys keys to redact on top of the defaults, matched as case-insensitive + * regexes. May be {@code null} or empty. + * @param urlSanitizer sanitizer applied to the request URL. Falls back to + * {@link DefaultUrlSanitizer#INSTANCE} when {@code null}. + */ + public ScrubDataTransformer(List redactedKeys, StringUrlSanitizer urlSanitizer) { + this(redactedKeys, urlSanitizer, true); + } + /** * 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. + * or empty. * @param urlSanitizer sanitizer applied to the request URL. Falls back to * {@link DefaultUrlSanitizer#INSTANCE} when {@code null}. + * @param useDefaultRedactedKeys whether {@link #DEFAULT_REDACTED_KEYS} are matched in addition + * to {@code redactedKeys}. When {@code false} only {@code redactedKeys} apply, so passing + * an empty list leaves the header deny-list and the URL sanitizer as the only redaction. */ - public ScrubDataTransformer(List redactedKeys, StringUrlSanitizer urlSanitizer) { + public ScrubDataTransformer(List redactedKeys, StringUrlSanitizer urlSanitizer, + boolean useDefaultRedactedKeys) { 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); + this.fieldPatterns = compile(redactedKeys, useDefaultRedactedKeys); + } + + private static List compile(List redactedKeys, boolean useDefaultRedactedKeys) { + List keys = new ArrayList<>(); + if (useDefaultRedactedKeys) { + keys.addAll(DEFAULT_REDACTED_KEYS); + } + if (redactedKeys != null) { + keys.addAll(redactedKeys); + } + if (keys.isEmpty()) { + return Collections.emptyList(); + } + List patterns = new ArrayList<>(keys.size()); + for (String key : keys) { + patterns.add(Pattern.compile(key, Pattern.CASE_INSENSITIVE)); } + return Collections.unmodifiableList(patterns); } @Override From f016ac299705e090235d9beacdfa44f93cead89c Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Tue, 11 Aug 2026 16:22:42 -0300 Subject: [PATCH 18/21] feat(config): expose useDefaultRedactedKeys --- .../com/rollbar/notifier/RollbarBase.java | 6 ++-- .../rollbar/notifier/config/CommonConfig.java | 18 ++++++++-- .../notifier/config/ConfigBuilder.java | 33 +++++++++++++++++-- .../notifier/config/ConfigBuilder.java | 29 ++++++++++++++-- 4 files changed, 77 insertions(+), 9 deletions(-) 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 8f53a9f3..0d16beb7 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java @@ -65,7 +65,8 @@ 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()); + this.builtInScrubber = new ScrubDataTransformer( + config.redactedKeys(), config.urlSanitizer(), config.useDefaultRedactedKeys()); this.telemetryUrlSanitizer = urlSanitizerOf(config); } @@ -144,7 +145,8 @@ protected void configure(C config) { this.config = config; configureTruncation(config); processAppPackages(config); - this.builtInScrubber = new ScrubDataTransformer(config.redactedKeys(), config.urlSanitizer()); + this.builtInScrubber = new ScrubDataTransformer( + config.redactedKeys(), config.urlSanitizer(), config.useDefaultRedactedKeys()); this.telemetryUrlSanitizer = urlSanitizerOf(config); } finally { this.configWriteLock.unlock(); 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 f59d9965..6c001f07 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 @@ -229,8 +229,10 @@ default boolean compressPayload() { /** * 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. + * These are additive to + * {@link com.rollbar.notifier.scrubbing.ScrubDataTransformer#DEFAULT_REDACTED_KEYS} (unless + * {@link #useDefaultRedactedKeys()} is false) and to the default header deny-list + * (Authorization, Cookie, etc.), which is always applied regardless of this list. * * @return list of regex patterns; empty list by default. */ @@ -238,6 +240,18 @@ default List redactedKeys() { return Collections.emptyList(); } + /** + * Whether {@link com.rollbar.notifier.scrubbing.ScrubDataTransformer#DEFAULT_REDACTED_KEYS} + * (password, secret, token, etc.) are redacted in addition to {@link #redactedKeys()}. Set to + * false to match only the keys you configure; the header deny-list and the URL sanitizer still + * apply. + * + * @return true to apply the built-in key list; true by default. + */ + default boolean useDefaultRedactedKeys() { + return true; + } + /** * 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, 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 0ff59a44..e2fb894d 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 @@ -14,6 +14,7 @@ import com.rollbar.notifier.provider.Provider; import com.rollbar.notifier.provider.notifier.NotifierProvider; import com.rollbar.notifier.provider.timestamp.TimestampProvider; +import com.rollbar.notifier.scrubbing.ScrubDataTransformer; import com.rollbar.notifier.sender.BufferedSender; import com.rollbar.notifier.sender.Sender; import com.rollbar.notifier.sender.SyncSender; @@ -93,6 +94,8 @@ public class ConfigBuilder { protected List redactedKeys; + protected boolean useDefaultRedactedKeys = true; + protected StringUrlSanitizer urlSanitizer; private int maximumTelemetryData = @@ -149,6 +152,7 @@ private ConfigBuilder(Config config) { this.maximumTelemetryData = config.maximumTelemetryData(); this.telemetryEventTracker = config.telemetryEventTracker(); this.redactedKeys = config.redactedKeys(); + this.useDefaultRedactedKeys = config.useDefaultRedactedKeys(); this.urlSanitizer = config.urlSanitizer(); } @@ -533,8 +537,9 @@ public ConfigBuilder telemetryEventTracker(TelemetryEventTracker telemetryEventT /** * 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.). + * query/POST parameters, custom data, and {@code Frame.locals}. These are additive to + * {@link ScrubDataTransformer#DEFAULT_REDACTED_KEYS} and to the built-in header deny-list + * (Authorization, Cookie, etc.). * * @param redactedKeys list of regex patterns. * @return the builder instance. @@ -544,6 +549,20 @@ public ConfigBuilder redactedKeys(List redactedKeys) { return this; } + /** + * Whether {@link ScrubDataTransformer#DEFAULT_REDACTED_KEYS} (password, secret, token, etc.) + * are redacted in addition to {@link #redactedKeys(List)}. Defaults to true; set to false to + * match only the keys you configure. The header deny-list and the URL sanitizer apply either + * way. + * + * @param useDefaultRedactedKeys true to apply the built-in key list. + * @return the builder instance. + */ + public ConfigBuilder useDefaultRedactedKeys(boolean useDefaultRedactedKeys) { + this.useDefaultRedactedKeys = useDefaultRedactedKeys; + return this; + } + /** * URL sanitizer applied to the request URL before the payload is sent. * Defaults to {@link DefaultUrlSanitizer#INSTANCE}. @@ -659,6 +678,8 @@ private static class ConfigImpl implements Config { private final List redactedKeys; + private final boolean useDefaultRedactedKeys; + private final StringUrlSanitizer urlSanitizer; ConfigImpl(ConfigBuilder builder) { @@ -697,7 +718,8 @@ private static class ConfigImpl implements Config { this.maximumTelemetryData = builder.maximumTelemetryData; this.telemetryEventTracker = builder.telemetryEventTracker; this.redactedKeys = builder.redactedKeys != null - ? builder.redactedKeys : Collections.emptyList(); + ? builder.redactedKeys : Collections.emptyList(); + this.useDefaultRedactedKeys = builder.useDefaultRedactedKeys; this.urlSanitizer = builder.urlSanitizer != null ? builder.urlSanitizer : DefaultUrlSanitizer.INSTANCE; } @@ -867,6 +889,11 @@ public List redactedKeys() { return redactedKeys; } + @Override + public boolean useDefaultRedactedKeys() { + return useDefaultRedactedKeys; + } + @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 b4619bb4..d310bbb4 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 @@ -15,6 +15,7 @@ import com.rollbar.notifier.provider.Provider; import com.rollbar.notifier.provider.notifier.NotifierProvider; import com.rollbar.notifier.provider.timestamp.TimestampProvider; +import com.rollbar.notifier.scrubbing.ScrubDataTransformer; import com.rollbar.notifier.sender.SyncSender; import com.rollbar.notifier.sender.json.JsonSerializer; import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; @@ -65,6 +66,7 @@ public final class ConfigBuilder { private boolean truncateLargePayloads; private boolean compressPayload; private List redactedKeys; + private boolean useDefaultRedactedKeys = true; private StringUrlSanitizer urlSanitizer; private int maximumTelemetryData = RollbarTelemetryEventTracker.MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS; @@ -116,6 +118,7 @@ private ConfigBuilder(Config config) { this.maximumTelemetryData = config.maximumTelemetryData(); this.telemetryEventTracker = config.telemetryEventTracker(); this.redactedKeys = config.redactedKeys(); + this.useDefaultRedactedKeys = config.useDefaultRedactedKeys(); this.urlSanitizer = config.urlSanitizer(); } @@ -519,8 +522,9 @@ public ConfigBuilder telemetryEventTracker(TelemetryEventTracker telemetryEventT /** * 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.). + * query/POST parameters, custom data, and {@code Frame.locals}. These are additive to + * {@link ScrubDataTransformer#DEFAULT_REDACTED_KEYS} and to the built-in header deny-list + * (Authorization, Cookie, etc.). * * @param redactedKeys list of regex patterns. * @return the builder instance. @@ -530,6 +534,20 @@ public ConfigBuilder redactedKeys(List redactedKeys) { return this; } + /** + * Whether {@link ScrubDataTransformer#DEFAULT_REDACTED_KEYS} (password, secret, token, etc.) + * are redacted in addition to {@link #redactedKeys(List)}. Defaults to true; set to false to + * match only the keys you configure. The header deny-list and the URL sanitizer apply either + * way. + * + * @param useDefaultRedactedKeys true to apply the built-in key list. + * @return the builder instance. + */ + public ConfigBuilder useDefaultRedactedKeys(boolean useDefaultRedactedKeys) { + this.useDefaultRedactedKeys = useDefaultRedactedKeys; + return this; + } + /** * URL sanitizer applied to the request URL before the payload is sent. * Defaults to {@link DefaultUrlSanitizer#INSTANCE}. @@ -616,6 +634,7 @@ private static class ConfigImpl implements Config { private final int maximumTelemetryData; private final TelemetryEventTracker telemetryEventTracker; private final List redactedKeys; + private final boolean useDefaultRedactedKeys; private final StringUrlSanitizer urlSanitizer; ConfigImpl(ConfigBuilder builder) { @@ -654,6 +673,7 @@ private static class ConfigImpl implements Config { this.telemetryEventTracker = builder.telemetryEventTracker; this.redactedKeys = builder.redactedKeys != null ? builder.redactedKeys : Collections.emptyList(); + this.useDefaultRedactedKeys = builder.useDefaultRedactedKeys; this.urlSanitizer = builder.urlSanitizer != null ? builder.urlSanitizer : DefaultUrlSanitizer.INSTANCE; } @@ -818,6 +838,11 @@ public List redactedKeys() { return redactedKeys; } + @Override + public boolean useDefaultRedactedKeys() { + return useDefaultRedactedKeys; + } + @Override public StringUrlSanitizer urlSanitizer() { return urlSanitizer; From 9ee5bc772158012988f6a3396f86d4529afea643 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Tue, 11 Aug 2026 16:22:54 -0300 Subject: [PATCH 19/21] test(scrubbing): cover the built-in key list and the opt-out --- .../scrubbing/ScrubDataTransformerTest.java | 155 +++++++++++++++--- 1 file changed, 136 insertions(+), 19 deletions(-) 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 d72817ad..72e0aabf 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 @@ -118,6 +118,138 @@ public void caseInsensitiveHeaderMatching() { assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getHeaders().get("authorization")); } + // --- built-in default field keys --- + + @Test + public void defaultKeysRedactRequestParametersWithoutConfiguration() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Request req = new Request.Builder() + .get(getParams("password", "hunter2")) + .post(objectMap("access_token", "abc123", "username", "alice")) + .params(headers("token", "reset-me", "userId", "42")) + .metadata(objectMap("apiKey", "key-12345", "region", "us-east-1")) + .queryString("password=hunter2&page=1") + .build(); + + Data result = t.transform(dataWithRequest(req)); + Request scrubbed = result.getRequest(); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.getGet().get("password").get(0)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.getPost().get("access_token")); + assertEquals("alice", scrubbed.getPost().get("username")); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.getParams().get("token")); + assertEquals("42", scrubbed.getParams().get("userId")); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.getMetadata().get("apiKey")); + assertEquals("us-east-1", scrubbed.getMetadata().get("region")); + assertEquals("password=" + ScrubDataTransformer.SCRUBBED_VALUE + "&page=1", + scrubbed.getQueryString()); + } + + @Test + public void defaultKeysRedactCustomDataWithoutConfiguration() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Map custom = objectMap("secret", "shhh", "colour", "blue"); + + Data result = t.transform(dataWithCustom(custom)); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("secret")); + assertEquals("blue", result.getCustom().get("colour")); + } + + @Test + public void defaultKeysRedactFrameLocalsWithoutConfiguration() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + Body body = new Body.Builder() + .bodyContent(traceWithLocals(objectMap("passwd", "hunter2", "userId", "42"))) + .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("passwd")); + assertEquals("42", frames.get(0).getLocals().get("userId")); + } + + @Test + public void defaultKeyVariantsMatchedBySubstring() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + String[] sensitive = { + "password", "Password", "user_password", "passwordConfirmation", "passwd", + "secret", "client_secret", "secretKey", + "token", "access_token", "accessToken", "auth_token", "csrfToken", "refresh_token", + "authorization", "Authentication", "auth", + "api_key", "apiKey", "API-KEY", "apikey" + }; + for (String key : sensitive) { + Data result = t.transform(dataWithCustom(objectMap(key, "hunter2"))); + assertEquals(key + " should be redacted by default", + ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get(key)); + } + } + + @Test + public void defaultKeysDoNotMatchInnocuousKeys() { + // "auth" is anchored precisely so that keys like "author" survive. + ScrubDataTransformer t = new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER); + String[] innocuous = {"author", "authorName", "unauthorized_count", "username", "email"}; + for (String key : innocuous) { + Data result = t.transform(dataWithCustom(objectMap(key, "visible"))); + assertEquals(key + " should not be redacted", "visible", result.getCustom().get(key)); + } + } + + @Test + public void configuredKeysAreAdditiveToTheDefaults() { + ScrubDataTransformer t = new ScrubDataTransformer(Collections.singletonList("ssn"), NO_OP_SANITIZER); + Map custom = objectMap("ssn", "123-45-6789", "password", "hunter2", "id", "7"); + + Data result = t.transform(dataWithCustom(custom)); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("ssn")); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("password")); + assertEquals("7", result.getCustom().get("id")); + } + + @Test + public void defaultKeysCanBeDisabled() { + ScrubDataTransformer t = + new ScrubDataTransformer(Collections.emptyList(), NO_OP_SANITIZER, false); + Map custom = objectMap("password", "hunter2"); + 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); + + // The header deny-list still applies; the built-in field keys no longer do. + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, + result.getRequest().getHeaders().get("Authorization")); + assertEquals("text/html", result.getRequest().getHeaders().get("Content-Type")); + assertEquals("hunter2", result.getCustom().get("password")); + } + + @Test + public void configuredKeysStillApplyWhenDefaultsAreDisabled() { + ScrubDataTransformer t = + new ScrubDataTransformer(Collections.singletonList("ssn"), NO_OP_SANITIZER, false); + Map custom = objectMap("ssn", "123-45-6789", "password", "hunter2"); + + Data result = t.transform(dataWithCustom(custom)); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("ssn")); + assertEquals("hunter2", result.getCustom().get("password")); + } + + @Test + public void nullRedactedKeysUsesTheDefaults() { + ScrubDataTransformer t = new ScrubDataTransformer(null, NO_OP_SANITIZER); + + Data result = t.transform(dataWithCustom(objectMap("password", "hunter2"))); + + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getCustom().get("password")); + } + // --- user redactedKeys --- @Test @@ -200,11 +332,11 @@ 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); + metadata.put("login", 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"); + Map scrubbed = (Map) result.getRequest().getMetadata().get("login"); assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.get("password")); assertEquals("alice", scrubbed.get("user")); } @@ -388,21 +520,6 @@ public void noMatchReturnsSameDataInstance() { 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); @@ -436,11 +553,11 @@ public void nestedCustomMapKeysScrubbed() { 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); + custom.put("login", inner); custom.put("visible", "yes"); Data result = t.transform(dataWithCustom(custom)); @SuppressWarnings("unchecked") - Map scrubbed = (Map) result.getCustom().get("auth"); + Map scrubbed = (Map) result.getCustom().get("login"); assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, scrubbed.get("password")); assertEquals("alice", scrubbed.get("user")); assertEquals("yes", result.getCustom().get("visible")); From f357b6c783d7225568968d02d8067942b82d4894 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Tue, 11 Aug 2026 16:23:08 -0300 Subject: [PATCH 20/21] test(scrubbing): prove secrets are redacted with no configuration --- .../com/rollbar/notifier/ScrubbingITest.java | 93 ++++++++++++++++--- .../notifier/ScrubbingReactiveITest.java | 26 ++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java b/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java index d3ba637c..3e03db2b 100644 --- a/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java +++ b/rollbar-java/src/integTest/java/com/rollbar/notifier/ScrubbingITest.java @@ -14,10 +14,10 @@ import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit.WireMockRule; -import com.github.tomakehurst.wiremock.verification.LoggedRequest; import com.google.gson.Gson; import com.rollbar.api.payload.data.Data; import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Request; import com.rollbar.notifier.config.Config; import com.rollbar.notifier.config.ConfigBuilder; import com.rollbar.notifier.scrubbing.ScrubDataTransformer; @@ -94,25 +94,81 @@ public void builtInScrubbingRunsAfterTheUserTransformer() { @Test public void reconfigurationChangesTheRedactedKeys() { + // Keys outside the built-in list, so that only the reconfiguration can explain the change. Rollbar rollbar = new Rollbar(configBuilder - .redactedKeys(Collections.singletonList("password")) + .redactedKeys(Collections.singletonList("ssn")) .build()); - rollbar.error("boom", objectMap("password", "hunter2", "token", "secret-token")); + rollbar.error("boom", objectMap("ssn", "123-45-6789", "pin", "1234")); Map before = getValue(sentData(0), "custom"); - assertThat(before.get("password"), is(SCRUBBED)); - assertThat(before.get("token"), is("secret-token")); + assertThat(before.get("ssn"), is(SCRUBBED)); + assertThat(before.get("pin"), is("1234")); rollbar.configure(configBuilder - .redactedKeys(Collections.singletonList("token")) + .redactedKeys(Collections.singletonList("pin")) .build()); - rollbar.error("boom", objectMap("password", "hunter2", "token", "secret-token")); + rollbar.error("boom", objectMap("ssn", "123-45-6789", "pin", "1234")); Map after = getValue(sentData(1), "custom"); - assertThat(after.get("password"), is("hunter2")); - assertThat(after.get("token"), is(SCRUBBED)); + assertThat(after.get("ssn"), is("123-45-6789")); + assertThat(after.get("pin"), is(SCRUBBED)); + } + + /** + * The whole point of the built-in key list: a {@code /login?password=hunter2} request must not + * leak the secret through any of the slots the request is serialized into, without the + * application configuring anything. + */ + @Test + public void secretsAreRedactedFromEveryRequestRepresentationWithoutConfiguration() { + Config config = configBuilder + .request(() -> new Request.Builder() + .url("https://example.com/login?password=hunter2") + .method("POST") + .headers(objectStringMap("Authorization", "Bearer hunter2", "Accept", "text/html")) + .params(objectStringMap("password", "hunter2", "userId", "42")) + .get(Collections.singletonMap("password", Collections.singletonList("hunter2"))) + .post(objectMap("password", "hunter2", "username", "alice")) + .metadata(objectMap("access_token", "hunter2", "region", "us-east-1")) + .queryString("password=hunter2") + .build()) + .build(); + + new Rollbar(config).error("boom", objectMap("password", "hunter2", "username", "alice")); + + // Nothing anywhere in the payload — telemetry, notifier metadata and all — carries the secret. + assertThat(sentPayload(0).contains("hunter2"), is(false)); + + Map request = getValue(sentData(0), "request"); + assertThat(request.get("url"), is("https://example.com/login")); + assertThat(request.get("query_string"), is("password=" + SCRUBBED)); + assertThat(getValue(request, "get", "password"), is(SCRUBBED)); + assertThat(getValue(request, "post", "password"), is(SCRUBBED)); + assertThat(getValue(request, "params", "password"), is(SCRUBBED)); + assertThat(getValue(request, "headers", "Authorization"), is(SCRUBBED)); + // Request.metadata is flattened onto the request object itself. + assertThat(request.get("access_token"), is(SCRUBBED)); + assertThat(getValue(sentData(0), "custom", "password"), is(SCRUBBED)); + + // Non-sensitive siblings are untouched. + assertThat(getValue(request, "post", "username"), is("alice")); + assertThat(getValue(request, "params", "userId"), is("42")); + assertThat(getValue(request, "headers", "Accept"), is("text/html")); + assertThat(request.get("region"), is("us-east-1")); + assertThat(getValue(sentData(0), "custom", "username"), is("alice")); + } + + @Test + public void defaultRedactedKeysCanBeTurnedOff() { + Config config = configBuilder + .useDefaultRedactedKeys(false) + .build(); + + new Rollbar(config).error("boom", objectMap("password", "hunter2")); + + assertThat(getValue(sentData(0), "custom", "password"), is("hunter2")); } @Test @@ -159,13 +215,16 @@ protected Sender buildSender(String url) { return new SyncSender.Builder().url(url).accessToken(ScrubbingITest.ACCESS_TOKEN).build(); } + /** The raw JSON body of the nth payload WireMock received. */ + private String sentPayload(int index) { + return WireMock.findAll(postRequestedFor(urlEqualTo("/api/1/item/"))) + .get(index).getBodyAsString(); + } + /** The parsed {@code data} object of the nth payload WireMock received. */ @SuppressWarnings("unchecked") private Map sentData(int index) { - List requests = - WireMock.findAll(postRequestedFor(urlEqualTo("/api/1/item/"))); - Map payload = - new Gson().fromJson(requests.get(index).getBodyAsString(), Map.class); + Map payload = new Gson().fromJson(sentPayload(index), Map.class); return getValue(payload, "data"); } @@ -197,6 +256,14 @@ private static Map objectMap(String... kvPairs) { return map; } + private static Map objectStringMap(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 String getUrl() { return format(Locale.US, "http://localhost:%d/api/1/item/", wireMockRule.port()); } diff --git a/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java b/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java index a0defef0..b672cf79 100644 --- a/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java +++ b/rollbar-reactive-streams/src/integTest/java/com/rollbar/reactivestreams/notifier/ScrubbingReactiveITest.java @@ -99,6 +99,26 @@ public void redactedKeysFromTheReactiveBuilderAreAppliedAfterTheUserTransformer( assertThat(users.get(0).get("password"), is(SCRUBBED)); } + @Test + public void defaultRedactedKeysApplyWithoutConfiguration() throws Exception { + try (Rollbar rollbar = new Rollbar(configBuilder.build())) { + await(rollbar.error("boom", customWith("password", "hunter2"))); + } + + assertThat(getValue(sentData(), "custom", "password"), is(SCRUBBED)); + } + + @Test + public void defaultRedactedKeysCanBeTurnedOff() throws Exception { + Config config = configBuilder.useDefaultRedactedKeys(false).build(); + + try (Rollbar rollbar = new Rollbar(config)) { + await(rollbar.error("boom", customWith("password", "hunter2"))); + } + + assertThat(getValue(sentData(), "custom", "password"), is("hunter2")); + } + @Test public void networkTelemetryUrlsAreSanitized() throws Exception { try (Rollbar rollbar = new Rollbar(configBuilder.build())) { @@ -115,6 +135,12 @@ public void networkTelemetryUrlsAreSanitized() throws Exception { // --- helpers --- + private static Map customWith(String key, String value) { + Map custom = new HashMap<>(); + custom.put(key, value); + return custom; + } + private static Map nestedCustom() { Map custom = new HashMap<>(); custom.put("password", "hunter2"); From 2ede51084b4d6ef69c58888b7931698436dffd96 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Tue, 11 Aug 2026 16:23:16 -0300 Subject: [PATCH 21/21] docs: document the built-in redacted key list --- README.md | 11 ++++++----- SCRUBBING.md | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e1b7e865..0c3c7892 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,14 @@ For actual usage, the easiest way to get started is by looking at the examples: ## Data scrubbing -Payloads are scrubbed before they are sent, with no configuration required: a deny-list of -authentication headers is redacted, and URLs have their userinfo, query string and fragment -stripped. You can add your own keys with `redactedKeys` and change the URL handling with -`urlSanitizer`. +Payloads are scrubbed before they are sent, with no configuration required: fields named after +secrets (`password`, `secret`, `token`, `authorization`, `api_key`, …) and a deny-list of +authentication headers are redacted, and URLs have their userinfo, query string and fragment +stripped. You can add your own keys with `redactedKeys`, turn the built-in key list off with +`useDefaultRedactedKeys(false)`, and change the URL handling with `urlSanitizer`. See [SCRUBBING.md](SCRUBBING.md) for what is redacted by default, how to configure it, and the -migration impact if you are upgrading. +migration impact if you are upgrading from a version minor or equal than 2.3.1. ## Release History & Changelog diff --git a/SCRUBBING.md b/SCRUBBING.md index 99023393..ba0e557c 100644 --- a/SCRUBBING.md +++ b/SCRUBBING.md @@ -19,6 +19,22 @@ applies to it; scrub that JSON yourself before passing it in. ## What is redacted without any configuration +- **Fields whose key names a secret**, wherever they appear in the payload. The built-in list is + + | Pattern | Also matches | + | --- | --- | + | `password` | `user_password`, `passwordConfirmation`, `passwordHash` | + | `passwd` | | + | `secret` | `client_secret`, `secretKey` | + | `token` | `access_token`, `auth_token`, `csrfToken`, `refreshToken` | + | `authorization` | `proxy_authorization` | + | `authentication` | | + | `^auth$` | anchored on purpose, so `author` is left alone | + | `api[-_]?key` | `api_key`, `apiKey`, `API-KEY` | + + Matching is case-insensitive and, apart from `^auth$`, matches anywhere in the key. So + `GET /login?password=hunter2` arrives with `request.get.password`, `request.query_string` and + the `request.url` query all redacted. - **Request headers**, matched case-insensitively against a built-in deny-list: `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Access-Token`, `X-Secret`, `Proxy-Authorization`, `WWW-Authenticate`. The value becomes `***`. @@ -27,14 +43,17 @@ applies to it; scrub that JSON yourself before passing it in. `https://user:pass@example.com/orders?token=secret` is reported as `https://example.com/orders`. +Not covered: `request.body`, which is a raw string the notifier cannot parse. If you populate it, +scrub it yourself. + ## Redacting your own keys -`redactedKeys` takes a list of **case-insensitive regexes**. A key is redacted when the regex is -found anywhere in it, so `"password"` also matches `user_password`. +`redactedKeys` takes a list of **case-insensitive regexes**, added to the built-in list above. A +key is redacted when the regex is found anywhere in it, so `"pin"` also matches `pin_code`. ```java Config config = ConfigBuilder.withAccessToken(ACCESS_TOKEN) - .redactedKeys(Arrays.asList("password", "secret", "ssn")) + .redactedKeys(Arrays.asList("ssn", "pin", "date_of_birth")) .build(); ``` @@ -43,6 +62,16 @@ GET and POST parameters, `request.metadata`, the raw `request.query_string`, cus `Frame.locals` — including the copies carried by `body.threads` when JVMTI locals capture is enabled. Matching values are replaced with `***`. +To match only your own keys, turn the built-in list off. The header deny-list and the URL +sanitizer still apply: + +```java +Config config = ConfigBuilder.withAccessToken(ACCESS_TOKEN) + .redactedKeys(Arrays.asList("ssn")) + .useDefaultRedactedKeys(false) + .build(); +``` + Nested data is walked recursively through maps, collections and arrays, up to 8 levels of nesting, and the surrounding shape is preserved. Given `redactedKeys(["password"])`: @@ -81,7 +110,9 @@ options. This is a behaviour change: no configuration is required to get the redaction above, and it cannot be disabled from a `Transformer`. If you are upgrading, expect that -- values matching the header deny-list or your `redactedKeys` now arrive as `***`; +- values matching the built-in key list, the header deny-list or your `redactedKeys` now arrive + as `***` — including keys you may not consider sensitive, such as `tokenCount`. Set + `useDefaultRedactedKeys(false)` if the built-in list is too broad for your payloads; - `request.url` and network telemetry URLs no longer carry credentials, query strings or fragments. If you rely on query parameters for grouping or search, configure a `urlSanitizer` that preserves them.