diff --git a/rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java new file mode 100644 index 00000000..1329db97 --- /dev/null +++ b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/DefaultUrlSanitizer.java @@ -0,0 +1,51 @@ +package com.rollbar.api.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-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java new file mode 100644 index 00000000..f526627f --- /dev/null +++ b/rollbar-api/src/main/java/com/rollbar/api/scrubbing/StringUrlSanitizer.java @@ -0,0 +1,18 @@ +package com.rollbar.api.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-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java b/rollbar-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java new file mode 100644 index 00000000..9ea44eda --- /dev/null +++ b/rollbar-api/src/test/java/com/rollbar/api/scrubbing/DefaultUrlSanitizerTest.java @@ -0,0 +1,96 @@ +package com.rollbar.api.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")); + } +} 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); 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..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,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.filter.Filter; import com.rollbar.notifier.fingerprint.FingerprintGenerator; import com.rollbar.notifier.provider.Provider; @@ -13,6 +15,7 @@ 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..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; @@ -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-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..84923ff6 --- /dev/null +++ b/rollbar-java/src/main/java/com/rollbar/notifier/scrubbing/ScrubDataTransformer.java @@ -0,0 +1,435 @@ +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.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; +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; +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 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 { + + 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; + + /** + * 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()) { + 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 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 = 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) { + return req; + } + + return new Request.Builder(req) + .url(scrubbedUrl) + .headers(scrubbedHeaders) + .params(scrubbedParams) + .get(scrubbedGet) + .post(scrubbedPost) + .metadata(scrubbedMetadata) + .queryString(scrubbedQueryString) + .build(); + } + + private Body scrubBody(Body body) { + if (body == null || fieldPatterns.isEmpty()) { + return body; + } + + 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) { + return scrubTrace((Trace) content); + } else if (content instanceof TraceChain) { + 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; + } + } + if (!anyChanged) { + return chain; + } + return new TraceChain.Builder(chain).traces(scrubbed).build(); + } + + 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 scrubHeaders(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; + } + + /** + * 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()) { + 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) || matchesAny(decodeParamName(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(); + } + + /** + * 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)); + } + + 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..d231eed7 --- /dev/null +++ b/rollbar-java/src/test/java/com/rollbar/notifier/scrubbing/ScrubDataTransformerTest.java @@ -0,0 +1,560 @@ +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.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; + +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(Collections.singletonList("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(Collections.singletonList("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(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)); + assertEquals(ScrubDataTransformer.SCRUBBED_VALUE, result.getRequest().getPost().get("password")); + assertEquals("alice", result.getRequest().getPost().get("username")); + } + + @Test + public void userKeyRedactsCustomMap() { + 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")); + 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); + 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(Collections.singletonList("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()); + } + + @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 + public void frameLocalsMatchingKeysScrubbed() { + 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(); + 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(Collections.singletonList("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(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(); + assertTrue(qs.contains("token=" + ScrubDataTransformer.SCRUBBED_VALUE)); + assertTrue(qs.contains("page=1")); + } + + @Test + public void valueLessQueryParamNoMatchPassedThrough() { + 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()); + } + + // --- nested map scrubbing (B4 fix) --- + + @Test + 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("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(Collections.singletonList("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(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(); + } +} 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..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,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 java.io.IOException; import java.util.Objects; @@ -18,18 +20,29 @@ 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; + /** + * 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) { this(recorder, DEFAULT_URL_SANITIZER); } 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..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; @@ -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; + } } }