Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2ba9494
feat(scrubbing): add StringUrlSanitizer and DefaultUrlSanitizer
buongarzoni Jul 13, 2026
9049736
feat(scrubbing): add ScrubDataTransformer
buongarzoni Jul 13, 2026
7a9ca73
feat(config): expose redactedKeys and urlSanitizer
buongarzoni Jul 13, 2026
00dc4cb
feat(notifier): apply built-in scrubbing to every payload
buongarzoni Jul 13, 2026
bf3629d
refactor(okhttp): reuse the shared DefaultUrlSanitizer
buongarzoni Jul 13, 2026
9fee327
refactor(api): update imports
buongarzoni Jul 13, 2026
347ad48
refactor(scrubbing): update imports
buongarzoni Jul 13, 2026
306b105
refactor(config): update imports
buongarzoni Jul 13, 2026
52d5d40
refactor(okhttp): reuse the shared DefaultUrlSanitizer
buongarzoni Jul 13, 2026
82fb70b
fix(scrubbing): scrub Frame.locals in Body.rollbarThreads
buongarzoni Jul 13, 2026
8c06fd1
fix(scrubbing): match percent-encoded query parameter names
buongarzoni Jul 13, 2026
a09f485
fix(scrubbing): scrub Request.params and Request.metadata
buongarzoni Jul 13, 2026
57399ec
fix(scrubbing): traverse collections and arrays when scrubbing nested…
buongarzoni Aug 3, 2026
5482e72
fix(telemetry): sanitize URLs recorded as network telemetry events
buongarzoni Aug 3, 2026
f7bc6f3
test(scrubbing): cover ordering, reconfiguration and the okhttp sanit…
buongarzoni Aug 3, 2026
4c21b07
docs: add scrubbing documentation
buongarzoni Aug 10, 2026
1fc3ed6
feat(scrubbing): seed field scrubbing with a built-in key list
buongarzoni Aug 11, 2026
f016ac2
feat(config): expose useDefaultRedactedKeys
buongarzoni Aug 11, 2026
9ee5bc7
test(scrubbing): cover the built-in key list and the opt-out
buongarzoni Aug 11, 2026
f357b6c
test(scrubbing): prove secrets are redacted with no configuration
buongarzoni Aug 11, 2026
2ede510
docs: document the built-in redacted key list
buongarzoni Aug 11, 2026
0aa3993
perf(scrubbing): match literal keys without the regex engine
buongarzoni Aug 14, 2026
0c5a376
fix(scrubbing): redact Message.metadata in the body content + ignore …
buongarzoni Sep 14, 2026
01cd471
fix(scrubbing): redact Message.metadata in the body content + ignore …
buongarzoni Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(scrubbing): traverse collections and arrays when scrubbing nested…
… data
  • Loading branch information
buongarzoni committed Aug 3, 2026
commit 57399ecef067873d43f427c2f40c9b14be2b785e
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.
*
* <p>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.
*
* <p>The built-in header deny-list above applies to {@code Request.headers} only; every other
* slot matches on the configured keys alone.
*/
Expand All @@ -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<Pattern> fieldPatterns;
Expand Down Expand Up @@ -323,35 +331,95 @@ private Map<String, String> scrubStringMap(Map<String, String> map, List<Pattern
return result != null ? result : map;
}

@SuppressWarnings("unchecked")
private Map<String, Object> scrubObjectMap(Map<String, Object> map, List<Pattern> patterns,
int depth) {
if (map == null || patterns.isEmpty()) {
return map;
}
Map<String, Object> result = null;
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = entry.getKey();
// scrubMap only ever copies keys across, so a Map<String, Object> in stays one on the way out.
return (Map<String, Object>) 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<Pattern> 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<Pattern> patterns, int depth) {
Map<Object, Object> 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<String, Object> nested = (Map<String, Object>) value;
Map<String, Object> 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<Pattern> patterns, int depth) {
if (collection.isEmpty()) {
return collection;
}
List<Object> 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<Pattern> 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<String, List<String>> scrubMultiMap(Map<String, List<String>> map,
List<Pattern> patterns) {
if (map == null || patterns.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand Down Expand Up @@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Frame> 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<String, Object> 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<Object> rows = new LinkedHashSet<>();
rows.add(objectMap("password", "hunter2"));
rows.add("plain");
Map<String, Object> 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<String, Object> 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<Object> users = Collections.singletonList(objectMap("name", "alice"));
Map<String, Object> 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<String, Object> 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<String, Object> 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<Object> cycle = new ArrayList<>();
cycle.add(objectMap("password", "hunter2"));
cycle.add(cycle);
Map<String, Object> 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<Object, Object> byId = new HashMap<>();
byId.put(1, objectMap("password", "hunter2"));
Map<String, Object> 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<String, Object> locals) {
Frame frame = new Frame.Builder().locals(locals).build();
return new Trace.Builder().frames(Collections.singletonList(frame)).build();
Expand Down