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
perf(scrubbing): match literal keys without the regex engine
  • Loading branch information
buongarzoni committed Aug 14, 2026
commit 0aa39930f6e661b951c804e4100b11cd4970850b
7 changes: 6 additions & 1 deletion SCRUBBING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ applies to it; scrub that JSON yourself before passing it in.
| `authorization` | `proxy_authorization` |
| `authentication` | |
| `^auth$` | anchored on purpose, so `author` is left alone |
| `api[-_]?key` | `api_key`, `apiKey`, `API-KEY` |
| `apikey`, `api_key`, `api-key` | `myApiKey`, `X-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
Expand Down Expand Up @@ -62,6 +62,11 @@ 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 `***`.

Keys that contain no regex syntax — plain names such as `ssn` or `x-tenant-secret`, and anchored
names such as `^pin$` — are matched without running the regex engine. This is an internal
optimization with no effect on what matches, but it is why scrubbing stays cheap on Android: a
payload of ~100 keys allocates about 7 KB rather than 170 KB.

To match only your own keys, turn the built-in list off. The header deny-list and the URL
sanitizer still apply:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,15 @@ public final class ScrubDataTransformer implements Transformer {
* <p>{@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.
*
* <p>The api key spellings are listed one by one rather than as {@code api[-_]?key} so that the
* whole default list is free of regex syntax, which lets {@link KeyMatcher} match it without
* allocating.
*/
public static final List<String> DEFAULT_REDACTED_KEYS = Collections.unmodifiableList(
Arrays.asList(
"password", "passwd", "secret", "token", "authorization", "authentication", "^auth$",
"api[-_]?key"
"apikey", "api_key", "api-key"
)
);

Expand All @@ -88,7 +92,7 @@ public final class ScrubDataTransformer implements Transformer {
// 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;
private final KeyMatcher fieldKeys;
private final StringUrlSanitizer urlSanitizer;

/**
Expand Down Expand Up @@ -117,25 +121,7 @@ public ScrubDataTransformer(List<String> redactedKeys, StringUrlSanitizer urlSan
public ScrubDataTransformer(List<String> redactedKeys, StringUrlSanitizer urlSanitizer,
boolean useDefaultRedactedKeys) {
this.urlSanitizer = urlSanitizer != null ? urlSanitizer : DefaultUrlSanitizer.INSTANCE;
this.fieldPatterns = compile(redactedKeys, useDefaultRedactedKeys);
}

private static List<Pattern> compile(List<String> redactedKeys, boolean useDefaultRedactedKeys) {
List<String> keys = new ArrayList<>();
if (useDefaultRedactedKeys) {
keys.addAll(DEFAULT_REDACTED_KEYS);
}
if (redactedKeys != null) {
keys.addAll(redactedKeys);
}
if (keys.isEmpty()) {
return Collections.emptyList();
}
List<Pattern> patterns = new ArrayList<>(keys.size());
for (String key : keys) {
patterns.add(Pattern.compile(key, Pattern.CASE_INSENSITIVE));
}
return Collections.unmodifiableList(patterns);
this.fieldKeys = KeyMatcher.of(redactedKeys, useDefaultRedactedKeys);
}

@Override
Expand All @@ -149,7 +135,7 @@ public Data transform(Data data) {
Body originalBody = data.getBody();

Request scrubbedRequest = scrubRequest(originalRequest);
Map<String, Object> scrubbedCustom = scrubObjectMap(originalCustom, fieldPatterns, 0);
Map<String, Object> scrubbedCustom = scrubObjectMap(originalCustom, fieldKeys, 0);
Body scrubbedBody = scrubBody(originalBody);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Sanitize URLs in network telemetry events

RollbarBase.recordNetworkEventFor(...) passes its URL directly to the tracker, which stores it unchanged. Those events are later carried in Body.telemetryEvents, but scrubBody() only rebuilds trace content and thread traces. A caller can therefore record https://user:[email protected]/path?token=secret and ship both userinfo and query string despite the built-in scrubber. Please apply the configured sanitizer before recording network telemetry, or rebuild network telemetry events here, and cover the public notifier API with a regression test.


boolean changed = scrubbedRequest != originalRequest
Expand Down Expand Up @@ -188,11 +174,11 @@ private Request scrubRequest(Request req) {

String scrubbedUrl = originalUrl != null ? urlSanitizer.sanitize(originalUrl) : null;
Map<String, String> scrubbedHeaders = scrubHeaders(originalHeaders);
Map<String, String> scrubbedParams = scrubStringMap(originalParams, fieldPatterns);
Map<String, List<String>> scrubbedGet = scrubMultiMap(originalGet, fieldPatterns);
Map<String, Object> scrubbedPost = scrubObjectMap(originalPost, fieldPatterns, 0);
Map<String, Object> scrubbedMetadata = scrubObjectMap(originalMetadata, fieldPatterns, 0);
String scrubbedQueryString = scrubQueryString(originalQueryString, fieldPatterns);
Map<String, String> scrubbedParams = scrubStringMap(originalParams, fieldKeys);
Map<String, List<String>> scrubbedGet = scrubMultiMap(originalGet, fieldKeys);
Map<String, Object> scrubbedPost = scrubObjectMap(originalPost, fieldKeys, 0);
Map<String, Object> scrubbedMetadata = scrubObjectMap(originalMetadata, fieldKeys, 0);
String scrubbedQueryString = scrubQueryString(originalQueryString, fieldKeys);

boolean changed = !equal(originalUrl, scrubbedUrl)
|| scrubbedHeaders != originalHeaders
Expand All @@ -218,7 +204,7 @@ private Request scrubRequest(Request req) {
}

private Body scrubBody(Body body) {
if (body == null || fieldPatterns.isEmpty()) {
if (body == null || fieldKeys.isEmpty()) {
return body;
}

Expand Down Expand Up @@ -330,7 +316,7 @@ private Frame scrubFrame(Frame frame) {
return null;
}
Map<String, Object> locals = frame.getLocals();
Map<String, Object> scrubbedLocals = scrubObjectMap(locals, fieldPatterns, 0);
Map<String, Object> scrubbedLocals = scrubObjectMap(locals, fieldKeys, 0);
if (scrubbedLocals == locals) {
return frame;
}
Expand All @@ -344,7 +330,7 @@ private Map<String, String> scrubHeaders(Map<String, String> map) {
Map<String, String> result = null;
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if (matchesDefaultHeader(key) || matchesAny(key, fieldPatterns)) {
if (matchesDefaultHeader(key) || fieldKeys.matches(key)) {
if (result == null) {
result = new HashMap<>(map);
}
Expand All @@ -360,14 +346,14 @@ private Map<String, String> scrubHeaders(Map<String, String> map) {
* {@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<String, String> scrubStringMap(Map<String, String> map, List<Pattern> patterns) {
if (map == null || patterns.isEmpty()) {
private Map<String, String> scrubStringMap(Map<String, String> map, KeyMatcher keys) {
if (map == null || keys.isEmpty()) {
return map;
}
Map<String, String> result = null;
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if (matchesAny(key, patterns)) {
if (keys.matches(key)) {
if (result == null) {
result = new HashMap<>(map);
}
Expand All @@ -378,13 +364,13 @@ private Map<String, String> scrubStringMap(Map<String, String> map, List<Pattern
}

@SuppressWarnings("unchecked")
private Map<String, Object> scrubObjectMap(Map<String, Object> map, List<Pattern> patterns,
private Map<String, Object> scrubObjectMap(Map<String, Object> map, KeyMatcher keys,
int depth) {
if (map == null || patterns.isEmpty()) {
if (map == null || keys.isEmpty()) {
return map;
}
// 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);
return (Map<String, Object>) scrubMap(map, keys, depth);
}

/**
Expand All @@ -393,30 +379,30 @@ private Map<String, Object> scrubObjectMap(Map<String, Object> map, List<Pattern
* 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) {
private Object scrubNested(Object value, KeyMatcher keys, int depth) {
if (depth >= MAX_SCRUB_DEPTH) {
return value;
}
if (value instanceof Map) {
return scrubMap((Map<?, ?>) value, patterns, depth + 1);
return scrubMap((Map<?, ?>) value, keys, depth + 1);
}
if (value instanceof Collection) {
return scrubCollection((Collection<?>) value, patterns, depth + 1);
return scrubCollection((Collection<?>) value, keys, depth + 1);
}
if (value instanceof Object[]) {
return scrubArray((Object[]) value, patterns, depth + 1);
return scrubArray((Object[]) value, keys, depth + 1);
}
return value;
}

private Object scrubMap(Map<?, ?> map, List<Pattern> patterns, int depth) {
private Object scrubMap(Map<?, ?> map, KeyMatcher keys, int depth) {
Map<Object, Object> result = null;
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object key = entry.getKey();
Object value = entry.getValue();
// 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);
boolean keyMatches = key instanceof String && keys.matches((String) key);
Object scrubbed = keyMatches ? SCRUBBED_VALUE : scrubNested(value, keys, depth);
if (keyMatches || scrubbed != value) {
if (result == null) {
result = new LinkedHashMap<>(map);
Expand All @@ -427,14 +413,14 @@ private Object scrubMap(Map<?, ?> map, List<Pattern> patterns, int depth) {
return result != null ? result : map;
}

private Object scrubCollection(Collection<?> collection, List<Pattern> patterns, int depth) {
private Object scrubCollection(Collection<?> collection, KeyMatcher keys, 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);
Object scrubbedElement = scrubNested(element, keys, depth);
scrubbed.add(scrubbedElement);
if (scrubbedElement != element) {
changed = true;
Expand All @@ -448,10 +434,10 @@ private Object scrubCollection(Collection<?> collection, List<Pattern> patterns,
return collection instanceof Set ? new LinkedHashSet<>(scrubbed) : scrubbed;
}

private Object scrubArray(Object[] array, List<Pattern> patterns, int depth) {
private Object scrubArray(Object[] array, KeyMatcher keys, int depth) {
Object[] result = null;
for (int i = 0; i < array.length; i++) {
Object scrubbedElement = scrubNested(array[i], patterns, depth);
Object scrubbedElement = scrubNested(array[i], keys, depth);
if (scrubbedElement != array[i]) {
if (result == null) {
// Object[] rather than array.clone(): a rebuilt value may not fit the original component
Expand All @@ -467,13 +453,13 @@ private Object scrubArray(Object[] array, List<Pattern> patterns, int depth) {
}

private Map<String, List<String>> scrubMultiMap(Map<String, List<String>> map,
List<Pattern> patterns) {
if (map == null || patterns.isEmpty()) {
KeyMatcher keys) {
if (map == null || keys.isEmpty()) {
return map;
}
Map<String, List<String>> result = null;
for (Map.Entry<String, List<String>> entry : map.entrySet()) {
if (matchesAny(entry.getKey(), patterns)) {
if (keys.matches(entry.getKey())) {
if (result == null) {
result = new HashMap<>(map);
}
Expand All @@ -483,8 +469,8 @@ private Map<String, List<String>> scrubMultiMap(Map<String, List<String>> map,
return result != null ? result : map;
}

private String scrubQueryString(String queryString, List<Pattern> patterns) {
if (queryString == null || queryString.isEmpty() || patterns.isEmpty()) {
private String scrubQueryString(String queryString, KeyMatcher keys) {
if (queryString == null || queryString.isEmpty() || keys.isEmpty()) {
return queryString;
}
String[] pairs = queryString.split("&", -1);
Expand All @@ -495,7 +481,7 @@ private String scrubQueryString(String queryString, List<Pattern> 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) || matchesAny(decodeParamName(key), patterns)) {
if (keys.matches(key) || keys.matches(decodeParamName(key))) {
output[i] = key + "=" + SCRUBBED_VALUE;
changed = true;
} else {
Expand Down Expand Up @@ -534,16 +520,111 @@ private static boolean matchesDefaultHeader(String key) {
return DEFAULT_HEADERS.contains(key.toLowerCase(Locale.ROOT));
}

private static boolean matchesAny(String key, List<Pattern> patterns) {
for (Pattern p : patterns) {
if (p.matcher(key).find()) {
return true;
private static boolean equal(String a, String b) {
return a == null ? b == null : a.equals(b);
}

/**
* Matches a key against the redacted key list. Keys are documented as case-insensitive regexes
* and keep those semantics, but running a {@link Pattern} over every key of every payload
* allocates a {@code Matcher} per key per pattern, which on a ~100 key payload is the bulk of
* what this transformer costs. In practice almost every key is a plain name - the whole
* built-in list is - so plain names are matched with one lowercase pass and a substring search,
* and only keys that actually carry regex syntax reach {@link Pattern}.
*
* <p>{@code String.toLowerCase} returns the receiver when there is nothing to fold, so a key
* that is already lower case costs no allocation at all.
*/
private static final class KeyMatcher {

// Special outside a character class, or the opening of one. '-' is deliberately absent: it
// only carries meaning inside a class, so "x-api-key" stays a literal.
private static final String METACHARACTERS = "\\.[]{}()*+?^$|";

private final String[] contains;
private final String[] exact;
private final Pattern[] patterns;

private KeyMatcher(List<String> contains, List<String> exact, List<Pattern> patterns) {
this.contains = contains.toArray(new String[0]);
this.exact = exact.toArray(new String[0]);
this.patterns = patterns.toArray(new Pattern[0]);
}

static KeyMatcher of(List<String> redactedKeys, boolean useDefaultRedactedKeys) {
List<String> keys = new ArrayList<>();
if (useDefaultRedactedKeys) {
keys.addAll(DEFAULT_REDACTED_KEYS);
}
if (redactedKeys != null) {
keys.addAll(redactedKeys);
}

List<String> contains = new ArrayList<>();
List<String> exact = new ArrayList<>();
List<Pattern> patterns = new ArrayList<>();
for (String key : keys) {
String anchored = anchoredLiteral(key);
if (anchored != null) {
exact.add(anchored.toLowerCase(Locale.ROOT));
} else if (isLiteral(key)) {
contains.add(key.toLowerCase(Locale.ROOT));
} else {
// Compiled up front, so an invalid regex still fails when the notifier is configured
// rather than when the first payload is scrubbed.
patterns.add(Pattern.compile(key, Pattern.CASE_INSENSITIVE));
}
}
return new KeyMatcher(contains, exact, patterns);
}
return false;
}

private static boolean equal(String a, String b) {
return a == null ? b == null : a.equals(b);
boolean isEmpty() {
return contains.length == 0 && exact.length == 0 && patterns.length == 0;
}

boolean matches(String key) {
if (contains.length > 0 || exact.length > 0) {
// CASE_INSENSITIVE folds ASCII only while toLowerCase folds the whole of Unicode, so an
// exotic key can match here where the equivalent regex would not. That direction only
// ever redacts more, which is the safe way to differ.
String lower = key.toLowerCase(Locale.ROOT);
for (String needle : contains) {
if (lower.contains(needle)) {
return true;
}
}
for (String name : exact) {
if (lower.equals(name)) {
return true;
}
}
}
for (Pattern pattern : patterns) {
if (pattern.matcher(key).find()) {
return true;
}
}
return false;
}

/**
* The literal inside an anchored key such as {@code ^auth$}, or null if it is not one.
*/
private static String anchoredLiteral(String key) {
if (key.length() <= 2 || key.charAt(0) != '^' || key.charAt(key.length() - 1) != '$') {
return null;
}
String inner = key.substring(1, key.length() - 1);
return isLiteral(inner) ? inner : null;
}

private static boolean isLiteral(String key) {
for (int i = 0; i < key.length(); i++) {
if (METACHARACTERS.indexOf(key.charAt(i)) >= 0) {
return false;
}
}
return true;
}
}
}
Loading
Loading